mirror of
https://codeberg.org/postscriptum/snac2nex.git
synced 2026-03-31 21:25:28 +00:00
initial commit
This commit is contained in:
commit
63f53528dc
10 changed files with 443 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
Cargo.lock
|
||||||
17
Cargo.toml
Normal file
17
Cargo.toml
Normal file
|
|
@ -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"
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -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.
|
||||||
60
README.md
Normal file
60
README.md
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# snac2nex
|
||||||
|
|
||||||
|
[](https://deps.rs/repo/github/YGGverse/snac2nex)
|
||||||
|
[](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 <SOURCE>
|
||||||
|
Path to the Snac2 profile directory
|
||||||
|
|
||||||
|
-t, --target <TARGET>
|
||||||
|
Target directory for public data export
|
||||||
|
|
||||||
|
-u, --user <USER>
|
||||||
|
Username to export
|
||||||
|
|
||||||
|
-r, --rotate <ROTATE>
|
||||||
|
Keep running as the daemon, renew every `n` seconds
|
||||||
|
|
||||||
|
-f, --format-content <FORMAT_CONTENT>
|
||||||
|
Post template pattern
|
||||||
|
|
||||||
|
[default: {content}{attachments}{link}{tags}{updated}]
|
||||||
|
|
||||||
|
--format-filename <FORMAT_FILENAME>
|
||||||
|
Post filenames format
|
||||||
|
|
||||||
|
* escaped with `%%`
|
||||||
|
|
||||||
|
[default: %H:%M:%S.txt]
|
||||||
|
|
||||||
|
--format-updated <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
|
||||||
|
```
|
||||||
37
src/config.rs
Normal file
37
src/config.rs
Normal 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
76
src/main.rs
Normal 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
110
src/nex.rs
Normal 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
25
src/snac.rs
Normal 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
52
src/snac/user.rs
Normal 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
43
src/snac/user/public.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue