Command-line tool for managing your AT Protocol bookmarks. Works with kipclip.com and any app that uses the same record format. kipclip.com
atproto rust kipclip bookmarks tags toread atprotocol
1
fork

Configure Feed

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

Add CI, search, ref-based commands, client-side enrichment

- GitHub Actions CI with fmt, clippy, test, build
- Search/filter bookmarks by title, URL, description
- Ref-based bookmark resolution (rkey prefix matching)
- Client-side URL enrichment with HTML parsing and sanitization
- JSON output mode for list and tags commands
- Terminal-aware display with unicode width handling
- Notes support on bookmarks via annotation sidecar

+618 -113
+32
.github/workflows/ci.yml
··· 1 + name: CI 2 + 3 + on: 4 + push: 5 + branches: [main] 6 + pull_request: 7 + branches: [main] 8 + 9 + env: 10 + CARGO_TERM_COLOR: always 11 + 12 + jobs: 13 + check: 14 + runs-on: ubuntu-latest 15 + steps: 16 + - uses: actions/checkout@v4 17 + - uses: dtolnay/rust-toolchain@stable 18 + with: 19 + components: rustfmt, clippy 20 + - uses: Swatinem/rust-cache@v2 21 + 22 + - name: Format 23 + run: cargo fmt -- --check 24 + 25 + - name: Clippy 26 + run: cargo clippy --all-targets -- -D warnings 27 + 28 + - name: Test 29 + run: cargo test 30 + 31 + - name: Build 32 + run: cargo build --release
+3
.gitignore
··· 1 1 /target 2 + .env 3 + *.pem 4 + *.key
+3
.rustfmt.toml
··· 1 + edition = "2024" 2 + max_width = 100 3 + use_field_init_shorthand = true
+1
clippy.toml
··· 1 + msrv = "1.85"
+5 -2
src/commands/add.rs
··· 3 3 use crate::kipclip::enrich; 4 4 use crate::kipclip::pds::PdsClient; 5 5 use crate::kipclip::types::*; 6 + use crate::kipclip::url::validate_http_url; 6 7 7 8 pub async fn run(pds: &PdsClient, url: &str, tags: &[String]) -> Result<()> { 8 - // Check for duplicates 9 - let existing = pds.fetch_enriched_bookmarks(None).await?; 9 + validate_http_url(url)?; 10 + 11 + // Check for duplicates (only need bookmark subjects, skip annotation fetch) 12 + let existing = pds.fetch_bookmarks_only(None).await?; 10 13 if existing.iter().any(|b| b.subject == url) { 11 14 return Err(miette!("Bookmark already exists for {url}")); 12 15 }
+1 -2
src/commands/delete.rs
··· 23 23 // Delete annotation sidecar (same rkey) 24 24 let _ = pds.delete_record(ANNOTATION_COLLECTION, rkey).await; 25 25 26 - let title = bookmark.title.as_deref().unwrap_or(&bookmark.subject); 27 - println!("Deleted: {title}"); 26 + println!("Deleted: {}", bookmark.display_title()); 28 27 Ok(()) 29 28 }
+134 -1
src/commands/list.rs
··· 58 58 } 59 59 60 60 if json { 61 - println!("{}", serde_json::to_string_pretty(&filtered).unwrap_or_default()); 61 + println!( 62 + "{}", 63 + serde_json::to_string_pretty(&filtered).unwrap_or_default() 64 + ); 62 65 } else { 63 66 display::print_bookmarks(&filtered); 64 67 } 65 68 66 69 Ok(()) 67 70 } 71 + 72 + #[cfg(test)] 73 + mod tests { 74 + use super::*; 75 + 76 + fn make_bookmark(subject: &str, title: Option<&str>, tags: &[&str]) -> EnrichedBookmark { 77 + EnrichedBookmark { 78 + uri: "at://did:plc:test/community.lexicon.bookmarks.bookmark/abc123".to_string(), 79 + cid: "bafytest".to_string(), 80 + rkey: "abc123".to_string(), 81 + subject: subject.to_string(), 82 + created_at: "2025-01-01T00:00:00Z".to_string(), 83 + tags: tags.iter().map(|t| t.to_string()).collect(), 84 + title: title.map(|t| t.to_string()), 85 + description: None, 86 + favicon: None, 87 + image: None, 88 + note: None, 89 + } 90 + } 91 + 92 + fn make_bookmark_with_note(subject: &str, note: &str) -> EnrichedBookmark { 93 + let mut b = make_bookmark(subject, None, &[]); 94 + b.note = Some(note.to_string()); 95 + b 96 + } 97 + 98 + fn make_bookmark_with_desc(subject: &str, desc: &str) -> EnrichedBookmark { 99 + let mut b = make_bookmark(subject, None, &[]); 100 + b.description = Some(desc.to_string()); 101 + b 102 + } 103 + 104 + #[test] 105 + fn no_filters_returns_all() { 106 + let bookmarks = vec![ 107 + make_bookmark("https://a.com", Some("A"), &[]), 108 + make_bookmark("https://b.com", Some("B"), &[]), 109 + ]; 110 + let result = filter_bookmarks(bookmarks, None, None); 111 + assert_eq!(result.len(), 2); 112 + } 113 + 114 + #[test] 115 + fn filter_by_tag() { 116 + let bookmarks = vec![ 117 + make_bookmark("https://a.com", Some("A"), &["rust"]), 118 + make_bookmark("https://b.com", Some("B"), &["go"]), 119 + make_bookmark("https://c.com", Some("C"), &["rust", "web"]), 120 + ]; 121 + let result = filter_bookmarks(bookmarks, Some("rust"), None); 122 + assert_eq!(result.len(), 2); 123 + assert_eq!(result[0].subject, "https://a.com"); 124 + assert_eq!(result[1].subject, "https://c.com"); 125 + } 126 + 127 + #[test] 128 + fn filter_by_tag_case_insensitive() { 129 + let bookmarks = vec![make_bookmark("https://a.com", Some("A"), &["Rust"])]; 130 + let result = filter_bookmarks(bookmarks, Some("rust"), None); 131 + assert_eq!(result.len(), 1); 132 + } 133 + 134 + #[test] 135 + fn search_matches_title() { 136 + let bookmarks = vec![ 137 + make_bookmark("https://a.com", Some("Rust Guide"), &[]), 138 + make_bookmark("https://b.com", Some("Go Guide"), &[]), 139 + ]; 140 + let result = filter_bookmarks(bookmarks, None, Some("rust")); 141 + assert_eq!(result.len(), 1); 142 + assert_eq!(result[0].subject, "https://a.com"); 143 + } 144 + 145 + #[test] 146 + fn search_matches_url() { 147 + let bookmarks = vec![ 148 + make_bookmark("https://rust-lang.org", Some("Home"), &[]), 149 + make_bookmark("https://golang.org", Some("Home"), &[]), 150 + ]; 151 + let result = filter_bookmarks(bookmarks, None, Some("rust")); 152 + assert_eq!(result.len(), 1); 153 + } 154 + 155 + #[test] 156 + fn search_matches_description() { 157 + let bookmarks = vec![make_bookmark_with_desc( 158 + "https://a.com", 159 + "A guide to Rust programming", 160 + )]; 161 + let result = filter_bookmarks(bookmarks, None, Some("rust")); 162 + assert_eq!(result.len(), 1); 163 + } 164 + 165 + #[test] 166 + fn search_matches_note() { 167 + let bookmarks = vec![make_bookmark_with_note( 168 + "https://a.com", 169 + "Read this rust book", 170 + )]; 171 + let result = filter_bookmarks(bookmarks, None, Some("rust")); 172 + assert_eq!(result.len(), 1); 173 + } 174 + 175 + #[test] 176 + fn search_is_case_insensitive() { 177 + let bookmarks = vec![make_bookmark("https://a.com", Some("RUST Guide"), &[])]; 178 + let result = filter_bookmarks(bookmarks, None, Some("rust")); 179 + assert_eq!(result.len(), 1); 180 + } 181 + 182 + #[test] 183 + fn combined_tag_and_search() { 184 + let bookmarks = vec![ 185 + make_bookmark("https://a.com", Some("Rust Guide"), &["reading"]), 186 + make_bookmark("https://b.com", Some("Rust Ref"), &["reference"]), 187 + make_bookmark("https://c.com", Some("Go Guide"), &["reading"]), 188 + ]; 189 + let result = filter_bookmarks(bookmarks, Some("reading"), Some("rust")); 190 + assert_eq!(result.len(), 1); 191 + assert_eq!(result[0].subject, "https://a.com"); 192 + } 193 + 194 + #[test] 195 + fn search_no_match_returns_empty() { 196 + let bookmarks = vec![make_bookmark("https://a.com", Some("Hello"), &[])]; 197 + let result = filter_bookmarks(bookmarks, None, Some("nonexistent")); 198 + assert!(result.is_empty()); 199 + } 200 + }
+5 -9
src/commands/note.rs
··· 5 5 use crate::kipclip::types::*; 6 6 7 7 pub async fn run(pds: &PdsClient, reference: &str, text: Option<&str>) -> Result<()> { 8 - let bookmarks = pds.fetch_enriched_bookmarks(None).await?; 8 + let bookmarks = pds.fetch_bookmarks_only(None).await?; 9 9 let bookmark = refs::resolve_ref(reference, &bookmarks)?; 10 10 let rkey = bookmark.rkey.clone(); 11 - let title = bookmark 12 - .title 13 - .as_deref() 14 - .unwrap_or(&bookmark.subject) 15 - .to_string(); 11 + let title = bookmark.display_title().to_string(); 16 12 17 13 // Get or create annotation record 18 14 match pds.get_record(ANNOTATION_COLLECTION, &rkey).await { ··· 21 17 match text { 22 18 Some(t) => value["note"] = serde_json::Value::String(t.to_string()), 23 19 None => { 24 - value 25 - .as_object_mut() 26 - .map(|obj| obj.remove("note")); 20 + if let Some(obj) = value.as_object_mut() { 21 + obj.remove("note"); 22 + } 27 23 } 28 24 } 29 25 pds.put_record(ANNOTATION_COLLECTION, &rkey, value).await?;
+6 -3
src/commands/open.rs
··· 2 2 3 3 use crate::kipclip::pds::PdsClient; 4 4 use crate::kipclip::refs; 5 + use crate::kipclip::url::validate_http_url; 5 6 6 7 pub async fn run(pds: &PdsClient, reference: &str) -> Result<()> { 7 - let bookmarks = pds.fetch_enriched_bookmarks(None).await?; 8 + let bookmarks = pds.fetch_bookmarks_only(None).await?; 8 9 let bookmark = refs::resolve_ref(reference, &bookmarks)?; 9 - let title = bookmark.title.as_deref().unwrap_or(&bookmark.subject); 10 - println!("Opening: {title}"); 10 + 11 + validate_http_url(&bookmark.subject)?; 12 + 13 + println!("Opening: {}", bookmark.display_title()); 11 14 open::that(&bookmark.subject).into_diagnostic()?; 12 15 Ok(()) 13 16 }
+2 -3
src/commands/tag.rs
··· 5 5 use crate::kipclip::types::*; 6 6 7 7 pub async fn run(pds: &PdsClient, reference: &str, new_tags: &[String]) -> Result<()> { 8 - let bookmarks = pds.fetch_enriched_bookmarks(None).await?; 8 + let bookmarks = pds.fetch_bookmarks_only(None).await?; 9 9 let bookmark = refs::resolve_ref(reference, &bookmarks)?; 10 10 11 11 // Merge existing + new tags (deduplicate, case-insensitive) ··· 25 25 pds.put_record(BOOKMARK_COLLECTION, &bookmark.rkey, value) 26 26 .await?; 27 27 28 - let title = bookmark.title.as_deref().unwrap_or(&bookmark.subject); 29 - println!("Updated tags on: {title}"); 28 + println!("Updated tags on: {}", bookmark.display_title()); 30 29 println!("Tags: {}", tags.join(", ")); 31 30 Ok(()) 32 31 }
+9 -4
src/commands/tags.rs
··· 4 4 use crate::kipclip::pds::PdsClient; 5 5 6 6 pub async fn run(pds: &PdsClient, json: bool) -> Result<()> { 7 - let bookmarks = pds.fetch_enriched_bookmarks(None).await?; 7 + // Only need bookmark tags, skip annotation fetch 8 + let bookmarks = pds.fetch_bookmarks_only(None).await?; 8 9 9 10 // Count bookmarks per tag 10 - let mut tag_counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new(); 11 + let mut tag_counts: std::collections::BTreeMap<String, usize> = 12 + std::collections::BTreeMap::new(); 11 13 for bookmark in &bookmarks { 12 14 for tag in &bookmark.tags { 13 - *tag_counts.entry(tag.clone()).or_insert(0) += 1; 15 + *tag_counts.entry(tag.clone()).or_default() += 1; 14 16 } 15 17 } 16 18 ··· 24 26 }) 25 27 }) 26 28 .collect(); 27 - println!("{}", serde_json::to_string_pretty(&tags).unwrap_or_default()); 29 + println!( 30 + "{}", 31 + serde_json::to_string_pretty(&tags).unwrap_or_default() 32 + ); 28 33 } else if tag_counts.is_empty() { 29 34 println!("No tags found."); 30 35 } else {
+2 -3
src/commands/untag.rs
··· 5 5 use crate::kipclip::types::*; 6 6 7 7 pub async fn run(pds: &PdsClient, reference: &str, remove_tags: &[String]) -> Result<()> { 8 - let bookmarks = pds.fetch_enriched_bookmarks(None).await?; 8 + let bookmarks = pds.fetch_bookmarks_only(None).await?; 9 9 let bookmark = refs::resolve_ref(reference, &bookmarks)?; 10 10 11 11 // Remove specified tags (case-insensitive) ··· 27 27 pds.put_record(BOOKMARK_COLLECTION, &bookmark.rkey, value) 28 28 .await?; 29 29 30 - let title = bookmark.title.as_deref().unwrap_or(&bookmark.subject); 31 - println!("Updated tags on: {title}"); 30 + println!("Updated tags on: {}", bookmark.display_title()); 32 31 if tags.is_empty() { 33 32 println!("Tags: (none)"); 34 33 } else {
+5 -5
src/kipclip/auth.rs
··· 20 20 21 21 /// Create an OAuth client with file-backed auth store 22 22 fn oauth_client() -> OAuthClient<JacquardResolver, FileAuthStore> { 23 - OAuthClient::with_default_config(FileAuthStore::new(&config::auth_store_path())) 23 + OAuthClient::with_default_config(FileAuthStore::new(config::auth_store_path())) 24 24 } 25 25 26 26 /// Login via OAuth loopback flow — opens browser for authorization ··· 43 43 session_id: sid_str, 44 44 }; 45 45 46 - // Persist session info 46 + // Persist session info with restrictive permissions (0600 atomically on Unix) 47 47 let path = config::session_info_path(); 48 48 let json = serde_json::to_string_pretty(&info).into_diagnostic()?; 49 - std::fs::write(&path, json).into_diagnostic()?; 49 + config::write_private_file(&path, json.as_bytes())?; 50 50 51 51 Ok(info) 52 52 } ··· 56 56 let info = get_session_info()?; 57 57 let oauth = oauth_client(); 58 58 59 - let did = jacquard::types::string::Did::new(&info.did) 60 - .map_err(|e| miette!("Invalid DID: {e}"))?; 59 + let did = 60 + jacquard::types::string::Did::new(&info.did).map_err(|e| miette!("Invalid DID: {e}"))?; 61 61 62 62 let session = oauth 63 63 .restore(&did, &info.session_id)
+32 -1
src/kipclip/config.rs
··· 1 - use std::path::PathBuf; 1 + use std::io::Write; 2 + use std::path::{Path, PathBuf}; 3 + 4 + use miette::{IntoDiagnostic, Result}; 2 5 3 6 /// Returns the kipclip config directory (~/.config/kipclip/) 7 + /// Created with 0700 permissions on Unix to protect credentials. 4 8 pub fn config_dir() -> PathBuf { 5 9 let dir = dirs::config_dir() 6 10 .unwrap_or_else(|| PathBuf::from("~/.config")) 7 11 .join("kipclip"); 8 12 std::fs::create_dir_all(&dir).ok(); 13 + #[cfg(unix)] 14 + { 15 + use std::os::unix::fs::PermissionsExt; 16 + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).ok(); 17 + } 9 18 dir 10 19 } 11 20 ··· 21 30 pub fn session_info_path() -> PathBuf { 22 31 config_dir().join("whoami.json") 23 32 } 33 + 34 + /// Write a file with 0600 permissions atomically (no TOCTOU race). 35 + /// On non-Unix, falls back to std::fs::write. 36 + pub fn write_private_file(path: &Path, data: &[u8]) -> Result<()> { 37 + #[cfg(unix)] 38 + { 39 + use std::os::unix::fs::OpenOptionsExt; 40 + let mut file = std::fs::OpenOptions::new() 41 + .write(true) 42 + .create(true) 43 + .truncate(true) 44 + .mode(0o600) 45 + .open(path) 46 + .into_diagnostic()?; 47 + file.write_all(data).into_diagnostic()?; 48 + } 49 + #[cfg(not(unix))] 50 + { 51 + std::fs::write(path, data).into_diagnostic()?; 52 + } 53 + Ok(()) 54 + }
+37 -6
src/kipclip/display.rs
··· 43 43 let url_width = remaining / 3; 44 44 45 45 for bookmark in bookmarks { 46 - let ref_str = &bookmark.rkey[..bookmark.rkey.len().min(ref_width)]; 47 - let title = bookmark 48 - .title 49 - .as_deref() 50 - .unwrap_or(&bookmark.subject); 46 + let ref_str: String = bookmark.rkey.chars().take(ref_width).collect(); 47 + let title = bookmark.title.as_deref().unwrap_or(&bookmark.subject); 51 48 let url = &bookmark.subject; 52 49 let tags = if bookmark.tags.is_empty() { 53 50 String::new() ··· 57 54 58 55 println!( 59 56 "{} {} {} {}", 60 - truncate(ref_str, ref_width).dimmed(), 57 + truncate(&ref_str, ref_width).dimmed(), 61 58 truncate(title, title_width).bold(), 62 59 truncate(url, url_width).blue(), 63 60 truncate(&tags, tags_width).green(), ··· 83 80 } 84 81 println!("{} {}", "Created:".dimmed(), bookmark.created_at); 85 82 } 83 + 84 + #[cfg(test)] 85 + mod tests { 86 + use super::*; 87 + 88 + #[test] 89 + fn truncate_short_string_unchanged() { 90 + assert_eq!(truncate("hello", 10), "hello"); 91 + } 92 + 93 + #[test] 94 + fn truncate_exact_width_unchanged() { 95 + assert_eq!(truncate("hello", 5), "hello"); 96 + } 97 + 98 + #[test] 99 + fn truncate_long_string_adds_ellipsis() { 100 + let result = truncate("hello world", 6); 101 + assert!(result.ends_with('…')); 102 + assert!(result.len() <= 10); // unicode ellipsis is 3 bytes 103 + } 104 + 105 + #[test] 106 + fn truncate_empty_string() { 107 + assert_eq!(truncate("", 10), ""); 108 + } 109 + 110 + #[test] 111 + fn truncate_wide_chars() { 112 + // CJK characters are 2 columns wide 113 + let result = truncate("日本語テスト", 6); 114 + assert!(result.ends_with('…')); 115 + } 116 + }
+169 -12
src/kipclip/enrich.rs
··· 2 2 use reqwest::header; 3 3 4 4 use crate::kipclip::types::UrlMetadata; 5 + use crate::kipclip::url::validate_http_url; 5 6 6 7 const MAX_TITLE_LENGTH: usize = 200; 7 8 const MAX_DESCRIPTION_LENGTH: usize = 500; 8 9 const MAX_URL_LENGTH: usize = 2000; 10 + const MAX_BODY_BYTES: usize = 512 * 1024; // 512KB — enough for <head> 9 11 const TIMEOUT_SECS: u64 = 10; 10 12 11 13 /// Fetch a URL and extract metadata (title, description, favicon, og:image) 12 14 pub async fn enrich_url(url: &str) -> Result<UrlMetadata> { 13 - let parsed = reqwest::Url::parse(url).map_err(|e| miette!("Invalid URL: {e}"))?; 14 - 15 - if !matches!(parsed.scheme(), "http" | "https") { 16 - return Err(miette!("Only HTTP(S) URLs are supported")); 17 - } 15 + let parsed = validate_http_url(url)?; 18 16 19 17 let client = reqwest::Client::builder() 20 18 .timeout(std::time::Duration::from_secs(TIMEOUT_SECS)) 19 + .redirect(reqwest::redirect::Policy::limited(5)) 21 20 .build() 22 21 .into_diagnostic()?; 23 22 ··· 57 56 }); 58 57 } 59 58 60 - let html = resp.text().await.into_diagnostic()?; 59 + // Read response body with size limit to prevent memory exhaustion. 60 + // Check Content-Length header first for early rejection, then cap the read. 61 + let content_length = resp.content_length().unwrap_or(0); 62 + let read_limit = if content_length > 0 { 63 + (content_length as usize).min(MAX_BODY_BYTES) 64 + } else { 65 + MAX_BODY_BYTES 66 + }; 67 + let bytes = resp.bytes().await.into_diagnostic()?; 68 + let capped = &bytes[..bytes.len().min(read_limit)]; 69 + let html = String::from_utf8_lossy(capped); 61 70 Ok(parse_html_metadata(&html, &parsed)) 62 71 } 63 72 ··· 156 165 /// Extract content from <meta> tag matching attr_name=attr_value 157 166 fn extract_meta_content(html: &str, attr_name: &str, attr_value: &str) -> Option<String> { 158 167 // Try: <meta attr="value" content="..."> 159 - let pattern1 = format!( 160 - r#"(?i)<meta[^>]+{attr_name}=["']{attr_value}["'][^>]+content=["']([^"']+)["']"# 161 - ); 168 + let pattern1 = 169 + format!(r#"(?i)<meta[^>]+{attr_name}=["']{attr_value}["'][^>]+content=["']([^"']+)["']"#); 162 170 if let Some(caps) = regex_lite::Regex::new(&pattern1) 163 171 .ok() 164 172 .and_then(|re| re.captures(html)) ··· 167 175 } 168 176 169 177 // Try: <meta content="..." attr="value"> 170 - let pattern2 = format!( 171 - r#"(?i)<meta[^>]+content=["']([^"']+)["'][^>]+{attr_name}=["']{attr_value}["']"# 172 - ); 178 + let pattern2 = 179 + format!(r#"(?i)<meta[^>]+content=["']([^"']+)["'][^>]+{attr_name}=["']{attr_value}["']"#); 173 180 if let Some(caps) = regex_lite::Regex::new(&pattern2) 174 181 .ok() 175 182 .and_then(|re| re.captures(html)) ··· 179 186 180 187 None 181 188 } 189 + 190 + #[cfg(test)] 191 + mod tests { 192 + use super::*; 193 + 194 + #[test] 195 + fn parse_title_from_html() { 196 + let html = "<html><head><title>Hello World</title></head></html>"; 197 + let url = reqwest::Url::parse("https://example.com").unwrap(); 198 + let meta = parse_html_metadata(html, &url); 199 + assert_eq!(meta.title.as_deref(), Some("Hello World")); 200 + } 201 + 202 + #[test] 203 + fn parse_og_title_fallback() { 204 + let html = r#"<html><head><meta property="og:title" content="OG Title"></head></html>"#; 205 + let url = reqwest::Url::parse("https://example.com").unwrap(); 206 + let meta = parse_html_metadata(html, &url); 207 + assert_eq!(meta.title.as_deref(), Some("OG Title")); 208 + } 209 + 210 + #[test] 211 + fn title_tag_takes_precedence_over_og() { 212 + let html = r#"<html><head> 213 + <title>Page Title</title> 214 + <meta property="og:title" content="OG Title"> 215 + </head></html>"#; 216 + let url = reqwest::Url::parse("https://example.com").unwrap(); 217 + let meta = parse_html_metadata(html, &url); 218 + assert_eq!(meta.title.as_deref(), Some("Page Title")); 219 + } 220 + 221 + #[test] 222 + fn parse_description() { 223 + let html = r#"<html><head><meta name="description" content="A great page"></head></html>"#; 224 + let url = reqwest::Url::parse("https://example.com").unwrap(); 225 + let meta = parse_html_metadata(html, &url); 226 + assert_eq!(meta.description.as_deref(), Some("A great page")); 227 + } 228 + 229 + #[test] 230 + fn parse_og_description_fallback() { 231 + let html = 232 + r#"<html><head><meta property="og:description" content="OG desc"></head></html>"#; 233 + let url = reqwest::Url::parse("https://example.com").unwrap(); 234 + let meta = parse_html_metadata(html, &url); 235 + assert_eq!(meta.description.as_deref(), Some("OG desc")); 236 + } 237 + 238 + #[test] 239 + fn parse_favicon_link() { 240 + let html = r#"<html><head><link rel="icon" href="/favicon.png"></head></html>"#; 241 + let url = reqwest::Url::parse("https://example.com").unwrap(); 242 + let meta = parse_html_metadata(html, &url); 243 + assert_eq!( 244 + meta.favicon.as_deref(), 245 + Some("https://example.com/favicon.png") 246 + ); 247 + } 248 + 249 + #[test] 250 + fn default_favicon_when_no_link() { 251 + let html = "<html><head><title>Test</title></head></html>"; 252 + let url = reqwest::Url::parse("https://example.com").unwrap(); 253 + let meta = parse_html_metadata(html, &url); 254 + assert_eq!( 255 + meta.favicon.as_deref(), 256 + Some("https://example.com/favicon.ico") 257 + ); 258 + } 259 + 260 + #[test] 261 + fn parse_og_image() { 262 + let html = r#"<html><head><meta property="og:image" content="https://img.example.com/pic.jpg"></head></html>"#; 263 + let url = reqwest::Url::parse("https://example.com").unwrap(); 264 + let meta = parse_html_metadata(html, &url); 265 + assert_eq!( 266 + meta.image.as_deref(), 267 + Some("https://img.example.com/pic.jpg") 268 + ); 269 + } 270 + 271 + #[test] 272 + fn parse_twitter_image_fallback() { 273 + let html = r#"<html><head><meta name="twitter:image" content="https://img.example.com/tw.jpg"></head></html>"#; 274 + let url = reqwest::Url::parse("https://example.com").unwrap(); 275 + let meta = parse_html_metadata(html, &url); 276 + assert_eq!( 277 + meta.image.as_deref(), 278 + Some("https://img.example.com/tw.jpg") 279 + ); 280 + } 281 + 282 + #[test] 283 + fn resolve_relative_og_image() { 284 + let html = 285 + r#"<html><head><meta property="og:image" content="/images/pic.jpg"></head></html>"#; 286 + let url = reqwest::Url::parse("https://example.com/page").unwrap(); 287 + let meta = parse_html_metadata(html, &url); 288 + assert_eq!( 289 + meta.image.as_deref(), 290 + Some("https://example.com/images/pic.jpg") 291 + ); 292 + } 293 + 294 + #[test] 295 + fn hostname_fallback_title() { 296 + let html = "<html><head></head></html>"; 297 + let url = reqwest::Url::parse("https://example.com").unwrap(); 298 + let meta = parse_html_metadata(html, &url); 299 + assert_eq!(meta.title.as_deref(), Some("example.com")); 300 + } 301 + 302 + #[test] 303 + fn sanitize_text_collapses_whitespace() { 304 + assert_eq!(sanitize_text(" hello world ", 100), "hello world"); 305 + } 306 + 307 + #[test] 308 + fn sanitize_text_truncates() { 309 + assert_eq!(sanitize_text("hello world", 5), "hello"); 310 + } 311 + 312 + #[test] 313 + fn sanitize_text_strips_control_chars() { 314 + assert_eq!(sanitize_text("hello\x00world", 100), "helloworld"); 315 + } 316 + 317 + #[test] 318 + fn resolve_url_rejects_non_http() { 319 + let base = reqwest::Url::parse("https://example.com").unwrap(); 320 + assert!(resolve_url("javascript:alert(1)", &base).is_none()); 321 + } 322 + 323 + #[test] 324 + fn resolve_url_resolves_relative() { 325 + let base = reqwest::Url::parse("https://example.com/page").unwrap(); 326 + assert_eq!( 327 + resolve_url("/img.png", &base).as_deref(), 328 + Some("https://example.com/img.png") 329 + ); 330 + } 331 + 332 + #[test] 333 + fn resolve_url_rejects_too_long() { 334 + let base = reqwest::Url::parse("https://example.com").unwrap(); 335 + let long_path = "/".to_string() + &"a".repeat(MAX_URL_LENGTH); 336 + assert!(resolve_url(&long_path, &base).is_none()); 337 + } 338 + }
+1
src/kipclip/mod.rs
··· 5 5 pub mod pds; 6 6 pub mod refs; 7 7 pub mod types; 8 + pub mod url;
+75 -56
src/kipclip/pds.rs
··· 1 + use jacquard::CowStr; 1 2 use jacquard::api::com_atproto::repo::{ 2 - create_record::CreateRecord, 3 - delete_record::DeleteRecord, 4 - get_record::GetRecord, 5 - list_records::ListRecords, 6 - put_record::PutRecord, 3 + create_record::CreateRecord, delete_record::DeleteRecord, get_record::GetRecord, 4 + list_records::ListRecords, put_record::PutRecord, 7 5 }; 8 6 use jacquard::common::types::ident::AtIdentifier; 9 7 use jacquard::common::types::recordkey::{RecordKey, Rkey}; 10 8 use jacquard::common::types::value::to_data; 11 9 use jacquard::common::xrpc::XrpcClient; 12 - use jacquard::CowStr; 13 10 use miette::{Result, miette}; 14 11 use serde::Serialize; 15 12 ··· 19 16 20 17 /// PDS client wrapping an authenticated jacquard session 21 18 pub struct PdsClient { 22 - pub session: Session, 23 - pub did: String, 19 + session: Session, 20 + did: jacquard::types::string::Did<'static>, 24 21 } 25 22 26 23 impl PdsClient { 24 + pub fn new(session: Session, did: &str) -> Result<Self> { 25 + let did_owned = jacquard::types::string::Did::new_owned(did) 26 + .map_err(|e| miette!("Invalid DID: {e}"))?; 27 + Ok(Self { 28 + session, 29 + did: did_owned, 30 + }) 31 + } 32 + 33 + fn parse_nsid(collection: &str) -> Result<jacquard::types::string::Nsid<'_>> { 34 + jacquard::types::string::Nsid::new(collection).map_err(|e| miette!("Invalid NSID: {e}")) 35 + } 36 + 27 37 /// List all records from a collection (handles pagination) 28 38 pub async fn list_records( 29 39 &self, ··· 31 41 limit: Option<i64>, 32 42 reverse: bool, 33 43 ) -> Result<Vec<PdsRecord>> { 34 - let did = jacquard::types::string::Did::new(&self.did) 35 - .map_err(|e| miette!("Invalid DID: {e}"))?; 36 - let nsid = jacquard::types::string::Nsid::new(collection) 37 - .map_err(|e| miette!("Invalid NSID: {e}"))?; 44 + let nsid = Self::parse_nsid(collection)?; 38 45 39 46 let mut all_records = Vec::new(); 40 47 let mut cursor_val: Option<String> = None; ··· 42 49 43 50 loop { 44 51 let mut builder = ListRecords::new() 45 - .repo(AtIdentifier::Did(did.clone())) 52 + .repo(AtIdentifier::Did(self.did.clone())) 46 53 .collection(nsid.clone()) 47 54 .limit(page_limit); 48 55 ··· 65 72 .map_err(|e| miette!("Failed to parse listRecords response: {e}"))?; 66 73 67 74 for record in output.records { 68 - let value_json = serde_json::to_value(&record.value) 69 - .unwrap_or(serde_json::Value::Null); 75 + let value_json = 76 + serde_json::to_value(&record.value).unwrap_or(serde_json::Value::Null); 70 77 all_records.push(PdsRecord { 71 78 uri: record.uri.to_string(), 72 79 cid: record.cid.to_string(), ··· 98 105 rkey: Option<&str>, 99 106 record: &T, 100 107 ) -> Result<CreateRecordResponse> { 101 - let did = jacquard::types::string::Did::new(&self.did) 102 - .map_err(|e| miette!("Invalid DID: {e}"))?; 103 - let nsid = jacquard::types::string::Nsid::new(collection) 104 - .map_err(|e| miette!("Invalid NSID: {e}"))?; 108 + let nsid = Self::parse_nsid(collection)?; 105 109 let data = to_data(record).map_err(|e| miette!("Failed to serialize record: {e}"))?; 106 110 107 111 let mut builder = CreateRecord::new() 108 - .repo(AtIdentifier::Did(did)) 112 + .repo(AtIdentifier::Did(self.did.clone())) 109 113 .collection(nsid) 110 114 .record(data); 111 115 ··· 131 135 } 132 136 133 137 /// Get a single record 134 - pub async fn get_record( 135 - &self, 136 - collection: &str, 137 - rkey: &str, 138 - ) -> Result<GetRecordResponse> { 139 - let did = jacquard::types::string::Did::new(&self.did) 140 - .map_err(|e| miette!("Invalid DID: {e}"))?; 141 - let nsid = jacquard::types::string::Nsid::new(collection) 142 - .map_err(|e| miette!("Invalid NSID: {e}"))?; 138 + pub async fn get_record(&self, collection: &str, rkey: &str) -> Result<GetRecordResponse> { 139 + let nsid = Self::parse_nsid(collection)?; 143 140 144 141 let rk = Rkey::new(rkey).map_err(|e| miette!("Invalid rkey: {e}"))?; 145 142 let request = GetRecord::new() 146 - .repo(AtIdentifier::Did(did)) 143 + .repo(AtIdentifier::Did(self.did.clone())) 147 144 .collection(nsid) 148 145 .rkey(RecordKey(rk)) 149 146 .build(); ··· 158 155 .into_output() 159 156 .map_err(|e| miette!("Failed to parse getRecord response: {e}"))?; 160 157 161 - let value_json = serde_json::to_value(&output.value) 162 - .unwrap_or(serde_json::Value::Null); 158 + let value_json = serde_json::to_value(&output.value).unwrap_or(serde_json::Value::Null); 163 159 164 - Ok(GetRecordResponse { 165 - value: value_json, 166 - }) 160 + Ok(GetRecordResponse { value: value_json }) 167 161 } 168 162 169 163 /// Update a record (put) ··· 173 167 rkey: &str, 174 168 record: serde_json::Value, 175 169 ) -> Result<()> { 176 - let did = jacquard::types::string::Did::new(&self.did) 177 - .map_err(|e| miette!("Invalid DID: {e}"))?; 178 - let nsid = jacquard::types::string::Nsid::new(collection) 179 - .map_err(|e| miette!("Invalid NSID: {e}"))?; 170 + let nsid = Self::parse_nsid(collection)?; 180 171 let data = to_data(&record).map_err(|e| miette!("Failed to serialize record: {e}"))?; 181 172 182 173 let rk = Rkey::new(rkey).map_err(|e| miette!("Invalid rkey: {e}"))?; 183 174 let request = PutRecord::new() 184 - .repo(AtIdentifier::Did(did)) 175 + .repo(AtIdentifier::Did(self.did.clone())) 185 176 .collection(nsid) 186 177 .rkey(RecordKey(rk)) 187 178 .record(data) ··· 202 193 203 194 /// Delete a record 204 195 pub async fn delete_record(&self, collection: &str, rkey: &str) -> Result<()> { 205 - let did = jacquard::types::string::Did::new(&self.did) 206 - .map_err(|e| miette!("Invalid DID: {e}"))?; 207 - let nsid = jacquard::types::string::Nsid::new(collection) 208 - .map_err(|e| miette!("Invalid NSID: {e}"))?; 196 + let nsid = Self::parse_nsid(collection)?; 209 197 210 198 let rk = Rkey::new(rkey).map_err(|e| miette!("Invalid rkey: {e}"))?; 211 199 let request = DeleteRecord::new() 212 - .repo(AtIdentifier::Did(did)) 200 + .repo(AtIdentifier::Did(self.did.clone())) 213 201 .collection(nsid) 214 202 .rkey(RecordKey(rk)) 215 203 .build(); ··· 222 210 Ok(()) 223 211 } 224 212 213 + /// Fetch bookmarks only (no annotation join). Use when you only need 214 + /// bookmark data (e.g., duplicate checks, tag counts, ref resolution for 215 + /// operations that don't display annotation fields). 216 + pub async fn fetch_bookmarks_only(&self, limit: Option<i64>) -> Result<Vec<EnrichedBookmark>> { 217 + let bookmarks = self.list_records(BOOKMARK_COLLECTION, limit, true).await?; 218 + 219 + let enriched = bookmarks 220 + .iter() 221 + .map(|record| { 222 + let rkey = rkey_from_uri(&record.uri); 223 + let bookmark: BookmarkRecord = serde_json::from_value(record.value.clone()) 224 + .unwrap_or(BookmarkRecord { 225 + subject: String::new(), 226 + created_at: String::new(), 227 + tags: Vec::new(), 228 + }); 229 + 230 + EnrichedBookmark { 231 + uri: record.uri.clone(), 232 + cid: record.cid.clone(), 233 + rkey, 234 + subject: bookmark.subject, 235 + created_at: bookmark.created_at, 236 + tags: bookmark.tags, 237 + title: None, 238 + description: None, 239 + favicon: None, 240 + image: None, 241 + note: None, 242 + } 243 + }) 244 + .collect(); 245 + 246 + Ok(enriched) 247 + } 248 + 225 249 /// Fetch bookmarks joined with annotations 226 250 pub async fn fetch_enriched_bookmarks( 227 251 &self, 228 252 limit: Option<i64>, 229 253 ) -> Result<Vec<EnrichedBookmark>> { 230 - let bookmarks = self 231 - .list_records(BOOKMARK_COLLECTION, limit, true) 232 - .await?; 233 - let annotations = self 234 - .list_records(ANNOTATION_COLLECTION, None, true) 235 - .await?; 254 + let bookmarks = self.list_records(BOOKMARK_COLLECTION, limit, true).await?; 255 + let annotations = self.list_records(ANNOTATION_COLLECTION, None, true).await?; 236 256 237 257 // Build annotation map keyed by rkey 238 - let mut annotation_map = std::collections::HashMap::new(); 258 + let mut annotation_map = std::collections::HashMap::with_capacity(annotations.len()); 239 259 for record in &annotations { 240 260 let rkey = rkey_from_uri(&record.uri); 241 - if let Ok(annotation) = 242 - serde_json::from_value::<AnnotationRecord>(record.value.clone()) 261 + if let Ok(annotation) = serde_json::from_value::<AnnotationRecord>(record.value.clone()) 243 262 { 244 263 annotation_map.insert(rkey, annotation); 245 264 } ··· 251 270 .map(|record| { 252 271 let rkey = rkey_from_uri(&record.uri); 253 272 let annotation = annotation_map.get(&rkey); 254 - let bookmark: BookmarkRecord = 255 - serde_json::from_value(record.value.clone()).unwrap_or(BookmarkRecord { 273 + let bookmark: BookmarkRecord = serde_json::from_value(record.value.clone()) 274 + .unwrap_or(BookmarkRecord { 256 275 subject: String::new(), 257 276 created_at: String::new(), 258 277 tags: Vec::new(),
+77 -1
src/kipclip/refs.rs
··· 28 28 29 29 /// Extract rkey from an AT URI (last path segment) 30 30 pub fn rkey_from_uri(uri: &str) -> String { 31 - uri.split('/').last().unwrap_or("").to_string() 31 + uri.split('/').next_back().unwrap_or("").to_string() 32 + } 33 + 34 + #[cfg(test)] 35 + mod tests { 36 + use super::*; 37 + 38 + fn make_bookmark(rkey: &str, subject: &str) -> EnrichedBookmark { 39 + EnrichedBookmark { 40 + uri: format!("at://did:plc:test/community.lexicon.bookmarks.bookmark/{rkey}"), 41 + cid: "bafytest".to_string(), 42 + rkey: rkey.to_string(), 43 + subject: subject.to_string(), 44 + created_at: "2025-01-01T00:00:00Z".to_string(), 45 + tags: vec![], 46 + title: None, 47 + description: None, 48 + favicon: None, 49 + image: None, 50 + note: None, 51 + } 52 + } 53 + 54 + #[test] 55 + fn rkey_from_uri_extracts_last_segment() { 56 + let uri = "at://did:plc:abc123/community.lexicon.bookmarks.bookmark/3lf5abc"; 57 + assert_eq!(rkey_from_uri(uri), "3lf5abc"); 58 + } 59 + 60 + #[test] 61 + fn rkey_from_uri_empty_string() { 62 + assert_eq!(rkey_from_uri(""), ""); 63 + } 64 + 65 + #[test] 66 + fn resolve_ref_exact_match() { 67 + let bookmarks = vec![ 68 + make_bookmark("3lf5abcd", "https://example.com"), 69 + make_bookmark("3lf5wxyz", "https://other.com"), 70 + ]; 71 + let result = resolve_ref("3lf5abcd", &bookmarks).unwrap(); 72 + assert_eq!(result.subject, "https://example.com"); 73 + } 74 + 75 + #[test] 76 + fn resolve_ref_prefix_match() { 77 + let bookmarks = vec![ 78 + make_bookmark("3lf5abcd", "https://example.com"), 79 + make_bookmark("3lf5wxyz", "https://other.com"), 80 + ]; 81 + let result = resolve_ref("3lf5a", &bookmarks).unwrap(); 82 + assert_eq!(result.rkey, "3lf5abcd"); 83 + } 84 + 85 + #[test] 86 + fn resolve_ref_too_short() { 87 + let bookmarks = vec![make_bookmark("3lf5abcd", "https://example.com")]; 88 + let result = resolve_ref("3lf", &bookmarks); 89 + assert!(result.is_err()); 90 + } 91 + 92 + #[test] 93 + fn resolve_ref_no_match() { 94 + let bookmarks = vec![make_bookmark("3lf5abcd", "https://example.com")]; 95 + let result = resolve_ref("9999", &bookmarks); 96 + assert!(result.is_err()); 97 + } 98 + 99 + #[test] 100 + fn resolve_ref_ambiguous() { 101 + let bookmarks = vec![ 102 + make_bookmark("3lf5abcd", "https://example.com"), 103 + make_bookmark("3lf5abef", "https://other.com"), 104 + ]; 105 + let result = resolve_ref("3lf5ab", &bookmarks); 106 + assert!(result.is_err()); 107 + } 32 108 }
+7
src/kipclip/types.rs
··· 59 59 pub note: Option<String>, 60 60 } 61 61 62 + impl EnrichedBookmark { 63 + /// Title for display, falling back to the bookmark URL 64 + pub fn display_title(&self) -> &str { 65 + self.title.as_deref().unwrap_or(&self.subject) 66 + } 67 + } 68 + 62 69 /// A PDS record as returned by listRecords 63 70 #[derive(Debug, Clone)] 64 71 pub struct PdsRecord {
+11
src/kipclip/url.rs
··· 1 + use miette::{Result, miette}; 2 + 3 + /// Validate that a string is a valid HTTP(S) URL. 4 + /// Returns the parsed URL on success. 5 + pub fn validate_http_url(url: &str) -> Result<reqwest::Url> { 6 + let parsed = reqwest::Url::parse(url).map_err(|e| miette!("Invalid URL: {e}"))?; 7 + if !matches!(parsed.scheme(), "http" | "https") { 8 + return Err(miette!("Only HTTP(S) URLs are supported")); 9 + } 10 + Ok(parsed) 11 + }
+1 -5
src/main.rs
··· 114 114 async fn make_pds_client() -> Result<PdsClient> { 115 115 let session = auth::restore_session().await?; 116 116 let info = auth::get_session_info()?; 117 - 118 - Ok(PdsClient { 119 - session, 120 - did: info.did, 121 - }) 117 + PdsClient::new(session, &info.did) 122 118 } 123 119 124 120 #[tokio::main]