diff --git a/Cargo.lock b/Cargo.lock index 6334ff6..1815a8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,6 +81,19 @@ dependencies = [ "syn", ] +[[package]] +name = "atom_syndication" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f68d23e2cb4fd958c705b91a6b4c80ceeaf27a9e11651272a8389d5ce1a4a3" +dependencies = [ + "chrono", + "derive_builder", + "diligent-date-parser", + "never", + "quick-xml", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -274,6 +287,81 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "diligent-date-parser" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ede7d79366f419921e2e2f67889c12125726692a313bffb474bd5f37a581e9" +dependencies = [ + "chrono", +] + [[package]] name = "dirs" version = "6.0.0" @@ -773,6 +861,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -942,6 +1036,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "never" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" + [[package]] name = "num-traits" version = "0.2.19" @@ -1023,18 +1123,17 @@ checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" name = "pai" version = "0.1.0" dependencies = [ - "axum", "chrono", "clap", "clap_mangen", "dirs", "owo-colors", "pai-core", - "rusqlite", + "pai-server", + "rss", "serde", "serde_json", "tempfile", - "tokio", ] [[package]] @@ -1051,6 +1150,22 @@ dependencies = [ "toml", ] +[[package]] +name = "pai-server" +version = "0.1.0" +dependencies = [ + "axum", + "chrono", + "owo-colors", + "pai-core", + "rss", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "pai-worker" version = "0.1.0" @@ -1257,6 +1372,9 @@ version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2107738f003660f0a91f56fd3e3bd3ab5d918b2ddaf1e1ec2136fb1c46f71bf" dependencies = [ + "atom_syndication", + "derive_builder", + "never", "quick-xml", ] diff --git a/Cargo.toml b/Cargo.toml index 2b9e25e..f11433b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["cli", "core", "worker"] +members = ["cli", "core", "server", "worker"] [workspace.lints.clippy] bool_comparison = "deny" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 3bf43cf..09e0d47 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -9,15 +9,14 @@ path = "src/main.rs" [dependencies] pai-core = { path = "../core" } +pai-server = { path = "../server" } clap = { version = "4.5", features = ["derive"] } -rusqlite = { version = "0.37", features = ["bundled"] } chrono = "0.4" dirs = "6.0" owo-colors = "4.1" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } -axum = "0.7" -tokio = { version = "1.40", features = ["macros", "rt-multi-thread", "signal"] } +rss = "2.0" [dev-dependencies] tempfile = "3.13" diff --git a/cli/src/main.rs b/cli/src/main.rs index a2bf97d..e18b20f 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,18 +1,17 @@ mod app; mod paths; -mod server; -mod storage; use app::{Cli, Commands, ExportOpts}; use chrono::{DateTime, Duration, Utc}; use clap::Parser; use owo_colors::OwoColorize; use pai_core::{Config, Item, ListFilter, PaiError, SourceKind}; +use pai_server::SqliteStorage; +use rss::{Channel, ChannelBuilder, ItemBuilder}; use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::str::FromStr; -use storage::SqliteStorage; const PUBLISHED_WIDTH: usize = 19; const KIND_WIDTH: usize = 9; @@ -133,7 +132,7 @@ fn handle_export(db_path: Option, opts: ExportOpts) -> Result<(), PaiEr fn handle_serve(db_path: Option, address: String) -> Result<(), PaiError> { let db_path = paths::resolve_db_path(db_path)?; - server::serve(db_path, address) + pai_server::serve(db_path, &address) } fn handle_db_check(db_path: Option) -> Result<(), PaiError> { @@ -532,53 +531,59 @@ fn write_ndjson(items: &[Item], writer: &mut dyn Write) -> Result<(), PaiError> } fn write_rss(items: &[Item], writer: &mut dyn Write) -> Result<(), PaiError> { - let feed = build_rss_feed(items)?; - writer.write_all(feed.as_bytes()).map_err(PaiError::Io)?; + let channel = build_rss_channel(items)?; + let rss_string = channel.to_string(); + writer.write_all(rss_string.as_bytes()).map_err(PaiError::Io)?; writer.write_all(b"\n").map_err(PaiError::Io) } -fn build_rss_feed(items: &[Item]) -> Result { +fn build_rss_channel(items: &[Item]) -> Result { const TITLE: &str = "Personal Activity Index"; const LINK: &str = "https://personal-activity-index.local/"; const DESCRIPTION: &str = "Aggregated feed exported by the Personal Activity Index CLI."; - let mut feed = String::new(); - feed.push_str(""); - feed.push_str(""); - feed.push_str(&format!("{TITLE}")); - feed.push_str(&format!("{LINK}")); - feed.push_str(&format!("{DESCRIPTION}")); - - for item in items { - let title = item.title.as_deref().or(item.summary.as_deref()).unwrap_or(&item.url); - let description = item.summary.as_deref().or(item.content_html.as_deref()).unwrap_or(""); - let author = item.author.as_deref().unwrap_or("Unknown"); - - feed.push_str(""); - feed.push_str(&format!("{}", escape_xml(title))); - feed.push_str(&format!("{}", escape_xml(&item.url))); - feed.push_str(&format!("{}", escape_xml(&item.id))); - feed.push_str(&format!( - "{}", - escape_xml(&item.source_kind.to_string()) - )); - feed.push_str(&format!("{}", escape_xml(author))); - feed.push_str(&format!("{}", escape_xml(description))); - feed.push_str(&format!("{}", format_rss_date(&item.published_at))); - feed.push_str(""); - } - - feed.push_str(""); - Ok(feed) -} - -fn escape_xml(input: &str) -> String { - input - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('\"', """) - .replace('\'', "'") + let rss_items: Vec = items + .iter() + .map(|item| { + let title = item + .title + .as_deref() + .or(item.summary.as_deref()) + .unwrap_or(&item.url) + .to_string(); + let description = item + .summary + .as_deref() + .or(item.content_html.as_deref()) + .unwrap_or("") + .to_string(); + let author = item.author.as_deref().unwrap_or("Unknown").to_string(); + let pub_date = format_rss_date(&item.published_at); + + ItemBuilder::default() + .title(Some(title)) + .link(Some(item.url.clone())) + .guid(Some( + rss::GuidBuilder::default().value(&item.id).permalink(false).build(), + )) + .pub_date(Some(pub_date)) + .author(Some(author)) + .description(Some(description)) + .categories(vec![rss::CategoryBuilder::default() + .name(item.source_kind.to_string()) + .build()]) + .build() + }) + .collect(); + + let channel = ChannelBuilder::default() + .title(TITLE) + .link(LINK) + .description(DESCRIPTION) + .items(rss_items) + .build(); + + Ok(channel) } fn format_rss_date(value: &str) -> String { @@ -728,7 +733,8 @@ mod tests { #[test] fn rss_export_contains_items() { - let feed = build_rss_feed(&[sample_item()]).unwrap(); + let channel = build_rss_channel(&[sample_item()]).unwrap(); + let feed = channel.to_string(); assert!(feed.contains("")); assert!(feed.contains("sample-id")); diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..c32c06f --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "pai-server" +version = "0.1.0" +edition = "2021" + +[dependencies] +pai-core = { path = "../core" } +axum = "0.7" +tokio = { version = "1.40", features = ["macros", "rt-multi-thread", "signal"] } +rusqlite = { version = "0.37", features = ["bundled"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +owo-colors = "4.1" +chrono = "0.4" +rss = "2.0" + +[dev-dependencies] +tempfile = "3.13" + +[lints] +workspace = true diff --git a/server/src/lib.rs b/server/src/lib.rs new file mode 100644 index 0000000..ec30bfc --- /dev/null +++ b/server/src/lib.rs @@ -0,0 +1,5 @@ +mod server; +mod storage; + +pub use server::serve; +pub use storage::SqliteStorage; diff --git a/cli/src/server.rs b/server/src/server.rs similarity index 68% rename from cli/src/server.rs rename to server/src/server.rs index ef2fbcf..ec85e0e 100644 --- a/cli/src/server.rs +++ b/server/src/server.rs @@ -1,14 +1,16 @@ use crate::storage::SqliteStorage; -use crate::{ensure_positive_limit, normalize_optional_string, normalize_since_input}; + use axum::{ extract::{Path, Query, State}, - http::StatusCode, + http::{header, StatusCode}, response::{IntoResponse, Response}, routing::get, Json, Router, }; +use chrono::DateTime; use owo_colors::OwoColorize; use pai_core::{Item, ListFilter, PaiError, SourceKind}; +use rss::{Channel, ChannelBuilder, ItemBuilder}; use serde::{Deserialize, Serialize}; use std::{io, net::SocketAddr, path::PathBuf, sync::Arc, time::Instant}; use tokio::net::TcpListener; @@ -17,7 +19,7 @@ const DEFAULT_LIMIT: usize = 20; const VERSION: &str = env!("CARGO_PKG_VERSION"); /// Launches the HTTP server using the provided SQLite database path and address. -pub(crate) fn serve(db_path: PathBuf, address: String) -> Result<(), PaiError> { +pub fn serve(db_path: PathBuf, address: &str) -> Result<(), PaiError> { let addr: SocketAddr = address .parse() .map_err(|e| PaiError::Config(format!("Invalid listen address '{address}': {e}")))?; @@ -41,6 +43,7 @@ async fn run_server(db_path: PathBuf, addr: SocketAddr) -> Result<(), PaiError> .route("/api/feed", get(feed_handler)) .route("/api/item/:id", get(item_handler)) .route("/status", get(status_handler)) + .route("/rss.xml", get(rss_handler)) .with_state(state); let listener = TcpListener::bind(addr).await.map_err(PaiError::Io)?; @@ -104,7 +107,7 @@ impl FeedQuery { source_kind: self.source_kind, source_id: normalize_optional_string(self.source_id), limit: Some(limit), - since: normalize_since_input(self.since)?, + since: normalize_optional_string(self.since), query: normalize_optional_string(self.q), }) } @@ -156,6 +159,87 @@ async fn status_handler(State(state): State) -> Result, Query(query): Query) -> Result { + let filter = query.into_filter()?; + let storage = state.open_storage()?; + let items = pai_core::Storage::list_items(&storage, &filter)?; + + let channel = build_rss_channel(&items)?; + Ok(RssResponse(channel)) +} + +fn build_rss_channel(items: &[Item]) -> Result { + const TITLE: &str = "Personal Activity Index"; + const LINK: &str = "https://personal-activity-index.local/"; + const DESCRIPTION: &str = "Aggregated feed exported by the Personal Activity Index."; + + let rss_items: Vec = items + .iter() + .map(|item| { + let title = item + .title + .as_deref() + .or(item.summary.as_deref()) + .unwrap_or(&item.url) + .to_string(); + let description = item + .summary + .as_deref() + .or(item.content_html.as_deref()) + .unwrap_or("") + .to_string(); + let author = item.author.as_deref().unwrap_or("Unknown").to_string(); + let pub_date = format_rss_date(&item.published_at); + + ItemBuilder::default() + .title(Some(title)) + .link(Some(item.url.clone())) + .guid(Some( + rss::GuidBuilder::default().value(&item.id).permalink(false).build(), + )) + .pub_date(Some(pub_date)) + .author(Some(author)) + .description(Some(description)) + .categories(vec![rss::CategoryBuilder::default() + .name(item.source_kind.to_string()) + .build()]) + .build() + }) + .collect(); + + let channel = ChannelBuilder::default() + .title(TITLE) + .link(LINK) + .description(DESCRIPTION) + .items(rss_items) + .build(); + + Ok(channel) +} + +fn format_rss_date(value: &str) -> String { + if let Ok(dt) = DateTime::parse_from_rfc3339(value) { + dt.to_rfc2822() + } else if let Ok(dt) = DateTime::parse_from_rfc2822(value) { + dt.to_rfc2822() + } else { + value.to_string() + } +} + +struct RssResponse(Channel); + +impl IntoResponse for RssResponse { + fn into_response(self) -> Response { + let rss_string = self.0.to_string(); + ( + [(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], + rss_string, + ) + .into_response() + } +} + struct ApiError { status: StatusCode, message: String, @@ -199,6 +283,24 @@ async fn shutdown_signal() { let _ = tokio::signal::ctrl_c().await; } +fn ensure_positive_limit(limit: usize) -> Result { + if limit == 0 { + return Err(PaiError::InvalidArgument("Limit must be greater than zero".to_string())); + } + Ok(limit) +} + +fn normalize_optional_string(value: Option) -> Option { + value.and_then(|input| { + let trimmed = input.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + #[cfg(test)] mod tests { use super::*; @@ -227,9 +329,9 @@ mod tests { let filter = query.into_filter().unwrap(); assert_eq!(filter.limit, Some(5)); assert_eq!(filter.source_kind, Some(SourceKind::Bluesky)); - assert_eq!(filter.source_id.unwrap(), "desertthunder.dev"); - assert_eq!(filter.query.unwrap(), "rust"); - assert_eq!(filter.since.unwrap(), "2024-01-01T00:00:00+00:00"); + assert_eq!(filter.source_id.as_deref(), Some("desertthunder.dev")); + assert_eq!(filter.query.as_deref(), Some("rust")); + assert_eq!(filter.since.as_deref(), Some("2024-01-01T00:00:00Z")); } #[test] @@ -250,7 +352,7 @@ mod tests { fn status_snapshot_reports_counts() { let dir = tempdir().unwrap(); let db_path = dir.path().join("status.db"); - let state = AppState { db_path: Arc::new(db_path.clone()), start_time: Instant::now() }; + let state = AppState { db_path: Arc::new(db_path), start_time: Instant::now() }; let storage = state.open_storage().unwrap(); let now = Utc::now().to_rfc3339(); diff --git a/cli/src/storage/mod.rs b/server/src/storage/mod.rs similarity index 100% rename from cli/src/storage/mod.rs rename to server/src/storage/mod.rs diff --git a/cli/src/storage/sqlite.rs b/server/src/storage/sqlite.rs similarity index 100% rename from cli/src/storage/sqlite.rs rename to server/src/storage/sqlite.rs