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(worker): add API docs, update status, and migrate to RSS for leaflet

+380 -101
+7 -6
Cargo.lock
··· 1148 1148 "thiserror", 1149 1149 "tokio", 1150 1150 "toml", 1151 + "uuid", 1151 1152 ] 1152 1153 1153 1154 [[package]] ··· 2293 2294 2294 2295 [[package]] 2295 2296 name = "worker" 2296 - version = "0.6.7" 2297 + version = "0.7.0" 2297 2298 source = "registry+https://github.com/rust-lang/crates.io-index" 2298 - checksum = "d9320293035d2074f1fb84baf7d79d7932c183dd04a7e0c143dc75db0d0037ac" 2299 + checksum = "c297e1a9f0e31ca0ba7e655977412bd85771278dafe5b84fc46f72b5caef5b6d" 2299 2300 dependencies = [ 2300 2301 "async-trait", 2301 2302 "bytes", ··· 2323 2324 2324 2325 [[package]] 2325 2326 name = "worker-macros" 2326 - version = "0.6.7" 2327 + version = "0.7.0" 2327 2328 source = "registry+https://github.com/rust-lang/crates.io-index" 2328 - checksum = "eb37d4f9d99921836a1e4dc21e6041df9b0c2c5fe3c230edddd172a8ef9e251e" 2329 + checksum = "f6d57d9439ad92355f1e561bbbb33abf403125850c9b25ea6be4371b8bebcd4e" 2329 2330 dependencies = [ 2330 2331 "async-trait", 2331 2332 "proc-macro2", ··· 2339 2340 2340 2341 [[package]] 2341 2342 name = "worker-sys" 2342 - version = "0.6.7" 2343 + version = "0.7.0" 2343 2344 source = "registry+https://github.com/rust-lang/crates.io-index" 2344 - checksum = "07b4e2ca5d405247a986d533bba78c396c941835747977631168b8b05304f1b6" 2345 + checksum = "d5abe9e356c630837d9dd7ee465946f809368b110672fdf1334ff64f89290981" 2345 2346 dependencies = [ 2346 2347 "cfg-if", 2347 2348 "js-sys",
+31 -8
DEPLOYMENT.md
··· 193 193 194 194 1. Cloudflare account with Workers enabled 195 195 2. [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/) installed 196 + `npx wrangler` works here as well. 196 197 3. Rust toolchain with `wasm32-unknown-unknown` target 198 + 4. Crate `worker-build` 197 199 198 200 ### Quick Start 199 201 ··· 228 230 [[d1_databases]] 229 231 binding = "DB" 230 232 database_name = "personal-activity-db" 231 - database_id = "your-database-id-here" # Replace with actual ID 233 + database_id = "your-database-id-here" # Replace with returned database_id 232 234 ``` 233 235 234 236 Then copy to the active config: ··· 240 242 #### 3. Initialize Database Schema 241 243 242 244 ```sh 243 - wrangler d1 execute personal-activity-db --file=schema.sql 245 + wrangler d1 execute personal-activity-db --remote --file=schema.sql 244 246 ``` 245 247 248 + Note that you can omit `--remote` for local development. 249 + 246 250 #### 4. Build and Deploy 247 251 248 252 ```sh 249 253 # Build the worker 250 254 cd .. 251 255 cargo install worker-build 252 - worker-build --release -p pai-worker 256 + worker-build --release worker 257 + ``` 258 + 259 + #### 5. Patch Generated Code 260 + 261 + The worker-build output requires two patches for compatibility with wrangler: 262 + 263 + ```sh 264 + # 1. Fix import syntax (remove 'source' keyword) 265 + sed -i.bak 's/import source wasmModule/import wasmModule/' worker/build/index.js 266 + 267 + # 2. Add default export for ES module format (required for D1 bindings) 268 + echo -e "\nexport default { fetch, scheduled };" >> worker/build/index.js 269 + ``` 270 + 271 + On macOS, use `sed -i '' ...` instead of `sed -i.bak ...`. 272 + 273 + #### 6. Deploy 253 274 254 - # Deploy 275 + ```sh 255 276 cd cloudflare-deployment 256 277 wrangler deploy 257 278 ``` ··· 292 313 293 314 ### API Endpoints 294 315 295 - The Worker exposes the same API as the self-hosted server: 316 + The Worker exposes the following API: 296 317 297 - - `GET /api/feed?source_kind=bluesky&limit=20` - List items 298 - - `GET /api/item/{id}` - Get single item 299 - - `GET /status` - Health check 318 + - `GET /` - API documentation (JSON) 319 + - `GET /api/feed?source_kind=bluesky&limit=20` - List items with optional filters 320 + - `GET /api/item/{id}` - Get single item by ID 321 + - `POST /api/sync` - Manually trigger synchronization from all configured sources 322 + - `GET /status` - Health check and version info 300 323 301 324 ### Local Development 302 325
+7 -1
core/Cargo.toml
··· 9 9 serde_json = "1.0" 10 10 toml = "0.9" 11 11 reqwest = { version = "0.12", features = ["json"] } 12 - tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] } 13 12 feed-rs = "2.2" 14 13 chrono = "0.4" 14 + 15 + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 16 + tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] } 17 + 18 + [target.'cfg(target_arch = "wasm32")'.dependencies] 19 + tokio = { version = "1.0", features = ["macros", "sync"] } 20 + uuid = { version = "1.18", features = ["v4", "js"] }
+3
core/src/lib.rs
··· 1 + #[cfg(not(target_arch = "wasm32"))] 1 2 mod fetchers; 2 3 3 4 use serde::{Deserialize, Serialize}; ··· 5 6 use std::{fmt, str::FromStr}; 6 7 use thiserror::Error; 7 8 9 + #[cfg(not(target_arch = "wasm32"))] 8 10 pub use fetchers::{BearBlogFetcher, BlueskyFetcher, LeafletFetcher, SubstackFetcher}; 9 11 10 12 /// Errors that can occur in the Personal Activity Index ··· 227 229 /// Returns the number of sources successfully synced. 228 230 /// 229 231 /// Filters sources based on optional kind and source_id parameters. 232 + #[cfg(not(target_arch = "wasm32"))] 230 233 pub fn sync_all_sources( 231 234 config: &Config, storage: &dyn Storage, kind: Option<SourceKind>, source_id: Option<&str>, 232 235 ) -> Result<usize> {
+1 -1
worker/Cargo.toml
··· 8 8 9 9 [dependencies] 10 10 pai-core = { path = "../core" } 11 - worker = { version = "0.6", features = ["d1"] } 11 + worker = { version = "0.7", features = ["d1"] } 12 12 serde = { version = "1.0", features = ["derive"] } 13 13 serde_json = "1.0" 14 14 serde_urlencoded = "0.7"
+110
worker/api-docs.json
··· 1 + { 2 + "name": "Personal Activity Index API", 3 + "version": "0.0.0", 4 + "description": "Aggregate and query your personal activity across multiple platforms", 5 + "endpoints": [ 6 + { 7 + "method": "GET", 8 + "path": "/", 9 + "description": "API documentation", 10 + "response": "This documentation in JSON format" 11 + }, 12 + { 13 + "method": "GET", 14 + "path": "/status", 15 + "description": "Health check and version information", 16 + "response": { 17 + "status": "ok", 18 + "version": "string" 19 + } 20 + }, 21 + { 22 + "method": "GET", 23 + "path": "/api/feed", 24 + "description": "List items from all sources with optional filtering", 25 + "parameters": [ 26 + { 27 + "name": "source_kind", 28 + "type": "string", 29 + "required": false, 30 + "description": "Filter by source type", 31 + "values": ["substack", "bluesky", "leaflet", "bearblog"] 32 + }, 33 + { 34 + "name": "source_id", 35 + "type": "string", 36 + "required": false, 37 + "description": "Filter by specific source identifier (domain or handle)" 38 + }, 39 + { 40 + "name": "limit", 41 + "type": "integer", 42 + "required": false, 43 + "default": 20, 44 + "description": "Maximum number of items to return" 45 + }, 46 + { 47 + "name": "since", 48 + "type": "string", 49 + "required": false, 50 + "description": "ISO 8601 timestamp - only return items published after this time" 51 + }, 52 + { 53 + "name": "q", 54 + "type": "string", 55 + "required": false, 56 + "description": "Search query - matches against title and summary" 57 + } 58 + ], 59 + "response": { 60 + "items": [ 61 + { 62 + "id": "string", 63 + "source_kind": "bluesky|substack|leaflet|bearblog", 64 + "source_id": "string", 65 + "author": "string?", 66 + "title": "string?", 67 + "summary": "string?", 68 + "url": "string", 69 + "content_html": "string?", 70 + "published_at": "ISO 8601 timestamp", 71 + "created_at": "ISO 8601 timestamp" 72 + } 73 + ] 74 + } 75 + }, 76 + { 77 + "method": "GET", 78 + "path": "/api/item/:id", 79 + "description": "Get a single item by its unique ID", 80 + "parameters": [ 81 + { 82 + "name": "id", 83 + "type": "string", 84 + "required": true, 85 + "description": "The unique identifier of the item" 86 + } 87 + ], 88 + "response": "Single item object or 404 if not found" 89 + }, 90 + { 91 + "method": "POST", 92 + "path": "/api/sync", 93 + "description": "Manually trigger synchronization from all configured sources", 94 + "response": { 95 + "status": "success", 96 + "message": "Sync completed successfully" 97 + } 98 + } 99 + ], 100 + "sources": { 101 + "substack": "RSS feeds from Substack publications", 102 + "bluesky": "Posts from Bluesky via AT Protocol API", 103 + "leaflet": "Publications from Leaflet (Bluesky-based blogging)", 104 + "bearblog": "Posts from Bear Blog RSS feeds" 105 + }, 106 + "scheduled_sync": { 107 + "description": "Automatic synchronization runs on a scheduled basis", 108 + "schedule": "Configured via cron triggers in wrangler.toml" 109 + } 110 + }
+213 -84
worker/src/lib.rs
··· 3 3 use wasm_bindgen::JsValue; 4 4 use worker::*; 5 5 6 + #[derive(Serialize, Deserialize)] 7 + struct ApiDocumentation { 8 + name: String, 9 + version: String, 10 + description: String, 11 + endpoints: Vec<Endpoint>, 12 + sources: Sources, 13 + scheduled_sync: ScheduledSync, 14 + } 15 + 16 + #[derive(Serialize, Deserialize)] 17 + struct Endpoint { 18 + method: String, 19 + path: String, 20 + url: Option<String>, 21 + description: String, 22 + #[serde(skip_serializing_if = "Option::is_none")] 23 + parameters: Option<Vec<Parameter>>, 24 + #[serde(skip_serializing_if = "Option::is_none")] 25 + examples: Option<Vec<String>>, 26 + response: serde_json::Value, 27 + } 28 + 29 + #[derive(Serialize, Deserialize)] 30 + struct Parameter { 31 + name: String, 32 + r#type: String, 33 + required: bool, 34 + #[serde(skip_serializing_if = "Option::is_none")] 35 + default: Option<serde_json::Value>, 36 + description: String, 37 + #[serde(skip_serializing_if = "Option::is_none")] 38 + values: Option<Vec<String>>, 39 + } 40 + 41 + #[derive(Serialize, Deserialize)] 42 + struct Sources { 43 + substack: String, 44 + bluesky: String, 45 + leaflet: String, 46 + bearblog: String, 47 + } 48 + 49 + #[derive(Serialize, Deserialize)] 50 + struct ScheduledSync { 51 + description: String, 52 + schedule: String, 53 + } 54 + 6 55 #[derive(Deserialize)] 7 56 struct SyncConfig { 8 57 substack: Option<SubstackConfig>, ··· 51 100 struct StatusResponse { 52 101 status: &'static str, 53 102 version: &'static str, 103 + total_items: usize, 104 + sources: std::collections::HashMap<String, usize>, 54 105 } 55 106 56 107 #[event(fetch)] 57 108 async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> { 58 109 let router = Router::new(); 59 110 router 111 + .get_async("/", |req, _ctx| async move { 112 + let url = req 113 + .url() 114 + .map_err(|e| Error::RustError(format!("Failed to get URL: {e}")))?; 115 + let base_url = url.origin().unicode_serialization(); 116 + 117 + let docs_template = include_str!("../api-docs.json"); 118 + let mut docs: ApiDocumentation = serde_json::from_str(docs_template) 119 + .map_err(|e| Error::RustError(format!("Failed to parse API docs: {e}")))?; 120 + 121 + docs.version = env!("CARGO_PKG_VERSION").to_string(); 122 + 123 + for endpoint in &mut docs.endpoints { 124 + endpoint.url = Some(format!("{}{}", base_url, endpoint.path)); 125 + 126 + if endpoint.path == "/api/feed" { 127 + endpoint.examples = Some(vec![ 128 + format!("{}/api/feed", base_url), 129 + format!("{}/api/feed?source_kind=bluesky&limit=10", base_url), 130 + format!("{}/api/feed?q=rust&limit=5", base_url), 131 + ]); 132 + } 133 + } 134 + 135 + Response::from_json(&docs) 136 + }) 60 137 .get_async("/api/feed", |req, ctx| async move { handle_feed(req, ctx).await }) 61 138 .get_async("/api/item/:id", |_req, ctx| async move { 62 139 let id = ctx ··· 64 141 .ok_or_else(|| Error::RustError("Missing id parameter".into()))?; 65 142 handle_item(id, &ctx).await 66 143 }) 67 - .get("/status", |_req, _ctx| { 68 - let version = env!("CARGO_PKG_VERSION"); 69 - let status = StatusResponse { status: "ok", version }; 144 + .post_async("/api/sync", |_req, ctx| async move { 145 + match run_sync(&ctx.env).await { 146 + Ok(_) => Response::from_json(&serde_json::json!({ 147 + "status": "success", 148 + "message": "Sync completed successfully" 149 + })), 150 + Err(e) => Response::error(format!("Sync failed: {e}"), 500), 151 + } 152 + }) 153 + .get_async("/status", |_req, ctx| async move { 154 + let db = ctx.env.d1("DB")?; 155 + 156 + let total_result = db 157 + .prepare("SELECT COUNT(*) as count FROM items") 158 + .first::<serde_json::Value>(None) 159 + .await?; 160 + 161 + let total_items = total_result.and_then(|v| v.get("count")?.as_u64()).unwrap_or(0) as usize; 162 + 163 + let sources_result = db 164 + .prepare("SELECT source_kind, COUNT(*) as count FROM items GROUP BY source_kind") 165 + .all() 166 + .await?; 167 + 168 + let mut sources = std::collections::HashMap::new(); 169 + if let Ok(results) = sources_result.results::<serde_json::Value>() { 170 + for result in results { 171 + if let (Some(kind), Some(count)) = ( 172 + result.get("source_kind").and_then(|v| v.as_str()), 173 + result.get("count").and_then(|v| v.as_u64()), 174 + ) { 175 + sources.insert(kind.to_string(), count as usize); 176 + } 177 + } 178 + } 179 + 180 + let status = StatusResponse { status: "ok", version: env!("CARGO_PKG_VERSION"), total_items, sources }; 70 181 Response::from_json(&status) 71 182 }) 72 183 .run(req, env) ··· 144 255 145 256 if let Some(limit) = filter.limit { 146 257 query.push_str(" LIMIT ?"); 147 - bindings.push((limit as i64).into()); 258 + bindings.push((limit as f64).into()); 148 259 } 149 260 150 - let mut stmt = db.prepare(&query); 151 - for binding in bindings { 152 - stmt = stmt.bind(&[binding])?; 153 - } 261 + let stmt = if bindings.is_empty() { db.prepare(&query) } else { db.prepare(&query).bind(&bindings)? }; 154 262 155 263 let results = stmt.all().await?; 156 264 let items: Vec<Item> = results.results()?; ··· 377 485 } 378 486 379 487 async fn sync_leaflet(config: &LeafletConfig, db: &D1Database) -> Result<usize> { 380 - let host = normalize_source_id(&config.base_url); 381 - let subdomain = host.split('.').next().unwrap_or(&host); 382 - let did = format!("{subdomain}.bsky.social"); 488 + let feed_url = format!("{}/rss", config.base_url.trim_end_matches('/')); 383 489 384 - let api_url = format!( 385 - "https://public.api.bsky.app/xrpc/com.atproto.repo.listRecords?repo={did}&collection=pub.leaflet.post&limit=50" 386 - ); 387 - 388 - let mut req = Request::new(&api_url, Method::Get)?; 490 + let mut req = Request::new(&feed_url, Method::Get)?; 389 491 req.headers_mut()?.set("User-Agent", "pai-worker/0.1.0")?; 390 492 391 493 let mut resp = Fetch::Request(req).send().await?; 392 - let json: serde_json::Value = resp.json().await?; 494 + let body = resp.text().await?; 393 495 394 - let records = json["records"] 395 - .as_array() 396 - .ok_or_else(|| Error::RustError("Invalid Leaflet response".into()))?; 496 + let channel = 497 + rss::Channel::read_from(body.as_bytes()).map_err(|e| Error::RustError(format!("Failed to parse RSS: {e}")))?; 397 498 398 499 let mut count = 0; 399 500 400 - for record in records { 401 - let uri = record["uri"] 402 - .as_str() 403 - .ok_or_else(|| Error::RustError("Missing URI".into()))?; 404 - let value = &record["value"]; 501 + for item in channel.items() { 502 + let id = item.guid().map(|g| g.value()).unwrap_or(item.link().unwrap_or("")); 503 + let url = item.link().unwrap_or(id); 504 + let title = item.title(); 505 + let summary = item.description(); 506 + let author = item.author(); 507 + let content_html = item.content(); 405 508 406 - let title = value["title"].as_str().unwrap_or("Untitled"); 407 - let summary = value["summary"].as_str().or(value["content"].as_str()).unwrap_or(""); 408 - let slug = value["slug"].as_str().unwrap_or(""); 409 - 410 - let url = if !slug.is_empty() { 411 - format!("{}/{}", config.base_url, slug) 412 - } else { 413 - format!("{}/post/{}", config.base_url, uri.split('/').next_back().unwrap_or("")) 414 - }; 415 - 416 - let published_at = value["publishedAt"] 417 - .as_str() 418 - .or(value["createdAt"].as_str()) 419 - .unwrap_or("") 420 - .to_string(); 509 + let published_at = item 510 + .pub_date() 511 + .and_then(|s| chrono::DateTime::parse_from_rfc2822(s).ok()) 512 + .map(|dt| dt.to_rfc3339()) 513 + .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); 421 514 422 515 let created_at = chrono::Utc::now().to_rfc3339(); 423 516 ··· 427 520 ); 428 521 429 522 stmt.bind(&[ 430 - uri.into(), 523 + id.into(), 431 524 "leaflet".into(), 432 525 config.id.clone().into(), 433 - JsValue::NULL, 434 - title.into(), 435 - summary.into(), 526 + author.map(|s| s.into()).unwrap_or(JsValue::NULL), 527 + title.map(|s| s.into()).unwrap_or(JsValue::NULL), 528 + summary.map(|s| s.into()).unwrap_or(JsValue::NULL), 436 529 url.into(), 437 - JsValue::NULL, 530 + content_html.map(|s| s.into()).unwrap_or(JsValue::NULL), 438 531 published_at.into(), 439 532 created_at.into(), 440 533 ])? ··· 448 541 } 449 542 450 543 async fn sync_bearblog(config: &BearBlogConfig, db: &D1Database) -> Result<usize> { 451 - let feed_url = format!("{}/feed/", config.base_url.trim_end_matches('/')); 544 + let feed_url = format!("{}/feed/?type=rss", config.base_url.trim_end_matches('/')); 452 545 453 546 let mut req = Request::new(&feed_url, Method::Get)?; 454 547 req.headers_mut()?.set("User-Agent", "pai-worker/0.1.0")?; ··· 516 609 use super::*; 517 610 518 611 #[test] 612 + fn test_api_docs_json_is_valid() { 613 + let docs_str = include_str!("../api-docs.json"); 614 + let result = serde_json::from_str::<ApiDocumentation>(docs_str); 615 + assert!(result.is_ok(), "API docs JSON should be valid: {:?}", result.err()); 616 + 617 + let docs = result.unwrap(); 618 + assert_eq!(docs.name, "Personal Activity Index API"); 619 + assert!(!docs.description.is_empty()); 620 + assert!(!docs.endpoints.is_empty()); 621 + } 622 + 623 + #[test] 624 + fn test_api_docs_has_all_endpoints() { 625 + let docs_str = include_str!("../api-docs.json"); 626 + let docs: ApiDocumentation = serde_json::from_str(docs_str).unwrap(); 627 + 628 + let paths: Vec<&str> = docs.endpoints.iter().map(|e| e.path.as_str()).collect(); 629 + 630 + assert!(paths.contains(&"/")); 631 + assert!(paths.contains(&"/status")); 632 + assert!(paths.contains(&"/api/feed")); 633 + assert!(paths.contains(&"/api/item/:id")); 634 + assert!(paths.contains(&"/api/sync")); 635 + } 636 + 637 + #[test] 638 + fn test_api_docs_feed_endpoint_parameters() { 639 + let docs_str = include_str!("../api-docs.json"); 640 + let docs: ApiDocumentation = serde_json::from_str(docs_str).unwrap(); 641 + 642 + let feed_endpoint = docs.endpoints.iter().find(|e| e.path == "/api/feed").unwrap(); 643 + let params = feed_endpoint.parameters.as_ref().unwrap(); 644 + let param_names: Vec<&str> = params.iter().map(|p| p.name.as_str()).collect(); 645 + 646 + assert!(param_names.contains(&"source_kind")); 647 + assert!(param_names.contains(&"source_id")); 648 + assert!(param_names.contains(&"limit")); 649 + assert!(param_names.contains(&"since")); 650 + assert!(param_names.contains(&"q")); 651 + } 652 + 653 + #[test] 654 + fn test_api_docs_has_source_descriptions() { 655 + let docs_str = include_str!("../api-docs.json"); 656 + let docs: ApiDocumentation = serde_json::from_str(docs_str).unwrap(); 657 + 658 + assert!(!docs.sources.substack.is_empty()); 659 + assert!(!docs.sources.bluesky.is_empty()); 660 + assert!(!docs.sources.leaflet.is_empty()); 661 + assert!(!docs.sources.bearblog.is_empty()); 662 + } 663 + 664 + #[test] 665 + fn test_api_docs_url_generation() { 666 + let docs_str = include_str!("../api-docs.json"); 667 + let mut docs: ApiDocumentation = serde_json::from_str(docs_str).unwrap(); 668 + 669 + let base_url = "https://example.workers.dev"; 670 + for endpoint in &mut docs.endpoints { 671 + endpoint.url = Some(format!("{}{}", base_url, endpoint.path)); 672 + } 673 + 674 + let root = docs.endpoints.iter().find(|e| e.path == "/").unwrap(); 675 + assert_eq!(root.url.as_ref().unwrap(), "https://example.workers.dev/"); 676 + 677 + let feed = docs.endpoints.iter().find(|e| e.path == "/api/feed").unwrap(); 678 + assert_eq!(feed.url.as_ref().unwrap(), "https://example.workers.dev/api/feed"); 679 + } 680 + 681 + #[test] 519 682 fn test_normalize_source_id_https() { 520 683 assert_eq!( 521 684 normalize_source_id("https://patternmatched.substack.com"), ··· 561 724 } 562 725 563 726 #[test] 564 - fn test_leaflet_url_with_slug() { 565 - let base_url = "https://test.leaflet.pub"; 566 - let slug = "my-post"; 567 - let url = if !slug.is_empty() { 568 - format!("{base_url}/{slug}") 569 - } else { 570 - format!("{}/post/{}", base_url, "fallback") 571 - }; 572 - assert_eq!(url, "https://test.leaflet.pub/my-post"); 573 - } 574 - 575 - #[test] 576 - fn test_leaflet_url_without_slug() { 577 - let base_url = "https://test.leaflet.pub"; 578 - let slug = ""; 579 - let uri = "at://did:plc:abc123/pub.leaflet.post/xyz789"; 580 - let post_id = uri.split('/').next_back().unwrap_or(""); 581 - let url = if !slug.is_empty() { format!("{base_url}/{slug}") } else { format!("{base_url}/post/{post_id}") }; 582 - assert_eq!(url, "https://test.leaflet.pub/post/xyz789"); 583 - } 584 - 585 - #[test] 586 727 fn test_bluesky_post_id_extraction() { 587 728 let uri = "at://did:plc:abc123/app.bsky.feed.post/3ld7xyqnvqk2a"; 588 729 let post_id = uri.split('/').next_back().unwrap_or(""); ··· 653 794 } 654 795 655 796 #[test] 656 - fn test_leaflet_did_construction() { 657 - let subdomain = "desertthunder"; 658 - let did = format!("{subdomain}.bsky.social"); 659 - assert_eq!(did, "desertthunder.bsky.social"); 660 - } 661 - 662 - #[test] 663 - fn test_leaflet_api_url_construction() { 664 - let did = "desertthunder.bsky.social"; 665 - let api_url = format!( 666 - "https://public.api.bsky.app/xrpc/com.atproto.repo.listRecords?repo={did}&collection=pub.leaflet.post&limit=50" 667 - ); 668 - assert_eq!( 669 - api_url, 670 - "https://public.api.bsky.app/xrpc/com.atproto.repo.listRecords?repo=desertthunder.bsky.social&collection=pub.leaflet.post&limit=50" 671 - ); 797 + fn test_leaflet_feed_url_construction() { 798 + let base_url = "https://desertthunder.leaflet.pub"; 799 + let feed_url = format!("{}/rss", base_url.trim_end_matches('/')); 800 + assert_eq!(feed_url, "https://desertthunder.leaflet.pub/rss"); 672 801 } 673 802 674 803 #[test]
+8 -1
worker/wrangler.example.toml
··· 2 2 # Copy this file to wrangler.toml and update with your values 3 3 4 4 name = "personal-activity-index" 5 - main = "build/worker/index.js" 5 + main = "build/index.js" 6 6 compatibility_date = "2025-01-15" 7 7 8 8 # D1 Database Binding ··· 38 38 39 39 # Optional: Logging level 40 40 # LOG_LEVEL = "info" 41 + 42 + [observability] 43 + [observability.logs] 44 + enabled = true 45 + head_sampling_rate = 1 46 + invocation_logs = true 47 + persist = true