commit 63f53528dc4e4e982a142cadd90455eff566f8a3 Author: postscriptum Date: Tue Jul 1 17:39:30 2025 +0300 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..869df07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4dc61b0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "snac2nex" +version = "0.1.0" +edition = "2024" +license = "MIT" +readme = "README.md" +description = "Export Snac profile to the Nex format" +keywords = ["snac", "fediverse", "nex", "nex-protocol", "export"] +categories = ["network-programming"] +repository = "https://codeberg.org/postscriptum/snac2nex" + +[dependencies] +anyhow = "1.0" +chrono = "^0.4.20" +clap = { version = "4.5", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0205824 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 https://codeberg.org/postscriptum/snac2nex + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9496fb3 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# snac2nex + +[![Dependencies](https://deps.rs/repo/github/YGGverse/snac2nex/status.svg)](https://deps.rs/repo/github/YGGverse/snac2nex) +[![crates.io](https://img.shields.io/crates/v/snac2nex.svg)](https://crates.io/crates/snac2nex) + +Export [Snac](https://codeberg.org/grunfink/snac2) profile to the [Nex](https://nightfall.city/nex/info/specification.txt) format. +Useful as a mirroring tool for an existing fediverse instance or for migrating the data once. + +## Install + +``` bash +cargo install snac2nex +``` + +## Usage + +``` bash +snac2nex -s /path/to/snac/storage -t /path/to/nex -u user1 -u user2 +``` + +### Options + +``` bash +-s, --source + Path to the Snac2 profile directory + +-t, --target + Target directory for public data export + +-u, --user + Username to export + +-r, --rotate + Keep running as the daemon, renew every `n` seconds + +-f, --format-content + Post template pattern + + [default: {content}{attachments}{link}{tags}{updated}] + + --format-filename + Post filenames format + + * escaped with `%%` + + [default: %H:%M:%S.txt] + + --format-updated + Post `{updated}` time format + + * escaped with `%%` + + [default: "%Y/%m/%d %H:%M:%S"] + +-h, --help + Print help (see a summary with '-h') + +-V, --version + Print version +``` \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..0802428 --- /dev/null +++ b/src/config.rs @@ -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, + + /// Keep running as the daemon, renew every `n` seconds + #[arg(short, long)] + pub rotate: Option, + + /// 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, +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..8990d2f --- /dev/null +++ b/src/main.rs @@ -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)) +} diff --git a/src/nex.rs b/src/nex.rs new file mode 100644 index 0000000..bc9350d --- /dev/null +++ b/src/nex.rs @@ -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, +} + +impl Nex { + pub fn init( + target_dir: String, + filename: String, + time_format: String, + pattern: String, + user_names: &Vec, + ) -> Result { + // 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>, + tags: Option>, + (published, updated): (DateTime, Option>), + ) -> 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(()) + } +} diff --git a/src/snac.rs b/src/snac.rs new file mode 100644 index 0000000..a3fb8e4 --- /dev/null +++ b/src/snac.rs @@ -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, +} + +impl Snac { + pub fn init(storage_dir: String, user_names: Vec) -> Result { + 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 }) + } +} diff --git a/src/snac/user.rs b/src/snac/user.rs new file mode 100644 index 0000000..2e70678 --- /dev/null +++ b/src/snac/user.rs @@ -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 { + Ok(Self { + public: init(storage, &name, "public")?, + name, + }) + } + + pub fn public(&self) -> Result> { + 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 { + 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) +} diff --git a/src/snac/user/public.rs b/src/snac/user/public.rs new file mode 100644 index 0000000..ab90e5d --- /dev/null +++ b/src/snac/user/public.rs @@ -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>, + pub id: String, + #[serde(default, deserialize_with = "time")] + pub published: DateTime, + pub source_content: Option, + pub tag: Option>, + #[serde(default, deserialize_with = "time_option")] + pub updated: Option>, + pub url: String, +} + +fn time<'de, D: serde::Deserializer<'de>>(d: D) -> Result, 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>, D::Error> { + let s: Option = Option::deserialize(d)?; + match s { + Some(ref t) => DateTime::from_str(t).map(Some).map_err(Error::custom), + None => Ok(None), + } +}