initial commit

This commit is contained in:
postscriptum 2025-07-01 17:39:30 +03:00
commit 63f53528dc
10 changed files with 443 additions and 0 deletions

37
src/config.rs Normal file
View file

@ -0,0 +1,37 @@
use clap::Parser;
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct Config {
/// Path to the Snac2 profile directory
#[arg(short, long)]
pub source: String,
/// Target directory for public data export
#[arg(short, long)]
pub target: String,
/// Username to export
#[arg(short, long)]
pub user: Vec<String>,
/// Keep running as the daemon, renew every `n` seconds
#[arg(short, long)]
pub rotate: Option<u64>,
/// Post template pattern
#[arg(short, long, default_value_t = String::from("{content}{attachments}{link}{tags}{updated}"))]
pub format_content: String,
/// Post filenames format
///
/// * escaped with `%%`
#[arg(long, default_value_t = String::from("%H:%M:%S.txt"))]
pub format_filename: String,
/// Post `{updated}` time format
///
/// * escaped with `%%`
#[arg(long, default_value_t = String::from("%Y/%m/%d %H:%M:%S"))]
pub format_updated: String,
}

76
src/main.rs Normal file
View file

@ -0,0 +1,76 @@
mod config;
mod nex;
mod snac;
use anyhow::Result;
use nex::Nex;
use snac::Snac;
fn main() -> Result<()> {
use clap::Parser;
use config::Config;
let c = Config::parse();
let n = Nex::init(
c.target,
c.format_filename,
c.format_updated,
c.format_content,
&c.user,
)?;
let s = Snac::init(c.source, c.user)?;
println!("export begin...");
let (mut u, mut t) = sync(&s, &n)?;
match c.rotate {
Some(r) => loop {
println!("queue completed (updated: {u} / total: {t}), await {r} seconds to rotate...");
std::thread::sleep(std::time::Duration::from_secs(r));
(u, t) = sync(&s, &n)?;
},
None => println!("export completed (updated: {u} / total: {t})."),
}
Ok(())
}
fn sync(snac: &Snac, nex: &Nex) -> Result<(usize, usize)> {
let mut t = 0; // total
let mut u = 0; // updated
for user in &snac.users {
println!("\tsync profile for `{}`...", user.name);
for post in user.public()? {
t += 1;
// skip non authorized content
if let Some(content) = post.source_content {
println!("\t\tsync post `{}`...", post.id);
nex.sync(
&user.name,
content,
post.url,
post.attachment.map(|a| {
let mut attachments = Vec::with_capacity(a.len());
for attachment in a {
attachments.push((
attachment.name,
attachment.media_type,
attachment.url,
))
}
attachments
}),
post.tag.map(|t| {
let mut tags = Vec::with_capacity(t.len());
for tag in t {
tags.push(tag.name)
}
tags
}),
(post.published, post.updated),
)?;
u += 1;
}
}
}
Ok((t, u))
}

110
src/nex.rs Normal file
View file

@ -0,0 +1,110 @@
use anyhow::{Result, bail};
use chrono::{DateTime, Utc};
use std::{collections::HashMap, path::PathBuf, str::FromStr};
pub struct Nex {
filename: String,
pattern: String,
time_format: String,
users: HashMap<String, PathBuf>,
}
impl Nex {
pub fn init(
target_dir: String,
filename: String,
time_format: String,
pattern: String,
user_names: &Vec<String>,
) -> Result<Self> {
// init data export location
let target = PathBuf::from_str(&target_dir)?.canonicalize()?;
if !target.is_dir() {
bail!("Target location is not directory!");
}
// init locations for each user
let mut users = HashMap::with_capacity(user_names.len());
for u in user_names {
let mut p = PathBuf::from(&target);
p.push(u);
std::fs::create_dir_all(&p)?;
users.insert(u.clone(), p);
}
Ok(Self {
filename,
time_format,
pattern,
users,
})
}
pub fn sync(
&self,
name: &str,
content: String,
link: String,
attachments: Option<Vec<(String, String, String)>>,
tags: Option<Vec<String>>,
(published, updated): (DateTime<Utc>, Option<DateTime<Utc>>),
) -> Result<()> {
// 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 (name, media_type, link) in a {
let mut t = Vec::with_capacity(3);
t.push(format!("=> {link}"));
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(),
);
// prepare destination
let mut p = PathBuf::from(self.users.get(name).unwrap());
p.push(published.format("%Y").to_string());
p.push(published.format("%m").to_string());
p.push(published.format("%d").to_string());
std::fs::create_dir_all(&p)?;
// write the data
p.push(published.format(&self.filename).to_string());
std::fs::write(p, c)?; // @TODO skip overwrite operations
Ok(())
}
}

25
src/snac.rs Normal file
View file

@ -0,0 +1,25 @@
mod user;
use anyhow::{Result, bail};
use std::{path::PathBuf, str::FromStr};
use user::User;
pub struct Snac {
pub users: Vec<User>,
}
impl Snac {
pub fn init(storage_dir: String, user_names: Vec<String>) -> Result<Self> {
let storage = PathBuf::from_str(&storage_dir)?.canonicalize()?;
if !storage.is_dir() {
bail!("Target location is not directory!");
}
let mut users = Vec::with_capacity(user_names.len());
for name in user_names {
users.push(User::init(&storage, name)?)
}
Ok(Self { users })
}
}

52
src/snac/user.rs Normal file
View file

@ -0,0 +1,52 @@
mod public;
use anyhow::{Result, bail};
use public::Post;
use std::path::PathBuf;
pub struct User {
pub name: String,
pub public: PathBuf,
}
impl User {
pub fn init(storage: &PathBuf, name: String) -> Result<Self> {
Ok(Self {
public: init(storage, &name, "public")?,
name,
})
}
pub fn public(&self) -> Result<Vec<Post>> {
use std::{
fs::{File, read_dir},
io::BufReader,
};
let entries = read_dir(&self.public)?;
let mut posts = Vec::with_capacity(100); // @TODO
for entry in entries {
let e = entry?;
if !e.file_type()?.is_file() {
continue;
}
let post: Post = serde_json::from_reader(BufReader::new(File::open(e.path())?))?;
posts.push(post)
}
Ok(posts)
}
}
fn init(storage: &PathBuf, name: &str, target: &str) -> Result<PathBuf> {
let mut p = PathBuf::from(&storage);
p.push("user");
p.push(name);
p.push(target);
if !p.exists() || !p.is_dir() {
bail!("User data location `{}` not found!", p.to_string_lossy());
}
Ok(p)
}

43
src/snac/user/public.rs Normal file
View file

@ -0,0 +1,43 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, de::Error};
use std::str::FromStr;
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Attachment {
pub media_type: String,
pub url: String,
pub name: String,
}
#[derive(Deserialize, Debug)]
pub struct Tag {
pub name: String,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Post {
pub attachment: Option<Vec<Attachment>>,
pub id: String,
#[serde(default, deserialize_with = "time")]
pub published: DateTime<Utc>,
pub source_content: Option<String>,
pub tag: Option<Vec<Tag>>,
#[serde(default, deserialize_with = "time_option")]
pub updated: Option<DateTime<Utc>>,
pub url: String,
}
fn time<'de, D: serde::Deserializer<'de>>(d: D) -> Result<DateTime<Utc>, D::Error> {
let s = String::deserialize(d)?;
DateTime::from_str(&s).map_err(Error::custom)
}
fn time_option<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<DateTime<Utc>>, D::Error> {
let s: Option<String> = Option::deserialize(d)?;
match s {
Some(ref t) => DateTime::from_str(t).map(Some).map_err(Error::custom),
None => Ok(None),
}
}