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: add status endpoint

* restructuring for manpage generation

+269 -121
+18
Cargo.lock
··· 243 243 checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" 244 244 245 245 [[package]] 246 + name = "clap_mangen" 247 + version = "0.2.31" 248 + source = "registry+https://github.com/rust-lang/crates.io-index" 249 + checksum = "439ea63a92086df93893164221ad4f24142086d535b3a0957b9b9bea2dc86301" 250 + dependencies = [ 251 + "clap", 252 + "roff", 253 + ] 254 + 255 + [[package]] 246 256 name = "colorchoice" 247 257 version = "1.0.4" 248 258 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 994 1004 "axum", 995 1005 "chrono", 996 1006 "clap", 1007 + "clap_mangen", 997 1008 "dirs", 998 1009 "owo-colors", 999 1010 "pai-core", 1000 1011 "rusqlite", 1001 1012 "serde", 1002 1013 "serde_json", 1014 + "tempfile", 1003 1015 "tokio", 1004 1016 ] 1005 1017 ··· 1181 1193 "untrusted", 1182 1194 "windows-sys 0.52.0", 1183 1195 ] 1196 + 1197 + [[package]] 1198 + name = "roff" 1199 + version = "0.2.2" 1200 + source = "registry+https://github.com/rust-lang/crates.io-index" 1201 + checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" 1184 1202 1185 1203 [[package]] 1186 1204 name = "rusqlite"
+3 -2
DEPLOYMENT.md
··· 160 160 161 161 ## Health Checks & Monitoring 162 162 163 - - `GET /api/feed?limit=1` ensures the server can read from SQLite. 163 + - `GET /status` – lightweight JSON (`status`, total items, counts per `source_kind`). Ideal for load balancer health probes. 164 + - `GET /api/feed?limit=1` ensures the server can read from SQLite and return real data. 164 165 - `GET /api/item/{id}` is handy for debugging a specific record. 165 - - Consider adding nginx/Caddy health check endpoints (`/healthz`) that proxy to `/api/feed?limit=1` and monitor via your platform. 166 + - Consider wiring `/status` into nginx/Caddy health checks (`/healthz`) or your platform’s monitoring agents. 166 167 167 168 ## Security Tips 168 169
+29 -3
README.md
··· 11 11 - **Bluesky** via AT Protocol 12 12 - **Leaflet** publications via RSS feeds 13 13 - Local SQLite storage with full-text search 14 - - Flexible filtering and querying 15 - - Self-hostable or serverless (Cloudflare Workers) 14 + - Flexible filtering and querying via `pai list` / `pai export` 15 + - Self-hostable HTTP API (`pai serve` exposes `/api/feed`, `/api/item/{id}`, and `/status`) 16 + - Cloudflare Worker deployment path (D1) for serverless setups 16 17 17 18 ## Quick Start 18 19 ··· 36 37 pai db-check 37 38 ``` 38 39 40 + <details> 41 + <summary>For server mode, run the built-in HTTP server against your SQLite database:</summary> 42 + 43 + <br> 44 + 45 + ```bash 46 + pai serve -d /var/lib/pai/pai.db -a 127.0.0.1:8080 47 + ``` 48 + 49 + Endpoints: 50 + 51 + - `GET /api/feed` – list newest items (supports `source_kind`, `source_id`, `limit`, `since`, `q`) 52 + - `GET /api/item/{id}` – fetch a single item 53 + - `GET /status` – health/status summary (total items, counts per source) 54 + 55 + For reverse-proxy examples (nginx, Caddy, Docker), see [DEPLOYMENT.md](./DEPLOYMENT.md). 56 + 57 + </details> 58 + 39 59 ## Configuration 40 60 41 61 Configuration is loaded from `$XDG_CONFIG_HOME/pai/config.toml` or `$HOME/.config/pai/config.toml`. 42 62 43 63 See [config.example.toml](./config.example.toml) for a complete example with all available options. 64 + 65 + ## Documentation 66 + 67 + - CLI synopsis: `pai -h`, `pai <command> -h`, or `pai man` for the generated `pai(1)` page. 68 + - Database schema and config reference: [config.example.toml](./config.example.toml). 69 + - Deployment topologies: [DEPLOYMENT.md](./DEPLOYMENT.md). 44 70 45 71 ## Architecture 46 72 ··· 207 233 208 234 ## License 209 235 210 - See [LICENSE file](./LICENSE) for details. 236 + See [LICENSE](./LICENSE)
+8
cli/Cargo.toml
··· 18 18 serde = { version = "1.0", features = ["derive"] } 19 19 axum = "0.7" 20 20 tokio = { version = "1.40", features = ["macros", "rt-multi-thread", "signal"] } 21 + 22 + [dev-dependencies] 23 + tempfile = "3.13" 24 + 25 + [build-dependencies] 26 + clap = { version = "4.5", features = ["derive"] } 27 + clap_mangen = "0.2" 28 + pai-core = { path = "../core" }
+26
cli/build.rs
··· 1 + use std::{env, fs, path::PathBuf}; 2 + 3 + fn main() { 4 + println!("cargo:rerun-if-changed=src/app.rs"); 5 + println!("cargo:rerun-if-changed=src/main.rs"); 6 + 7 + let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR not set by Cargo build script environment")); 8 + let man_path = out_dir.join("pai.1"); 9 + 10 + let man = build_manpage(); 11 + fs::write(&man_path, man).expect("failed to write manpage"); 12 + println!("cargo:rustc-env=PAI_MAN_PAGE={}", man_path.display()); 13 + } 14 + 15 + #[path = "src/app.rs"] 16 + mod app; 17 + 18 + fn build_manpage() -> Vec<u8> { 19 + use clap::CommandFactory; 20 + 21 + let cmd = app::Cli::command(); 22 + let man = clap_mangen::Man::new(cmd); 23 + let mut buffer = Vec::new(); 24 + man.render(&mut buffer).expect("failed to render man page"); 25 + buffer 26 + }
+112
cli/src/app.rs
··· 1 + use clap::{Parser, Subcommand}; 2 + use pai_core::SourceKind; 3 + use std::path::PathBuf; 4 + 5 + /// Personal Activity Index - POSIX-style CLI for content aggregation 6 + #[derive(Parser, Debug)] 7 + #[command(name = "pai")] 8 + #[command(version, about, long_about = None)] 9 + pub struct Cli { 10 + /// Set configuration directory 11 + #[arg(short = 'C', value_name = "DIR", global = true)] 12 + pub config_dir: Option<PathBuf>, 13 + 14 + /// Path to SQLite database file 15 + #[arg(short = 'd', value_name = "PATH", global = true)] 16 + pub db_path: Option<PathBuf>, 17 + 18 + #[command(subcommand)] 19 + pub command: Commands, 20 + } 21 + 22 + #[derive(Parser, Debug)] 23 + pub struct ExportOpts { 24 + /// Filter by source kind 25 + #[arg(short = 'k', value_name = "KIND")] 26 + pub kind: Option<SourceKind>, 27 + 28 + /// Filter by specific source ID 29 + #[arg(short = 'S', value_name = "ID")] 30 + pub source_id: Option<String>, 31 + 32 + /// Maximum number of items 33 + #[arg(short = 'n', value_name = "NUMBER")] 34 + pub limit: Option<usize>, 35 + 36 + /// Only items published at or after this time 37 + #[arg(short = 's', value_name = "TIME")] 38 + pub since: Option<String>, 39 + 40 + /// Filter items by substring 41 + #[arg(short = 'q', value_name = "PATTERN")] 42 + pub query: Option<String>, 43 + 44 + /// Output format 45 + #[arg(short = 'f', value_name = "FORMAT", default_value = "json")] 46 + pub format: String, 47 + 48 + /// Output file (default: stdout) 49 + #[arg(short = 'o', value_name = "FILE")] 50 + pub output: Option<PathBuf>, 51 + } 52 + 53 + #[derive(Subcommand, Debug)] 54 + pub enum Commands { 55 + /// Fetch and store content from configured sources 56 + Sync { 57 + /// Sync all configured sources (default) 58 + #[arg(short = 'a')] 59 + all: bool, 60 + 61 + /// Sync only a particular source kind 62 + #[arg(short = 'k', value_name = "KIND")] 63 + kind: Option<SourceKind>, 64 + 65 + /// Sync only a specific source instance 66 + #[arg(short = 'S', value_name = "ID")] 67 + source_id: Option<String>, 68 + }, 69 + 70 + /// Inspect stored items 71 + List { 72 + /// Filter by source kind 73 + #[arg(short = 'k', value_name = "KIND")] 74 + kind: Option<SourceKind>, 75 + 76 + /// Filter by specific source ID 77 + #[arg(short = 'S', value_name = "ID")] 78 + source_id: Option<String>, 79 + 80 + /// Maximum number of items to display 81 + #[arg(short = 'n', value_name = "NUMBER", default_value = "20")] 82 + limit: usize, 83 + 84 + /// Only show items published at or after this time 85 + #[arg(short = 's', value_name = "TIME")] 86 + since: Option<String>, 87 + 88 + /// Filter items by substring in title/summary 89 + #[arg(short = 'q', value_name = "PATTERN")] 90 + query: Option<String>, 91 + }, 92 + 93 + /// Produce feeds or export files 94 + Export(ExportOpts), 95 + 96 + /// Self-host HTTP API 97 + Serve { 98 + /// Address to bind HTTP server to 99 + #[arg(short = 'a', value_name = "ADDRESS", default_value = "127.0.0.1:8080")] 100 + address: String, 101 + }, 102 + 103 + /// Verify database schema and print statistics 104 + DbCheck, 105 + 106 + /// Initialize configuration file 107 + Init { 108 + /// Force overwrite existing config 109 + #[arg(short = 'f')] 110 + force: bool, 111 + }, 112 + }
+7 -114
cli/src/main.rs
··· 1 + mod app; 1 2 mod paths; 2 3 mod server; 3 4 mod storage; 4 5 6 + use app::{Cli, Commands, ExportOpts}; 5 7 use chrono::{DateTime, Duration, Utc}; 6 - use clap::{Parser, Subcommand}; 8 + use clap::Parser; 7 9 use owo_colors::OwoColorize; 8 10 use pai_core::{Config, Item, ListFilter, PaiError, SourceKind}; 9 11 use std::fs::File; ··· 12 14 use std::str::FromStr; 13 15 use storage::SqliteStorage; 14 16 15 - /// Personal Activity Index - POSIX-style CLI for content aggregation 16 - #[derive(Parser, Debug)] 17 - #[command(name = "pai")] 18 - #[command(version, about, long_about = None)] 19 - struct Cli { 20 - /// Set configuration directory 21 - #[arg(short = 'C', value_name = "DIR", global = true)] 22 - config_dir: Option<PathBuf>, 23 - 24 - /// Path to SQLite database file 25 - #[arg(short = 'd', value_name = "PATH", global = true)] 26 - db_path: Option<PathBuf>, 27 - 28 - #[command(subcommand)] 29 - command: Commands, 30 - } 31 - 32 - #[derive(Parser, Debug)] 33 - struct ExportOpts { 34 - /// Filter by source kind 35 - #[arg(short = 'k', value_name = "KIND")] 36 - kind: Option<SourceKind>, 37 - 38 - /// Filter by specific source ID 39 - #[arg(short = 'S', value_name = "ID")] 40 - source_id: Option<String>, 41 - 42 - /// Maximum number of items 43 - #[arg(short = 'n', value_name = "NUMBER")] 44 - limit: Option<usize>, 45 - 46 - /// Only items published at or after this time 47 - #[arg(short = 's', value_name = "TIME")] 48 - since: Option<String>, 49 - 50 - /// Filter items by substring 51 - #[arg(short = 'q', value_name = "PATTERN")] 52 - query: Option<String>, 53 - 54 - /// Output format 55 - #[arg(short = 'f', value_name = "FORMAT", default_value = "json")] 56 - format: String, 57 - 58 - /// Output file (default: stdout) 59 - #[arg(short = 'o', value_name = "FILE")] 60 - output: Option<PathBuf>, 61 - } 62 - 63 - #[derive(Subcommand, Debug)] 64 - enum Commands { 65 - /// Fetch and store content from configured sources 66 - Sync { 67 - /// Sync all configured sources (default) 68 - #[arg(short = 'a')] 69 - all: bool, 70 - 71 - /// Sync only a particular source kind 72 - #[arg(short = 'k', value_name = "KIND")] 73 - kind: Option<SourceKind>, 74 - 75 - /// Sync only a specific source instance 76 - #[arg(short = 'S', value_name = "ID")] 77 - source_id: Option<String>, 78 - }, 79 - 80 - /// Inspect stored items 81 - List { 82 - /// Filter by source kind 83 - #[arg(short = 'k', value_name = "KIND")] 84 - kind: Option<SourceKind>, 85 - 86 - /// Filter by specific source ID 87 - #[arg(short = 'S', value_name = "ID")] 88 - source_id: Option<String>, 89 - 90 - /// Maximum number of items to display 91 - #[arg(short = 'n', value_name = "NUMBER", default_value = "20")] 92 - limit: usize, 93 - 94 - /// Only show items published at or after this time 95 - #[arg(short = 's', value_name = "TIME")] 96 - since: Option<String>, 97 - 98 - /// Filter items by substring in title/summary 99 - #[arg(short = 'q', value_name = "PATTERN")] 100 - query: Option<String>, 101 - }, 102 - 103 - /// Produce feeds or export files 104 - Export(ExportOpts), 105 - 106 - /// Self-host HTTP API 107 - Serve { 108 - /// Address to bind HTTP server to 109 - #[arg(short = 'a', value_name = "ADDRESS", default_value = "127.0.0.1:8080")] 110 - address: String, 111 - }, 112 - 113 - /// Verify database schema and print statistics 114 - DbCheck, 115 - 116 - /// Initialize configuration file 117 - Init { 118 - /// Force overwrite existing config 119 - #[arg(short = 'f')] 120 - force: bool, 121 - }, 122 - } 17 + const PUBLISHED_WIDTH: usize = 19; 18 + const KIND_WIDTH: usize = 9; 19 + const SOURCE_WIDTH: usize = 24; 20 + const TITLE_WIDTH: usize = 60; 123 21 124 22 fn main() { 125 23 let cli = Cli::parse(); ··· 527 425 } 528 426 529 427 fn write_items_table<W: Write>(items: &[Item], writer: &mut W) -> io::Result<()> { 530 - const PUBLISHED_WIDTH: usize = 19; 531 - const KIND_WIDTH: usize = 9; 532 - const SOURCE_WIDTH: usize = 24; 533 - const TITLE_WIDTH: usize = 60; 534 - 535 428 let header = format!( 536 429 "| {published:<pub_width$} | {kind:<kind_width$} | {source:<source_width$} | {title:<title_width$} |", 537 430 published = "Published",
+66 -2
cli/src/server.rs
··· 10 10 use owo_colors::OwoColorize; 11 11 use pai_core::{Item, ListFilter, PaiError, SourceKind}; 12 12 use serde::{Deserialize, Serialize}; 13 + use std::io; 13 14 use std::{net::SocketAddr, path::PathBuf, sync::Arc}; 14 15 use tokio::net::TcpListener; 15 16 ··· 30 31 } 31 32 32 33 async fn run_server(db_path: PathBuf, addr: SocketAddr) -> Result<(), PaiError> { 33 - // Ensure the database exists and schema is ready before serving requests. 34 34 let storage = SqliteStorage::new(&db_path)?; 35 35 storage.verify_schema()?; 36 36 drop(storage); ··· 40 40 let app = Router::new() 41 41 .route("/api/feed", get(feed_handler)) 42 42 .route("/api/item/:id", get(item_handler)) 43 + .route("/status", get(status_handler)) 43 44 .with_state(state); 44 45 45 46 let listener = TcpListener::bind(addr).await.map_err(PaiError::Io)?; ··· 49 50 axum::serve(listener, app.into_make_service()) 50 51 .with_graceful_shutdown(shutdown_signal()) 51 52 .await 52 - .map_err(|err| PaiError::Io(std::io::Error::new(std::io::ErrorKind::Other, err))) 53 + .map_err(|e| io::Error::other(e).into()) 53 54 } 54 55 55 56 #[derive(Clone)] ··· 60 61 impl AppState { 61 62 fn open_storage(&self) -> Result<SqliteStorage, PaiError> { 62 63 SqliteStorage::new(self.db_path.as_ref()) 64 + } 65 + 66 + fn status_snapshot(&self) -> Result<StatusResponse, PaiError> { 67 + let storage = self.open_storage()?; 68 + let total_items = storage.count_items()?; 69 + let sources = storage 70 + .get_stats()? 71 + .into_iter() 72 + .map(|(kind, count)| SourceStat { kind, count }) 73 + .collect(); 74 + 75 + Ok(StatusResponse { status: "ok", database_path: self.db_path.display().to_string(), total_items, sources }) 63 76 } 64 77 } 65 78 ··· 95 108 items: Vec<Item>, 96 109 } 97 110 111 + #[derive(Serialize)] 112 + struct StatusResponse { 113 + status: &'static str, 114 + database_path: String, 115 + total_items: usize, 116 + sources: Vec<SourceStat>, 117 + } 118 + 119 + #[derive(Serialize)] 120 + struct SourceStat { 121 + kind: String, 122 + count: usize, 123 + } 124 + 98 125 async fn feed_handler( 99 126 State(state): State<AppState>, Query(query): Query<FeedQuery>, 100 127 ) -> Result<Json<FeedResponse>, ApiError> { ··· 114 141 Ok(Json(item)) 115 142 } 116 143 144 + async fn status_handler(State(state): State<AppState>) -> Result<Json<StatusResponse>, ApiError> { 145 + let snapshot = state.status_snapshot()?; 146 + Ok(Json(snapshot)) 147 + } 148 + 117 149 struct ApiError { 118 150 status: StatusCode, 119 151 message: String, ··· 160 192 #[cfg(test)] 161 193 mod tests { 162 194 use super::*; 195 + use chrono::Utc; 196 + use pai_core::Storage; 197 + use tempfile::tempdir; 163 198 164 199 #[test] 165 200 fn feed_query_defaults() { ··· 199 234 fn api_error_into_response_sets_status() { 200 235 let resp = ApiError::bad_request("oops").into_response(); 201 236 assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 237 + } 238 + 239 + #[test] 240 + fn status_snapshot_reports_counts() { 241 + let dir = tempdir().unwrap(); 242 + let db_path = dir.path().join("status.db"); 243 + let state = AppState { db_path: Arc::new(db_path.clone()) }; 244 + 245 + let storage = state.open_storage().unwrap(); 246 + let now = Utc::now().to_rfc3339(); 247 + let item = Item { 248 + id: "status-test".to_string(), 249 + source_kind: SourceKind::Substack, 250 + source_id: "status.substack.com".to_string(), 251 + author: None, 252 + title: Some("Status".to_string()), 253 + summary: None, 254 + url: "https://example.com/status".to_string(), 255 + content_html: None, 256 + published_at: now.clone(), 257 + created_at: now, 258 + }; 259 + storage.insert_or_replace_item(&item).unwrap(); 260 + 261 + let snapshot = state.status_snapshot().unwrap(); 262 + assert_eq!(snapshot.status, "ok"); 263 + assert_eq!(snapshot.total_items, 1); 264 + assert_eq!(snapshot.sources.len(), 1); 265 + assert_eq!(snapshot.sources[0].kind, "substack"); 202 266 } 203 267 }