init separated format controller, detect content pattern by format_filename extension or trailing slash availability

This commit is contained in:
postscriptum 2025-07-04 15:01:00 +03:00
parent 776e929b01
commit 678f0c6d57
4 changed files with 174 additions and 78 deletions

View file

@ -50,8 +50,10 @@ snac2nex -s /path/to/snac/storage -t /path/to/nex -u user1 -u user2
--format-filename <FORMAT_FILENAME>
Post filenames format
* escaped with `%%`
* be careful when removing seconds, as it may cause overwrite collisions
* append trailing slash to parse documents as the directory
* append `.gmi` or `.gemtext` extension to parse documents as the Gemtext
* escape with `%%` when using in `systemd` context
* be careful when removing the seconds part, as it may cause overwrite collisions!
[default: %H-%M-%S]

View file

@ -35,8 +35,10 @@ pub struct Config {
/// Post filenames format
///
/// * escaped with `%%`
/// * be careful when removing seconds, as it may cause overwrite collisions
/// * append trailing slash to parse documents as the directory
/// * append `.gmi` or `.gemtext` extension to parse documents as the Gemtext
/// * escape with `%%` when using in `systemd` context
/// * be careful when removing the seconds part, as it may cause overwrite collisions!
#[arg(long, default_value_t = String::from("%H-%M-%S"))]
pub format_filename: String,

View file

@ -1,10 +1,12 @@
mod attachment;
mod format;
pub mod response;
pub mod source;
use anyhow::{Result, bail};
use attachment::Attachment;
use chrono::{DateTime, Utc};
use format::Format;
use response::{Clean, Sync, change::Change};
use source::Source;
use std::{
@ -17,10 +19,9 @@ use std::{
pub struct Nex {
attachment: Attachment,
filename: String,
format: Format,
is_cleanup: bool,
is_debug: bool,
pattern: String,
time_format: String,
users: HashMap<String, PathBuf>,
}
@ -34,10 +35,6 @@ impl Nex {
(is_cleanup, is_debug): (bool, bool),
user_names: &Vec<String>,
) -> Result<Self> {
use std::path::MAIN_SEPARATOR;
if filename.contains(MAIN_SEPARATOR) {
bail!("Filename pattern should not contain `{MAIN_SEPARATOR}` characters!")
}
// init data export location
let target = PathBuf::from_str(&target_dir)?.canonicalize()?;
if !target.is_dir() {
@ -51,14 +48,15 @@ impl Nex {
fs::create_dir_all(&p)?;
users.insert(u.clone(), p);
}
// init document format
let format = Format::init(&filename, pattern, time_format);
Ok(Self {
attachment: Attachment::init(attachment_mode)?,
filename,
format,
is_cleanup,
is_debug,
pattern,
time_format,
users,
})
}
@ -106,7 +104,9 @@ impl Nex {
fs::create_dir_all(&p)?;
// init shared post ID once
let id = published.format(&self.filename).to_string();
let id = published
.format(self.filename.trim_matches('/'))
.to_string();
// init post filepath
let mut f = PathBuf::from(&p);
@ -146,77 +146,59 @@ impl Nex {
});
}
// format content pattern
let c = self
.pattern
.replace(
"{content}",
&if self.pattern.contains("{tags}") {
content.replace("#", "")
} else {
content
},
)
.replace(
"{attachments}",
&attachments
.map(|a| {
let mut b = Vec::with_capacity(a.len());
b.push("\n".to_string());
for (n, (name, media_type, source)) in a.into_iter().enumerate() {
let mut t = Vec::with_capacity(3);
t.push(format!(
"=> {}",
match source {
Source::Url(url) => url,
Source::File(from) => {
let to = attachment::filepath(&d, &from, n);
let uri = format!(
"{}/{}",
d.file_name().unwrap().to_string_lossy(),
to.file_name().unwrap().to_string_lossy()
);
self.attachment.sync(&from, &to).unwrap();
if let Some(ref mut i) = index {
i.push(to);
}
uri
}
}
));
if !name.is_empty() {
t.push(name)
}
if !media_type.is_empty() {
t.push(format!("({media_type})"))
}
b.push(t.join(" "))
}
b.join("\n")
})
.unwrap_or_default(),
)
.replace(
"{tags}",
&tags
.map(|t| format!("\n\n{}", t.join(", ")))
.unwrap_or_default(),
)
.replace("{link}", &format!("\n\n=> {link}"))
.replace(
"{updated}",
&updated
.map(|t| format!("\n\n{}", t.format(&self.time_format)))
.unwrap_or_default(),
);
// save post file, set its change status
let change = if fs::exists(&f)? {
Change::Updated
} else {
Change::Created
};
fs::write(&f, c)?;
fs::write(
&f,
self.format.to_string(
updated,
content,
link,
tags,
attachments.map(|a| {
let mut b = Vec::with_capacity(a.len());
for (n, (name, media_type, source)) in a.into_iter().enumerate() {
b.push((
match source {
Source::Url(url) => url,
Source::File(from) => {
let to = attachment::filepath(&d, &from, n);
let uri = format!(
"{}/{}",
d.file_name().unwrap().to_string_lossy(),
to.file_name().unwrap().to_string_lossy()
);
self.attachment.sync(&from, &to).unwrap();
if let Some(ref mut i) = index {
i.push(to);
}
uri
}
},
{
let mut alt = Vec::with_capacity(2);
if !name.is_empty() {
alt.push(name)
}
if !media_type.is_empty() {
alt.push(format!("({media_type})"))
}
if alt.is_empty() {
None
} else {
Some(alt.join(" "))
}
},
))
}
b
}),
),
)?;
// move all paths processed to cleanup ignore
if let Some(ref mut i) = index {

110
src/nex/format.rs Normal file
View file

@ -0,0 +1,110 @@
use chrono::{DateTime, Utc};
pub enum Format {
/// Format as [Gemtext](https://geminiprotocol.net/docs/gemtext.gmi)
/// * detected in case, when the `format_filename` option has `.gmi` or `.gemtext` suffix in pattern
Gemtext {
pattern: String,
time_format: String,
},
/// It is useful to enable clickable links
/// * detected in case, when the `format_filename` option has trailing slash in pattern
/// * the server should support appending a trailing slash in this case
Dir {
pattern: String,
time_format: String,
},
/// Ignore markdown
Plain {
pattern: String,
time_format: String,
},
}
impl Format {
pub fn init(filename: &str, pattern: String, time_format: String) -> Self {
if filename.ends_with('/') {
Format::Dir {
pattern,
time_format,
}
} else if filename.ends_with(".gmi") | filename.ends_with(".gemtext") {
Format::Gemtext {
pattern,
time_format,
}
} else {
Format::Plain {
pattern,
time_format,
}
}
}
pub fn to_string(
&self,
updated: Option<DateTime<Utc>>,
content: String,
link: String,
tags: Option<Vec<String>>,
attachments: Option<Vec<(String, Option<String>)>>,
) -> String {
match self {
Self::Dir {
pattern,
time_format,
}
| Self::Gemtext {
pattern,
time_format,
}
| Self::Plain {
pattern,
time_format,
} => pattern
.replace(
"{content}",
&if pattern.contains("{tags}") {
content.replace("#", "")
} else {
content
},
)
.replace(
"{attachments}",
&attachments
.map(|a| {
let mut b = Vec::with_capacity(a.len());
b.push("\n".to_string());
for (link, alt) in a {
let mut t = Vec::with_capacity(2);
t.push(if matches!(self, Self::Dir { .. }) {
format!("=> ../{link}")
} else {
format!("=> {link}")
});
if let Some(text) = alt {
t.push(text)
}
b.push(t.join(" "))
}
b.join("\n")
})
.unwrap_or_default(),
)
.replace(
"{tags}",
&tags
.map(|t| format!("\n\n{}", t.join(", ")))
.unwrap_or_default(),
)
.replace("{link}", &format!("\n\n=> {link}"))
.replace(
"{updated}",
&updated
.map(|t| format!("\n\n{}", t.format(time_format)))
.unwrap_or_default(),
),
} // @TODO implement separated templates
}
}