use crate::Paths;
use anyhow::{bail, Context, Result};
use clap::Args;
use linkmarks_core::store;
use std::path::PathBuf;
#[derive(Args, Debug)]
pub struct SyncArgs {
#[arg(long)]
pub remote: Option<String>,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub out_dir: Option<PathBuf>,
#[arg(long, default_value = "100000")]
pub limit: usize,
}
pub fn run(args: SyncArgs, _format: crate::Format, paths: Paths) -> Result<i32> {
if args.remote.is_some() && !args.dry_run {
bail!(
"live sync is relay-binary work; today only --dry-run is implemented."
);
}
if !args.dry_run {
bail!("pass --dry-run (the only mode implemented in this preview)");
}
if !paths.store.exists() {
bail!(
"store not found at {}; run `linkmarks init` first",
paths.store.display()
);
}
let s = store::open(&paths.store).context("open store")?;
let total = s.count_all().context("count_all")?;
if total == 0 {
println!("(store is empty — nothing to encode)");
return Ok(crate::exit_codes::OK);
}
let bookmarks = s.list(args.limit.max(1), 0).context("list bookmarks")?;
let by_collection = group_by_collection(&bookmarks);
if let Some(out_dir) = &args.out_dir {
std::fs::create_dir_all(out_dir)
.with_context(|| format!("create out-dir {}", out_dir.display()))?;
}
println!(
"linkmarks sync --dry-run (preview)\n \
store: {}\n \
bookmarks: {}\n \
collections: {}\n",
paths.store.display(),
bookmarks.len(),
by_collection.len()
);
println!(
"{:<24} {:>10} {:>12} {:>16} hash",
"collection", "bookmarks", "bytes", "fingerprint"
);
println!("{}", "-".repeat(80));
let mut grand_total_bytes: usize = 0;
let mut grand_total_bookmarks: usize = 0;
for (collection, items) in &by_collection {
let encoded = encode_collection(collection, items);
let hash = fnv1a_64(&encoded);
let slug = collection_slug(collection);
println!(
"{:<24} {:>10} {:>12} {:>16x} {}",
truncate(collection, 24),
items.len(),
encoded.len(),
hash,
slug
);
grand_total_bytes = grand_total_bytes.saturating_add(encoded.len());
grand_total_bookmarks = grand_total_bookmarks.saturating_add(items.len());
if let Some(out_dir) = &args.out_dir {
let path = out_dir.join(format!("{slug}.ydoc.bin"));
std::fs::write(&path, &encoded)
.with_context(|| format!("write {}", path.display()))?;
}
}
println!("{}", "-".repeat(80));
println!(
"{:<24} {:>10} {:>12}",
"TOTAL", grand_total_bookmarks, grand_total_bytes
);
println!(
"\ndry-run complete. {} sub-docs, {} bytes uncompressed yrs payload.",
by_collection.len(),
grand_total_bytes
);
if let Some(out_dir) = args.out_dir {
println!(
"wrote per-collection encoded files to {}",
out_dir.display()
);
}
println!(
"Note: the actual relay wire size will be ~6-14× smaller after LZ4 compression.\n\
Run `lz4 <file>` on a `.ydoc.bin` to verify."
);
Ok(crate::exit_codes::OK)
}
fn group_by_collection(
bookmarks: &[linkmarks_core::Bookmark],
) -> std::collections::BTreeMap<String, Vec<linkmarks_core::Bookmark>> {
let mut map: std::collections::BTreeMap<String, Vec<linkmarks_core::Bookmark>> =
Default::default();
for bm in bookmarks {
let key = bm
.collection
.clone()
.unwrap_or_else(|| "(uncategorized)".to_string());
map.entry(key).or_default().push(bm.clone());
}
map
}
fn encode_collection(collection: &str, bookmarks: &[linkmarks_core::Bookmark]) -> Vec<u8> {
use yrs::types::map::MapPrelim;
use yrs::{Doc, Map, ReadTxn, StateVector, Transact, WriteTxn};
let doc = Doc::new();
{
let mut t = doc.transact_mut();
let meta = t.get_or_insert_map("meta");
meta.insert(&mut t, "collection_name", collection);
meta.insert(&mut t, "spike_marker", "sync-preview");
let bookmarks_map = t.get_or_insert_map("bookmarks");
let tags_map = t.get_or_insert_map("tags_by_bookmark");
for bm in bookmarks {
let bm_entry = bookmarks_map.insert(
&mut t,
bm.id.0.as_str(),
MapPrelim::default(),
);
bm_entry.insert(&mut t, "original_url", bm.original_url.clone());
bm_entry.insert(&mut t, "canonical_url", bm.canonical_url.clone());
bm_entry.insert(&mut t, "title", bm.title.clone());
bm_entry.insert(&mut t, "archived", bm.archived);
if let Some(ct) = &bm.content_type {
bm_entry.insert(&mut t, "content_type", ct.clone());
}
bm_entry.insert(
&mut t,
"source_kind",
bm.source.kind.as_cli_str().to_string(),
);
let tags_entry = tags_map.insert(&mut t, bm.id.0.as_str(), MapPrelim::default());
for tag in &bm.tags {
tags_entry.insert(&mut t, tag.clone(), 1i64);
}
}
t.commit();
}
let txn = doc.transact();
txn.encode_state_as_update_v1(&StateVector::default())
}
fn collection_slug(name: &str) -> String {
name.chars()
.map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '_' })
.collect::<String>()
.trim_matches('_')
.to_string()
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}
fn fnv1a_64(b: &[u8]) -> u64 {
let mut h: u64 = 0xcbf29ce484222325;
for &byte in b {
h ^= byte as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn collection_slug_lowercases_and_replaces_separators() {
assert_eq!(collection_slug("Work / Research"), "work___research");
assert_eq!(collection_slug("(uncategorized)"), "uncategorized");
assert_eq!(collection_slug("a:b"), "a_b");
}
#[test]
fn truncate_handles_short_and_long() {
assert_eq!(truncate("short", 10), "short");
assert_eq!(truncate("a long collection name here", 10), "a long co…");
assert_eq!(truncate("a long collection name here", 24), "a long collection name …");
}
#[test]
fn fnv_hash_is_deterministic() {
let a = fnv1a_64(b"hello");
let b = fnv1a_64(b"hello");
assert_eq!(a, b);
assert_ne!(a, fnv1a_64(b"hellp"));
}
}