personal activity index (bluesky, leaflet, substack) pai.desertthunder.dev
rss bluesky
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(server): add RSS endpoint and migrate to separate crate

+308 -57
+121 -3
Cargo.lock
··· 82 82 ] 83 83 84 84 [[package]] 85 + name = "atom_syndication" 86 + version = "0.12.7" 87 + source = "registry+https://github.com/rust-lang/crates.io-index" 88 + checksum = "d2f68d23e2cb4fd958c705b91a6b4c80ceeaf27a9e11651272a8389d5ce1a4a3" 89 + dependencies = [ 90 + "chrono", 91 + "derive_builder", 92 + "diligent-date-parser", 93 + "never", 94 + "quick-xml", 95 + ] 96 + 97 + [[package]] 85 98 name = "atomic-waker" 86 99 version = "1.1.2" 87 100 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 275 288 checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 276 289 277 290 [[package]] 291 + name = "darling" 292 + version = "0.20.11" 293 + source = "registry+https://github.com/rust-lang/crates.io-index" 294 + checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 295 + dependencies = [ 296 + "darling_core", 297 + "darling_macro", 298 + ] 299 + 300 + [[package]] 301 + name = "darling_core" 302 + version = "0.20.11" 303 + source = "registry+https://github.com/rust-lang/crates.io-index" 304 + checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 305 + dependencies = [ 306 + "fnv", 307 + "ident_case", 308 + "proc-macro2", 309 + "quote", 310 + "strsim", 311 + "syn", 312 + ] 313 + 314 + [[package]] 315 + name = "darling_macro" 316 + version = "0.20.11" 317 + source = "registry+https://github.com/rust-lang/crates.io-index" 318 + checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 319 + dependencies = [ 320 + "darling_core", 321 + "quote", 322 + "syn", 323 + ] 324 + 325 + [[package]] 326 + name = "derive_builder" 327 + version = "0.20.2" 328 + source = "registry+https://github.com/rust-lang/crates.io-index" 329 + checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" 330 + dependencies = [ 331 + "derive_builder_macro", 332 + ] 333 + 334 + [[package]] 335 + name = "derive_builder_core" 336 + version = "0.20.2" 337 + source = "registry+https://github.com/rust-lang/crates.io-index" 338 + checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" 339 + dependencies = [ 340 + "darling", 341 + "proc-macro2", 342 + "quote", 343 + "syn", 344 + ] 345 + 346 + [[package]] 347 + name = "derive_builder_macro" 348 + version = "0.20.2" 349 + source = "registry+https://github.com/rust-lang/crates.io-index" 350 + checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" 351 + dependencies = [ 352 + "derive_builder_core", 353 + "syn", 354 + ] 355 + 356 + [[package]] 357 + name = "diligent-date-parser" 358 + version = "0.1.5" 359 + source = "registry+https://github.com/rust-lang/crates.io-index" 360 + checksum = "c8ede7d79366f419921e2e2f67889c12125726692a313bffb474bd5f37a581e9" 361 + dependencies = [ 362 + "chrono", 363 + ] 364 + 365 + [[package]] 278 366 name = "dirs" 279 367 version = "6.0.0" 280 368 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 774 862 ] 775 863 776 864 [[package]] 865 + name = "ident_case" 866 + version = "1.0.1" 867 + source = "registry+https://github.com/rust-lang/crates.io-index" 868 + checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 869 + 870 + [[package]] 777 871 name = "idna" 778 872 version = "1.1.0" 779 873 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 943 1037 ] 944 1038 945 1039 [[package]] 1040 + name = "never" 1041 + version = "0.1.0" 1042 + source = "registry+https://github.com/rust-lang/crates.io-index" 1043 + checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" 1044 + 1045 + [[package]] 946 1046 name = "num-traits" 947 1047 version = "0.2.19" 948 1048 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1023 1123 name = "pai" 1024 1124 version = "0.1.0" 1025 1125 dependencies = [ 1026 - "axum", 1027 1126 "chrono", 1028 1127 "clap", 1029 1128 "clap_mangen", 1030 1129 "dirs", 1031 1130 "owo-colors", 1032 1131 "pai-core", 1033 - "rusqlite", 1132 + "pai-server", 1133 + "rss", 1034 1134 "serde", 1035 1135 "serde_json", 1036 1136 "tempfile", 1037 - "tokio", 1038 1137 ] 1039 1138 1040 1139 [[package]] ··· 1052 1151 ] 1053 1152 1054 1153 [[package]] 1154 + name = "pai-server" 1155 + version = "0.1.0" 1156 + dependencies = [ 1157 + "axum", 1158 + "chrono", 1159 + "owo-colors", 1160 + "pai-core", 1161 + "rss", 1162 + "rusqlite", 1163 + "serde", 1164 + "serde_json", 1165 + "tempfile", 1166 + "tokio", 1167 + ] 1168 + 1169 + [[package]] 1055 1170 name = "pai-worker" 1056 1171 version = "0.1.0" 1057 1172 dependencies = [ ··· 1257 1372 source = "registry+https://github.com/rust-lang/crates.io-index" 1258 1373 checksum = "b2107738f003660f0a91f56fd3e3bd3ab5d918b2ddaf1e1ec2136fb1c46f71bf" 1259 1374 dependencies = [ 1375 + "atom_syndication", 1376 + "derive_builder", 1377 + "never", 1260 1378 "quick-xml", 1261 1379 ] 1262 1380
+1 -1
Cargo.toml
··· 1 1 [workspace] 2 2 resolver = "2" 3 - members = ["cli", "core", "worker"] 3 + members = ["cli", "core", "server", "worker"] 4 4 5 5 [workspace.lints.clippy] 6 6 bool_comparison = "deny"
+2 -3
cli/Cargo.toml
··· 9 9 10 10 [dependencies] 11 11 pai-core = { path = "../core" } 12 + pai-server = { path = "../server" } 12 13 clap = { version = "4.5", features = ["derive"] } 13 - rusqlite = { version = "0.37", features = ["bundled"] } 14 14 chrono = "0.4" 15 15 dirs = "6.0" 16 16 owo-colors = "4.1" 17 17 serde_json = "1.0" 18 18 serde = { version = "1.0", features = ["derive"] } 19 - axum = "0.7" 20 - tokio = { version = "1.40", features = ["macros", "rt-multi-thread", "signal"] } 19 + rss = "2.0" 21 20 22 21 [dev-dependencies] 23 22 tempfile = "3.13"
+48 -42
cli/src/main.rs
··· 1 1 mod app; 2 2 mod paths; 3 - mod server; 4 - mod storage; 5 3 6 4 use app::{Cli, Commands, ExportOpts}; 7 5 use chrono::{DateTime, Duration, Utc}; 8 6 use clap::Parser; 9 7 use owo_colors::OwoColorize; 10 8 use pai_core::{Config, Item, ListFilter, PaiError, SourceKind}; 9 + use pai_server::SqliteStorage; 10 + use rss::{Channel, ChannelBuilder, ItemBuilder}; 11 11 use std::fs; 12 12 use std::io::{self, Write}; 13 13 use std::path::{Path, PathBuf}; 14 14 use std::str::FromStr; 15 - use storage::SqliteStorage; 16 15 17 16 const PUBLISHED_WIDTH: usize = 19; 18 17 const KIND_WIDTH: usize = 9; ··· 133 132 134 133 fn handle_serve(db_path: Option<PathBuf>, address: String) -> Result<(), PaiError> { 135 134 let db_path = paths::resolve_db_path(db_path)?; 136 - server::serve(db_path, address) 135 + pai_server::serve(db_path, &address) 137 136 } 138 137 139 138 fn handle_db_check(db_path: Option<PathBuf>) -> Result<(), PaiError> { ··· 532 531 } 533 532 534 533 fn write_rss(items: &[Item], writer: &mut dyn Write) -> Result<(), PaiError> { 535 - let feed = build_rss_feed(items)?; 536 - writer.write_all(feed.as_bytes()).map_err(PaiError::Io)?; 534 + let channel = build_rss_channel(items)?; 535 + let rss_string = channel.to_string(); 536 + writer.write_all(rss_string.as_bytes()).map_err(PaiError::Io)?; 537 537 writer.write_all(b"\n").map_err(PaiError::Io) 538 538 } 539 539 540 - fn build_rss_feed(items: &[Item]) -> Result<String, PaiError> { 540 + fn build_rss_channel(items: &[Item]) -> Result<Channel, PaiError> { 541 541 const TITLE: &str = "Personal Activity Index"; 542 542 const LINK: &str = "https://personal-activity-index.local/"; 543 543 const DESCRIPTION: &str = "Aggregated feed exported by the Personal Activity Index CLI."; 544 544 545 - let mut feed = String::new(); 546 - feed.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"); 547 - feed.push_str("<rss version=\"2.0\"><channel>"); 548 - feed.push_str(&format!("<title>{TITLE}</title>")); 549 - feed.push_str(&format!("<link>{LINK}</link>")); 550 - feed.push_str(&format!("<description>{DESCRIPTION}</description>")); 551 - 552 - for item in items { 553 - let title = item.title.as_deref().or(item.summary.as_deref()).unwrap_or(&item.url); 554 - let description = item.summary.as_deref().or(item.content_html.as_deref()).unwrap_or(""); 555 - let author = item.author.as_deref().unwrap_or("Unknown"); 545 + let rss_items: Vec<rss::Item> = items 546 + .iter() 547 + .map(|item| { 548 + let title = item 549 + .title 550 + .as_deref() 551 + .or(item.summary.as_deref()) 552 + .unwrap_or(&item.url) 553 + .to_string(); 554 + let description = item 555 + .summary 556 + .as_deref() 557 + .or(item.content_html.as_deref()) 558 + .unwrap_or("") 559 + .to_string(); 560 + let author = item.author.as_deref().unwrap_or("Unknown").to_string(); 561 + let pub_date = format_rss_date(&item.published_at); 556 562 557 - feed.push_str("<item>"); 558 - feed.push_str(&format!("<title>{}</title>", escape_xml(title))); 559 - feed.push_str(&format!("<link>{}</link>", escape_xml(&item.url))); 560 - feed.push_str(&format!("<guid isPermaLink=\"false\">{}</guid>", escape_xml(&item.id))); 561 - feed.push_str(&format!( 562 - "<category>{}</category>", 563 - escape_xml(&item.source_kind.to_string()) 564 - )); 565 - feed.push_str(&format!("<author>{}</author>", escape_xml(author))); 566 - feed.push_str(&format!("<description>{}</description>", escape_xml(description))); 567 - feed.push_str(&format!("<pubDate>{}</pubDate>", format_rss_date(&item.published_at))); 568 - feed.push_str("</item>"); 569 - } 563 + ItemBuilder::default() 564 + .title(Some(title)) 565 + .link(Some(item.url.clone())) 566 + .guid(Some( 567 + rss::GuidBuilder::default().value(&item.id).permalink(false).build(), 568 + )) 569 + .pub_date(Some(pub_date)) 570 + .author(Some(author)) 571 + .description(Some(description)) 572 + .categories(vec![rss::CategoryBuilder::default() 573 + .name(item.source_kind.to_string()) 574 + .build()]) 575 + .build() 576 + }) 577 + .collect(); 570 578 571 - feed.push_str("</channel></rss>"); 572 - Ok(feed) 573 - } 579 + let channel = ChannelBuilder::default() 580 + .title(TITLE) 581 + .link(LINK) 582 + .description(DESCRIPTION) 583 + .items(rss_items) 584 + .build(); 574 585 575 - fn escape_xml(input: &str) -> String { 576 - input 577 - .replace('&', "&amp;") 578 - .replace('<', "&lt;") 579 - .replace('>', "&gt;") 580 - .replace('\"', "&quot;") 581 - .replace('\'', "&apos;") 586 + Ok(channel) 582 587 } 583 588 584 589 fn format_rss_date(value: &str) -> String { ··· 728 733 729 734 #[test] 730 735 fn rss_export_contains_items() { 731 - let feed = build_rss_feed(&[sample_item()]).unwrap(); 736 + let channel = build_rss_channel(&[sample_item()]).unwrap(); 737 + let feed = channel.to_string(); 732 738 assert!(feed.contains("<rss")); 733 739 assert!(feed.contains("<item>")); 734 740 assert!(feed.contains("sample-id"));
+110 -8
cli/src/server.rs server/src/server.rs
··· 1 1 use crate::storage::SqliteStorage; 2 - use crate::{ensure_positive_limit, normalize_optional_string, normalize_since_input}; 2 + 3 3 use axum::{ 4 4 extract::{Path, Query, State}, 5 - http::StatusCode, 5 + http::{header, StatusCode}, 6 6 response::{IntoResponse, Response}, 7 7 routing::get, 8 8 Json, Router, 9 9 }; 10 + use chrono::DateTime; 10 11 use owo_colors::OwoColorize; 11 12 use pai_core::{Item, ListFilter, PaiError, SourceKind}; 13 + use rss::{Channel, ChannelBuilder, ItemBuilder}; 12 14 use serde::{Deserialize, Serialize}; 13 15 use std::{io, net::SocketAddr, path::PathBuf, sync::Arc, time::Instant}; 14 16 use tokio::net::TcpListener; ··· 17 19 const VERSION: &str = env!("CARGO_PKG_VERSION"); 18 20 19 21 /// Launches the HTTP server using the provided SQLite database path and address. 20 - pub(crate) fn serve(db_path: PathBuf, address: String) -> Result<(), PaiError> { 22 + pub fn serve(db_path: PathBuf, address: &str) -> Result<(), PaiError> { 21 23 let addr: SocketAddr = address 22 24 .parse() 23 25 .map_err(|e| PaiError::Config(format!("Invalid listen address '{address}': {e}")))?; ··· 41 43 .route("/api/feed", get(feed_handler)) 42 44 .route("/api/item/:id", get(item_handler)) 43 45 .route("/status", get(status_handler)) 46 + .route("/rss.xml", get(rss_handler)) 44 47 .with_state(state); 45 48 46 49 let listener = TcpListener::bind(addr).await.map_err(PaiError::Io)?; ··· 104 107 source_kind: self.source_kind, 105 108 source_id: normalize_optional_string(self.source_id), 106 109 limit: Some(limit), 107 - since: normalize_since_input(self.since)?, 110 + since: normalize_optional_string(self.since), 108 111 query: normalize_optional_string(self.q), 109 112 }) 110 113 } ··· 156 159 Ok(Json(snapshot)) 157 160 } 158 161 162 + async fn rss_handler(State(state): State<AppState>, Query(query): Query<FeedQuery>) -> Result<RssResponse, ApiError> { 163 + let filter = query.into_filter()?; 164 + let storage = state.open_storage()?; 165 + let items = pai_core::Storage::list_items(&storage, &filter)?; 166 + 167 + let channel = build_rss_channel(&items)?; 168 + Ok(RssResponse(channel)) 169 + } 170 + 171 + fn build_rss_channel(items: &[Item]) -> Result<Channel, PaiError> { 172 + const TITLE: &str = "Personal Activity Index"; 173 + const LINK: &str = "https://personal-activity-index.local/"; 174 + const DESCRIPTION: &str = "Aggregated feed exported by the Personal Activity Index."; 175 + 176 + let rss_items: Vec<rss::Item> = items 177 + .iter() 178 + .map(|item| { 179 + let title = item 180 + .title 181 + .as_deref() 182 + .or(item.summary.as_deref()) 183 + .unwrap_or(&item.url) 184 + .to_string(); 185 + let description = item 186 + .summary 187 + .as_deref() 188 + .or(item.content_html.as_deref()) 189 + .unwrap_or("") 190 + .to_string(); 191 + let author = item.author.as_deref().unwrap_or("Unknown").to_string(); 192 + let pub_date = format_rss_date(&item.published_at); 193 + 194 + ItemBuilder::default() 195 + .title(Some(title)) 196 + .link(Some(item.url.clone())) 197 + .guid(Some( 198 + rss::GuidBuilder::default().value(&item.id).permalink(false).build(), 199 + )) 200 + .pub_date(Some(pub_date)) 201 + .author(Some(author)) 202 + .description(Some(description)) 203 + .categories(vec![rss::CategoryBuilder::default() 204 + .name(item.source_kind.to_string()) 205 + .build()]) 206 + .build() 207 + }) 208 + .collect(); 209 + 210 + let channel = ChannelBuilder::default() 211 + .title(TITLE) 212 + .link(LINK) 213 + .description(DESCRIPTION) 214 + .items(rss_items) 215 + .build(); 216 + 217 + Ok(channel) 218 + } 219 + 220 + fn format_rss_date(value: &str) -> String { 221 + if let Ok(dt) = DateTime::parse_from_rfc3339(value) { 222 + dt.to_rfc2822() 223 + } else if let Ok(dt) = DateTime::parse_from_rfc2822(value) { 224 + dt.to_rfc2822() 225 + } else { 226 + value.to_string() 227 + } 228 + } 229 + 230 + struct RssResponse(Channel); 231 + 232 + impl IntoResponse for RssResponse { 233 + fn into_response(self) -> Response { 234 + let rss_string = self.0.to_string(); 235 + ( 236 + [(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], 237 + rss_string, 238 + ) 239 + .into_response() 240 + } 241 + } 242 + 159 243 struct ApiError { 160 244 status: StatusCode, 161 245 message: String, ··· 199 283 let _ = tokio::signal::ctrl_c().await; 200 284 } 201 285 286 + fn ensure_positive_limit(limit: usize) -> Result<usize, PaiError> { 287 + if limit == 0 { 288 + return Err(PaiError::InvalidArgument("Limit must be greater than zero".to_string())); 289 + } 290 + Ok(limit) 291 + } 292 + 293 + fn normalize_optional_string(value: Option<String>) -> Option<String> { 294 + value.and_then(|input| { 295 + let trimmed = input.trim(); 296 + if trimmed.is_empty() { 297 + None 298 + } else { 299 + Some(trimmed.to_string()) 300 + } 301 + }) 302 + } 303 + 202 304 #[cfg(test)] 203 305 mod tests { 204 306 use super::*; ··· 227 329 let filter = query.into_filter().unwrap(); 228 330 assert_eq!(filter.limit, Some(5)); 229 331 assert_eq!(filter.source_kind, Some(SourceKind::Bluesky)); 230 - assert_eq!(filter.source_id.unwrap(), "desertthunder.dev"); 231 - assert_eq!(filter.query.unwrap(), "rust"); 232 - assert_eq!(filter.since.unwrap(), "2024-01-01T00:00:00+00:00"); 332 + assert_eq!(filter.source_id.as_deref(), Some("desertthunder.dev")); 333 + assert_eq!(filter.query.as_deref(), Some("rust")); 334 + assert_eq!(filter.since.as_deref(), Some("2024-01-01T00:00:00Z")); 233 335 } 234 336 235 337 #[test] ··· 250 352 fn status_snapshot_reports_counts() { 251 353 let dir = tempdir().unwrap(); 252 354 let db_path = dir.path().join("status.db"); 253 - let state = AppState { db_path: Arc::new(db_path.clone()), start_time: Instant::now() }; 355 + let state = AppState { db_path: Arc::new(db_path), start_time: Instant::now() }; 254 356 255 357 let storage = state.open_storage().unwrap(); 256 358 let now = Utc::now().to_rfc3339();
cli/src/storage/mod.rs server/src/storage/mod.rs
cli/src/storage/sqlite.rs server/src/storage/sqlite.rs
+21
server/Cargo.toml
··· 1 + [package] 2 + name = "pai-server" 3 + version = "0.1.0" 4 + edition = "2021" 5 + 6 + [dependencies] 7 + pai-core = { path = "../core" } 8 + axum = "0.7" 9 + tokio = { version = "1.40", features = ["macros", "rt-multi-thread", "signal"] } 10 + rusqlite = { version = "0.37", features = ["bundled"] } 11 + serde = { version = "1.0", features = ["derive"] } 12 + serde_json = "1.0" 13 + owo-colors = "4.1" 14 + chrono = "0.4" 15 + rss = "2.0" 16 + 17 + [dev-dependencies] 18 + tempfile = "3.13" 19 + 20 + [lints] 21 + workspace = true
+5
server/src/lib.rs
··· 1 + mod server; 2 + mod storage; 3 + 4 + pub use server::serve; 5 + pub use storage::SqliteStorage;