snac2nex/src/nex.rs

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, &timestamp, 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,
&timestamp,
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)
}
}