mirror of
https://codeberg.org/postscriptum/snac2nex.git
synced 2026-03-31 13:15:27 +00:00
301 lines
10 KiB
Rust
301 lines
10 KiB
Rust
mod attachment;
|
|
pub mod response;
|
|
pub mod source;
|
|
mod template;
|
|
|
|
use anyhow::{Result, bail};
|
|
use attachment::Attachment;
|
|
use chrono::{DateTime, Utc};
|
|
use response::{Clean, Sync, change::Change};
|
|
use source::Source;
|
|
use std::{
|
|
collections::{HashMap, HashSet},
|
|
fs::{self, Permissions},
|
|
path::{MAIN_SEPARATOR, PathBuf},
|
|
str::FromStr,
|
|
};
|
|
use template::Template;
|
|
|
|
pub struct Nex {
|
|
attachment: Attachment,
|
|
file_permissions: Option<Permissions>,
|
|
filename: String,
|
|
is_cleanup: bool,
|
|
is_debug: bool,
|
|
is_filesystem_sync: bool,
|
|
template: Template,
|
|
users: HashMap<String, PathBuf>,
|
|
}
|
|
|
|
impl Nex {
|
|
pub fn init(config: &crate::Config) -> Result<Self> {
|
|
// validate filename
|
|
if config
|
|
.format_content
|
|
.trim_matches(MAIN_SEPARATOR)
|
|
.contains(MAIN_SEPARATOR)
|
|
{
|
|
bail!("File name should not contain subdir `{MAIN_SEPARATOR}` separator!")
|
|
}
|
|
// init data export location
|
|
let target = PathBuf::from_str(&config.target)?.canonicalize()?;
|
|
if !target.is_dir() {
|
|
bail!("Target location is not directory!")
|
|
}
|
|
// init locations for each user
|
|
let mut users = HashMap::with_capacity(config.user.len());
|
|
for u in &config.user {
|
|
let mut p = PathBuf::from(&target);
|
|
p.push(u);
|
|
fs::create_dir_all(&p)?;
|
|
users.insert(u.clone(), p);
|
|
}
|
|
// init document template formatter
|
|
let template = Template::init(config);
|
|
|
|
Ok(Self {
|
|
attachment: Attachment::init(config.attachment.as_ref())?,
|
|
file_permissions: {
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
{
|
|
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
|
|
Some(Permissions::from_mode(
|
|
config.filesystem_file_permissions.unwrap_or(0o644),
|
|
))
|
|
}
|
|
#[cfg(not(any(target_os = "linux", target_os = "macos",)))]
|
|
{
|
|
None // @TODO
|
|
}
|
|
},
|
|
filename: config.format_filename.clone(),
|
|
is_cleanup: !config.keep,
|
|
is_debug: !config.daemon,
|
|
is_filesystem_sync: config.filesystem_sync,
|
|
template,
|
|
users,
|
|
})
|
|
}
|
|
|
|
// Sync Snac post with Nex entry
|
|
pub fn sync(
|
|
&self,
|
|
name: &str,
|
|
content: String,
|
|
link: String,
|
|
attachments: Option<Vec<(String, String, Source)>>,
|
|
tags: Option<Vec<String>>,
|
|
(published, updated): (DateTime<Utc>, Option<DateTime<Utc>>),
|
|
) -> Result<Sync> {
|
|
// collect existing entries to ignore on cleanup
|
|
let mut index = if self.is_cleanup {
|
|
Some(Vec::with_capacity(10)) // includes est. attachments len
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// prepare destination
|
|
let root = PathBuf::from(self.users.get(name).unwrap());
|
|
let mut p = PathBuf::from(&root);
|
|
if let Some(ref mut i) = index {
|
|
i.push(root)
|
|
}
|
|
|
|
p.push(published.format("%Y").to_string());
|
|
if let Some(ref mut i) = index {
|
|
i.push(p.clone())
|
|
}
|
|
|
|
p.push(published.format("%m").to_string());
|
|
if let Some(ref mut i) = index {
|
|
i.push(p.clone())
|
|
}
|
|
|
|
p.push(published.format("%d").to_string());
|
|
if let Some(ref mut i) = index {
|
|
i.push(p.clone())
|
|
}
|
|
|
|
fs::create_dir_all(&p)?;
|
|
|
|
// init shared post ID once
|
|
let id = published
|
|
.format(self.filename.trim_matches('/'))
|
|
.to_string();
|
|
|
|
// init post filepath
|
|
let mut f = PathBuf::from(&p);
|
|
f.push(&id);
|
|
|
|
// init storage directory for the post state file and attachments
|
|
// * by the current implementation, its name starts with dot
|
|
// as usually hidden but accessible (in servers like Nexy)
|
|
let mut d = PathBuf::from(&p);
|
|
d.push(format!(".{id}"));
|
|
fs::create_dir_all(&d)?;
|
|
|
|
// create system meta file to track post time updated
|
|
// if this meta file exists and has timestamp changed, also cleanup attachment files to update
|
|
let mut s = PathBuf::from(&d);
|
|
s.push(".state");
|
|
let timestamp = updated.unwrap_or(published);
|
|
let state = timestamp.to_string();
|
|
if !fs::read_to_string(&s).is_ok_and(|this| this == state) {
|
|
fs::remove_dir_all(&d)?;
|
|
fs::create_dir_all(&d)?;
|
|
self.persist(&s, ×tamp, state.as_bytes())?;
|
|
} else {
|
|
if let Some(ref mut i) = index {
|
|
if let Some(ref a) = attachments {
|
|
for (n, (_, _, source)) in a.iter().enumerate() {
|
|
match source {
|
|
Source::File(f) => i.push(attachment::filepath(&d, f, n)),
|
|
_ => continue,
|
|
}
|
|
}
|
|
}
|
|
i.extend([d, f, p, s])
|
|
}
|
|
return Ok(Sync {
|
|
change: Change::Ignored,
|
|
keep: index,
|
|
});
|
|
}
|
|
|
|
// save post file, set its change status
|
|
let change = self.persist(
|
|
&f,
|
|
×tamp,
|
|
self.template
|
|
.build(
|
|
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,
|
|
if self.is_filesystem_sync {
|
|
Some(timestamp.into())
|
|
} else {
|
|
None
|
|
},
|
|
self.file_permissions.clone(),
|
|
)
|
|
.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
|
|
}),
|
|
)
|
|
.as_bytes(),
|
|
)?;
|
|
// move all paths processed to cleanup ignore
|
|
if let Some(ref mut i) = index {
|
|
i.extend([d, f, p, s]);
|
|
}
|
|
|
|
Ok(Sync {
|
|
change,
|
|
keep: index,
|
|
})
|
|
}
|
|
|
|
pub fn clean(&self, name: &str, ignore: HashSet<PathBuf>) -> Result<Clean> {
|
|
if self.is_debug {
|
|
println!("\t\tcleanup...");
|
|
}
|
|
let mut r = Clean::default();
|
|
for entry in
|
|
walkdir::WalkDir::new(PathBuf::from(self.users.get(name).unwrap())).follow_links(false)
|
|
{
|
|
let e = entry?;
|
|
let p = e.path();
|
|
let s = p.to_string_lossy();
|
|
if self.is_debug {
|
|
println!("\t\tcheck `{s}`...");
|
|
}
|
|
if ignore.contains(p) {
|
|
continue;
|
|
}
|
|
let m = fs::metadata(p)?;
|
|
if m.is_file() {
|
|
fs::remove_file(p)?;
|
|
r.files += 1;
|
|
if self.is_debug {
|
|
println!("\t\t\tdelete file `{s}`");
|
|
}
|
|
} else if m.is_dir() {
|
|
fs::remove_dir_all(p)?;
|
|
r.directories += 1;
|
|
if self.is_debug {
|
|
println!("\t\t\tdelete directory `{s}`");
|
|
}
|
|
} else {
|
|
panic!()
|
|
}
|
|
}
|
|
Ok(r)
|
|
}
|
|
|
|
pub fn is_attachments_disabled(&self) -> bool {
|
|
matches!(self.attachment, Attachment::Disabled)
|
|
}
|
|
|
|
pub fn is_cleanup(&self) -> bool {
|
|
self.is_cleanup
|
|
}
|
|
|
|
/// Wrapper function to change time updated for new file, according to the Snac time.
|
|
fn persist(&self, path: &PathBuf, modified: &DateTime<Utc>, data: &[u8]) -> Result<Change> {
|
|
use std::io::Write;
|
|
let status = if fs::exists(path)? {
|
|
Change::Updated
|
|
} else {
|
|
Change::Created
|
|
};
|
|
let mut f = fs::File::create(path)?;
|
|
f.write_all(data)?;
|
|
if self.is_filesystem_sync {
|
|
f.set_modified((*modified).into())?; // it's important to call after write
|
|
}
|
|
if let Some(ref p) = self.file_permissions {
|
|
f.set_permissions(p.clone())?
|
|
}
|
|
Ok(status)
|
|
}
|
|
}
|