203 lines
6.3 KiB
Rust
203 lines
6.3 KiB
Rust
|
use jiff::tz::TimeZone;
|
||
|
use jiff::Timestamp;
|
||
|
use pleroma::Activities;
|
||
|
use serde::{Deserialize, Serialize};
|
||
|
use std::sync::OnceLock;
|
||
|
use std::{
|
||
|
collections::HashMap,
|
||
|
env, fmt,
|
||
|
fs::File,
|
||
|
io::{self, BufReader, BufWriter},
|
||
|
path::{Path, PathBuf},
|
||
|
process::ExitCode,
|
||
|
};
|
||
|
use url::Url;
|
||
|
|
||
|
mod pleroma;
|
||
|
|
||
|
type BoxError = Box<dyn std::error::Error>;
|
||
|
type Mappings = HashMap<String, Option<Url>>;
|
||
|
|
||
|
static TZ: OnceLock<TimeZone> = OnceLock::new();
|
||
|
static MAPPINGS: OnceLock<Mappings> = OnceLock::new();
|
||
|
|
||
|
fn main() -> ExitCode {
|
||
|
let Some(path) = env::args_os().skip(1).next().map(PathBuf::from) else {
|
||
|
eprintln!("Usage: pleroma-archive path/to/pleroma-archive");
|
||
|
return ExitCode::FAILURE;
|
||
|
};
|
||
|
|
||
|
let timezone = TimeZone::system();
|
||
|
TZ.set(timezone).unwrap();
|
||
|
|
||
|
match try_main(&path) {
|
||
|
Ok(()) => ExitCode::SUCCESS,
|
||
|
Err(err) => {
|
||
|
eprintln!("Error: {err}");
|
||
|
ExitCode::FAILURE
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
fn try_main(path: &Path) -> Result<(), BoxError> {
|
||
|
let actor_path = path.join("actor.json");
|
||
|
let outbox_path = path.join("outbox.json");
|
||
|
let mappings_path = path.join("mappings.json");
|
||
|
|
||
|
let file = BufReader::new(File::open(&actor_path)?);
|
||
|
let actor: pleroma::Actor = serde_json::from_reader(file)?;
|
||
|
|
||
|
let file = BufReader::new(File::open(&outbox_path)?);
|
||
|
let activities: Activities = serde_json::from_reader(file)?;
|
||
|
|
||
|
// Load mappings of ids to public URLs
|
||
|
let mut mappings: Mappings = match File::open(&mappings_path) {
|
||
|
Ok(file) => serde_json::from_reader(BufReader::new(file))?,
|
||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => HashMap::new(),
|
||
|
Err(err) => return Err(err.into()),
|
||
|
};
|
||
|
|
||
|
let agent = ureq::AgentBuilder::new().redirects(0).build();
|
||
|
|
||
|
let mut posts = Vec::with_capacity(activities.ordered_items.len());
|
||
|
for item in &activities.ordered_items {
|
||
|
match &item.object {
|
||
|
pleroma::activity::ObjectUnion::ObjectClass(activity) => {
|
||
|
let id: Url = item.id.parse()?;
|
||
|
if !mappings.contains_key(id.as_str()) {
|
||
|
let response = match agent.head(id.as_str()).call() {
|
||
|
Ok(res) => res,
|
||
|
Err(ureq::Error::Status(status, _res)) => {
|
||
|
eprintln!("expected 3xx response, got {} for {}", status, id);
|
||
|
mappings.insert(item.id.clone(), None);
|
||
|
continue;
|
||
|
}
|
||
|
Err(err) => return Err(err.into()),
|
||
|
};
|
||
|
if !(300..400).contains(&response.status()) {
|
||
|
eprintln!(
|
||
|
"expected 3xx response, got {} for {}",
|
||
|
response.status(),
|
||
|
id
|
||
|
);
|
||
|
mappings.insert(item.id.clone(), None);
|
||
|
continue;
|
||
|
}
|
||
|
let Some(location) = response.header("location") else {
|
||
|
return Err("expected a Location header, but it's missing".into());
|
||
|
};
|
||
|
|
||
|
let url = id.join(location)?;
|
||
|
mappings.insert(item.id.clone(), Some(url));
|
||
|
}
|
||
|
|
||
|
posts.push(activity);
|
||
|
}
|
||
|
pleroma::activity::ObjectUnion::String(s) => {
|
||
|
eprintln!("ObjectUnion::String: {s}");
|
||
|
// TODO
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
let mappings_writer = BufWriter::new(File::create(&mappings_path)?);
|
||
|
serde_json::to_writer_pretty(mappings_writer, &mappings)?;
|
||
|
|
||
|
MAPPINGS.set(mappings).unwrap();
|
||
|
|
||
|
// dbg!(&posts);
|
||
|
|
||
|
println!(
|
||
|
"{}",
|
||
|
Page {
|
||
|
title: "Pleroma Archive",
|
||
|
actor: &actor,
|
||
|
activities: &posts,
|
||
|
}
|
||
|
);
|
||
|
|
||
|
Ok(())
|
||
|
}
|
||
|
|
||
|
markup::define! {
|
||
|
Page<'a>(title: &'a str, actor: &'a pleroma::Actor, activities: &'a [&'a pleroma::Activity]) {
|
||
|
@markup::doctype()
|
||
|
html {
|
||
|
head {
|
||
|
meta[charset="utf-8"];
|
||
|
meta[name="viewport", content="width=device-width, initial-scale=1"];
|
||
|
title { @title }
|
||
|
link[rel="stylesheet", type="text/css", href="style.css"];
|
||
|
}
|
||
|
body {
|
||
|
@Header { title }
|
||
|
main {
|
||
|
@for activity in *activities {
|
||
|
@Activity { actor, activity }
|
||
|
}
|
||
|
}
|
||
|
@Footer { }
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
Header<'a>(title: &'a str) {
|
||
|
header {
|
||
|
h1 { @title }
|
||
|
}
|
||
|
}
|
||
|
|
||
|
Footer() {
|
||
|
footer {
|
||
|
"Generated by "
|
||
|
a[href="https://forge.wezm.net/wezm/pleroma-archive"] { "pleroma-archive" }
|
||
|
}
|
||
|
}
|
||
|
|
||
|
Activity<'a>(actor: &'a pleroma::Actor, activity: &'a pleroma::Activity) {
|
||
|
div[class=activity_class(&activity.object_type)] {
|
||
|
@Actor { actor }
|
||
|
|
||
|
div[class="activity-content"] {
|
||
|
a[href=MAPPINGS.get().unwrap().get(&activity.id).and_then(|url| url.as_ref().map(|url| url.as_str()))] {
|
||
|
time[datetime=&activity.published] { @activity.human_published() }
|
||
|
}
|
||
|
|
||
|
@if let Some(in_reply_to) = &activity.in_reply_to {
|
||
|
a[href=in_reply_to, class="activity-reply-to"] { "↩ reply to" }
|
||
|
" "
|
||
|
}
|
||
|
@markup::raw(&activity.content)
|
||
|
}
|
||
|
}
|
||
|
hr;
|
||
|
}
|
||
|
|
||
|
Actor<'a>(actor: &'a pleroma::Actor) {
|
||
|
@if let Some(icon) = &actor.icon {
|
||
|
@if icon.icon_type == "Image" {
|
||
|
img[src=&icon.url, alt=&actor.preferred_username, class="actor-icon"];
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
fn activity_class(object_type: &pleroma::activity::OneOfType) -> &'static str {
|
||
|
match object_type {
|
||
|
pleroma::activity::OneOfType::Note => "activity activity-note",
|
||
|
pleroma::activity::OneOfType::Question => "activity activity-question",
|
||
|
}
|
||
|
}
|
||
|
|
||
|
impl pleroma::Activity {
|
||
|
fn human_published(&self) -> String {
|
||
|
let published = self
|
||
|
.published
|
||
|
.parse()
|
||
|
.map(|timestamp: Timestamp| timestamp.to_zoned(TZ.get().unwrap().clone()))
|
||
|
.expect("invalid published value");
|
||
|
|
||
|
published.strftime("%d %b %Y").to_string()
|
||
|
}
|
||
|
}
|