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.

at 2eb2bd05f4bf674ebd5dd767f64a40906453bc74 301 lines 9.6 kB view raw
1mod paths; 2mod storage; 3 4use clap::{Parser, Subcommand}; 5use owo_colors::OwoColorize; 6use pai_core::{Config, ListFilter, PaiError, SourceKind}; 7use std::path::PathBuf; 8use storage::SqliteStorage; 9 10/// Personal Activity Index - POSIX-style CLI for content aggregation 11#[derive(Parser, Debug)] 12#[command(name = "pai")] 13#[command(version, about, long_about = None)] 14struct Cli { 15 /// Set configuration directory 16 #[arg(short = 'C', value_name = "DIR", global = true)] 17 config_dir: Option<PathBuf>, 18 19 /// Path to SQLite database file 20 #[arg(short = 'd', value_name = "PATH", global = true)] 21 db_path: Option<PathBuf>, 22 23 #[command(subcommand)] 24 command: Commands, 25} 26 27#[derive(Parser, Debug)] 28struct ExportOpts { 29 /// Filter by source kind 30 #[arg(short = 'k', value_name = "KIND")] 31 kind: Option<SourceKind>, 32 33 /// Filter by specific source ID 34 #[arg(short = 'S', value_name = "ID")] 35 source_id: Option<String>, 36 37 /// Maximum number of items 38 #[arg(short = 'n', value_name = "NUMBER")] 39 limit: Option<usize>, 40 41 /// Only items published at or after this time 42 #[arg(short = 's', value_name = "TIME")] 43 since: Option<String>, 44 45 /// Filter items by substring 46 #[arg(short = 'q', value_name = "PATTERN")] 47 query: Option<String>, 48 49 /// Output format 50 #[arg(short = 'f', value_name = "FORMAT", default_value = "json")] 51 format: String, 52 53 /// Output file (default: stdout) 54 #[arg(short = 'o', value_name = "FILE")] 55 output: Option<PathBuf>, 56} 57 58impl From<ExportOpts> for ListFilter { 59 fn from(opts: ExportOpts) -> Self { 60 ListFilter { 61 source_kind: opts.kind, 62 source_id: opts.source_id, 63 limit: opts.limit, 64 since: opts.since, 65 query: opts.query, 66 } 67 } 68} 69 70#[derive(Subcommand, Debug)] 71enum Commands { 72 /// Fetch and store content from configured sources 73 Sync { 74 /// Sync all configured sources (default) 75 #[arg(short = 'a')] 76 all: bool, 77 78 /// Sync only a particular source kind 79 #[arg(short = 'k', value_name = "KIND")] 80 kind: Option<SourceKind>, 81 82 /// Sync only a specific source instance 83 #[arg(short = 'S', value_name = "ID")] 84 source_id: Option<String>, 85 }, 86 87 /// Inspect stored items 88 List { 89 /// Filter by source kind 90 #[arg(short = 'k', value_name = "KIND")] 91 kind: Option<SourceKind>, 92 93 /// Filter by specific source ID 94 #[arg(short = 'S', value_name = "ID")] 95 source_id: Option<String>, 96 97 /// Maximum number of items to display 98 #[arg(short = 'n', value_name = "NUMBER", default_value = "20")] 99 limit: usize, 100 101 /// Only show items published at or after this time 102 #[arg(short = 's', value_name = "TIME")] 103 since: Option<String>, 104 105 /// Filter items by substring in title/summary 106 #[arg(short = 'q', value_name = "PATTERN")] 107 query: Option<String>, 108 }, 109 110 /// Produce feeds or export files 111 Export(ExportOpts), 112 113 /// Self-host HTTP API 114 Serve { 115 /// Address to bind HTTP server to 116 #[arg(short = 'a', value_name = "ADDRESS", default_value = "127.0.0.1:8080")] 117 address: String, 118 }, 119 120 /// Verify database schema and print statistics 121 DbCheck, 122 123 /// Initialize configuration file 124 Init { 125 /// Force overwrite existing config 126 #[arg(short = 'f')] 127 force: bool, 128 }, 129} 130 131fn main() { 132 let cli = Cli::parse(); 133 134 let result = match cli.command { 135 Commands::Sync { all, kind, source_id } => handle_sync(cli.config_dir, cli.db_path, all, kind, source_id), 136 Commands::List { kind, source_id, limit, since, query } => { 137 handle_list(cli.db_path, kind, source_id, limit, since, query) 138 } 139 Commands::Export(opts) => handle_export(cli.db_path, opts), 140 Commands::Serve { address } => handle_serve(cli.db_path, address), 141 Commands::DbCheck => handle_db_check(cli.db_path), 142 Commands::Init { force } => handle_init(cli.config_dir, force), 143 }; 144 145 if let Err(e) = result { 146 eprintln!("{} {}", "Error:".red().bold(), e); 147 std::process::exit(1); 148 } 149} 150 151fn handle_sync( 152 config_dir: Option<PathBuf>, db_path: Option<PathBuf>, _all: bool, kind: Option<SourceKind>, 153 source_id: Option<String>, 154) -> Result<(), PaiError> { 155 let db_path = paths::resolve_db_path(db_path)?; 156 let config_dir = paths::resolve_config_dir(config_dir)?; 157 158 let storage = SqliteStorage::new(db_path)?; 159 160 let config_path = config_dir.join("config.toml"); 161 let config = if config_path.exists() { 162 Config::from_file(&config_path)? 163 } else { 164 println!( 165 "{} No config file found, using default configuration", 166 "Warning:".yellow() 167 ); 168 Config::default() 169 }; 170 171 let count = pai_core::sync_all_sources(&config, &storage, kind, source_id.as_deref())?; 172 173 if count == 0 { 174 println!("{} No sources synced (check your config or filters)", "Info:".cyan()); 175 } else { 176 println!("{} Synced {}", "Success:".green(), format!("{count} source(s)").bold()); 177 } 178 179 Ok(()) 180} 181 182fn handle_list( 183 db_path: Option<PathBuf>, kind: Option<SourceKind>, source_id: Option<String>, limit: usize, since: Option<String>, 184 query: Option<String>, 185) -> Result<(), PaiError> { 186 let db_path = paths::resolve_db_path(db_path)?; 187 let storage = SqliteStorage::new(db_path)?; 188 189 let filter = ListFilter { source_kind: kind, source_id, limit: Some(limit), since, query }; 190 191 let items = pai_core::Storage::list_items(&storage, &filter)?; 192 193 if items.is_empty() { 194 println!("{}", "No items found".yellow()); 195 return Ok(()); 196 } 197 198 println!("{} {}\n", "Found".cyan(), format!("{} items:", items.len()).bold()); 199 for item in items { 200 println!("{} {}", "ID:".bright_black(), item.id); 201 println!( 202 "{} {} {}", 203 "Source:".bright_black(), 204 item.source_kind.to_string().cyan(), 205 format!("({})", item.source_id).bright_black() 206 ); 207 if let Some(title) = &item.title { 208 println!("{} {}", "Title:".bright_black(), title.bold()); 209 } 210 if let Some(author) = &item.author { 211 println!("{} {}", "Author:".bright_black(), author); 212 } 213 println!("{} {}", "URL:".bright_black(), item.url.blue().underline()); 214 println!("{} {}", "Published:".bright_black(), item.published_at); 215 println!(); 216 } 217 218 Ok(()) 219} 220 221fn handle_export(db_path: Option<PathBuf>, opts: ExportOpts) -> Result<(), PaiError> { 222 let db_path = paths::resolve_db_path(db_path)?; 223 let _storage = SqliteStorage::new(db_path)?; 224 225 let format = opts.format.clone(); 226 let output = opts.output.clone(); 227 let filter: ListFilter = opts.into(); 228 229 println!("export command - format: {format}, output: {output:?}, filter: {filter:?}"); 230 Ok(()) 231} 232 233fn handle_serve(db_path: Option<PathBuf>, address: String) -> Result<(), PaiError> { 234 let db_path = paths::resolve_db_path(db_path)?; 235 let _storage = SqliteStorage::new(db_path)?; 236 237 println!("serve command - address: {address}"); 238 Ok(()) 239} 240 241fn handle_db_check(db_path: Option<PathBuf>) -> Result<(), PaiError> { 242 let db_path = paths::resolve_db_path(db_path)?; 243 let storage = SqliteStorage::new(db_path)?; 244 245 println!("{}", "Verifying database schema...".cyan()); 246 storage.verify_schema()?; 247 println!("{} {}\n", "Schema verification:".green(), "OK".bold()); 248 249 println!("{}", "Database statistics:".cyan().bold()); 250 let total = storage.count_items()?; 251 println!(" {}: {}", "Total items".bright_black(), total.to_string().bold()); 252 253 let stats = storage.get_stats()?; 254 if !stats.is_empty() { 255 println!("\n{}", "Items by source:".cyan().bold()); 256 for (source_kind, count) in stats { 257 println!(" {}: {}", source_kind.bright_black(), count.to_string().bold()); 258 } 259 } 260 261 Ok(()) 262} 263 264fn handle_init(config_dir: Option<PathBuf>, force: bool) -> Result<(), PaiError> { 265 let config_dir = paths::resolve_config_dir(config_dir)?; 266 let config_path = config_dir.join("config.toml"); 267 268 if config_path.exists() && !force { 269 println!( 270 "{} Config file already exists at {}", 271 "Error:".red().bold(), 272 config_path.display() 273 ); 274 println!("{} Use {} to overwrite", "Hint:".yellow(), "pai init -f".bold()); 275 return Err(PaiError::Config("Config file already exists".to_string())); 276 } 277 278 std::fs::create_dir_all(&config_dir) 279 .map_err(|e| PaiError::Config(format!("Failed to create config directory: {e}")))?; 280 281 let default_config = include_str!("../../config.example.toml"); 282 std::fs::write(&config_path, default_config) 283 .map_err(|e| PaiError::Config(format!("Failed to write config file: {e}")))?; 284 285 println!("{} Created configuration file", "Success:".green().bold()); 286 println!( 287 " {}: {}", 288 "Location".bright_black(), 289 config_path.display().to_string().bold() 290 ); 291 println!(); 292 println!("{}", "Next steps:".cyan().bold()); 293 println!(" 1. Edit the config file to add your sources:"); 294 println!(" {}", format!("$EDITOR {}", config_path.display()).bright_black()); 295 println!(" 2. Run sync to fetch content:"); 296 println!(" {}", "pai sync".bright_black()); 297 println!(" 3. List your items:"); 298 println!(" {}", "pai list -n 10".bright_black()); 299 300 Ok(()) 301}