learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs
5
fork

Configure Feed

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

at main 401 lines 14 kB view raw
1use clap::{Parser, Subcommand}; 2use std::fs; 3use std::path::Path; 4use tokio_postgres::NoTls; 5 6#[derive(Parser)] 7#[command(name = "malfestio")] 8#[command(author = "Author <author@example.com>")] 9#[command(version = "0.1.0")] 10#[command(about = "Malfestio CLI", long_about = None)] 11struct Cli { 12 #[command(subcommand)] 13 command: Commands, 14} 15 16#[derive(Subcommand)] 17enum Commands { 18 /// Start the backend server 19 Start, 20 /// Run database migrations 21 Migrate { 22 /// Database URL (defaults to DB_URL env var) 23 #[arg(long)] 24 db_url: Option<String>, 25 }, 26 /// Check OAuth flow and database state for a Bluesky handle 27 Check { 28 /// Bluesky handle to test (e.g., alice.bsky.social) 29 handle: String, 30 }, 31 #[cfg(debug_assertions)] 32 /// [DEBUG ONLY] Debug utilities 33 Debug { 34 #[command(subcommand)] 35 command: DebugCommands, 36 }, 37} 38 39#[cfg(debug_assertions)] 40#[derive(Subcommand)] 41enum DebugCommands { 42 /// Test article extraction and markdown conversion 43 Article { 44 /// Article URL to extract 45 url: String, 46 /// Save to file instead of printing to terminal 47 #[arg(short, long)] 48 output: Option<String>, 49 }, 50} 51 52#[tokio::main] 53async fn main() -> malfestio_core::Result<()> { 54 let _ = dotenvy::from_filename(".env.local"); 55 let _ = dotenvy::dotenv(); 56 57 let cli = Cli::parse(); 58 59 match &cli.command { 60 Commands::Start => { 61 malfestio_server::start().await?; 62 } 63 Commands::Migrate { db_url } => { 64 run_migrations(db_url.as_deref()).await?; 65 } 66 Commands::Check { handle } => { 67 check_flow(handle).await?; 68 } 69 #[cfg(debug_assertions)] 70 Commands::Debug { command } => match command { 71 DebugCommands::Article { url, output } => { 72 debug_article(url, output.as_deref()).await?; 73 } 74 }, 75 } 76 77 Ok(()) 78} 79 80async fn run_migrations(db_url: Option<&str>) -> malfestio_core::Result<()> { 81 let db_url = db_url 82 .map(String::from) 83 .or_else(|| std::env::var("DB_URL").ok()) 84 .ok_or_else(|| { 85 malfestio_core::Error::InvalidArgument("DB_URL not provided via --db-url or DB_URL env var".to_string()) 86 })?; 87 88 println!("Connecting to database..."); 89 let (mut client, connection) = tokio_postgres::connect(&db_url, NoTls) 90 .await 91 .map_err(|e| malfestio_core::Error::Database(format!("Failed to connect to database: {}", e)))?; 92 93 tokio::spawn(async move { 94 if let Err(e) = connection.await { 95 eprintln!("Database connection error: {}", e); 96 } 97 }); 98 99 println!("Connected to database"); 100 101 client 102 .execute( 103 "CREATE TABLE IF NOT EXISTS schema_migrations ( 104 id SERIAL PRIMARY KEY, 105 version TEXT NOT NULL UNIQUE, 106 applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 107 )", 108 &[], 109 ) 110 .await 111 .map_err(|e| malfestio_core::Error::Database(format!("Failed to create migrations table: {}", e)))?; 112 113 let migrations_dir = Path::new("migrations"); 114 if !migrations_dir.exists() { 115 return Err(malfestio_core::Error::InvalidArgument( 116 "migrations directory not found".to_string(), 117 )); 118 } 119 120 let mut entries: Vec<_> = fs::read_dir(migrations_dir) 121 .map_err(|e| malfestio_core::Error::Other(format!("Failed to read migrations directory: {}", e)))? 122 .filter_map(|e| e.ok()) 123 .filter(|e| { 124 e.path() 125 .extension() 126 .and_then(|s| s.to_str()) 127 .map(|s| s == "sql") 128 .unwrap_or(false) 129 }) 130 .collect(); 131 132 entries.sort_by_key(|e| e.file_name()); 133 134 println!("Found {} migration files", entries.len()); 135 136 for entry in entries { 137 let path = entry.path(); 138 let filename = path.file_name().unwrap().to_str().unwrap(); 139 let version = filename.trim_end_matches(".sql"); 140 141 let row = client 142 .query_opt("SELECT version FROM schema_migrations WHERE version = $1", &[&version]) 143 .await 144 .map_err(|e| malfestio_core::Error::Database(format!("Failed to check migration status: {}", e)))?; 145 146 if row.is_some() { 147 println!("Skipping {}: already applied", filename); 148 continue; 149 } 150 151 println!("Applying {}...", filename); 152 153 let sql = fs::read_to_string(&path) 154 .map_err(|e| malfestio_core::Error::Other(format!("Failed to read migration file: {}", e)))?; 155 156 let tx = client 157 .transaction() 158 .await 159 .map_err(|e| malfestio_core::Error::Database(format!("Failed to start transaction: {}", e)))?; 160 161 tx.batch_execute(&sql) 162 .await 163 .map_err(|e| malfestio_core::Error::Database(format!("Failed to execute migration {}: {}", filename, e)))?; 164 165 tx.execute("INSERT INTO schema_migrations (version) VALUES ($1)", &[&version]) 166 .await 167 .map_err(|e| malfestio_core::Error::Database(format!("Failed to record migration: {}", e)))?; 168 169 tx.commit() 170 .await 171 .map_err(|e| malfestio_core::Error::Database(format!("Failed to commit migration: {}", e)))?; 172 173 println!("Applied {}", filename); 174 } 175 176 println!("All migrations complete!"); 177 178 Ok(()) 179} 180 181async fn check_flow(handle: &str) -> malfestio_core::Result<()> { 182 println!("Checking OAuth flow for {}...\n", handle); 183 184 // Get database URL 185 let db_url = std::env::var("DB_URL") 186 .or_else(|_| std::env::var("DATABASE_URL")) 187 .map_err(|_| malfestio_core::Error::InvalidArgument("DB_URL or DATABASE_URL not set".to_string()))?; 188 189 // Test database connection 190 print!("• Testing database connection... "); 191 let (client, connection) = tokio_postgres::connect(&db_url, NoTls) 192 .await 193 .map_err(|e| malfestio_core::Error::Database(format!("Failed to connect: {}", e)))?; 194 195 tokio::spawn(async move { 196 if let Err(e) = connection.await { 197 eprintln!("Database connection error: {}", e); 198 } 199 }); 200 201 println!("✓ Connected"); 202 203 let resolver = malfestio_server::oauth::resolver::IdentityResolver::new(); 204 205 print!("• Resolving handle to DID... "); 206 let did = match resolver.resolve_handle(handle).await { 207 Ok(did) => { 208 println!("{}", did); 209 did 210 } 211 Err(e) => { 212 println!("✗ Failed: {}", e); 213 return Err(malfestio_core::Error::Other(format!("Handle resolution failed: {}", e))); 214 } 215 }; 216 217 print!("• Resolving DID to PDS... "); 218 let _resolved = match resolver.resolve_did(&did).await { 219 Ok(resolved) => { 220 println!("{}", resolved.pds_url); 221 resolved 222 } 223 Err(e) => { 224 println!("✗ Failed: {}", e); 225 return Err(malfestio_core::Error::Other(format!("DID resolution failed: {}", e))); 226 } 227 }; 228 229 print!("• Checking OAuth tokens... "); 230 let token_row = client 231 .query_opt( 232 "SELECT did, pds_url, created_at, updated_at FROM oauth_tokens WHERE did = $1", 233 &[&did], 234 ) 235 .await 236 .map_err(|e| malfestio_core::Error::Database(format!("Token query failed: {}", e)))?; 237 238 if let Some(row) = token_row { 239 let updated_at: chrono::DateTime<chrono::Utc> = row.get(3); 240 println!("✓ Found (last updated: {})", updated_at.format("%Y-%m-%d %H:%M:%S UTC")); 241 } else { 242 println!("✗ Not found"); 243 println!("\nℹ No OAuth tokens stored yet. Complete OAuth login first:"); 244 println!(" 1. Start server: just start"); 245 println!(" 2. Start frontend: just web-dev"); 246 println!(" 3. Navigate to http://localhost:3000/login"); 247 println!(" 4. Enter handle: {}", handle); 248 return Ok(()); 249 } 250 251 print!("• Checking indexed decks... "); 252 let deck_rows = client 253 .query( 254 "SELECT at_uri, title, indexed_at FROM indexed_decks WHERE did = $1 ORDER BY indexed_at DESC LIMIT 5", 255 &[&did], 256 ) 257 .await 258 .map_err(|e| malfestio_core::Error::Database(format!("Deck query failed: {}", e)))?; 259 260 if deck_rows.is_empty() { 261 println!("0 decks"); 262 } else { 263 println!("{} deck(s)", deck_rows.len()); 264 for row in &deck_rows { 265 let at_uri: String = row.get(0); 266 let title: Option<String> = row.get(1); 267 let indexed_at: chrono::DateTime<chrono::Utc> = row.get(2); 268 let time_ago = format_time_ago(indexed_at); 269 println!(" - {} ({})", title.unwrap_or_else(|| "Untitled".to_string()), time_ago); 270 println!(" {}", at_uri); 271 } 272 } 273 274 print!("• Checking indexed cards... "); 275 let card_count: i64 = client 276 .query_one("SELECT COUNT(*) FROM indexed_cards WHERE did = $1", &[&did]) 277 .await 278 .map_err(|e| malfestio_core::Error::Database(format!("Card count query failed: {}", e)))? 279 .get(0); 280 281 println!("{} card(s)", card_count); 282 283 print!("• Checking indexed notes... "); 284 let note_count: i64 = client 285 .query_one("SELECT COUNT(*) FROM indexed_notes WHERE did = $1", &[&did]) 286 .await 287 .map_err(|e| malfestio_core::Error::Database(format!("Note count query failed: {}", e)))? 288 .get(0); 289 290 println!("{} note(s)", note_count); 291 292 println!("\n✓ Status: Ready for testing"); 293 println!("\nNext steps:"); 294 println!(" - Publish content via UI to see it indexed"); 295 println!(" - Check Bluesky profile: https://bsky.app/profile/{}", handle); 296 println!(" - Inspect records: https://pdsls.dev/at/{}", did); 297 298 Ok(()) 299} 300 301fn format_time_ago(timestamp: chrono::DateTime<chrono::Utc>) -> String { 302 let now = chrono::Utc::now(); 303 let duration = now.signed_duration_since(timestamp); 304 305 if duration.num_seconds() < 60 { 306 format!("{} seconds ago", duration.num_seconds()) 307 } else if duration.num_minutes() < 60 { 308 format!("{} minutes ago", duration.num_minutes()) 309 } else if duration.num_hours() < 24 { 310 format!("{} hours ago", duration.num_hours()) 311 } else if duration.num_days() < 30 { 312 format!("{} days ago", duration.num_days()) 313 } else { 314 format!("{} months ago", duration.num_days() / 30) 315 } 316} 317 318#[cfg(debug_assertions)] 319async fn debug_article(url: &str, output_file: Option<&str>) -> malfestio_core::Result<()> { 320 use malfestio_readability::Readability; 321 322 println!("Fetching article from: {}", url); 323 324 // Fetch HTML content with user-agent 325 let client = reqwest::Client::builder() 326 .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") 327 .build() 328 .map_err(|e| malfestio_core::Error::Other(format!("Failed to build client: {}", e)))?; 329 330 let response = client 331 .get(url) 332 .send() 333 .await 334 .map_err(|e| malfestio_core::Error::Other(format!("Failed to fetch URL: {}", e)))?; 335 336 let html_content = response 337 .text() 338 .await 339 .map_err(|e| malfestio_core::Error::Other(format!("Failed to read response: {}", e)))?; 340 341 println!("Fetched {} bytes of HTML", html_content.len()); 342 343 // Extract article using malfestio-readability 344 println!("Extracting article content..."); 345 let url_clone = url.to_string(); 346 let result = tokio::task::spawn_blocking(move || -> Result<malfestio_readability::Article, String> { 347 let readability = Readability::new(html_content, Some(&url_clone)); 348 readability.parse().map_err(|e| format!("Parse error: {}", e)) 349 }) 350 .await 351 .map_err(|e| malfestio_core::Error::Other(format!("Task join error: {}", e)))? 352 .map_err(malfestio_core::Error::Other)?; 353 354 let article = result; 355 356 println!("✓ Extracted article:"); 357 println!(" Title: {}", article.title); 358 if let Some(ref author) = article.author { 359 println!(" Author: {}", author); 360 } 361 if let Some(ref date) = article.published_date { 362 println!(" Published: {}", date); 363 } 364 println!(" Content length: {} bytes", article.content.len()); 365 println!(" Markdown length: {} bytes", article.markdown.len()); 366 367 if let Some(file_path) = output_file { 368 println!("\nSaving to file: {}", file_path); 369 370 let mut output = String::new(); 371 output.push_str(&format!("# {}\n\n", article.title)); 372 if let Some(ref author) = article.author { 373 output.push_str(&format!("**Author:** {}\n", author)); 374 } 375 if let Some(ref date) = article.published_date { 376 output.push_str(&format!("**Published:** {}\n", date)); 377 } 378 output.push_str(&format!("**Source:** {}\n\n", url)); 379 output.push_str("---\n\n"); 380 output.push_str(&article.markdown); 381 382 fs::write(file_path, output) 383 .map_err(|e| malfestio_core::Error::Other(format!("Failed to write file: {}", e)))?; 384 385 println!("✓ Saved to {}", file_path); 386 } else { 387 println!("\n{}", "=".repeat(80)); 388 println!("# {}", article.title); 389 if let Some(ref author) = article.author { 390 println!("\n**Author:** {}", author); 391 } 392 if let Some(ref date) = article.published_date { 393 println!("**Published:** {}", date); 394 } 395 println!("**Source:** {}", url); 396 println!("{}", "=".repeat(80)); 397 println!("\n{}", article.markdown); 398 } 399 400 Ok(()) 401}