personal activity index (bluesky, leaflet, substack)
pai.desertthunder.dev
rss
bluesky
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}