flarumdown/src/main.rs
2026-03-19 19:40:20 +02:00

286 lines
9.5 KiB
Rust

mod config;
mod database;
use anyhow::Result;
use chrono::{DateTime, Local, Utc};
use clap::Parser;
use config::Config;
use database::Database;
use html_to_markdown_rs::convert;
use log::*;
use regex::{Captures, Regex};
use std::{
collections::{HashMap, HashSet},
env::var,
fs::{File, copy, create_dir_all, remove_dir_all},
io::Write,
path::PathBuf,
};
pub struct User {
pub username: String,
}
pub struct Post {
pub id: i64,
pub user_id: i64,
pub created_at: DateTime<Utc>,
pub edited_at: Option<DateTime<Utc>>,
pub content: String,
}
pub struct Discussion {
pub id: i64,
pub created_at: DateTime<Utc>,
pub title: String,
pub slug: String,
pub posts: Vec<Post>,
}
fn main() -> Result<()> {
if var("RUST_LOG").is_ok() {
use tracing_subscriber::{EnvFilter, fmt::*};
struct T;
impl time::FormatTime for T {
fn format_time(&self, w: &mut format::Writer<'_>) -> std::fmt::Result {
write!(w, "{}", Local::now())
}
}
fmt()
.with_timer(T)
.with_env_filter(EnvFilter::from_default_env())
.init()
}
let config = Config::parse();
if config.target.exists() {
remove_dir_all(&config.target)?
}
create_dir_all(&config.target)?;
let mut db = Database::connect(config.source)?;
let mut users = HashMap::new();
for user in db.users()? {
assert!(
users
.insert(
user.id,
User {
username: user.username,
}
)
.is_none()
)
}
let mut tags = HashMap::new();
for tag in db.tags()? {
if !config.filter_tag.is_empty() && !config.filter_tag.contains(&tag.slug) {
continue;
}
assert!(tags.insert(tag.id, tag.slug).is_none())
}
let mut discussions = Vec::new();
for discussion in db.discussions()? {
if !db
.discussion_tag_ids(discussion.id)?
.iter()
.any(|id| tags.contains_key(id))
{
continue;
}
assert!(users.contains_key(&discussion.user_id));
let mut posts = Vec::new();
for post in db.posts(discussion.id)? {
posts.push(Post {
id: post.id,
user_id: post.user_id,
created_at: post.created_at,
edited_at: post.edited_at,
content: post.content,
})
}
assert_eq!(discussion.first_post_id, posts.first().unwrap().id);
discussions.push(Discussion {
id: discussion.id,
created_at: discussion.created_at,
title: discussion.title,
slug: discussion.slug,
posts,
})
}
if let Some(index) = config.index {
let mut file = File::create_new({
let mut path = PathBuf::from(&config.target);
path.push(format!("{}.md", index.trim_end_matches(".md")));
path
})?;
for discussion in &discussions {
file.write_all(
format!(
"* [{}]({}.md)\n",
discussion
.title
.replace("[", "\\[")
.replace("]", "\\]")
.replace("(", "\\(")
.replace(")", "\\)"),
discussion.id
)
.as_bytes(),
)?;
}
let mut footer = Vec::new();
footer.push("\n---\n".into());
footer.push(format!("Generated at {}\n", Utc::now()));
for refer in &config.refer {
footer.push(format!("* {refer}"));
}
file.write_all(footer.join("\n").as_bytes())?
}
for discussion in &discussions {
let mut file = File::create_new({
let mut path = PathBuf::from(&config.target);
path.push(format!("{}.md", discussion.id));
path
})?;
file.write_all(
{
let mut page = Vec::new();
page.push(format!("# {}\n", discussion.title.trim()));
page.push({
let mut content = Vec::new();
for post in &discussion.posts {
content.push(format!(
"_@{} / {}{}_\n",
users.get(&post.user_id).unwrap().username,
post.created_at,
post.edited_at
.map(|edited_at| format!(" / {}", edited_at))
.unwrap_or_default()
));
let mut uploads = HashSet::new();
content.push({
let mut post = post_format(&convert(
pre_format(&post.content, &mut uploads).trim(),
None,
)?);
for d in &discussions {
post = post
.replace(
&format!("](/d/{}-{})", d.id, d.slug),
&format!("]({}.md)", d.id),
)
.replace(
&format!("](d/{}-{})", d.id, d.slug),
&format!("]({}.md)", d.id),
)
.replace(
&format!("]({}-{})", d.id, d.slug),
&format!("]({}.md)", d.id),
)
.replace(&format!("]({})", d.id), &format!("]({}.md)", d.id))
}
post
});
for upload in &uploads {
let path_source = {
let mut p = PathBuf::from(&config.upload);
p.push(upload);
p
};
let path_target = {
let mut p = PathBuf::from(&config.target);
p.push(upload);
p
};
let path_parent = path_target.parent().unwrap();
create_dir_all(path_parent)?;
if !path_target.exists() {
if path_source.exists() {
copy(path_source, path_target)?;
} else {
warn!(
"Source file does not exist: `{}`",
path_source.to_string_lossy()
)
}
}
}
content.push("---\n".into())
}
content.push(format!("Generated at {}\n", Utc::now()));
for refer in &config.refer {
content.push(format!(
"* {}/d/{}",
refer.trim_end_matches("/"),
discussion.id
));
}
content.join("\n")
});
page.join("\n")
}
.as_bytes(),
)?
}
Ok(())
}
fn pre_format(data: &str, uploads: &mut HashSet<PathBuf>) -> String {
Regex::new(r"<e>[^<]+</e>")
.unwrap()
.replace_all(
&Regex::new(r"<s>[^<]+</s>").unwrap().replace_all(
&Regex::new(r"(?s)<UPL-IMAGE-PREVIEW([^>]+)>\[[^\]]+\]</UPL-IMAGE-PREVIEW>")
.unwrap()
.replace_all(data, |c: &Captures| {
uploads.insert(
Regex::new(r#"url="([^"]+)""#)
.unwrap()
.captures(&c[1])
.unwrap()[1]
.trim_start_matches("/")
.replace("d/", "")
.into(),
);
format!(
"<img{}>",
c[1].replace(" url=\"/", " src=\"")
.replace(" url=\"d/", " src=\"")
.replace(" url=", " src=")
)
}),
"",
),
"",
)
.replace("<C", "<code")
.replace("</C>", "</code>")
.replace("<QUOTE", "<blockquote")
.replace("</QUOTE>", "</blockquote>")
.replace("<LIST", "<ul")
.replace("</LIST>", "</ul>")
.replace("<URL", "<a")
.replace("</URL>", "</a>")
.replace(" url=\"/", " href=\"")
.replace(" url=\"d/", " href=\"")
.replace(" url=", " href=")
.replace("<r>", "")
.replace("</r>", "")
}
fn post_format(data: &str) -> String {
Regex::new(r"\n{3,}")
.unwrap()
.replace(data, "\n\n")
.to_string()
}