linkmarks_cli/cmd/
sync.rs1use crate::Paths;
35use anyhow::{bail, Context, Result};
36use clap::Args;
37use linkmarks_core::store;
38use std::path::PathBuf;
39
40#[derive(Args, Debug)]
41pub struct SyncArgs {
42 #[arg(long)]
45 pub remote: Option<String>,
46
47 #[arg(long)]
50 pub dry_run: bool,
51
52 #[arg(long)]
55 pub out_dir: Option<PathBuf>,
56
57 #[arg(long, default_value = "100000")]
60 pub limit: usize,
61}
62
63pub fn run(args: SyncArgs, _format: crate::Format, paths: Paths) -> Result<i32> {
64 if args.remote.is_some() && !args.dry_run {
65 bail!(
66 "live sync is relay-binary work; today only --dry-run is implemented."
67 );
68 }
69 if !args.dry_run {
70 bail!("pass --dry-run (the only mode implemented in this preview)");
71 }
72 if !paths.store.exists() {
73 bail!(
74 "store not found at {}; run `linkmarks init` first",
75 paths.store.display()
76 );
77 }
78
79 let s = store::open(&paths.store).context("open store")?;
80 let total = s.count_all().context("count_all")?;
81 if total == 0 {
82 println!("(store is empty — nothing to encode)");
83 return Ok(crate::exit_codes::OK);
84 }
85
86 let bookmarks = s.list(args.limit.max(1), 0).context("list bookmarks")?;
87 let by_collection = group_by_collection(&bookmarks);
88
89 if let Some(out_dir) = &args.out_dir {
90 std::fs::create_dir_all(out_dir)
91 .with_context(|| format!("create out-dir {}", out_dir.display()))?;
92 }
93
94 println!(
95 "linkmarks sync --dry-run (preview)\n \
96 store: {}\n \
97 bookmarks: {}\n \
98 collections: {}\n",
99 paths.store.display(),
100 bookmarks.len(),
101 by_collection.len()
102 );
103 println!(
104 "{:<24} {:>10} {:>12} {:>16} hash",
105 "collection", "bookmarks", "bytes", "fingerprint"
106 );
107 println!("{}", "-".repeat(80));
108
109 let mut grand_total_bytes: usize = 0;
110 let mut grand_total_bookmarks: usize = 0;
111 for (collection, items) in &by_collection {
112 let encoded = encode_collection(collection, items);
113 let hash = fnv1a_64(&encoded);
114 let slug = collection_slug(collection);
115 println!(
116 "{:<24} {:>10} {:>12} {:>16x} {}",
117 truncate(collection, 24),
118 items.len(),
119 encoded.len(),
120 hash,
121 slug
122 );
123 grand_total_bytes = grand_total_bytes.saturating_add(encoded.len());
124 grand_total_bookmarks = grand_total_bookmarks.saturating_add(items.len());
125
126 if let Some(out_dir) = &args.out_dir {
127 let path = out_dir.join(format!("{slug}.ydoc.bin"));
128 std::fs::write(&path, &encoded)
129 .with_context(|| format!("write {}", path.display()))?;
130 }
131 }
132
133 println!("{}", "-".repeat(80));
134 println!(
135 "{:<24} {:>10} {:>12}",
136 "TOTAL", grand_total_bookmarks, grand_total_bytes
137 );
138 println!(
139 "\ndry-run complete. {} sub-docs, {} bytes uncompressed yrs payload.",
140 by_collection.len(),
141 grand_total_bytes
142 );
143 if let Some(out_dir) = args.out_dir {
144 println!(
145 "wrote per-collection encoded files to {}",
146 out_dir.display()
147 );
148 }
149 println!(
150 "Note: the actual relay wire size will be ~6-14× smaller after LZ4 compression.\n\
151 Run `lz4 <file>` on a `.ydoc.bin` to verify."
152 );
153
154 Ok(crate::exit_codes::OK)
155}
156
157fn group_by_collection(
158 bookmarks: &[linkmarks_core::Bookmark],
159) -> std::collections::BTreeMap<String, Vec<linkmarks_core::Bookmark>> {
160 let mut map: std::collections::BTreeMap<String, Vec<linkmarks_core::Bookmark>> =
161 Default::default();
162 for bm in bookmarks {
163 let key = bm
164 .collection
165 .clone()
166 .unwrap_or_else(|| "(uncategorized)".to_string());
167 map.entry(key).or_default().push(bm.clone());
168 }
169 map
170}
171
172fn encode_collection(collection: &str, bookmarks: &[linkmarks_core::Bookmark]) -> Vec<u8> {
173 use yrs::types::map::MapPrelim;
174 use yrs::{Doc, Map, ReadTxn, StateVector, Transact, WriteTxn};
175
176 let doc = Doc::new();
177 {
178 let mut t = doc.transact_mut();
179 let meta = t.get_or_insert_map("meta");
180 meta.insert(&mut t, "collection_name", collection);
181 meta.insert(&mut t, "spike_marker", "sync-preview");
182 let bookmarks_map = t.get_or_insert_map("bookmarks");
183 let tags_map = t.get_or_insert_map("tags_by_bookmark");
184 for bm in bookmarks {
185 let bm_entry = bookmarks_map.insert(
186 &mut t,
187 bm.id.0.as_str(),
188 MapPrelim::default(),
189 );
190 bm_entry.insert(&mut t, "original_url", bm.original_url.clone());
191 bm_entry.insert(&mut t, "canonical_url", bm.canonical_url.clone());
192 bm_entry.insert(&mut t, "title", bm.title.clone());
193 bm_entry.insert(&mut t, "archived", bm.archived);
194 if let Some(ct) = &bm.content_type {
195 bm_entry.insert(&mut t, "content_type", ct.clone());
196 }
197 bm_entry.insert(
198 &mut t,
199 "source_kind",
200 bm.source.kind.as_cli_str().to_string(),
201 );
202 let tags_entry = tags_map.insert(&mut t, bm.id.0.as_str(), MapPrelim::default());
203 for tag in &bm.tags {
204 tags_entry.insert(&mut t, tag.clone(), 1i64);
205 }
206 }
207 t.commit();
208 }
209 let txn = doc.transact();
210 txn.encode_state_as_update_v1(&StateVector::default())
211}
212
213fn collection_slug(name: &str) -> String {
214 name.chars()
215 .map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '_' })
216 .collect::<String>()
217 .trim_matches('_')
218 .to_string()
219}
220
221fn truncate(s: &str, max: usize) -> String {
222 if s.chars().count() <= max {
223 s.to_string()
224 } else {
225 let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
226 out.push('…');
227 out
228 }
229}
230
231fn fnv1a_64(b: &[u8]) -> u64 {
232 let mut h: u64 = 0xcbf29ce484222325;
233 for &byte in b {
234 h ^= byte as u64;
235 h = h.wrapping_mul(0x100000001b3);
236 }
237 h
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn collection_slug_lowercases_and_replaces_separators() {
246 assert_eq!(collection_slug("Work / Research"), "work___research");
247 assert_eq!(collection_slug("(uncategorized)"), "uncategorized");
248 assert_eq!(collection_slug("a:b"), "a_b");
249 }
250
251 #[test]
252 fn truncate_handles_short_and_long() {
253 assert_eq!(truncate("short", 10), "short");
255 assert_eq!(truncate("a long collection name here", 10), "a long co…");
259 assert_eq!(truncate("a long collection name here", 24), "a long collection name …");
260 }
261
262 #[test]
263 fn fnv_hash_is_deterministic() {
264 let a = fnv1a_64(b"hello");
265 let b = fnv1a_64(b"hello");
266 assert_eq!(a, b);
267 assert_ne!(a, fnv1a_64(b"hellp"));
268 }
269}