>())
251 | }
252 |
253 | fn query_single_column(
254 | conn: &Connection,
255 | sql: &str,
256 | params: P,
257 | ) -> Result, rusqlite::Error>
258 | where
259 | P: rusqlite::Params,
260 | {
261 | let mut stmt = conn.prepare_cached(sql)?;
262 | let result = stmt.query_map(params, |r| r.get(0))?.collect();
263 | result
264 | }
265 |
266 | fn query_count(conn: &Connection, sql: &str, params: P) -> Result
267 | where
268 | P: rusqlite::Params,
269 | {
270 | let mut stmt = conn.prepare_cached(sql)?;
271 | // todo: wow, this is ugly. find a more elegant way to extract count from rows?
272 | let count = stmt
273 | .query(params)?
274 | .next()?
275 | .ok_or(anyhow!("no count returned"))?
276 | .get(0)?;
277 | Ok(count)
278 | }
279 |
280 | fn query_activities(conn: &Connection, sql: &str, params: P) -> Result>
281 | where
282 | P: rusqlite::Params,
283 | {
284 | let mut stmt = conn.prepare_cached(sql)?;
285 | let result = stmt
286 | .query_and_then(params, |r| -> Result {
287 | let json_data: String = r.get(0)?;
288 | let schema_str: String = r.get(1)?;
289 | match schema_str.parse::()? {
290 | ActivitySchema::Activity => Ok(serde_json::from_str::(&json_data)?),
291 | ActivitySchema::Status => Ok(serde_json::from_str::(&json_data)?.into()),
292 | _ => Err(anyhow!("unknown schema {:?}", schema_str)),
293 | }
294 | })?
295 | .collect::>>();
296 | result
297 | }
298 |
299 | #[cfg(test)]
300 | mod tests {
301 | use super::*;
302 | use test_log::test;
303 |
304 | #[test]
305 | fn test_activityschema_serde() -> Result<()> {
306 | let cases = vec![
307 | (ActivitySchema::Activity, ACTIVITYSCHEMA_ACTIVITY),
308 | (ActivitySchema::Status, ACTIVITYSCHEMA_STATUS),
309 | (
310 | ActivitySchema::Unknown(String::from("lolbutts")),
311 | "lolbutts",
312 | ),
313 | ];
314 | for (expected_schema, expected_str) in cases {
315 | assert_eq!(expected_schema.to_string(), expected_str);
316 | let result_schema: ActivitySchema = expected_str.parse()?;
317 | assert_eq!(result_schema, expected_schema);
318 | }
319 | Ok(())
320 | }
321 | }
322 |
--------------------------------------------------------------------------------
/src/db/actors.rs:
--------------------------------------------------------------------------------
1 | use anyhow::Result;
2 | use rusqlite::{params, Connection};
3 | use serde::{Deserialize, Serialize};
4 | use std::collections::HashMap;
5 | use std::error::Error;
6 |
7 | use crate::activitystreams;
8 |
9 | pub struct Actors<'a> {
10 | conn: &'a Connection,
11 | }
12 |
13 | impl<'a> Actors<'a> {
14 | pub fn new(conn: &'a Connection) -> Self {
15 | Self { conn }
16 | }
17 |
18 | pub fn import_actor(&self, actor: T) -> Result<()> {
19 | // todo: throw an error if id is null?
20 | let json_text = serde_json::to_string_pretty(&actor)?;
21 | self.conn.execute(
22 | "INSERT OR REPLACE INTO actors (json) VALUES (?1)",
23 | params![json_text],
24 | )?;
25 | Ok(())
26 | }
27 |
28 | pub fn get_actor Deserialize<'de>>(
29 | &self,
30 | id: &String,
31 | ) -> Result> {
32 | let conn = &self.conn;
33 | let mut stmt = conn.prepare("SELECT json FROM actors WHERE id = ?")?;
34 | let json_text: String = stmt.query_row([id], |row| row.get(0))?;
35 | let actor: T = serde_json::from_str(json_text.as_str())?;
36 | Ok(actor)
37 | }
38 |
39 | pub fn get_actors Deserialize<'de>>(&self) -> Result> {
40 | let conn = &self.conn;
41 | // todo: fix actor import that results in null id? (i.e. failed request, error imported as "actor")
42 | let mut stmt = conn.prepare("SELECT json FROM actors WHERE id IS NOT NULL")?;
43 | let mut rows = stmt.query([])?;
44 | let mut out = Vec::new();
45 | while let Some(r) = rows.next()? {
46 | let json_text: String = r.get(0)?;
47 | let actor: T = serde_json::from_str(json_text.as_str())?;
48 | out.push(actor);
49 | }
50 | Ok(out)
51 | }
52 |
53 | pub fn get_actors_by_id(
54 | &self,
55 | ) -> Result, Box> {
56 | Ok(self
57 | .get_actors::()?
58 | .into_iter()
59 | .map(|actor| (actor.id.clone(), actor))
60 | .collect())
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/db/migrations/202306241304-init.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE activities (
2 | json TEXT,
3 | created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
4 |
5 | id TEXT GENERATED ALWAYS AS (json_extract(json, "$.id")) VIRTUAL UNIQUE,
6 | type TEXT GENERATED ALWAYS AS (json_extract(json, "$.type")) VIRTUAL,
7 | url TEXT GENERATED ALWAYS AS (json_extract(json, "$.object.url")) VIRTUAL,
8 | actorId TEXT GENERATED ALWAYS AS (json_extract(json, "$.actor")) VIRTUAL,
9 | summary TEXT GENERATED ALWAYS AS (json_extract(json, "$.object.summary")) VIRTUAL,
10 | content TEXT GENERATED ALWAYS AS (json_extract(json, "$.object.content")) VIRTUAL,
11 | published DATETIME GENERATED ALWAYS AS (json_extract(json, "$.published")) VIRTUAL,
12 | publishedYear TEXT GENERATED ALWAYS AS (strftime("%Y", json_extract(json, "$.published"))) VIRTUAL,
13 | publishedYearMonth TEXT GENERATED ALWAYS AS (strftime("%Y/%m", json_extract(json, "$.published"))) VIRTUAL,
14 | publishedYearMonthDay TEXT GENERATED ALWAYS AS (strftime("%Y/%m/%d", json_extract(json, "$.published"))) VIRTUAL
15 | )
16 |
--------------------------------------------------------------------------------
/src/db/migrations/202306261338-object-type-and-indexes-up.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE activities
2 | ADD COLUMN objectType TEXT GENERATED ALWAYS AS (json_extract(json, "$.object.type")) VIRTUAL;
3 |
4 | CREATE INDEX idx_activities_by_publishedYearMonthDay ON activities(publishedYearMonthDay);
5 | CREATE INDEX idx_activities_by_publishedYearMonth ON activities(publishedYearMonth);
6 | CREATE INDEX idx_activities_by_publishedYear ON activities(publishedYear);
--------------------------------------------------------------------------------
/src/db/migrations/202306262036-actors-up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE actors (
2 | json TEXT,
3 | created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
4 | id TEXT GENERATED ALWAYS AS (json_extract(json, "$.id")) VIRTUAL UNIQUE,
5 | type TEXT GENERATED ALWAYS AS (json_extract(json, "$.type")) VIRTUAL
6 | );
7 | CREATE INDEX idx_actors_by_id ON actors(id);
8 |
--------------------------------------------------------------------------------
/src/db/migrations/202307021314-ispublic-up.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE activities
2 | ADD COLUMN isPublic INTEGER
3 | GENERATED ALWAYS AS
4 | (
5 | like("%https://www.w3.org/ns/activitystreams#Public%", json_extract(json, "$.to")) or
6 | like("%https://www.w3.org/ns/activitystreams#Public%", json_extract(json, "$.cc"))
7 | )
8 | VIRTUAL;
9 |
10 | CREATE INDEX idx_activities_by_ispublic ON activities(isPublic);
11 |
--------------------------------------------------------------------------------
/src/db/migrations/202307021325-index-ispublic-up.sql:
--------------------------------------------------------------------------------
1 | CREATE INDEX idx_activities_by_publishedYearMonthDay_2 ON activities(publishedYearMonthDay, isPublic);
2 | CREATE INDEX idx_activities_by_publishedYearMonth_2 ON activities(publishedYearMonth, isPublic);
3 | CREATE INDEX idx_activities_by_publishedYear_2 ON activities(publishedYear, isPublic);
4 |
5 | DROP INDEX idx_activities_by_publishedYearMonthDay;
6 | DROP INDEX idx_activities_by_publishedYearMonth;
7 | DROP INDEX idx_activities_by_publishedYear;
8 |
--------------------------------------------------------------------------------
/src/db/migrations/202307191416-ingest-mastodon-statuses-up.sql:
--------------------------------------------------------------------------------
1 | -- drop generated columns not directly involved in lookup indexes, we can always re-add later
2 | ALTER TABLE activities DROP COLUMN type;
3 | ALTER TABLE activities DROP COLUMN url;
4 | ALTER TABLE activities DROP COLUMN actorId;
5 | ALTER TABLE activities DROP COLUMN summary;
6 | ALTER TABLE activities DROP COLUMN content;
7 | -- drop indexes, because we're going to redefine the dependent columns
8 | DROP INDEX idx_activities_by_ispublic;
9 | DROP INDEX idx_activities_by_publishedYearMonthDay_2;
10 | DROP INDEX idx_activities_by_publishedYearMonth_2;
11 | DROP INDEX idx_activities_by_publishedYear_2;
12 | -- drop the columns for published time, since we're going to redefine these
13 | ALTER TABLE activities DROP COLUMN isPublic;
14 | ALTER TABLE activities DROP COLUMN published;
15 | ALTER TABLE activities DROP COLUMN publishedYear;
16 | ALTER TABLE activities DROP COLUMN publishedYearMonth;
17 | ALTER TABLE activities DROP COLUMN publishedYearMonthDay;
18 | -- add revised generated columns to accomodate mastodon status JSON
19 | ALTER TABLE activities
20 | ADD COLUMN isPublic INTEGER GENERATED ALWAYS AS (
21 | json_extract(json, "$.visibility") == 'public'
22 | or like(
23 | '%https://www.w3.org/ns/activitystreams#Public%',
24 | json_extract(json, '$.to')
25 | )
26 | or like(
27 | '%https://www.w3.org/ns/activitystreams#Public%',
28 | json_extract(json, '$.cc')
29 | )
30 | ) VIRTUAL;
31 | ALTER TABLE activities
32 | ADD COLUMN published DATETIME GENERATED ALWAYS AS (
33 | coalesce(
34 | json_extract(json, "$.published"),
35 | json_extract(json, "$.created_at")
36 | )
37 | ) VIRTUAL;
38 | ALTER TABLE activities
39 | ADD COLUMN publishedYear DATETIME GENERATED ALWAYS AS (
40 | strftime(
41 | "%Y",
42 | coalesce(
43 | json_extract(json, "$.published"),
44 | json_extract(json, "$.created_at")
45 | )
46 | )
47 | ) VIRTUAL;
48 | ALTER TABLE activities
49 | ADD COLUMN publishedYearMonth DATETIME GENERATED ALWAYS AS (
50 | strftime(
51 | "%Y/%m",
52 | coalesce(
53 | json_extract(json, "$.published"),
54 | json_extract(json, "$.created_at")
55 | )
56 | )
57 | ) VIRTUAL;
58 | ALTER TABLE activities
59 | ADD COLUMN publishedYearMonthDay DATETIME GENERATED ALWAYS AS (
60 | strftime(
61 | "%Y/%m/%d",
62 | coalesce(
63 | json_extract(json, "$.published"),
64 | json_extract(json, "$.created_at")
65 | )
66 | )
67 | ) VIRTUAL;
68 | -- all JSON added until now should be an activitystreams Activity
69 | ALTER TABLE activities
70 | ADD COLUMN schema TEXT DEFAULT "fossilizer::activitystreams::Activity";
71 | -- re-add the lookup indexes
72 | CREATE INDEX idx_activities_by_ispublic ON activities(isPublic);
73 | CREATE INDEX idx_activities_by_publishedYearMonthDay ON activities(publishedYearMonthDay, isPublic);
74 | CREATE INDEX idx_activities_by_publishedYearMonth ON activities(publishedYearMonth, isPublic);
75 | CREATE INDEX idx_activities_by_publishedYear ON activities(publishedYear, isPublic);
--------------------------------------------------------------------------------
/src/db/migrations/202404140958-id-from-mastodon-status.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE new_activities (
2 | schema TEXT DEFAULT "fossilizer::activitystreams::Activity",
3 | json TEXT,
4 | created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
5 | -- try to munge together an id column from Mastodon status properties which matches activitypub export
6 | id TEXT GENERATED ALWAYS AS (
7 | iif(
8 | schema == "megalodon::entities::Status",
9 | iif(
10 | json_extract(json, '$.reblog') is not null,
11 | json_extract(json, '$.uri'),
12 | json_extract(json, '$.uri') || "/activity"
13 | ),
14 | json_extract(json, "$.id")
15 | )
16 | ) VIRTUAL UNIQUE,
17 | objectType TEXT GENERATED ALWAYS AS (
18 | json_extract(json, '$.object.type')
19 | ) VIRTUAL,
20 | isPublic INTEGER GENERATED ALWAYS AS (
21 | json_extract(json, "$.visibility") == 'public'
22 | or like(
23 | '%https://www.w3.org/ns/activitystreams#Public%',
24 | json_extract(json, '$.to')
25 | )
26 | or like(
27 | '%https://www.w3.org/ns/activitystreams#Public%',
28 | json_extract(json, '$.cc')
29 | )
30 | ) VIRTUAL,
31 | published DATETIME GENERATED ALWAYS AS (
32 | coalesce(
33 | json_extract(json, "$.published"),
34 | json_extract(json, "$.created_at")
35 | )
36 | ) VIRTUAL,
37 | publishedYear DATETIME GENERATED ALWAYS AS (
38 | strftime(
39 | "%Y",
40 | coalesce(
41 | json_extract(json, "$.published"),
42 | json_extract(json, "$.created_at")
43 | )
44 | )
45 | ) VIRTUAL,
46 | publishedYearMonth DATETIME GENERATED ALWAYS AS (
47 | strftime(
48 | "%Y/%m",
49 | coalesce(
50 | json_extract(json, "$.published"),
51 | json_extract(json, "$.created_at")
52 | )
53 | )
54 | ) VIRTUAL,
55 | publishedYearMonthDay DATETIME GENERATED ALWAYS AS (
56 | strftime(
57 | "%Y/%m/%d",
58 | coalesce(
59 | json_extract(json, "$.published"),
60 | json_extract(json, "$.created_at")
61 | )
62 | )
63 | ) VIRTUAL
64 | );
65 |
66 | INSERT OR REPLACE INTO new_activities(schema, json, created_at)
67 | SELECT schema, json, created_at FROM activities;
68 |
69 | DROP INDEX IF EXISTS idx_activities_by_ispublic;
70 | DROP INDEX IF EXISTS idx_activities_by_publishedYearMonthDay;
71 | DROP INDEX IF EXISTS idx_activities_by_publishedYearMonth;
72 | DROP INDEX IF EXISTS idx_activities_by_publishedYear;
73 |
74 | ALTER TABLE activities RENAME TO old_activities;
75 | ALTER TABLE new_activities RENAME TO activities;
76 |
77 | CREATE INDEX idx_activities_by_ispublic ON activities(isPublic);
78 | CREATE INDEX idx_activities_by_publishedYearMonthDay ON activities(publishedYearMonthDay, isPublic);
79 | CREATE INDEX idx_activities_by_publishedYearMonth ON activities(publishedYearMonth, isPublic);
80 | CREATE INDEX idx_activities_by_publishedYear ON activities(publishedYear, isPublic);
81 |
82 | -- DROP TABLE old_activities;
--------------------------------------------------------------------------------
/src/db/migrations/202404141112-drop-old-activities.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE old_activities;
--------------------------------------------------------------------------------
/src/downloader.rs:
--------------------------------------------------------------------------------
1 | use anyhow::{anyhow, Result};
2 | use serde::{Deserialize, Serialize};
3 | use std::collections::VecDeque;
4 | use std::fs;
5 | use std::path::PathBuf;
6 | use std::sync::{Arc, Mutex};
7 | use std::{
8 | fs::File,
9 | io::{copy, Cursor},
10 | };
11 | use tokio::sync::Notify;
12 | use tokio::task::JoinSet;
13 | use url::Url;
14 |
15 | static DEFAULT_CONCURRENCY: usize = 4;
16 |
17 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18 | #[serde(rename_all = "camelCase")]
19 | pub struct DownloadTask {
20 | pub url: Url,
21 | pub destination: PathBuf,
22 | }
23 |
24 | impl DownloadTask {
25 | async fn execute(self) -> Result {
26 | // todo: download with progress narration? https://gist.github.com/giuliano-oliveira/4d11d6b3bb003dba3a1b53f43d81b30d
27 | let client = reqwest::ClientBuilder::new().build().unwrap();
28 | let response = client.get(self.url.clone()).send().await?;
29 |
30 | let file_parent_path = self.destination.parent().ok_or(anyhow!("no parent path"))?;
31 | fs::create_dir_all(file_parent_path)?;
32 |
33 | let mut file = File::create(&self.destination)?;
34 | let mut content = Cursor::new(response.bytes().await?);
35 |
36 | copy(&mut content, &mut file)?;
37 |
38 | Ok(self)
39 | }
40 | }
41 |
42 | pub struct Downloader {
43 | // todo: make concurrency adjustable during run via channels?
44 | pub concurrency: usize,
45 | tasks: Arc>>,
46 | new_task_notify: Arc,
47 | queue_closed: Arc,
48 | }
49 |
50 | impl Default for Downloader {
51 | fn default() -> Self {
52 | Self::new(DEFAULT_CONCURRENCY)
53 | }
54 | }
55 |
56 | impl Downloader {
57 | pub fn new(concurrency: usize) -> Self {
58 | Self {
59 | concurrency,
60 | tasks: Arc::new(Mutex::new(VecDeque::new())),
61 | new_task_notify: Arc::new(Notify::new()),
62 | queue_closed: Arc::new(Notify::new()),
63 | }
64 | }
65 |
66 | pub fn queue(&self, task: DownloadTask) -> Result<()> {
67 | let mut tasks = self.tasks.lock().unwrap();
68 | tasks.push_back(task);
69 | self.new_task_notify.notify_one();
70 | Ok(())
71 | }
72 |
73 | pub fn close(&self) -> Result<()> {
74 | self.queue_closed.notify_one();
75 | Ok(())
76 | }
77 |
78 | pub fn run(&self) -> tokio::task::JoinHandle> {
79 | let concurrency = self.concurrency;
80 | let tasks = self.tasks.clone();
81 | let new_task_notify = self.new_task_notify.clone();
82 | let queue_closed = self.queue_closed.clone();
83 |
84 | tokio::spawn(async move {
85 | let mut should_exit_when_empty = false;
86 | let mut workers = JoinSet::new();
87 | loop {
88 | // Check whether it's time to bail out when all known work is done
89 | {
90 | let tasks = tasks.lock().or(Err(anyhow!("failed to lock tasks")))?;
91 | if tasks.is_empty() && workers.is_empty() && should_exit_when_empty {
92 | trace!("Exiting after last task");
93 | break;
94 | }
95 | }
96 |
97 | // Fire up workers for available tasks up to concurrency limit
98 | loop {
99 | let mut tasks = tasks.lock().or(Err(anyhow!("failed to lock tasks")))?;
100 | if tasks.is_empty() || workers.len() >= concurrency {
101 | trace!(
102 | "Not spawning worker - tasks.is_empty = {}; workers.len() = {}",
103 | tasks.is_empty(),
104 | workers.len()
105 | );
106 | break;
107 | }
108 | if let Some(task) = tasks.pop_front() {
109 | trace!("Spawning worker for task - tasks.len() = {}; workers.len() = {} - {:?}", tasks.len(), workers.len(), task);
110 | workers.spawn(task.execute());
111 | }
112 | }
113 |
114 | // Wait for something important to happen...
115 | tokio::select! {
116 | // todo: report progress via some channel
117 | _ = workers.join_next(), if !workers.is_empty() => {
118 | trace!("Worker done - workers.len() = {}", workers.len());
119 | }
120 | _ = new_task_notify.notified() => {
121 | trace!("New task queued");
122 | }
123 | _ = queue_closed.notified() => {
124 | trace!("Queue closed");
125 | should_exit_when_empty = true;
126 | }
127 | }
128 | }
129 | anyhow::Ok(())
130 | })
131 | }
132 | }
133 |
134 | #[cfg(test)]
135 | mod tests {
136 | use super::*;
137 | use rand::prelude::*;
138 | use std::env;
139 | use test_log::test;
140 | use tokio::time::{sleep, Duration};
141 |
142 | #[test(tokio::test)]
143 | async fn test_downloadtask_execute_downloads_url() -> Result<()> {
144 | let base_path = generate_base_path();
145 | let mut server = mockito::Server::new();
146 | let (task, mock, expected_data) = generate_download_task(&base_path, &mut server);
147 | task.clone().execute().await?;
148 | mock.assert();
149 | let result_data = fs::read_to_string(task.destination)?;
150 | assert_eq!(result_data, expected_data);
151 | fs::remove_dir_all(base_path)?;
152 | Ok(())
153 | }
154 |
155 | #[test(tokio::test)]
156 | async fn test_downloadtasks_producer_consumer() -> Result<()> {
157 | let base_path = generate_base_path();
158 |
159 | let mut server = mockito::Server::new();
160 |
161 | let task_count = 32;
162 | let mut test_downloads = Vec::new();
163 | for _ in 0..task_count {
164 | test_downloads.push(generate_download_task(&base_path, &mut server));
165 | }
166 |
167 | let tasks: Vec = test_downloads
168 | .iter()
169 | .map(|(task, _, _)| task.clone())
170 | .collect();
171 |
172 | let downloader = Downloader::default();
173 | let consumer = downloader.run();
174 | let producer = tokio::spawn(async move {
175 | for task in tasks {
176 | trace!("Enqueuing task {:?}", task);
177 | downloader
178 | .queue(task)
179 | .or(Err(anyhow!("downloader queue failed")))?;
180 | random_sleep(50, 200).await;
181 | }
182 | downloader
183 | .close()
184 | .or(Err(anyhow!("downloader close failed")))?;
185 |
186 | trace!("Consumer done enqueuing tasks");
187 | anyhow::Ok(())
188 | });
189 |
190 | let result = tokio::join!(consumer, producer,);
191 | trace!("Consumer result = {:?}", result.0??);
192 | trace!("Producer result = {:?}", result.1??);
193 |
194 | for (task, mock, expected_data) in test_downloads {
195 | mock.assert();
196 | let result_data = fs::read_to_string(task.destination)?;
197 | assert_eq!(result_data, expected_data);
198 | }
199 |
200 | fs::remove_dir_all(base_path)?;
201 |
202 | Ok(())
203 | }
204 |
205 | async fn random_sleep(min: u64, max: u64) {
206 | let duration = {
207 | let mut rng = rand::thread_rng();
208 | Duration::from_millis(rng.gen_range(min..max))
209 | };
210 | sleep(duration).await;
211 | }
212 |
213 | fn generate_base_path() -> PathBuf {
214 | let rand_path: u16 = random();
215 | let base_path = env::temp_dir().join(format!("fossilizer-{rand_path}"));
216 | base_path
217 | }
218 |
219 | fn generate_download_task(
220 | base_path: &PathBuf,
221 | server: &mut mockito::ServerGuard,
222 | ) -> (DownloadTask, mockito::Mock, std::string::String) {
223 | let rand_path: u16 = random();
224 |
225 | let data = format!("task {rand_path} data");
226 |
227 | let url = Url::parse(&server.url())
228 | .unwrap()
229 | .join(format!("/task-{rand_path}").as_str())
230 | .unwrap();
231 |
232 | let destination = base_path.join(format!("tasks/task-{rand_path}.txt"));
233 |
234 | let server_mock = server
235 | .mock("GET", url.path())
236 | .with_status(200)
237 | .with_header("content-type", "text/plain")
238 | .with_body(data.clone())
239 | .create();
240 |
241 | let task = DownloadTask { url, destination };
242 |
243 | (task, server_mock, data)
244 | }
245 | }
246 |
--------------------------------------------------------------------------------
/src/lib.rs:
--------------------------------------------------------------------------------
1 | #[macro_use]
2 | extern crate lazy_static;
3 |
4 | #[macro_use]
5 | extern crate log;
6 |
7 | #[macro_use]
8 | extern crate tera;
9 |
10 | pub mod activitystreams;
11 | pub mod app;
12 | pub mod config;
13 | pub mod db;
14 | pub mod downloader;
15 | pub mod mastodon;
16 | pub mod site_generator;
17 | pub mod templates;
18 | pub mod themes;
19 |
--------------------------------------------------------------------------------
/src/main.rs:
--------------------------------------------------------------------------------
1 | mod cli;
2 |
3 | #[macro_use]
4 | extern crate log;
5 |
6 | #[tokio::main]
7 | async fn main() {
8 | match cli::execute().await {
9 | Ok(_) => {}
10 | Err(err) => println!("Error: {err:?}"),
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/mastodon.rs:
--------------------------------------------------------------------------------
1 | // todo: move this to config / cli options?
2 | pub static CLIENT_NAME: &str = "Fossilizer";
3 | pub static CLIENT_WEBSITE: &str = "https://lmorchard.github.io/fossilizer/";
4 | pub static OAUTH_SCOPES: &str = "read read:notifications read:statuses write follow push";
5 | pub static REDIRECT_URI_OOB: &str = "urn:ietf:wg:oauth:2.0:oob";
6 |
7 | pub mod fetcher;
8 | pub mod importer;
9 | pub mod instance;
10 |
--------------------------------------------------------------------------------
/src/mastodon/fetcher.rs:
--------------------------------------------------------------------------------
1 | use anyhow::Result;
2 | use megalodon;
3 | use megalodon::entities::Status;
4 | use megalodon::megalodon::GetAccountStatusesInputOptions;
5 | use rusqlite::Connection;
6 |
7 | use std::default::Default;
8 |
9 | use std::path::PathBuf;
10 | use url::Url;
11 |
12 | use crate::mastodon::instance::InstanceConfig;
13 | use crate::{
14 | activitystreams::{Activity, Attachments},
15 | db, downloader,
16 | };
17 | pub struct Fetcher {
18 | conn: Connection,
19 | instance: String,
20 | instance_config: InstanceConfig,
21 | media_path: PathBuf,
22 | page: u32,
23 | max: u32,
24 | incremental: bool,
25 | }
26 |
27 | impl Fetcher {
28 | pub fn new(
29 | conn: Connection,
30 | instance: String,
31 | instance_config: InstanceConfig,
32 | media_path: PathBuf,
33 | page: u32,
34 | max: u32,
35 | incremental: bool,
36 | ) -> Self {
37 | Self {
38 | conn,
39 | instance,
40 | instance_config,
41 | media_path,
42 | page,
43 | max,
44 | incremental,
45 | }
46 | }
47 |
48 | pub async fn fetch(&mut self) -> Result<()> {
49 | let max = self.max;
50 | let page = self.page;
51 | let incremental: bool = self.incremental;
52 | let media_path = self.media_path.clone();
53 | let instance = self.instance.clone();
54 |
55 | let media_downloader = downloader::Downloader::default();
56 | let media_download_manager = media_downloader.run();
57 |
58 | let access_token = self.instance_config.access_token.as_ref().unwrap().clone();
59 |
60 | let client = megalodon::generator(
61 | megalodon::SNS::Mastodon,
62 | format!("https://{instance}"),
63 | Some(access_token),
64 | None,
65 | );
66 |
67 | let account = client.verify_account_credentials().await?.json();
68 | trace!("Fetched account {:?}", account);
69 | info!("Fetching statuses for account {}", account.url);
70 | // todo: update actor from mastodon data
71 |
72 | let conn = &self.conn;
73 | let db_activities = db::activities::Activities::new(&conn);
74 | let db_actors = db::actors::Actors::new(&conn);
75 | let actors = db_actors.get_actors_by_id().unwrap();
76 |
77 | let mut keep_fetching = true;
78 | let mut fetched_count = 0;
79 | let mut current_fetch_options = GetAccountStatusesInputOptions {
80 | limit: Some(page),
81 | ..Default::default()
82 | };
83 |
84 | // todo: should this loop be async to cooperate with the media downloader better? or is it fine as is?
85 | while keep_fetching && fetched_count < max {
86 | let statuses_resp = client
87 | .get_account_statuses(String::from(&account.id), Some(¤t_fetch_options))
88 | .await?;
89 |
90 | let statuses_and_activities: Vec<(Status, Activity)> = statuses_resp
91 | .json()
92 | .iter()
93 | .map(|status| (status.clone(), status.clone().into()))
94 | .collect();
95 |
96 | if statuses_and_activities.is_empty() {
97 | info!("Reached the end of available activities");
98 | break;
99 | }
100 |
101 | if incremental {
102 | let activity_ids: Vec = statuses_and_activities
103 | .iter()
104 | .map(|item| item.1.id.clone())
105 | .collect();
106 | let existing_activities_count =
107 | db_activities.count_activities_by_ids(&activity_ids)?;
108 | if existing_activities_count > 0 {
109 | keep_fetching = false;
110 | }
111 | }
112 |
113 | for (status, activity) in statuses_and_activities {
114 | trace!("Importing status {:?}", status.url);
115 | db_activities.import(&status)?;
116 | fetched_count = fetched_count + 1;
117 | current_fetch_options.max_id = Some(status.id);
118 |
119 | // If this is a note, import any attachments
120 | if activity.object.is_object() {
121 | let object = activity.object.object().unwrap();
122 | let actor_id: &String = activity.actor.id().unwrap();
123 | let actor = actors.get(actor_id).unwrap();
124 |
125 | trace!("Importing {} attachments", &object.attachments().len());
126 | for &attachment in &object.attachments() {
127 | media_downloader.queue(downloader::DownloadTask {
128 | url: Url::parse(attachment.url.as_str())?,
129 | destination: attachment.local_media_path(&media_path, &actor)?,
130 | })?;
131 | }
132 | }
133 | }
134 |
135 | info!("Fetched {fetched_count} (of {max} max)...");
136 | if !keep_fetching {
137 | info!("Stopping incremental fetch after catching up to imported activities");
138 | }
139 | }
140 |
141 | // Signal that we're done enqueueing and wait for any remaining downloads to finish
142 | media_downloader.close()?;
143 | media_download_manager.await??;
144 |
145 | Ok(())
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/src/mastodon/importer.rs:
--------------------------------------------------------------------------------
1 | use crate::activitystreams::Actor;
2 |
3 | use crate::{activitystreams, db};
4 | use anyhow::{anyhow, Result};
5 | use flate2::read::GzDecoder;
6 | use rand::distributions::Alphanumeric;
7 | use rand::{thread_rng, Rng};
8 | use rusqlite::Connection;
9 |
10 | use std::convert::From;
11 |
12 | use std::fs;
13 | use std::fs::File;
14 |
15 | use std::io::{copy, Read};
16 | use std::path::{Component, PathBuf};
17 | use tar::Archive;
18 | use walkdir::WalkDir;
19 |
20 | pub struct Importer {
21 | conn: Connection,
22 | media_path: PathBuf,
23 | skip_media: bool,
24 | current_media_subpath: String,
25 | }
26 |
27 | impl Importer {
28 | pub fn new(conn: Connection, media_path: PathBuf, skip_media: bool) -> Self {
29 | // Start with a temporary path, until we have found the actor JSON to derive a real path
30 | let current_media_subpath: String = format!(
31 | "tmp-{}",
32 | thread_rng()
33 | .sample_iter(&Alphanumeric)
34 | .take(30)
35 | .map(char::from)
36 | .collect::()
37 | );
38 | Self {
39 | conn,
40 | media_path,
41 | skip_media,
42 | current_media_subpath,
43 | }
44 | }
45 |
46 | pub fn import(&mut self, filepath: PathBuf) -> Result<()> {
47 | let filepath = filepath.as_path();
48 | let file = File::open(filepath)?;
49 |
50 | // todo: do something with filemagic here to auto-detect archive format based on file contents?
51 | let extension = filepath
52 | .extension()
53 | .ok_or(anyhow!("no file extension"))?
54 | .to_str()
55 | .ok_or(anyhow!("no file extension"))?;
56 | match extension {
57 | "gz" => self.import_tar(file, true)?,
58 | "tar" => self.import_tar(file, false)?,
59 | "zip" => self.import_zip(file)?,
60 | _ => println!("NO SCANNER AVAILABLE"),
61 | };
62 |
63 | Ok(())
64 | }
65 |
66 | pub fn import_tar(&mut self, file: File, use_gzip: bool) -> Result<()> {
67 | // hack: this optional decompression seems funky, but it works
68 | let file: Box = if use_gzip {
69 | Box::new(GzDecoder::new(file))
70 | } else {
71 | Box::new(file)
72 | };
73 | let mut archive = Archive::new(file);
74 | let entries = archive.entries()?;
75 | for entry in entries {
76 | let mut entry = entry?;
77 | let entry_path: PathBuf = entry.path()?.into();
78 | self.handle_entry(&entry_path, &mut entry)?;
79 | }
80 | Ok(())
81 | }
82 |
83 | pub fn import_zip(&mut self, file: File) -> Result<()> {
84 | let mut archive = zip::ZipArchive::new(file).unwrap();
85 |
86 | for i in 0..archive.len() {
87 | let mut file = archive.by_index(i).unwrap();
88 | let outpath = match file.enclosed_name() {
89 | Some(path) => path.to_owned(),
90 | None => continue,
91 | };
92 | // is this really the best way to detect that an entry isn't a directory?
93 | if !(*file.name()).ends_with('/') {
94 | self.handle_entry(&outpath, &mut file)?;
95 | }
96 | }
97 |
98 | Ok(())
99 | }
100 |
101 | fn handle_entry(&mut self, path: &PathBuf, read: &mut impl Read) -> Result<()> {
102 | if path.ends_with("outbox.json") {
103 | self.handle_outbox(read)?;
104 | } else if path.ends_with("actor.json") {
105 | self.handle_actor(read)?;
106 | } else if !self.skip_media {
107 | if path.to_str().unwrap().contains("media_attachments") {
108 | // HACK: some exports seem to have leading directory paths before `media_attachments`, so strip that off
109 | let normalized_path: PathBuf = path
110 | .components()
111 | .skip_while(|c| match c {
112 | Component::Normal(name) => name != &"media_attachments",
113 | _ => true,
114 | })
115 | .collect();
116 | self.handle_media_attachment(&normalized_path, read)?;
117 | } else if let Some(ext) = path.extension() {
118 | // mainly for {avatar,header}.{jpg,png}, but there may be more?
119 | if "png" == ext || "jpg" == ext {
120 | self.handle_media_attachment(path, read)?;
121 | }
122 | }
123 | }
124 | Ok(())
125 | }
126 |
127 | fn handle_media_attachment(&self, entry_path: &PathBuf, entry_read: &mut R) -> Result<()>
128 | where
129 | R: ?Sized,
130 | R: Read,
131 | {
132 | let media_path = self.media_path.as_path();
133 |
134 | let output_path = PathBuf::new()
135 | .join(media_path)
136 | .join(&self.current_media_subpath)
137 | .join(entry_path);
138 |
139 | debug!("Extracting {:?}", output_path);
140 |
141 | let output_parent_path = output_path.parent().unwrap();
142 | fs::create_dir_all(output_parent_path)?;
143 |
144 | let mut output_file = fs::File::create(&output_path)?;
145 | copy(entry_read, &mut output_file)?;
146 |
147 | Ok(())
148 | }
149 |
150 | fn handle_outbox(&self, read: &mut impl Read) -> Result<()> {
151 | let outbox: activitystreams::Outbox = serde_json::from_reader(read)?;
152 | info!("Found {:?} items", outbox.ordered_items.len());
153 | let activities = db::activities::Activities::new(&self.conn);
154 | activities.import_collection(&outbox)?;
155 | Ok(())
156 | }
157 |
158 | fn handle_actor(&mut self, read: &mut impl Read) -> Result<()> {
159 | debug!("Found actor");
160 |
161 | // Grab the Actor as a Value to import it with max fidelity
162 | let actor: serde_json::Value = serde_json::from_reader(read)?;
163 | let actors = db::actors::Actors::new(&self.conn);
164 | actors.import_actor(&actor)?;
165 |
166 | // Convert the actor to our local type and figure out the new media subpath
167 | let local_actor: Actor = actor.into();
168 | let previous_media_subpath = String::from(&self.current_media_subpath);
169 | self.current_media_subpath = local_actor.id_hash();
170 |
171 | let temp_media_path = PathBuf::new()
172 | .join(&self.media_path)
173 | .join(&previous_media_subpath);
174 |
175 | // Move everything we have so far to the per-actor media path, if we have anything
176 | if temp_media_path.is_dir() {
177 | info!(
178 | "Moving temporary files from {:?} to {:?}",
179 | previous_media_subpath, self.current_media_subpath
180 | );
181 |
182 | let new_media_path = PathBuf::new()
183 | .join(&self.media_path)
184 | .join(&self.current_media_subpath);
185 |
186 | for entry in WalkDir::new(&temp_media_path)
187 | .into_iter()
188 | .filter_map(|e| e.ok())
189 | {
190 | if !entry.file_type().is_file() {
191 | continue;
192 | }
193 |
194 | let old_path = entry.path();
195 | let new_path = &new_media_path.join(old_path.strip_prefix(&temp_media_path)?);
196 |
197 | let new_parent_path = new_path.parent().unwrap();
198 | fs::create_dir_all(new_parent_path)?;
199 |
200 | trace!(
201 | "Moving temporary file from {:?} to {:?}",
202 | old_path,
203 | new_path
204 | );
205 | fs::rename(old_path, new_path)?;
206 | }
207 |
208 | // Clean up the temporary media path
209 | fs::remove_dir_all(&temp_media_path)?;
210 | }
211 |
212 | Ok(())
213 | }
214 | }
215 |
--------------------------------------------------------------------------------
/src/mastodon/instance.rs:
--------------------------------------------------------------------------------
1 | use crate::config;
2 |
3 | use anyhow::Result;
4 |
5 | use serde::{Deserialize, Serialize};
6 | use std::collections::HashMap;
7 |
8 | use std::error::Error;
9 | use std::fs;
10 |
11 | use std::io::prelude::*;
12 |
13 | use std::path::PathBuf;
14 |
15 | use crate::mastodon::{CLIENT_NAME, CLIENT_WEBSITE, OAUTH_SCOPES, REDIRECT_URI_OOB};
16 |
17 | #[derive(Default, Serialize, Deserialize, Debug, Clone)]
18 | pub struct InstanceConfig {
19 | pub host: String,
20 | pub client_id: Option,
21 | pub client_secret: Option,
22 | pub vapid_key: Option,
23 | pub access_token: Option,
24 | pub created_at: Option,
25 | }
26 | impl InstanceConfig {
27 | pub fn new(instance: &String) -> Self {
28 | InstanceConfig {
29 | host: instance.clone(),
30 | ..Default::default()
31 | }
32 | }
33 | }
34 |
35 | fn build_instance_config_path(instance: &String) -> Result> {
36 | let config = config::config()?;
37 | let data_path = config.data_path;
38 | // todo: hash the instance domain string rather than using it verbatim?
39 | Ok(data_path.join(format!("config-instance-{instance}.toml")))
40 | }
41 |
42 | pub fn load_instance_config(instance: &String) -> Result> {
43 | let config_path = build_instance_config_path(instance)?;
44 | trace!(
45 | "Loading {} instance config file from {:?}",
46 | instance,
47 | config_path
48 | );
49 | if config_path.exists() {
50 | let instance_config_file = fs::read_to_string(config_path)?;
51 | Ok(toml::from_str(instance_config_file.as_str())?)
52 | } else {
53 | Ok(InstanceConfig::new(instance))
54 | }
55 | }
56 |
57 | pub fn save_instance_config(
58 | instance: &String,
59 | instance_config: &InstanceConfig,
60 | ) -> Result<(), Box> {
61 | let config_path = build_instance_config_path(instance)?;
62 | trace!(
63 | "Saving {} instance config file to {:?}",
64 | instance,
65 | config_path
66 | );
67 | let instance_config_str = toml::to_string_pretty(&instance_config)?;
68 | let mut file = fs::File::create(config_path)?;
69 | file.write_all(instance_config_str.as_bytes())?;
70 | Ok(())
71 | }
72 |
73 | #[derive(Serialize, Deserialize, Debug)]
74 | struct AppRegistrationResult {
75 | client_id: String,
76 | client_secret: String,
77 | vapid_key: String,
78 | }
79 |
80 | pub async fn register_client_app(
81 | instance: &String,
82 | instance_config: &mut InstanceConfig,
83 | ) -> Result<()> {
84 | let mut params = HashMap::new();
85 | params.insert("client_name", CLIENT_NAME);
86 | params.insert("website", CLIENT_WEBSITE);
87 | params.insert("redirect_uris", REDIRECT_URI_OOB);
88 | params.insert("scopes", OAUTH_SCOPES);
89 |
90 | let url = format!("https://{instance}/api/v1/apps");
91 | let client = reqwest::ClientBuilder::new().build().unwrap();
92 | let res = client.post(url).json(¶ms).send().await?;
93 |
94 | debug!("Registering new app with instance {}", instance);
95 |
96 | if res.status() == reqwest::StatusCode::OK {
97 | let result: AppRegistrationResult = res.json().await?;
98 | instance_config.client_id = Some(result.client_id);
99 | instance_config.client_secret = Some(result.client_secret);
100 | instance_config.vapid_key = Some(result.vapid_key);
101 | Ok(())
102 | } else {
103 | // todo: throw an error here
104 | error!("Failed to register app");
105 | Ok(())
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/resources/default_config.toml:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lmorchard/fossilizer/1b9ef90af18c7e1e4d58e2a0509270c2630310d1/src/resources/default_config.toml
--------------------------------------------------------------------------------
/src/resources/test/activity-with-attachment.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "https://hackers.town/users/lmorchard/statuses/106502605909434719/activity",
3 | "type": "Create",
4 | "actor": "https://hackers.town/users/lmorchard",
5 | "published": "2021-07-01T00:53:18Z",
6 | "to": ["https://www.w3.org/ns/activitystreams#Public"],
7 | "cc": [
8 | "https://hackers.town/users/lmorchard/followers",
9 | "https://a2mi.social/users/samfirke",
10 | "https://a2mi.social/users/george"
11 | ],
12 | "object": {
13 | "id": "https://hackers.town/users/lmorchard/statuses/106502605909434719",
14 | "type": "Note",
15 | "summary": "poop (literally)",
16 | "inReplyTo": "https://hackers.town/users/lmorchard/statuses/106502601818051554",
17 | "published": "2021-07-01T00:53:18Z",
18 | "url": "https://hackers.town/@lmorchard/106502605909434719",
19 | "attributedTo": "https://hackers.town/users/lmorchard",
20 | "to": ["https://www.w3.org/ns/activitystreams#Public"],
21 | "cc": [
22 | "https://hackers.town/users/lmorchard/followers",
23 | "https://a2mi.social/users/samfirke",
24 | "https://a2mi.social/users/george"
25 | ],
26 | "sensitive": false,
27 | "atomUri": "https://hackers.town/users/lmorchard/statuses/106502605909434719",
28 | "inReplyToAtomUri": "https://hackers.town/users/lmorchard/statuses/106502601818051554",
29 | "conversation": "tag:hackers.town,2021-07-01:objectId=19198143:objectType=Conversation",
30 | "content": "\u003cp\u003e\u003cspan class=\"h-card\"\u003e\u003ca href=\"https://a2mi.social/@george\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"\u003e@\u003cspan\u003egeorge\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e \u003cspan class=\"h-card\"\u003e\u003ca href=\"https://a2mi.social/@samfirke\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"\u003e@\u003cspan\u003esamfirke\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e but, the paint is because we're moving to a bigger house with a shed. So who knows, I might get a bigger bike with a trailer :ablobjoy:\u003c/p\u003e",
31 | "contentMap": {
32 | "en": "\u003cp\u003e\u003cspan class=\"h-card\"\u003e\u003ca href=\"https://a2mi.social/@george\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"\u003e@\u003cspan\u003egeorge\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e \u003cspan class=\"h-card\"\u003e\u003ca href=\"https://a2mi.social/@samfirke\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"\u003e@\u003cspan\u003esamfirke\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e but, the paint is because we're moving to a bigger house with a shed. So who knows, I might get a bigger bike with a trailer :ablobjoy:\u003c/p\u003e"
33 | },
34 | "attachment": [
35 | {
36 | "type": "Document",
37 | "mediaType": "image/jpeg",
38 | "url": "media_attachments/files/002/337/518/original/ebbb5d342877102f.jpg",
39 | "name": null,
40 | "blurhash": "UTGRVb~pWBjGxGX9kXkWDiM{xuxunNWBRjRj",
41 | "width": 1478,
42 | "height": 1109
43 | },
44 | {
45 | "type": "Document",
46 | "mediaType": "image/jpeg",
47 | "url": "media_attachments/files/002/337/520/original/63a81769839a7ef6.jpg",
48 | "name": null,
49 | "blurhash": "UUD+rPRj0KR*RjRjf7t7I;xas.M|RPWBtRt7",
50 | "width": 1478,
51 | "height": 1109
52 | }
53 | ],
54 | "tag": [
55 | {
56 | "type": "Mention",
57 | "href": "https://a2mi.social/users/george",
58 | "name": "@george@a2mi.social"
59 | },
60 | {
61 | "type": "Mention",
62 | "href": "https://a2mi.social/users/samfirke",
63 | "name": "@samfirke@a2mi.social"
64 | },
65 | {
66 | "id": "https://hackers.town/emojis/43882",
67 | "type": "Emoji",
68 | "name": ":ablobjoy:",
69 | "updated": "2022-10-21T15:07:56Z",
70 | "icon": {
71 | "type": "Image",
72 | "mediaType": "image/png",
73 | "url": "https://hackers.town/system/custom_emojis/images/000/043/882/original/5cd6640bb919cf64.png"
74 | }
75 | }
76 | ],
77 | "replies": {
78 | "id": "https://hackers.town/users/lmorchard/statuses/106502605909434719/replies",
79 | "type": "Collection",
80 | "first": {
81 | "type": "CollectionPage",
82 | "next": "https://hackers.town/users/lmorchard/statuses/106502605909434719/replies?only_other_accounts=true\u0026page=true",
83 | "partOf": "https://hackers.town/users/lmorchard/statuses/106502605909434719/replies",
84 | "items": []
85 | }
86 | }
87 | },
88 | "signature": {
89 | "type": "RsaSignature2017",
90 | "creator": "https://hackers.town/users/lmorchard#main-key",
91 | "created": "2023-01-15T04:15:30Z",
92 | "signatureValue": "ganLLaH4rCmsf6bo6pnBpzbHGQiLIMqgMlk/usyuS8hiGJH9sR3gBjak35f2KRdIyPW64NgKJQPjaP6AYttsSTGxrtS0sCHo2/0ttkFU5QbnIK/VdzGeVOXHmXX/oP3eiUCDQnMwOQaR0jT9HPZuhNJ6LI0tyojDCLhd883cxt571VBv4DcvHVexKbuFHKdLw/og1erJthYk5RqkvMSM4J/dgOJx85yrmaIHdOmjUhU4sbb2ck9uYj64/sG0LlAUEORppe3FTL/N0wQr9xK92YcRoC19XuFnWThncvPX0J9O7PTrPyyeu/l+rtAwRv1xlEprO+vPbk0iEJWuZbpHMQ=="
93 | }
94 | }
--------------------------------------------------------------------------------
/src/resources/test/activity-with-emoji.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "https://toot.cafe/users/lmorchard/statuses/100599986688993237/activity",
3 | "type": "Create",
4 | "actor": "https://toot.cafe/users/lmorchard",
5 | "published": "2018-08-23T14:19:36Z",
6 | "to": ["https://www.w3.org/ns/activitystreams#Public"],
7 | "cc": ["https://toot.cafe/users/lmorchard/followers"],
8 | "object": {
9 | "atomUri": "https://toot.cafe/users/lmorchard/statuses/100599986688993237",
10 | "attachment": [],
11 | "attributedTo": "https://toot.cafe/users/lmorchard",
12 | "cc": ["https://toot.cafe/users/lmorchard/followers"],
13 | "content": "@snailix@scicomm.xyz sounds like an amazing job :blobaww:
",
14 | "conversation": "tag:toot.cafe,2018-08-23:objectId=4009141:objectType=Conversation",
15 | "id": "https://toot.cafe/users/lmorchard/statuses/100599986688993237",
16 | "inReplyTo": null,
17 | "inReplyToAtomUri": null,
18 | "published": "2018-08-23T14:19:36Z",
19 | "replies": {
20 | "first": {
21 | "items": [],
22 | "next": "https://toot.cafe/users/lmorchard/statuses/100599986688993237/replies?only_other_accounts=true&page=true",
23 | "partOf": "https://toot.cafe/users/lmorchard/statuses/100599986688993237/replies",
24 | "type": "CollectionPage"
25 | },
26 | "id": "https://toot.cafe/users/lmorchard/statuses/100599986688993237/replies",
27 | "type": "Collection"
28 | },
29 | "sensitive": false,
30 | "summary": null,
31 | "tag": [
32 | {
33 | "icon": {
34 | "mediaType": "image/png",
35 | "type": "Image",
36 | "url": "https://assets.toot.cafe/custom_emojis/images/000/001/051/original/75b826037cd7d344.png"
37 | },
38 | "id": "https://toot.cafe/emojis/1051",
39 | "name": ":blobaww:",
40 | "type": "Emoji",
41 | "updated": "2017-10-24T00:45:35Z"
42 | }
43 | ],
44 | "to": ["https://www.w3.org/ns/activitystreams#Public"],
45 | "type": "Note",
46 | "url": "https://toot.cafe/@lmorchard/100599986688993237"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/resources/test/actor-remote.json:
--------------------------------------------------------------------------------
1 | {
2 | "@context": [
3 | "https://www.w3.org/ns/activitystreams",
4 | "https://w3id.org/security/v1",
5 | {
6 | "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
7 | "toot": "http://joinmastodon.org/ns#",
8 | "featured": {
9 | "@id": "toot:featured",
10 | "@type": "@id"
11 | },
12 | "featuredTags": {
13 | "@id": "toot:featuredTags",
14 | "@type": "@id"
15 | },
16 | "alsoKnownAs": {
17 | "@id": "as:alsoKnownAs",
18 | "@type": "@id"
19 | },
20 | "movedTo": {
21 | "@id": "as:movedTo",
22 | "@type": "@id"
23 | },
24 | "schema": "http://schema.org#",
25 | "PropertyValue": "schema:PropertyValue",
26 | "value": "schema:value",
27 | "discoverable": "toot:discoverable",
28 | "Device": "toot:Device",
29 | "Ed25519Signature": "toot:Ed25519Signature",
30 | "Ed25519Key": "toot:Ed25519Key",
31 | "Curve25519Key": "toot:Curve25519Key",
32 | "EncryptedMessage": "toot:EncryptedMessage",
33 | "publicKeyBase64": "toot:publicKeyBase64",
34 | "deviceId": "toot:deviceId",
35 | "claim": {
36 | "@type": "@id",
37 | "@id": "toot:claim"
38 | },
39 | "fingerprintKey": {
40 | "@type": "@id",
41 | "@id": "toot:fingerprintKey"
42 | },
43 | "identityKey": {
44 | "@type": "@id",
45 | "@id": "toot:identityKey"
46 | },
47 | "devices": {
48 | "@type": "@id",
49 | "@id": "toot:devices"
50 | },
51 | "messageFranking": "toot:messageFranking",
52 | "messageType": "toot:messageType",
53 | "cipherText": "toot:cipherText",
54 | "suspended": "toot:suspended",
55 | "focalPoint": {
56 | "@container": "@list",
57 | "@id": "toot:focalPoint"
58 | }
59 | }
60 | ],
61 | "id": "https://hackers.town/users/lmorchard",
62 | "type": "Person",
63 | "following": "https://hackers.town/users/lmorchard/following",
64 | "followers": "https://hackers.town/users/lmorchard/followers",
65 | "inbox": "https://hackers.town/users/lmorchard/inbox",
66 | "outbox": "https://hackers.town/users/lmorchard/outbox",
67 | "featured": "https://hackers.town/users/lmorchard/collections/featured",
68 | "featuredTags": "https://hackers.town/users/lmorchard/collections/tags",
69 | "preferredUsername": "lmorchard",
70 | "name": "Les Orchard",
71 | "summary": "he / him; semi-hermit in PDX, USA; tinkerer; old adhd cat dad; serial enthusiast; editor-at-large for http:// lmorchard.com ; astra mortemque superare gradatim; tootfinder
",
72 | "url": "https://hackers.town/@lmorchard",
73 | "manuallyApprovesFollowers": false,
74 | "discoverable": true,
75 | "published": "2020-06-28T00:00:00Z",
76 | "devices": "https://hackers.town/users/lmorchard/collections/devices",
77 | "publicKey": {
78 | "id": "https://hackers.town/users/lmorchard#main-key",
79 | "owner": "https://hackers.town/users/lmorchard",
80 | "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp2fiC0Me5LwbTH+FkEtj\n+v9baRm9GD9BKwOqn+VE2z9lw6ZYu5nwiD9f0uDyjGtBQB0H+bvvWwetmZYRFM02\ncr+xJMlv1n6PWiKGvDr0rs1+iOvrCRWQIFgQ19DoZEd7HAgtM92LPxztfVlfZgll\n+OdG1nmW188cYM9BnFocJvZJpnzoDZrm5nXr53sBQet/+OulLq7anw7esvI1DuAC\n3W7kYyNyOUbHJ/CdniXlR0YlWGmzD1GR/YtV8CsC+K6e1kmHjeQTCPstXnbZwuj/\n5lIDDpXTEPunN5Nv3VelQNn8jqDrjo4/uoVJWcUvcub9l7a3YIxwcD34qV0Sqkqy\nyQIDAQAB\n-----END PUBLIC KEY-----\n"
81 | },
82 | "tag": [],
83 | "attachment": [
84 | {
85 | "type": "PropertyValue",
86 | "name": "Home",
87 | "value": "http:// lmorchard.com/ "
88 | },
89 | {
90 | "type": "PropertyValue",
91 | "name": "Webring Enthusiasts!",
92 | "value": "https:// fediverse-webring-enthusiasts. glitch.me/profiles/lmorchard_hackers.town/index.html "
93 | },
94 | {
95 | "type": "PropertyValue",
96 | "name": "0xDECAFBAD BBS",
97 | "value": "https:// bbs.decafbad.com/ "
98 | }
99 | ],
100 | "endpoints": {
101 | "sharedInbox": "https://hackers.town/inbox"
102 | },
103 | "icon": {
104 | "type": "Image",
105 | "mediaType": "image/png",
106 | "url": "https://hackers.town/system/accounts/avatars/000/136/533/original/1a8c651efe14fcd6.png"
107 | },
108 | "image": {
109 | "type": "Image",
110 | "mediaType": "image/jpeg",
111 | "url": "https://hackers.town/system/accounts/headers/000/136/533/original/60af00520bbf3704.jpg"
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/src/resources/test/actor.json:
--------------------------------------------------------------------------------
1 | {
2 | "@context": [
3 | "https://www.w3.org/ns/activitystreams",
4 | "https://w3id.org/security/v1",
5 | {
6 | "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
7 | "toot": "http://joinmastodon.org/ns#",
8 | "featured": { "@id": "toot:featured", "@type": "@id" },
9 | "featuredTags": { "@id": "toot:featuredTags", "@type": "@id" },
10 | "alsoKnownAs": { "@id": "as:alsoKnownAs", "@type": "@id" },
11 | "movedTo": { "@id": "as:movedTo", "@type": "@id" },
12 | "schema": "http://schema.org#",
13 | "PropertyValue": "schema:PropertyValue",
14 | "value": "schema:value",
15 | "discoverable": "toot:discoverable",
16 | "Device": "toot:Device",
17 | "Ed25519Signature": "toot:Ed25519Signature",
18 | "Ed25519Key": "toot:Ed25519Key",
19 | "Curve25519Key": "toot:Curve25519Key",
20 | "EncryptedMessage": "toot:EncryptedMessage",
21 | "publicKeyBase64": "toot:publicKeyBase64",
22 | "deviceId": "toot:deviceId",
23 | "claim": { "@type": "@id", "@id": "toot:claim" },
24 | "fingerprintKey": { "@type": "@id", "@id": "toot:fingerprintKey" },
25 | "identityKey": { "@type": "@id", "@id": "toot:identityKey" },
26 | "devices": { "@type": "@id", "@id": "toot:devices" },
27 | "messageFranking": "toot:messageFranking",
28 | "messageType": "toot:messageType",
29 | "cipherText": "toot:cipherText",
30 | "suspended": "toot:suspended",
31 | "focalPoint": { "@container": "@list", "@id": "toot:focalPoint" }
32 | }
33 | ],
34 | "id": "https://mastodon.social/users/lmorchard",
35 | "type": "Person",
36 | "following": "https://mastodon.social/users/lmorchard/following",
37 | "followers": "https://mastodon.social/users/lmorchard/followers",
38 | "inbox": "https://mastodon.social/users/lmorchard/inbox",
39 | "outbox": "outbox.json",
40 | "featured": "https://mastodon.social/users/lmorchard/collections/featured",
41 | "featuredTags": "https://mastodon.social/users/lmorchard/collections/tags",
42 | "preferredUsername": "lmorchard",
43 | "name": "Les Orchard 🕹️🔧🐱🐰",
44 | "summary": "Tinkerer; maker of things; webdev; crazy cat gentleman; serial enthusiast; Mozillian. He / him. Streams sometimes at https:// twitch.tv/lmorchard/
",
45 | "url": "https://mastodon.social/@lmorchard",
46 | "manuallyApprovesFollowers": false,
47 | "discoverable": false,
48 | "published": "2016-11-01T00:00:00Z",
49 | "devices": "https://mastodon.social/users/lmorchard/collections/devices",
50 | "movedTo": "https://hackers.town/users/lmorchard",
51 | "publicKey": {
52 | "id": "https://mastodon.social/users/lmorchard#main-key",
53 | "owner": "https://mastodon.social/users/lmorchard",
54 | "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAupYJQDcOvBPNDIJlyCsj\nGbXNYw7UJB7KejjThB0mZXUv5QWPJDoZ/isETX9/n2ulOx9NBQbXSzEOd2FrWB2m\nTb2cvo3vtINp6acXEOQwE+DpchBxLPnbS1v1oZVCckADTcoZXfcOd8qUyvj+49PQ\ncGiZIFbd6zEfRGhldnoqiU6e3GTjHgh9HMbEVBqYuYYUMT+cmW3GhiDQjMqEUUnJ\nN4LigZuXrz1Fjrya0foFd2VvF3CsjmQxN1TndI/mGXHonuKhz6SkOYbusJ6IIfgC\nDqNWQuqAovAppoXND48sAf3uN1j8VPWc38qbFuAbxNq76Iu2Ub9aQe2IEiNxhKFU\ndQIDAQAB\n-----END PUBLIC KEY-----\n"
55 | },
56 | "tag": [],
57 | "attachment": [],
58 | "endpoints": { "sharedInbox": "https://mastodon.social/inbox" },
59 | "icon": { "type": "Image", "mediaType": "image/jpeg", "url": "avatar.jpg" },
60 | "image": { "type": "Image", "mediaType": "image/jpeg", "url": "header.jpg" },
61 | "likes": "likes.json",
62 | "bookmarks": "bookmarks.json"
63 | }
64 |
--------------------------------------------------------------------------------
/src/resources/test/mastodon-export.tar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lmorchard/fossilizer/1b9ef90af18c7e1e4d58e2a0509270c2630310d1/src/resources/test/mastodon-export.tar
--------------------------------------------------------------------------------
/src/resources/test/mastodon-export.tar.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lmorchard/fossilizer/1b9ef90af18c7e1e4d58e2a0509270c2630310d1/src/resources/test/mastodon-export.tar.gz
--------------------------------------------------------------------------------
/src/resources/test/mastodon-export.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lmorchard/fossilizer/1b9ef90af18c7e1e4d58e2a0509270c2630310d1/src/resources/test/mastodon-export.zip
--------------------------------------------------------------------------------
/src/resources/test/mastodon-status-with-attachment.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "110726017288384411",
3 | "uri": "https://hackers.town/users/lmorchard/statuses/110726017288384411",
4 | "url": "https://hackers.town/@lmorchard/110726017288384411",
5 | "account": {
6 | "id": "136533",
7 | "username": "lmorchard",
8 | "acct": "lmorchard",
9 | "display_name": "Les Orchard",
10 | "locked": false,
11 | "discoverable": true,
12 | "group": false,
13 | "created_at": "2020-06-28T00:00:00Z",
14 | "followers_count": 2029,
15 | "following_count": 2802,
16 | "statuses_count": 7129,
17 | "note": "he / him; semi-hermit in PDX, USA; tinkerer; old adhd cat dad; serial enthusiast; editor-at-large for http:// lmorchard.com ; astra mortemque superare gradatim; tootfinder
",
18 | "url": "https://hackers.town/@lmorchard",
19 | "avatar": "https://hackers.town/system/accounts/avatars/000/136/533/original/1a8c651efe14fcd6.png",
20 | "avatar_static": "https://hackers.town/system/accounts/avatars/000/136/533/original/1a8c651efe14fcd6.png",
21 | "header": "https://hackers.town/system/accounts/headers/000/136/533/original/60af00520bbf3704.jpg",
22 | "header_static": "https://hackers.town/system/accounts/headers/000/136/533/original/60af00520bbf3704.jpg",
23 | "emojis": [],
24 | "moved": null,
25 | "fields": [
26 | {
27 | "name": "Home",
28 | "value": "http:// lmorchard.com/ ",
29 | "verified_at": "2022-11-02T03:17:15.006Z"
30 | },
31 | {
32 | "name": "Webring Enthusiasts!",
33 | "value": "https:// fediverse-webring-enthusiasts. glitch.me/profiles/lmorchard_hackers.town/index.html ",
34 | "verified_at": "2022-12-04T04:59:02.079Z"
35 | },
36 | {
37 | "name": "0xDECAFBAD BBS",
38 | "value": "https:// bbs.decafbad.com/ ",
39 | "verified_at": null
40 | }
41 | ],
42 | "bot": false,
43 | "source": null
44 | },
45 | "in_reply_to_id": null,
46 | "in_reply_to_account_id": null,
47 | "reblog": null,
48 | "content": "Also, belated happy #caturday from Cosmo, Cheddars, and Catsby all waiting for a treat
",
49 | "plain_content": null,
50 | "created_at": "2023-07-16T22:02:21.535Z",
51 | "emojis": [],
52 | "replies_count": 0,
53 | "reblogs_count": 1,
54 | "favourites_count": 22,
55 | "reblogged": false,
56 | "favourited": false,
57 | "muted": false,
58 | "sensitive": false,
59 | "spoiler_text": "",
60 | "visibility": "public",
61 | "media_attachments": [
62 | {
63 | "id": "110726013786047891",
64 | "type": "image",
65 | "url": "https://hackers.town/system/media_attachments/files/110/726/013/786/047/891/original/6e65fa5605309c81.jpeg",
66 | "remote_url": null,
67 | "preview_url": "https://hackers.town/system/media_attachments/files/110/726/013/786/047/891/small/6e65fa5605309c81.jpeg",
68 | "text_url": null,
69 | "meta": {
70 | "original": {
71 | "width": 1152,
72 | "height": 1536,
73 | "size": "1152x1536",
74 | "aspect": 0.75,
75 | "frame_rate": null,
76 | "duration": null,
77 | "bitrate": null
78 | },
79 | "small": {
80 | "width": 416,
81 | "height": 554,
82 | "size": "416x554",
83 | "aspect": 0.7509025270758123,
84 | "frame_rate": null,
85 | "duration": null,
86 | "bitrate": null
87 | },
88 | "focus": {
89 | "x": 0.0,
90 | "y": 0.0
91 | },
92 | "length": null,
93 | "duration": null,
94 | "fps": null,
95 | "size": null,
96 | "width": null,
97 | "height": null,
98 | "aspect": null,
99 | "audio_encode": null,
100 | "audio_bitrate": null,
101 | "audio_channel": null
102 | },
103 | "description": "Three cats in a kitchen. Cosmo, a white and orange cat, sits on the corner of a counter licking his chops. Cheddars, a round orange and white cat, sits at my feet looking up at me. Catsby, a more horse-shaped orange and white cat, has his ears back and is about to yell at me.",
104 | "blurhash": "U98|#8=_DhD$?ws.R4MxAKM}ng$~NgE2NG%M"
105 | }
106 | ],
107 | "mentions": [],
108 | "tags": [
109 | {
110 | "name": "caturday",
111 | "url": "https://hackers.town/tags/caturday"
112 | }
113 | ],
114 | "card": null,
115 | "poll": null,
116 | "application": {
117 | "name": "IceCubesApp",
118 | "website": "https://github.com/Dimillian/IceCubesApp",
119 | "vapid_key": null
120 | },
121 | "language": "en",
122 | "pinned": false,
123 | "emoji_reactions": null,
124 | "quote": false,
125 | "bookmarked": false
126 | }
127 |
--------------------------------------------------------------------------------
/src/resources/test/outbox.json:
--------------------------------------------------------------------------------
1 | {
2 | "@context": "https://www.w3.org/ns/activitystreams",
3 | "id": "outbox.json",
4 | "type": "OrderedCollection",
5 | "totalItems": 1,
6 | "orderedItems": [
7 | {
8 | "id": "https://mastodon.social/users/lmorchard/statuses/55864/activity",
9 | "type": "Create",
10 | "actor": "https://mastodon.social/users/lmorchard",
11 | "published": "2016-11-01T19:18:43Z",
12 | "to": ["https://www.w3.org/ns/activitystreams#Public"],
13 | "cc": ["https://mastodon.social/users/lmorchard/followers"],
14 | "object": {
15 | "id": "https://mastodon.social/users/lmorchard/statuses/55864",
16 | "type": "Note",
17 | "summary": null,
18 | "inReplyTo": null,
19 | "published": "2016-11-01T19:18:43Z",
20 | "url": "https://mastodon.social/@lmorchard/55864",
21 | "attributedTo": "https://mastodon.social/users/lmorchard",
22 | "to": ["https://www.w3.org/ns/activitystreams#Public"],
23 | "cc": ["https://mastodon.social/users/lmorchard/followers"],
24 | "sensitive": false,
25 | "atomUri": "tag:mastodon.social,2016-11-01:objectId=55864:objectType=Status",
26 | "inReplyToAtomUri": null,
27 | "conversation": null,
28 | "content": "\u003cp\u003eHello world!\u003c/p\u003e",
29 | "contentMap": { "en": "\u003cp\u003eHello world!\u003c/p\u003e" },
30 | "attachment": [],
31 | "tag": [],
32 | "replies": {
33 | "id": "https://mastodon.social/users/lmorchard/statuses/55864/replies",
34 | "type": "Collection",
35 | "first": {
36 | "type": "CollectionPage",
37 | "next": "https://mastodon.social/users/lmorchard/statuses/55864/replies?only_other_accounts=true\u0026page=true",
38 | "partOf": "https://mastodon.social/users/lmorchard/statuses/55864/replies",
39 | "items": []
40 | }
41 | }
42 | },
43 | "signature": {
44 | "type": "RsaSignature2017",
45 | "creator": "https://mastodon.social/users/lmorchard#main-key",
46 | "created": "2023-01-15T04:14:32Z",
47 | "signatureValue": "UYcMjb8l0j9zol/Ljjaxo+aEylaAAAD+Iw6hpohFr9zxb56K9j4fIWDVqYwnHX1JR7a92R6Ybn9dobonXzHQo/oKviIJhwxDW6qkqvYHV3iOZG3raA9wGa6JLDPwdl1MYdpuLmZneEo4BtHHLVsj3lGbNPjFjMGRbkmyczV37Sz/Hm6fqLzLRCfBOAC1GY83RsV04C25asZrPZTRNUDoU94bni81dubUR8pZYNH0OVSLAJH02B+N0YmP/ti3dyg8XUXLIXM6u1eW1IIU0L+e459BhLhTNvVH/ISnHn/n1QMZDuQ1G9VBU0NSdt7jnTrykdd2yv7pNbRxJ7HrvUtnkg=="
48 | }
49 | },
50 | {
51 | "id": "https://mastodon.social/users/lmorchard/statuses/237393/activity",
52 | "type": "Create",
53 | "actor": "https://mastodon.social/users/lmorchard",
54 | "published": "2016-11-27T23:56:46Z",
55 | "to": ["https://www.w3.org/ns/activitystreams#Public"],
56 | "cc": ["https://mastodon.social/users/lmorchard/followers"],
57 | "object": {
58 | "id": "https://mastodon.social/users/lmorchard/statuses/237393",
59 | "type": "Note",
60 | "summary": null,
61 | "inReplyTo": null,
62 | "published": "2016-11-27T23:56:46Z",
63 | "url": "https://mastodon.social/@lmorchard/237393",
64 | "attributedTo": "https://mastodon.social/users/lmorchard",
65 | "to": ["https://www.w3.org/ns/activitystreams#Public"],
66 | "cc": ["https://mastodon.social/users/lmorchard/followers"],
67 | "sensitive": false,
68 | "atomUri": "tag:mastodon.social,2016-11-27:objectId=237393:objectType=Status",
69 | "inReplyToAtomUri": null,
70 | "conversation": null,
71 | "content": "\u003cp\u003eI should post here more, but I\u0026#39;ve been procrastinating installing something GNU-Social-ish on my own domain\u003c/p\u003e",
72 | "contentMap": {
73 | "en": "\u003cp\u003eI should post here more, but I\u0026#39;ve been procrastinating installing something GNU-Social-ish on my own domain\u003c/p\u003e"
74 | },
75 | "attachment": [],
76 | "tag": [],
77 | "replies": {
78 | "id": "https://mastodon.social/users/lmorchard/statuses/237393/replies",
79 | "type": "Collection",
80 | "first": {
81 | "type": "CollectionPage",
82 | "next": "https://mastodon.social/users/lmorchard/statuses/237393/replies?only_other_accounts=true\u0026page=true",
83 | "partOf": "https://mastodon.social/users/lmorchard/statuses/237393/replies",
84 | "items": []
85 | }
86 | }
87 | },
88 | "signature": {
89 | "type": "RsaSignature2017",
90 | "creator": "https://mastodon.social/users/lmorchard#main-key",
91 | "created": "2023-01-15T04:14:32Z",
92 | "signatureValue": "BByb/GjzI/JAYONGumaySNWvwyyRX9NacsPlgppOb2MTAp6Qy1wUPA58vCeaa6zd5ItRBSYNJtx9TT7UVnpjNihlEZ4HEGA4IkHi1f8J9v6pNVD+5RfP8q5GTnlGqPul68dSKh/FOdjCwjoQiaGz/llOBcq+8lGbtdEw018cvcFDAaoxznJ8iIjtIj5IbmrRGwAgEZJFyB3F4jwn0sCve8gJL6x6qZpAb/nFXGgpoEcozBu0Hzb9yBIXrQOARcCMw54eQX2MrqqBvuzF1Y4w+uDuTu8OmFQS7RhBOqumJciokGkN4ZB/jRTTnNUZ4sEhklRbJ7Zq0+QAK42bod1WWA=="
93 | }
94 | }
95 | ]
96 | }
97 |
--------------------------------------------------------------------------------
/src/resources/themes/default/templates/activity.html:
--------------------------------------------------------------------------------
1 | {%- set actor = activity.actor -%}
2 | {%- set actor_hash = actor.id | sha256 %}
3 | {%- set media_root = site_root ~ "/media/" ~ actor_hash -%}
4 | {%- set object = activity.object -%}
5 |
6 |
19 |
20 |
21 | {%- if "Announce" == activity.type -%}
22 |
{{ object }}
23 | {%- elif "Create" == activity.type and "Note" == object.type -%}
24 | {%- if object.summary -%}
25 |
{{ object.summary }}
26 | {%- endif -%}
27 | {%- if object.content -%}
28 | {{ object.content | safe }}
29 | {%- endif -%}
30 | {%- else -%}
31 | (unknown activity type {{ activity.type }})
32 | {%- endif -%}
33 |
34 | {%- if "Create" == activity.type and "Note" == object.type and object.attachment -%}
35 |
36 | {%- for attachment in object.attachment -%}
37 |
38 |
39 | {%- if attachment.mediaType is starting_with("video/") -%}
40 |
41 |
42 |
43 | {%- else -%}
44 |
45 | {%- endif -%}
46 |
47 |
48 | {%- endfor -%}
49 |
50 | {%- endif -%}
51 |
52 |
53 |
--------------------------------------------------------------------------------
/src/resources/themes/default/templates/day.html:
--------------------------------------------------------------------------------
1 | {% extends "layout.html" %}
2 |
3 | {% block head %}
4 | {{ day.current.date | safe }}
5 | {% if day.previous %}
6 |
7 | {% endif %}
8 | {% if day.next %}
9 |
10 | {% endif %}
11 | {% endblock head %}
12 |
13 | {% block pagetitle %}
14 | {{ day.current.date | safe }}
15 | {% endblock pagetitle %}
16 |
17 | {% block content %}
18 |
19 |
20 | {% for activity in activities %}
21 | {% include "activity.html" %}
22 | {% endfor %}
23 |
24 | {% if day.next %}
25 |
26 |
29 | {{ day.next.date }}
30 |
31 |
32 | {% endif %}
33 |
34 | {% endblock content %}
35 |
--------------------------------------------------------------------------------
/src/resources/themes/default/templates/index.html:
--------------------------------------------------------------------------------
1 | {% extends "layout.html" %}
2 |
3 | {% block head %}
4 | Fossilizer Index
5 | {% endblock head %}
6 |
7 | {% block pagetitle %}Fossilizer Index{% endblock pagetitle %}
8 |
9 | {% block content %}
10 |
11 |
12 | {%- for year, months in calendar -%}
13 |
14 | {{ year }} ({{ months | length }})
15 | {%- for month, days in months -%}
16 |
17 | {{ month }} ({{ days | length }})
18 |
27 |
28 | {%- endfor -%}
29 |
30 | {%- endfor -%}
31 |
32 |
33 | {% endblock content %}
34 |
--------------------------------------------------------------------------------
/src/resources/themes/default/templates/layout.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {% block head %}
9 | {% block title %}{% endblock title %}
10 | {% endblock head %}
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | {% block content %} {% endblock content %}
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/src/resources/themes/default/web/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --avatar-size: 48px;
3 | --avatar-border-radius: 8px;
4 |
5 | --media-lightbox-item-img-width: 128px;
6 | --media-lightbox-item-video-width: 100%;
7 | --media-lightbox-item-max-height: 1024px;
8 |
9 | --pagefind-ui-scale: .8;
10 | --pagefind-ui-border-width: 2px;
11 | --pagefind-ui-border-radius: 8px;
12 | --pagefind-ui-image-border-radius: 8px;
13 | --pagefind-ui-image-box-ratio: 3 / 2;
14 | --pagefind-ui-font: sans-serif;
15 | --pagefind-ui-font: system, -apple-system, "BlinkMacSystemFont", ".SFNSText-Regular", "San Francisco", "Roboto", "Segoe UI", "Helvetica Neue", "Lucida Grande", "Ubuntu", "arial", sans-serif;
16 |
17 | --theme-font-family: system, -apple-system, "BlinkMacSystemFont", ".SFNSText-Regular", "San Francisco", "Roboto", "Segoe UI", "Helvetica Neue", "Lucida Grande", "Ubuntu", "arial", sans-serif;
18 | --theme-font-size: 15px;
19 |
20 | --activity-normal-width: 38em;
21 | }
22 |
23 | @media (prefers-color-scheme: light) {
24 | :root {
25 | --theme-bg-color: #eee;
26 | --theme-highlighted-bg-color: #dfdfdf;
27 | --theme-dialog-bg-color: rgba(192,192,192,0.8);
28 | --theme-text-color: #111;
29 | --theme-border-color: #333;
30 | --theme-link-color: #383;
31 |
32 | --pagefind-ui-primary: #393939;
33 | --pagefind-ui-text: #393939;
34 | --pagefind-ui-background: #ffffff;
35 | --pagefind-ui-border: #eeeeee;
36 | --pagefind-ui-tag: #eeeeee;
37 | }
38 | }
39 |
40 | @media (prefers-color-scheme: dark) {
41 | :root {
42 | --theme-bg-color: #222;
43 | --theme-highlighted-bg-color: #332f33;
44 | --theme-dialog-bg-color: rgba(0,0,0,0.8);
45 | --theme-text-color: #eee;
46 | --theme-border-color: #444;
47 | --theme-link-color: #a9f;
48 |
49 | --pagefind-ui-primary: #eeeeee;
50 | --pagefind-ui-text: #eeeeee;
51 | --pagefind-ui-background: rgba(21, 32, 40, 0.95);
52 | --pagefind-ui-border: #999;
53 | --pagefind-ui-tag: #999;
54 | }
55 | }
56 |
57 | html {
58 | scroll-padding-top: 5em;
59 | }
60 |
61 | body {
62 | padding: 0;
63 | margin: 3em 1em 1em 1em;
64 |
65 | font-family: var(--theme-font-family);
66 | font-size: var(--theme-font-size);
67 |
68 | background-color: var(--theme-bg-color);
69 | color: var(--theme-text-color);
70 | }
71 |
72 | a {
73 | color: var(--theme-link-color);
74 | }
75 |
76 | theme-selector {}
77 |
78 | theme-selector button {
79 | color: var(--theme-text-color);
80 | }
81 |
82 | theme-selector button .icon {
83 | display: none
84 | }
85 |
86 | archive-nav {
87 | position: fixed;
88 | top: 0;
89 | left: 0;
90 | z-index: 1000;
91 | width: 100vw;
92 | max-height: 100vh;
93 | overflow: auto;
94 | display: flex;
95 | flex-direction: column;
96 | align-items: center;
97 | justify-content: center;
98 | background-color: var(--theme-bg-color);
99 | }
100 |
101 | archive-nav > section {
102 | display: flex;
103 | flex-direction: row;
104 | width: calc(var(--activity-normal-width) + 3em);
105 | background-color: var(--theme-bg-color);
106 | align-items: flex-start;
107 | }
108 |
109 | archive-nav details {
110 | }
111 |
112 | archive-nav details summary:before {
113 | content: "☰";
114 | font-size: 36px;
115 | padding: 8px;
116 | }
117 |
118 | archive-nav details summary {
119 | cursor: pointer;
120 | list-style: none;
121 | border: none;
122 | overflow: hidden;
123 | padding: 0 44px 0 0;
124 | margin: 0;
125 | width: 0px;
126 | }
127 |
128 | archive-nav details[open] section {
129 | position: fixed;
130 | display: flex;
131 | flex-direction: column;
132 | top: 3.5em;
133 | padding: 1em;
134 | background: var(--theme-dialog-bg-color);
135 | }
136 | archive-nav details[open] section > * {
137 | padding: 0.5em;
138 | }
139 |
140 | archive-nav archive-nav-date-selector {}
141 |
142 | archive-nav archive-nav-search {
143 | display: block;
144 | width: 100vw;
145 | max-height: 100vh;
146 | overflow: auto;
147 | }
148 |
149 | theme-selector button {
150 | border: none;
151 | background: none;
152 | cursor: pointer;
153 | }
154 |
155 | archive-main {}
156 |
157 | .index-calendar-outline {
158 | width: 100%;
159 | display: flex;
160 | flex-direction: column;
161 | align-items: center;
162 | padding-top: 1em;
163 | }
164 |
165 | .index-calendar-outline .year {
166 | width: 20em;
167 | margin: 0.5em;
168 | }
169 |
170 | .index-calendar-outline .year > .month {
171 | padding-left: 1.5em;
172 | margin: 0.5em;
173 | }
174 |
175 | .index-calendar-outline .year > .month ul.index-list {
176 | margin: 0.5em;
177 | }
178 |
179 | .index-calendar-outline .year h2 {
180 | display: inline;
181 | }
182 |
183 | .index-calendar-outline .year .month h3 {
184 | display: inline;
185 | }
186 |
187 | .index-calendar-outline .year .month ul.index-list {
188 | margin-left: 2em;
189 | padding: 0.5em 0;
190 | }
191 |
192 | .index-calendar-outline .year .month .day {
193 | margin-bottom: 0.5em;
194 | }
195 |
196 | archive-activity-list {
197 | display: flex;
198 | width: 100%;
199 | flex-direction: column;
200 | }
201 |
202 | archive-activity-list archive-activity-list-next-page {
203 | display: flex;
204 | width: 100%;
205 | flex-direction: row;
206 | justify-content: center;
207 | }
208 |
209 | archive-activity-list-controls {
210 | display: flex;
211 | flex-direction: column;
212 | justify-content: center;
213 | align-self: center;
214 | }
215 |
216 | archive-activity-list archive-activity-list-contents {
217 | display: flex;
218 | flex-direction: column;
219 | align-items: center;
220 | justify-content: space-around;
221 | }
222 |
223 | archive-activity {
224 | width: var(--activity-normal-width);
225 | padding: 1.5em;
226 | position: relative;
227 | overflow: auto;
228 | border-bottom: 1px solid var(--theme-border-color);
229 | }
230 |
231 | archive-activity.highlighted {
232 | background-color: var(--theme-highlighted-bg-color);
233 | }
234 |
235 | archive-activity .header {
236 | height: var(--avatar-size);
237 | padding-left: calc(var(--avatar-size) + 1em);
238 | }
239 |
240 | archive-activity .header .published {
241 | position: absolute;
242 | padding: 0;
243 | margin: 0;
244 | top: 1.5em;
245 | right: 1.5em;
246 | font-weight: normal;
247 | font-size: 0.85em;
248 | }
249 |
250 | archive-activity .header .published time {
251 | padding-right: 0.5em;
252 | }
253 |
254 | archive-activity .header .avatar {
255 | position: absolute;
256 | left: 1.5em;
257 | top: 1.5em;
258 | }
259 |
260 | archive-activity .header .avatar img {
261 | width: var(--avatar-size);
262 | height: var(--avatar-size);
263 | border-radius: var(--avatar-border-radius);
264 | }
265 |
266 | archive-activity .header .title {
267 | margin: 0 0 0.25em 0;
268 | font-size: 1em;
269 | }
270 |
271 | archive-activity .header .subtitle {
272 | padding: 0;
273 | margin: 0;
274 | font-size: 0.9em;
275 | }
276 |
277 | archive-activity .body .text .boost {
278 | display: block;
279 | margin: 1em 0;
280 | }
281 |
282 | archive-activity .body .text .boost:before {
283 | content: "♻️ ";
284 | }
285 |
286 | archive-activity .body .text .summary {
287 | padding: 0;
288 | margin: 1em 0;
289 | font-size: 1em;
290 | font-weight: bold;
291 | }
292 |
293 | archive-activity .body .text .summary:before {
294 | content: "⚠️ ";
295 | }
296 |
297 | archive-activity media-lightbox-list {
298 | display: flex;
299 | margin: 1em 0 0 0;
300 | width: 100%;
301 | flex-direction: row;
302 | flex-wrap: wrap;
303 | justify-items: flex-end;
304 | justify-content: flex-start;
305 | }
306 |
307 | archive-activity media-lightbox-list media-lightbox-item {
308 | margin: 0 0.5em 0 0;
309 | max-height: var(--media-lightbox-item-max-height);
310 | overflow: hidden;
311 | }
312 |
313 | archive-activity media-lightbox-list media-lightbox-item img {
314 | width: var(--media-lightbox-item-img-width);
315 | }
316 |
317 | archive-activity media-lightbox-list media-lightbox-item video {
318 | width: var(--media-lightbox-item-video-width);
319 | }
320 |
321 | archive-activity:last-of-type {
322 | border-bottom: none;
323 | }
324 |
325 | archive-activity-list.grid archive-activity-list-contents {
326 | flex-direction: row;
327 | flex-wrap: wrap;
328 | align-items: flex-start;
329 | }
330 |
331 | archive-activity-list.grid archive-activity-list-contents::after {
332 | content: "";
333 | flex: auto;
334 | align-items: flex-start;
335 | }
336 |
337 | archive-activity-list.grid archive-activity-list-contents archive-activity {
338 | width: 24em;
339 | padding: 2em;
340 | border-bottom: 1px solid var(--theme-border-color);
341 | max-height: 18em;
342 | overflow: auto;
343 | border-bottom: none;
344 | border-top: 1px solid var(--theme-border-color);
345 | }
346 |
347 | @media (max-width: 700px) {
348 | :root {
349 | --activity-normal-width: 90vw;
350 | }
351 | archive-nav archive-nav-search {
352 | padding: 0;
353 | }
354 | }
355 |
356 | @media (max-width: 600px) {
357 | archive-activity .header {
358 | height: auto;
359 | padding-bottom: 0.5em;
360 | }
361 | archive-activity {
362 | padding-bottom: 2.5em;
363 | }
364 | archive-activity .header .published {
365 | top: inherit;
366 | bottom: 1.5em;
367 | }
368 | }
369 |
--------------------------------------------------------------------------------
/src/resources/themes/default/web/index.js:
--------------------------------------------------------------------------------
1 | import "./lib/theme-selector.js";
2 | import "./lib/lazy-load-observer.js";
3 | import "./lib/formatted-time.js";
4 | import "./lib/media-lightbox.js";
5 | import "./lib/archive-nav/index.js";
6 | import "./lib/archive-main.js";
7 | import "./lib/archive-activity-list.js";
8 |
--------------------------------------------------------------------------------
/src/resources/themes/default/web/lib/archive-activity-list.js:
--------------------------------------------------------------------------------
1 | class ArchiveActivityList extends HTMLElement { }
2 |
3 | customElements.define("archive-activity-list", ArchiveActivityList);
4 |
5 | class ArchiveActivityListContents extends HTMLElement { }
6 |
7 | customElements.define("archive-activity-list-contents", ArchiveActivityListContents);
8 |
9 | class ArchiveActivityListControls extends HTMLElement {
10 | connectedCallback() {
11 | this.querySelector("input[name=grid]")
12 | .addEventListener("change", (ev) => this.handleChangeGrid(ev));
13 | this.querySelector("input[name=relative-time]")
14 | .addEventListener("change", (ev) => this.handleChangeRelativeTime(ev));
15 | }
16 |
17 | handleChangeGrid(ev) {
18 | this.getList().classList[ev.target.checked ? "add" : "remove"]("grid");
19 | }
20 |
21 | handleChangeRelativeTime(ev) {
22 | document.querySelector("formatted-time-context")
23 | .setRelativeTime(ev.target.checked);
24 | }
25 |
26 | getList() {
27 | const forId = this.getAttribute("for");
28 | return forId
29 | ? document.body.querySelector(`#${forId}`)
30 | : this.closest("archive-activity-list");
31 | }
32 | }
33 |
34 | customElements.define("archive-activity-list-controls", ArchiveActivityListControls);
35 |
36 | class ArchiveActivity extends HTMLElement {
37 | connectedCallback() {
38 | const hash = window.location.hash;
39 | if (hash === `#anchor-${this.id}`) {
40 | this.classList.add("highlighted");
41 | }
42 | }
43 | }
44 |
45 | customElements.define("archive-activity", ArchiveActivity);
46 |
47 | class ArchiveActivityListNextPage extends HTMLElement {
48 | connectedCallback() {
49 | this.addEventListener("click", (ev) => this.handleClick(ev));
50 | }
51 |
52 | async handleClick(ev) {
53 | if (ev.target.tagName !== "A") return;
54 |
55 | ev.preventDefault();
56 | ev.stopPropagation();
57 |
58 | // Get the nav link href and then remove the link from the DOM
59 | const target = ev.target;
60 | const href = target.getAttribute("href");
61 | this.removeChild(target);
62 |
63 | // Fetch the nav link href and parse into a document
64 | const response = await fetch(href);
65 | const content = await response.text();
66 | const parser = new DOMParser();
67 | const doc = parser.parseFromString(content, "text/html");
68 | const body = doc.body;
69 |
70 | // Find the archive-activity nodes in the loaded document, adopt them into the current page
71 | const parentList = this.closest("archive-activity-list");
72 | const container = parentList.querySelector("archive-activity-list-contents");
73 | for (const node of body.querySelectorAll("archive-activity")) {
74 | container.appendChild(document.adoptNode(node));
75 | }
76 |
77 | // Find a next link from the loaded document, adopt it into the current page if found
78 | const newNextPageLink = body.querySelector("archive-activity-list-next-page a");
79 | if (newNextPageLink) {
80 | this.appendChild(document.adoptNode(newNextPageLink));
81 | }
82 | }
83 | }
84 |
85 | customElements.define("archive-activity-list-next-page", ArchiveActivityListNextPage);
86 |
--------------------------------------------------------------------------------
/src/resources/themes/default/web/lib/archive-main.js:
--------------------------------------------------------------------------------
1 | class ArchiveMain extends HTMLElement { }
2 |
3 | customElements.define("archive-main", ArchiveMain);
--------------------------------------------------------------------------------
/src/resources/themes/default/web/lib/archive-nav/date-selector.js:
--------------------------------------------------------------------------------
1 | class ArchiveNavDateSelector extends HTMLElement {
2 | constructor() {
3 | super();
4 | }
5 |
6 | connectedCallback() {
7 | const linkTop = document.head.querySelector("link[rel=top]");
8 | this.topUrl = new URL(linkTop.getAttribute("href"), window.location);
9 | this.indexJsonURL = new URL("./index.json", this.topUrl);
10 |
11 | this.addEventListener("change", ev => {
12 | if (ev.target.classList.contains("date-nav")) {
13 | this.handleNavigationChange(ev);
14 | }
15 | });
16 |
17 | this.fetchIndexJSON();
18 | }
19 |
20 | async fetchIndexJSON() {
21 | const resp = await fetch(this.indexJsonURL);
22 | const indexJson = await resp.json();
23 | const pages = indexJson.sort((a, b) => a.current.date.localeCompare(b.current.date));
24 |
25 | let previous, next;
26 |
27 | const innerHTML = [``];
28 | for (const page of pages) {
29 | const { date, path, count } = page.current;
30 | const selected = new URL(path, this.topUrl).toString() === window.location.toString();
31 | if (selected) {
32 | ({ previous, next } = page);
33 | }
34 | innerHTML.push(`
35 |
36 | ${date} (${count})
37 |
38 | `)
39 | }
40 | innerHTML.push(` `);
41 |
42 | /*
43 | if (previous) {
44 | innerHTML.unshift(`Previous `);
45 | }
46 | if (next) {
47 | innerHTML.push(`Next `);
48 | }
49 | */
50 |
51 | this.innerHTML = innerHTML.join("\n");
52 | }
53 |
54 | handleNavigationChange(ev) {
55 | const url = new URL(ev.target.value, this.topUrl);
56 | window.location.assign(url);
57 | }
58 | }
59 |
60 | customElements.define("archive-nav-date-selector", ArchiveNavDateSelector);
--------------------------------------------------------------------------------
/src/resources/themes/default/web/lib/archive-nav/index.js:
--------------------------------------------------------------------------------
1 | import "./date-selector.js";
2 |
3 | class ArchiveNav extends HTMLElement {
4 | constructor() {
5 | super();
6 | }
7 |
8 | connectedCallback() {
9 | }
10 | }
11 |
12 | customElements.define("archive-nav", ArchiveNav);
13 |
14 | class ArchiveNavSearch extends HTMLElement {
15 | constructor() {
16 | super();
17 | }
18 | connectedCallback() {
19 | const linkTop = document.head.querySelector("link[rel=base]");
20 | const topUrl = new URL(linkTop.getAttribute("href"), window.location);
21 | const id = `archive-nav-search-${Date.now()}-${Math.ceil(1000 * Math.random())}`;
22 | this.setAttribute("id", id);
23 |
24 | if (PagefindUI) {
25 | new PagefindUI({
26 | element: `#${this.id}`,
27 | showImages: true,
28 | showSubResults: true,
29 | highlightParam: "highlight",
30 | pageSize: 3,
31 | baseUrl: topUrl
32 | });
33 | }
34 | }
35 | }
36 |
37 | customElements.define("archive-nav-search", ArchiveNavSearch);
38 |
--------------------------------------------------------------------------------
/src/resources/themes/default/web/lib/formatted-time.js:
--------------------------------------------------------------------------------
1 | // TODO: control this via attribute in formatted-time-context
2 | const ARCHIVE_ACTIVITY_TIME_UPDATE_PERIOD = 10000;
3 |
4 | class FormattedTimeContext extends HTMLElement {
5 | connectedCallback() {
6 | this.updateTimer = setInterval(
7 | () => this.setAttribute("last-update", Date.now()),
8 | ARCHIVE_ACTIVITY_TIME_UPDATE_PERIOD
9 | );
10 | }
11 | disconnectedCallback() {
12 | if (this.updateTimer) clearInterval(this.updateTimer);
13 | }
14 | toggleRelativeTime() {
15 | // TODO: switch to an attribute
16 | this.classList.toggle("relative-time");
17 | }
18 | setRelativeTime(value) {
19 | this.classList[value ? "add" : "remove"]("relative-time");
20 | }
21 | shouldUseRelativeTime() {
22 | return this.classList.contains("relative-time");
23 | }
24 | }
25 |
26 | customElements.define("formatted-time-context", FormattedTimeContext);
27 |
28 | class FormattedTime extends HTMLTimeElement {
29 | connectedCallback() {
30 | this.update();
31 |
32 | this.context = this.closest("formatted-time-context");
33 | if (this.context) {
34 | this.contextObserver = new MutationObserver(() => this.update());
35 | this.contextObserver.observe(
36 | this.context,
37 | { attributeFilter: ["class", "last-update"] }
38 | )
39 | }
40 | }
41 | disconnectedCallback() {
42 | this.contextObserver.disconnect();
43 | }
44 | update() {
45 | // TODO: maybe also control this with a local attribute?
46 | let timeSince = false;
47 | if (this.context && this.context.shouldUseRelativeTime()) {
48 | timeSince = true;
49 | }
50 |
51 | const datetime = new Date(this.getAttribute("datetime"));
52 | if (timeSince && timeago && timeago.format) {
53 | this.innerHTML = timeago.format(datetime);
54 | } else {
55 | this.innerHTML = datetime.toLocaleString();
56 | }
57 | }
58 | }
59 |
60 | customElements.define("formatted-time", FormattedTime, { extends: "time" });
61 |
--------------------------------------------------------------------------------
/src/resources/themes/default/web/lib/lazy-load-observer.js:
--------------------------------------------------------------------------------
1 | const LAZY_LOAD_THRESHOLD = 0.1;
2 | const LAZY_LOAD_CLASS_NAME = "lazy-load";
3 |
4 | class LazyLoadObserver extends HTMLElement {
5 | constructor() {
6 | super();
7 |
8 | // TODO: use an attribute for these
9 | this.threshold = LAZY_LOAD_THRESHOLD;
10 | this.lazyLoadClassName = LAZY_LOAD_CLASS_NAME;
11 |
12 | this.intersectionObserver = new IntersectionObserver(
13 | entries => this.handleIntersections(entries),
14 | { threshold: this.threshold }
15 | );
16 |
17 | this.mutationObserver = new MutationObserver(
18 | (records) => this.handleMutations(records)
19 | );
20 | }
21 |
22 | connectedCallback() {
23 | this.mutationObserver.observe(this, {
24 | subtree: true,
25 | childList: true,
26 | });
27 | for (const node of this.querySelectorAll(`.${this.lazyLoadClassName}`)) {
28 | this.intersectionObserver.observe(node);
29 | }
30 | }
31 |
32 | disconnectedCallback() {
33 | this.intersectionObserver.disconnect();
34 | this.mutationObserver.disconnect();
35 | }
36 |
37 | handleMutations(records) {
38 | for (const record of records) {
39 | for (const node of record.addedNodes) {
40 | if (node.nodeType === Node.ELEMENT_NODE) {
41 | for (const subnode of node.querySelectorAll(`.${this.lazyLoadClassName}`)) {
42 | this.intersectionObserver.observe(subnode);
43 | }
44 | if (node.classList.contains(this.lazyLoadClassName)) {
45 | this.intersectionObserver.observe(node);
46 | }
47 | }
48 | }
49 | for (const node of record.removedNodes) {
50 | if (node.nodeType === Node.ELEMENT_NODE) {
51 | for (const subnode of node.querySelectorAll(`.${this.lazyLoadClassName}`)) {
52 | this.intersectionObserver.unobserve(subnode);
53 | }
54 | if (node.classList.contains(this.lazyLoadClassName)) {
55 | this.intersectionObserver.unobserve(node);
56 | }
57 | }
58 | }
59 | }
60 | }
61 |
62 | handleIntersections(entries) {
63 | for (const entry of entries) {
64 | if (entry.isIntersecting) {
65 | this.handleIntersection(entry);
66 | }
67 | }
68 | }
69 |
70 | async handleIntersection({ target }) {
71 | if (/img/i.test(target.tagName)) {
72 | const src = target.getAttribute("data-src");
73 | if (src) {
74 | target.setAttribute("src", src);
75 | target.removeAttribute("data-src");
76 | }
77 | }
78 |
79 | if (target.classList.contains("load-href")) {
80 | await this.replaceElementWithHTMLResource(
81 | target.parentNode,
82 | target.getAttribute("href")
83 | );
84 | }
85 |
86 | if (target.classList.contains("auto-click")) {
87 | target.classList.remove("auto-click");
88 | target.click();
89 | }
90 | }
91 |
92 | async replaceElementWithHTMLResource(element, href) {
93 | if (element.classList.contains("loading")) return;
94 | element.classList.add("loading");
95 | element.setAttribute("disabled", true);
96 |
97 | const response = await fetch(href);
98 | const content = await response.text();
99 |
100 | const parser = new DOMParser();
101 | const doc = parser.parseFromString(content, "text/html");
102 | const loadedNodes = Array.from(doc.body.children);
103 |
104 | const parent = element.parentNode;
105 | for (const node of loadedNodes) {
106 | parent.insertBefore(document.adoptNode(node), element);
107 | }
108 |
109 | element.remove();
110 | }
111 | }
112 |
113 | customElements.define("lazy-load-observer", LazyLoadObserver);
114 |
--------------------------------------------------------------------------------
/src/resources/themes/default/web/lib/media-lightbox.js:
--------------------------------------------------------------------------------
1 | class MediaLightboxContext extends HTMLElement {
2 | connectedCallback() {
3 | this.sheet = document.createElement("style");
4 | this.sheet.type = "text/css";
5 | this.sheet.innerText = this.constructor.css;
6 | document.head.appendChild(this.sheet);
7 |
8 | this.lightbox = document.adoptNode(document
9 | .createRange()
10 | .createContextualFragment(this.constructor.html).firstElementChild);
11 | document.body.appendChild(this.lightbox);
12 | }
13 |
14 | disconnectedCallback() {
15 | this.sheet.remove();
16 | this.lightbox.remove();
17 | }
18 |
19 | show(src, description, previous, next) {
20 | this.lightbox.show(src, description, previous, next);
21 | }
22 |
23 | static html = /*html*/ `
24 |
25 |
26 |
27 |
28 |
29 | 𐌢
30 | ⏴
31 | ⏵
32 |
33 | `
34 |
35 | static css = /*css*/ `
36 | media-lightbox {
37 | display: none;
38 | position: fixed;
39 | left: 0;
40 | top: 0;
41 | z-index: 10000;
42 | width: 100vw;
43 | height: 100vh;
44 | background: rgba(0, 0, 0, 0.9);
45 | }
46 | media-lightbox.visible {
47 | display: flex;
48 | flex-direction: column;
49 | align-items: center;
50 | justify-content: center;
51 | }
52 | media-lightbox section.main img {
53 | max-width: 80vw;
54 | max-height: 80vh;
55 | }
56 | media-lightbox .description {
57 | display: none;
58 | max-width: 50vw;
59 | margin-top: 3em;
60 | padding: 1.5em;
61 | background: rgba(255,255,255,0.125);
62 | }
63 | media-lightbox .description.visible {
64 | display: block;
65 | }
66 | media-lightbox button {
67 | display: none;
68 | position: absolute;
69 | font-size: 2.5em;
70 | cursor: pointer;
71 | border: none;
72 | background: none;
73 | color: #888;
74 | z-index: 25000;
75 | }
76 | media-lightbox button.visible {
77 | display: block;
78 | }
79 | media-lightbox button.dismiss {
80 | top: 1vh;
81 | right: 1vw;
82 | }
83 | media-lightbox button.previous {
84 | top: 50vh;
85 | left: 1vw;
86 | }
87 | media-lightbox button.next {
88 | top: 50vh;
89 | right: 1vw;
90 | }
91 | @media (prefers-color-scheme: light) {
92 | media-lightbox button.dismiss {
93 | color: #333;
94 | }
95 | media-lightbox section.main img {
96 | background: rgba(128,128,128,0.2);
97 | }
98 | media-lightbox .description {
99 | background: rgba(128,128,128,0.2);
100 | }
101 | }
102 | @media (prefers-color-scheme: dark) {
103 | media-lightbox button.dismiss {
104 | color: #ddd;
105 | }
106 | media-lightbox section.main img {
107 | border: 1px solid rgba(255,255,255,0.2);
108 | }
109 | media-lightbox .description {
110 | background: rgba(255,255,255,0.2);
111 | }
112 | }
113 | `
114 | }
115 |
116 | customElements.define("media-lightbox-context", MediaLightboxContext);
117 |
118 | class MediaLightbox extends HTMLElement {
119 | connectedCallback() {
120 | this.addEventListener(
121 | "click",
122 | (ev) => (ev.target === this) && this.dismiss()
123 | );
124 |
125 | Object.entries({
126 | "button.dismiss": () => this.dismiss(),
127 | "button.previous": () => this.showPrevious(),
128 | "button.next": () => this.showNext(),
129 | }).forEach(([sel, fn]) => this.querySelector(sel).addEventListener("click", fn));
130 |
131 | this.keyListener = (ev) => this.handleKeyDown(ev);
132 | document.addEventListener("keyup", this.keyListener);
133 | }
134 |
135 | disconnectedCallback() {
136 | document.removeEventListener("keyup", this.keyListener);
137 | }
138 |
139 | show(src, description, previous, next) {
140 | const img = this.querySelector("section.main img");
141 | img.setAttribute("src", src);
142 | img.setAttribute("title", description);
143 |
144 | const descriptionEl = this.querySelector(".description");
145 | descriptionEl.innerHTML = description;
146 | descriptionEl.classList[!!description ? "add" : "remove"]("visible");
147 |
148 | this.previous = previous;
149 | this.querySelector("button.previous").classList[!!previous ? "add" : "remove"]("visible");
150 |
151 | this.next = next;
152 | this.querySelector("button.next").classList[!!next ? "add" : "remove"]("visible");
153 |
154 | this.classList.add("visible");
155 | }
156 |
157 | dismiss() {
158 | this.classList.remove("visible");
159 | }
160 |
161 | handleKeyDown(ev) {
162 | if (!this.classList.contains("visible")) return;
163 |
164 | switch (ev.key) {
165 | case "Escape":
166 | return this.dismiss();
167 | case "ArrowLeft":
168 | return this.showPrevious();
169 | case "ArrowRight":
170 | return this.showNext();
171 | }
172 | }
173 |
174 | showPrevious() {
175 | this.previous && this.previous.show();
176 | }
177 |
178 | showNext() {
179 | this.next && this.next.show();
180 | }
181 | }
182 |
183 | customElements.define("media-lightbox", MediaLightbox);
184 |
185 | class MediaLightboxList extends HTMLElement { }
186 |
187 | customElements.define("media-lightbox-list", MediaLightboxList);
188 |
189 | class MediaLightboxItem extends HTMLElement {
190 | connectedCallback() {
191 | this.addEventListener("click", ev => this.handleClick(ev));
192 | }
193 |
194 | handleClick(ev) {
195 | ev.preventDefault();
196 | ev.stopPropagation();
197 | this.show();
198 | }
199 |
200 | show() {
201 | const context = this.closest("media-lightbox-context");
202 | if (context) {
203 | const link = this.querySelector("a");
204 | context.show(
205 | link.href,
206 | link.title,
207 | this.previousElementSibling,
208 | this.nextElementSibling
209 | );
210 | }
211 | }
212 | }
213 |
214 | customElements.define("media-lightbox-item", MediaLightboxItem);
215 |
--------------------------------------------------------------------------------
/src/resources/themes/default/web/lib/theme-selector.js:
--------------------------------------------------------------------------------
1 | // adapted from https://stackoverflow.com/questions/56300132/how-to-override-css-prefers-color-scheme-setting/75124760#75124760
2 | class ThemeSelector extends HTMLElement {
3 |
4 | connectedCallback() {
5 | for (const el of this.querySelectorAll(".icon")) {
6 | el.style.display = "none";
7 | }
8 |
9 | const button = this.querySelector("button");
10 | if (button) {
11 | button.addEventListener("click", () => this.toggleColorScheme());
12 | }
13 |
14 | const scheme = this.getPreferredColorScheme();
15 | this.applyPreferredColorScheme(scheme);
16 | }
17 |
18 | toggleColorScheme() {
19 | let newScheme = "light";
20 | let scheme = this.getPreferredColorScheme();
21 | if (scheme === "light") newScheme = "dark";
22 | this.applyPreferredColorScheme(newScheme);
23 | this.savePreferredColorScheme(newScheme);
24 | }
25 |
26 | getPreferredColorScheme() {
27 | let systemScheme = 'light';
28 | if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
29 | systemScheme = 'dark';
30 | }
31 | let chosenScheme = systemScheme;
32 | if (localStorage.getItem("scheme")) {
33 | chosenScheme = localStorage.getItem("scheme");
34 | }
35 | if (systemScheme === chosenScheme) {
36 | localStorage.removeItem("scheme");
37 | }
38 | return chosenScheme;
39 | }
40 |
41 | savePreferredColorScheme(scheme) {
42 | let systemScheme = 'light';
43 | if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
44 | systemScheme = 'dark';
45 | }
46 | if (systemScheme === scheme) {
47 | localStorage.removeItem("scheme");
48 | }
49 | else {
50 | localStorage.setItem("scheme", scheme);
51 | }
52 | }
53 |
54 | applyPreferredColorScheme(scheme) {
55 | for (let s = 0; s < document.styleSheets.length; s++) {
56 | for (let i = 0; i < document.styleSheets[s].cssRules.length; i++) {
57 | const rule = document.styleSheets[s].cssRules[i];
58 | if (rule && rule.media && rule.media.mediaText.includes("prefers-color-scheme")) {
59 | switch (scheme) {
60 | case "light":
61 | rule.media.appendMedium("original-prefers-color-scheme");
62 | if (rule.media.mediaText.includes("light")) rule.media.deleteMedium("(prefers-color-scheme: light)");
63 | if (rule.media.mediaText.includes("dark")) rule.media.deleteMedium("(prefers-color-scheme: dark)");
64 | break;
65 | case "dark":
66 | rule.media.appendMedium("(prefers-color-scheme: light)");
67 | rule.media.appendMedium("(prefers-color-scheme: dark)");
68 | if (rule.media.mediaText.includes("original")) rule.media.deleteMedium("original-prefers-color-scheme");
69 | break;
70 | default:
71 | rule.media.appendMedium("(prefers-color-scheme: dark)");
72 | if (rule.media.mediaText.includes("light")) rule.media.deleteMedium("(prefers-color-scheme: light)");
73 | if (rule.media.mediaText.includes("original")) rule.media.deleteMedium("original-prefers-color-scheme");
74 | break;
75 | }
76 | }
77 | }
78 | }
79 |
80 | if (scheme === "dark") {
81 | this.querySelector(".icon.light").style.display = "inline";
82 | this.querySelector(".icon.dark").style.display = "none";
83 | } else {
84 | this.querySelector(".icon.dark").style.display = "inline";
85 | this.querySelector(".icon.light").style.display = "none";
86 | }
87 | }
88 | }
89 |
90 | customElements.define("theme-selector", ThemeSelector);
91 |
--------------------------------------------------------------------------------
/src/resources/themes/default/web/vendor/timeago.min.js:
--------------------------------------------------------------------------------
1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e=e||self).timeago={})}(this,function(e){"use strict";var r=["second","minute","hour","day","week","month","year"];var a=["秒","分钟","小时","天","周","个月","年"];function t(e,t){n[e]=t}function i(e){return n[e]||n.en_US}var n={},f=[60,60,24,7,365/7/12,12];function o(e){return e instanceof Date?e:!isNaN(e)||/^\d+$/.test(e)?new Date(parseInt(e)):(e=(e||"").trim().replace(/\.\d+/,"").replace(/-/,"/").replace(/-/,"/").replace(/(\d)T(\d)/,"$1 $2").replace(/Z/," UTC").replace(/([+-]\d\d):?(\d\d)/," $1$2"),new Date(e))}function d(e,t){for(var n=e<0?1:0,r=e=Math.abs(e),a=0;e>=f[a]&&a=f[n]&&n Result<(), Box> {
16 | if *clean {
17 | info!("Cleaning build path");
18 | if let Err(err) = fs::remove_dir_all(build_path) {
19 | if err.kind() != std::io::ErrorKind::NotFound {
20 | // todo: improve error handling here
21 | return Err(Box::new(err));
22 | }
23 | }
24 | }
25 | fs::create_dir_all(build_path)?;
26 | Ok(())
27 | }
28 |
29 | pub fn setup_data_path(clean: &bool) -> Result<(), Box> {
30 | let config = config::config()?;
31 | let data_path = &config.data_path;
32 |
33 | if *clean {
34 | info!("Cleaning data path");
35 | if let Err(err) = fs::remove_dir_all(data_path) {
36 | if err.kind() != std::io::ErrorKind::NotFound {
37 | // todo: improve error handling here
38 | return Err(Box::new(err));
39 | }
40 | }
41 | }
42 |
43 | fs::create_dir_all(data_path)?;
44 | Ok(())
45 | }
46 |
47 | pub fn unpack_customizable_resources() -> Result<(), Box> {
48 | let config = config::config()?;
49 |
50 | let mut config_outfile = open_outfile_with_parent_dir(&config.config_path())?;
51 | config_outfile.write_all(DEFAULT_CONFIG.as_bytes())?;
52 |
53 | copy_embedded_themes(&config.themes_path())?;
54 |
55 | Ok(())
56 | }
57 |
58 | // todo: move this to a shared utils module? build.rs also uses
59 | pub fn copy_embedded_assets(
60 | assets_output_path: &PathBuf,
61 | ) -> Result<(), Box> {
62 | for filename in Assets::iter() {
63 | let file = Assets::get(&filename).ok_or("no asset")?;
64 | let outpath = PathBuf::from(&assets_output_path).join(&filename.to_string());
65 |
66 | let mut outfile = open_outfile_with_parent_dir(&outpath)?;
67 | outfile.write_all(file.data.as_ref())?;
68 |
69 | debug!("Wrote {} to {:?}", filename, outpath);
70 | }
71 | Ok(())
72 | }
73 |
74 | pub fn open_outfile_with_parent_dir(outpath: &PathBuf) -> Result> {
75 | let outparent = outpath.parent().ok_or("no parent path")?;
76 | fs::create_dir_all(outparent)?;
77 | let outfile = fs::File::create(outpath)?;
78 | Ok(outfile)
79 | }
80 |
81 | pub fn copy_web_assets(build_path: &PathBuf) -> Result<(), Box> {
82 | let config = config::config()?;
83 |
84 | let web_assets_path = config.web_assets_path();
85 | if web_assets_path.is_dir() {
86 | let mut web_assets_contents = Vec::new();
87 | for entry in (web_assets_path.read_dir()?).flatten() {
88 | web_assets_contents.push(entry.path());
89 | }
90 | copy_files(web_assets_contents.as_slice(), build_path)?;
91 | } else {
92 | info!("Copying embedded static web assets");
93 | copy_embedded_web_assets(&config.theme, build_path)?;
94 | }
95 |
96 | Ok(())
97 | }
98 |
99 | pub fn copy_files(media_path: &[P], build_path: &P) -> Result<(), Box>
100 | where
101 | P: AsRef + std::fmt::Debug,
102 | {
103 | info!("Copying {:?} to {:?}", media_path, build_path);
104 | fs_extra::copy_items_with_progress(
105 | media_path,
106 | build_path,
107 | &fs_extra::dir::CopyOptions {
108 | overwrite: true,
109 | skip_exist: false,
110 | buffer_size: 64000,
111 | copy_inside: true,
112 | content_only: false,
113 | depth: 0,
114 | },
115 | |process_info| {
116 | debug!(
117 | "Copied {} ({} / {})",
118 | process_info.file_name, process_info.copied_bytes, process_info.total_bytes
119 | );
120 | fs_extra::dir::TransitProcessResult::ContinueOrAbort
121 | },
122 | )?;
123 | Ok(())
124 | }
125 |
126 | pub fn plan_activities_pages(
127 | build_path: &PathBuf,
128 | db_activities: &db::activities::Activities<'_>,
129 | ) -> Result, Box> {
130 | let mut entries: Vec = Vec::new();
131 | let all_days = db_activities.get_published_days()?;
132 | for (date, count) in all_days {
133 | let day_path = PathBuf::from(build_path).join(&date).with_extension("html");
134 | let mut context = contexts::IndexDayContext {
135 | current: contexts::IndexDayEntry {
136 | date: date.clone(),
137 | path: day_path.clone().strip_prefix(build_path)?.to_path_buf(),
138 | count,
139 | },
140 | previous: None,
141 | next: None,
142 | };
143 | if let Some(mut previous) = entries.pop() {
144 | previous.next = Some(context.current.clone());
145 | context.previous = Some(previous.current.clone());
146 | entries.push(previous);
147 | }
148 | entries.push(context);
149 | }
150 | Ok(entries)
151 | }
152 |
153 | pub fn generate_activities_pages(
154 | build_path: &PathBuf,
155 | tera: &Tera,
156 | actors: &HashMap,
157 | day_entries: &Vec,
158 | ) -> Result<(), Box> {
159 | info!("Generating {} per-day pages", day_entries.len());
160 | day_entries
161 | .par_iter()
162 | .for_each(|day_entry| generate_activity_page(build_path, tera, actors, day_entry).unwrap());
163 | Ok(())
164 | }
165 |
166 | pub fn generate_activity_page(
167 | build_path: &PathBuf,
168 | tera: &Tera,
169 | actors: &HashMap,
170 | day_entry: &contexts::IndexDayContext,
171 | ) -> Result<(), Box> {
172 | // let tera = templates::init()?;
173 | let db_conn = db::conn()?;
174 | let db_activities = db::activities::Activities::new(&db_conn);
175 |
176 | let day = &day_entry.current.date;
177 | let day_path = &day_entry.current.path;
178 |
179 | let items: Vec = db_activities
180 | .get_activities_for_day(day)?
181 | .iter()
182 | .map(|activity| {
183 | let actor_id: &String = activity.actor.id().unwrap();
184 | let actor: &activitystreams::Actor = actors.get(actor_id).unwrap();
185 | (activity, actor)
186 | })
187 | .filter(|(activity, _actor)| {
188 | // todo: any actor-related filtering needed here?
189 | activity.is_public()
190 | })
191 | .map(|(activity, actor)| {
192 | let mut activity = activity.clone();
193 | activity.actor = activitystreams::IdOrObject::Object(actor.clone());
194 | activity
195 | })
196 | .collect();
197 |
198 | templates::render_to_file(
199 | tera,
200 | &PathBuf::from(&build_path).join(day_path),
201 | "day.html",
202 | contexts::DayTemplateContext {
203 | site_root: "../..".to_string(),
204 | activities: items,
205 | day: day_entry.clone(),
206 | },
207 | )?;
208 |
209 | Ok(())
210 | }
211 |
212 | pub fn generate_index_page(
213 | build_path: &PathBuf,
214 | day_entries: &Vec,
215 | tera: &tera::Tera,
216 | ) -> Result<(), Box> {
217 | info!("Generating site index page");
218 |
219 | let index_path = PathBuf::from(&build_path)
220 | .join("index")
221 | .with_extension("html");
222 |
223 | templates::render_to_file(
224 | tera,
225 | &index_path,
226 | "index.html",
227 | contexts::IndexTemplateContext {
228 | site_root: ".".to_string(),
229 | calendar: day_entries.into(),
230 | },
231 | )?;
232 |
233 | Ok(())
234 | }
235 |
236 | pub fn generate_index_json(
237 | build_path: &PathBuf,
238 | day_entries: &Vec,
239 | ) -> Result<(), Box> {
240 | info!("Generating site index JSON");
241 |
242 | let file_path = PathBuf::from(&build_path)
243 | .join("index")
244 | .with_extension("json");
245 |
246 | let output = serde_json::to_string_pretty(&day_entries)?;
247 |
248 | let file_parent_path = file_path.parent().ok_or("no parent path")?;
249 | fs::create_dir_all(file_parent_path)?;
250 |
251 | let mut file = fs::File::create(file_path)?;
252 | file.write_all(output.as_bytes())?;
253 |
254 | Ok(())
255 | }
256 |
--------------------------------------------------------------------------------
/src/templates.rs:
--------------------------------------------------------------------------------
1 | use serde::Serialize;
2 | use serde_json::value::{to_value, Value};
3 | use std::collections::HashMap;
4 | use std::error::Error;
5 | use std::fs;
6 | use std::io::prelude::*;
7 | use std::path::PathBuf;
8 | use tera::Tera;
9 | use url::Url;
10 |
11 | use crate::config;
12 | use crate::themes::templates_source;
13 |
14 | pub mod contexts;
15 |
16 | pub fn init() -> Result> {
17 | let config = config::config()?;
18 |
19 | let mut tera: Tera;
20 | let templates_path = config.templates_path();
21 | if templates_path.is_dir() {
22 | debug!("Using templates from {:?}", templates_path);
23 | let templates_glob = templates_path.join("**/*.html");
24 | tera = Tera::new(
25 | templates_glob
26 | .to_str()
27 | .ok_or("failed to construct templates glob")?,
28 | )?;
29 | } else {
30 | debug!("Using embedded templates");
31 | tera = Tera::default();
32 | let templates = templates_source(&config.theme);
33 | debug!("TEMPLATES {:?} {:?}", templates, &config);
34 | tera.add_raw_templates(templates)?;
35 | }
36 |
37 | tera.register_filter("sha256", filter_sha256);
38 | tera.register_filter("urlpath", filter_urlpath);
39 |
40 | Ok(tera)
41 | }
42 |
43 | /// Produce the sha256 hash of a string
44 | pub fn filter_sha256(value: &Value, _: &HashMap) -> tera::Result {
45 | let s = try_get_value!("filter_sha256", "value", String, value);
46 | Ok(to_value(sha256::digest(s)).unwrap())
47 | }
48 |
49 | /// Strip a URL down to just its path
50 | pub fn filter_urlpath(value: &Value, _: &HashMap) -> tera::Result {
51 | let s = try_get_value!("filter_sha256", "value", String, value);
52 | // todo: this is pretty ugly:
53 | let url = Url::parse("http://example.com")
54 | .unwrap()
55 | .join(s.as_str())
56 | .unwrap();
57 | Ok(to_value(url.path()).unwrap())
58 | }
59 |
60 | pub fn render_to_file(
61 | tera: &Tera,
62 | file_path: &PathBuf,
63 | template_name: &str,
64 | context: impl Serialize,
65 | ) -> Result<(), Box> {
66 | let file_parent_path = file_path.parent().ok_or("no parent path")?;
67 | fs::create_dir_all(file_parent_path)?;
68 | let context = tera::Context::from_serialize(context)?;
69 | let output = tera.render(template_name, &context)?;
70 | let mut file = fs::File::create(file_path)?;
71 | file.write_all(output.as_bytes())?;
72 | debug!("Wrote {} to {:?}", template_name, file_path);
73 | Ok(())
74 | }
75 |
76 | #[cfg(test)]
77 | mod tests {
78 | /* TODO: get this test passing on windows-x86_64?
79 | use super::*;
80 | use std::{error::Error, path::Path};
81 |
82 | use crate::activitystreams::{Activity, Actor, IdOrObject};
83 |
84 | const JSON_ACTIVITY_WITH_ATTACHMENT: &str =
85 | include_str!("./resources/test/activity-with-attachment.json");
86 |
87 | const JSON_ACTOR: &str = include_str!("./resources/test/actor.json");
88 |
89 | #[test]
90 | fn test_activity_template_with_attachment() -> Result<(), Box> {
91 | config::init(&Path::new("./resources/default_config.toml"))?;
92 | let tera = init()?;
93 |
94 | let mut activity: Activity = serde_json::from_str(JSON_ACTIVITY_WITH_ATTACHMENT)?;
95 | let actor: Actor = serde_json::from_str(JSON_ACTOR)?;
96 | activity.actor = IdOrObject::Object(actor);
97 |
98 | let mut context = tera::Context::new();
99 | context.insert("site_root", "../..");
100 | context.insert("activity", &activity);
101 |
102 | let rendered_source = tera.render("activity.html", &context)?;
103 | println!("RENDERED {rendered_source}");
104 |
105 | Ok(())
106 | }
107 | */
108 | }
109 |
--------------------------------------------------------------------------------
/src/templates/contexts.rs:
--------------------------------------------------------------------------------
1 | //! Structs associated with templates to define available variables.
2 |
3 | use serde::{Deserialize, Serialize};
4 | use std::collections::hash_map::Entry::{Occupied, Vacant};
5 | use std::collections::HashMap;
6 | use std::path::PathBuf;
7 |
8 | use crate::activitystreams;
9 |
10 | /// Base template context for the `index.html` template.
11 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12 | pub struct IndexTemplateContext {
13 | /// Relative path to the site root from the current page
14 | pub site_root: String,
15 | /// Calendar of nested hashmaps, organizing [IndexDayContext]s by year, month, and day
16 | pub calendar: CalendarContext,
17 | }
18 |
19 | /// Base template context for the `day.html` template.
20 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21 | pub struct DayTemplateContext {
22 | /// Relative path to the site root from the current page
23 | pub site_root: String,
24 | /// Context for the page's current day
25 | pub day: IndexDayContext,
26 | /// The set of activities posted on the current day
27 | pub activities: Vec,
28 | }
29 |
30 | /// Context for a day, including previous and next days for navigation purposes
31 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32 | pub struct IndexDayContext {
33 | pub previous: Option,
34 | pub current: IndexDayEntry,
35 | pub next: Option,
36 | }
37 |
38 | /// Details on a single day
39 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40 | pub struct IndexDayEntry {
41 | /// The date in yyyy/mm/dd format
42 | pub date: String,
43 | /// The file path to the day's HTML page
44 | pub path: PathBuf,
45 | /// A count of activities posted on this day
46 | pub count: usize,
47 | }
48 |
49 | /// Calendar of [IndexDayContext] structs, organized by into nested [HashMap]s
50 | /// indexed by year, month, and day.
51 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52 | pub struct CalendarContext(HashMap>>);
53 |
54 | impl CalendarContext {
55 | pub fn new() -> Self {
56 | CalendarContext(HashMap::new())
57 | }
58 |
59 | /// Insert an [IndexDayContext] into the [CalendarContext], using
60 | /// year / month / day keys based on the date property
61 | pub fn insert(&mut self, day_context: &IndexDayContext) {
62 | let calendar = &mut self.0;
63 |
64 | let parts = day_context
65 | .current
66 | .date
67 | .split('/')
68 | .take(3)
69 | .collect::>();
70 |
71 | if let [year, month, day] = parts[..] {
72 | let year_map = match calendar.entry(year.to_string()) {
73 | Vacant(entry) => entry.insert(HashMap::new()),
74 | Occupied(entry) => entry.into_mut(),
75 | };
76 | let month_map = match year_map.entry(month.to_string()) {
77 | Vacant(entry) => entry.insert(HashMap::new()),
78 | Occupied(entry) => entry.into_mut(),
79 | };
80 | month_map.insert(day.to_string(), day_context.clone());
81 | }
82 | // else: throw an error because the date format was bunk?
83 | }
84 | }
85 | impl Default for CalendarContext {
86 | fn default() -> Self {
87 | Self::new()
88 | }
89 | }
90 | impl From<&Vec> for CalendarContext {
91 | /// Produce a [CalendarContext] from a [`Vec`]
92 | fn from(value: &Vec) -> Self {
93 | let mut calendar = Self::new();
94 | for entry in value {
95 | calendar.insert(entry);
96 | }
97 | calendar
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/themes.rs:
--------------------------------------------------------------------------------
1 | use rust_embed::RustEmbed;
2 | use std::error::Error;
3 | use std::fs;
4 | use std::io::prelude::*;
5 | use std::path::PathBuf;
6 |
7 | #[derive(RustEmbed)]
8 | #[folder = "src/resources/themes"]
9 | pub struct ThemeAsset;
10 |
11 | pub fn copy_embedded_themes(assets_output_path: &PathBuf) -> Result<(), Box> {
12 | for filename in ThemeAsset::iter() {
13 | let file = ThemeAsset::get(&filename).ok_or("no asset")?;
14 | let outpath = PathBuf::from(&assets_output_path).join(&filename.to_string());
15 |
16 | let mut outfile = open_outfile_with_parent_dir(&outpath)?;
17 | outfile.write_all(file.data.as_ref())?;
18 |
19 | debug!("Wrote {} to {:?}", filename, outpath);
20 | }
21 | Ok(())
22 | }
23 |
24 | pub fn copy_embedded_web_assets(
25 | theme_prefix: &str,
26 | assets_output_path: &PathBuf,
27 | ) -> Result<(), Box> {
28 | let prefix = PathBuf::from(&theme_prefix)
29 | .join("web")
30 | .to_string_lossy()
31 | .into_owned();
32 | for filename in ThemeAsset::iter() {
33 | if !filename.to_string().starts_with(&prefix) {
34 | continue;
35 | }
36 | // FIXME: this is all pretty ugly - can be done better?
37 | let local_path = PathBuf::from(filename.to_string())
38 | .strip_prefix(&prefix)
39 | .unwrap()
40 | .to_string_lossy()
41 | .into_owned();
42 | let file = ThemeAsset::get(&filename).ok_or("no asset")?;
43 | let outpath = PathBuf::from(&assets_output_path).join(&local_path);
44 |
45 | let mut outfile = open_outfile_with_parent_dir(&outpath)?;
46 | outfile.write_all(file.data.as_ref())?;
47 |
48 | debug!("Wrote {} to {:?}", filename, outpath);
49 | }
50 | Ok(())
51 | }
52 |
53 | pub fn templates_source(theme_prefix: &str) -> Vec<(String, String)> {
54 | let prefix = PathBuf::from(&theme_prefix)
55 | .join("templates")
56 | .to_string_lossy()
57 | .into_owned();
58 | ThemeAsset::iter()
59 | .filter(|filename| filename.to_string().starts_with(&prefix))
60 | .map(|filename| {
61 | // FIXME: this is all pretty ugly - can be done better?
62 | let local_path = PathBuf::from(filename.to_string())
63 | .strip_prefix(&prefix)
64 | .unwrap()
65 | .to_string_lossy()
66 | .into_owned();
67 | let file = std::str::from_utf8(ThemeAsset::get(&filename).unwrap().data.as_ref())
68 | .unwrap()
69 | .to_owned();
70 | (local_path, file)
71 | })
72 | .collect::>()
73 | }
74 |
75 | pub fn open_outfile_with_parent_dir(outpath: &PathBuf) -> Result> {
76 | let outparent = outpath.parent().ok_or("no parent path")?;
77 | fs::create_dir_all(outparent)?;
78 | let outfile = fs::File::create(outpath)?;
79 | Ok(outfile)
80 | }
81 |
--------------------------------------------------------------------------------