web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
feat(raindrop): a sync log, one line per bookmark that moved
Each pass that changes something appends to raindrop-sync.log beside
the base: time, what, link, title — added, removed or renamed on either
side, plus refusals, forced passes and failures. Local lines are the
difference the edit actually made, Raindrop lines the calls that
succeeded, so it records what happened rather than what was planned.
Idle passes write nothing; past 512 KB it keeps its newer half.
The status line only counts, and the process log does not survive a
restart: after the first day's fetch bug, "which two bookmarks came
back?" had no answer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RAINDROP-SYNC.md | 9 +++++
src/raindrop/api.rs | 27 +++++++++++++
src/raindrop/sync.rs | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 146 insertions(+), 2 deletions(-)
diff --git a/RAINDROP-SYNC.md b/RAINDROP-SYNC.md
index 5d3a72a..da4dec8 100644
--- a/RAINDROP-SYNC.md
+++ b/RAINDROP-SYNC.md
@@ -150,6 +150,15 @@ key name in step with `settings.rs`. Choices:
reloading its URL forces nothing.
- The page is static: the line is as of the page's load, and reloading
`cce://bookmarks/sync` asks for another (harmless) pass.
+- **Every change is logged by name** in `raindrop-sync.log` beside the base:
+ `time \t what \t link \t title`, one line per bookmark added, removed or
+ renamed on either side, plus refusals, forced passes and failures. Local
+ lines are the difference the edit actually made and Raindrop lines the
+ calls that succeeded, so the log says what happened, not what was planned.
+ Passes that change nothing write nothing; past 512 KB it keeps its newer
+ half. Added after the first day's fetch bug, when "which two bookmarks came
+ back?" had no answer — the status line only counts, and the process log was
+ gone with the restart.
**Testing without an account:** `CCE_RAINDROP_API=<base url>` points the
client at a stand-in (it logs a warning when it does). The end-to-end run used
diff --git a/src/raindrop/api.rs b/src/raindrop/api.rs
index e1b11d1..9dfb75a 100644
--- a/src/raindrop/api.rs
+++ b/src/raindrop/api.rs
@@ -261,6 +261,24 @@ pub fn iso8601_secs(s: &str) -> Option<u64> {
u64::try_from(days * 86_400 + h * 3600 + mi * 60 + se).ok()
}
+/// Seconds since the epoch → `2026-10-02T16:04:05Z`; the inverse of
+/// [`iso8601_secs`], for the sync log.
+pub fn iso8601(secs: u64) -> String {
+ let days = (secs / 86_400) as i64;
+ let rem = secs % 86_400;
+ // Civil date from days (Howard Hinnant's algorithm).
+ let z = days + 719_468;
+ let era = z.div_euclid(146_097);
+ let doe = z - era * 146_097;
+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ let mp = (5 * doy + 2) / 153;
+ let d = doy - (153 * mp + 2) / 5 + 1;
+ let m = if mp < 10 { mp + 3 } else { mp - 9 };
+ let y = yoe + era * 400 + i64::from(m <= 2);
+ format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
+}
+
/// What applying a plan's remote half did — the input to `Plan::base_after`,
/// which keeps the base honest about anything that failed.
#[derive(Debug, Default, Clone, PartialEq)]
@@ -537,6 +555,15 @@ pub(super) mod tests {
assert_eq!(r.title, "two lines and tab");
}
+ #[test]
+ fn timestamps_format_and_round_trip() {
+ assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
+ assert_eq!(iso8601(951_868_800), "2000-03-01T00:00:00Z");
+ for secs in [1_790_957_045, 1_709_164_800, 4_102_444_799] {
+ assert_eq!(iso8601_secs(&iso8601(secs)), Some(secs));
+ }
+ }
+
#[test]
fn timestamps_parse() {
assert_eq!(iso8601_secs("1970-01-01T00:00:00Z"), Some(0));
diff --git a/src/raindrop/sync.rs b/src/raindrop/sync.rs
index d407450..4313918 100644
--- a/src/raindrop/sync.rs
+++ b/src/raindrop/sync.rs
@@ -12,7 +12,9 @@
//! A pass is [`run_pass`]: fetch, plan, apply Raindrop's half, apply the local
//! half through `Bookmarks::edit_rows` (one locked edit — no star can land in
//! the middle of it), and save the base last. Its outcome becomes the status
-//! line on `cce://bookmarks`.
+//! line on `cce://bookmarks`, and what it changed is appended to the sync log
+//! (`raindrop-sync.log`, beside the base) — one line per bookmark, so "which
+//! ones?" has an answer after the status line and the process are gone.
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -27,6 +29,42 @@ const POLL: Duration = Duration::from_secs(2);
/// A full pass this often while nothing local changes: Raindrop's side.
const FULL: Duration = Duration::from_secs(600);
+/// The sync log beside a base: `raindrop-sync.tsv` → `raindrop-sync.log`.
+pub fn log_path(base_path: &Path) -> std::path::PathBuf {
+ base_path.with_extension("log")
+}
+
+/// Past this size the log keeps only its newer half — months of passes, kept
+/// to a size nobody has to think about.
+const LOG_MAX: usize = 512 * 1024;
+
+/// Append `events` (`(what, link, title)`) to the log, one line each:
+/// `time \t what \t link \t title`. Best effort — a log that cannot be
+/// written must not fail a pass that already happened — but said once.
+pub fn append_log(path: &Path, events: &[(String, String, String)]) {
+ if events.is_empty() {
+ return;
+ }
+ let now = api::iso8601(super::unix_now());
+ let flat = |s: &str| s.replace(['\t', '\n', '\r'], " ");
+ let mut text = std::fs::read_to_string(path).unwrap_or_default();
+ for (what, link, title) in events {
+ text.push_str(&format!("{now}\t{}\t{}\t{}\n", flat(what), flat(link), flat(title)));
+ }
+ if text.len() > LOG_MAX {
+ let cut = text.len() - LOG_MAX / 2;
+ let from = text[cut..].find('\n').map(|i| cut + i + 1).unwrap_or(cut);
+ text = text[from..].to_string();
+ }
+ if let Some(dir) = path.parent() {
+ let _ = std::fs::create_dir_all(dir);
+ }
+ let tmp = path.with_extension("log.tmp");
+ if let Err(e) = std::fs::write(&tmp, text).and_then(|_| std::fs::rename(&tmp, path)) {
+ log::warn!("raindrop: could not write {}: {e}", path.display());
+ }
+}
+
/// What one pass did.
#[derive(Debug, Default, PartialEq)]
pub struct Summary {
@@ -88,13 +126,18 @@ pub fn run_pass(
let remote = client.fetch(api::UNSORTED).map_err(|e| e.to_string())?;
let snapshot = locals(bookmarks.rows());
let base = load_base(base_path);
+ let log = log_path(base_path);
let plan = match plan(&snapshot, &remote, &base) {
Ok(p) => p,
Err(refused) if force => {
log::warn!("raindrop: running a refused pass on request ({})", refused.reason);
+ append_log(&log, &[("forced".into(), String::new(), refused.reason.clone())]);
refused.plan
}
- Err(refused) => return Ok(Outcome::Refused(refused.reason)),
+ Err(refused) => {
+ append_log(&log, &[("refused".into(), String::new(), refused.reason.clone())]);
+ return Ok(Outcome::Refused(refused.reason));
+ }
};
let applied = api::apply_remote(client, api::UNSORTED, &plan).map_err(|e| e.to_string())?;
let touches_local = !(plan.relink_local.is_empty()
@@ -103,16 +146,54 @@ pub fn run_pass(
&& plan.add_local.is_empty());
// Only rewrite the file when there is something to write: a pass every
// ten minutes that changes nothing must not touch the disk.
+ // The log says what *happened*: the local lines are the difference the
+ // edit actually made (a deferred operation made none), the Raindrop lines
+ // the calls that succeeded.
+ let mut events: Vec<(String, String, String)> = Vec::new();
let skipped = if touches_local {
bookmarks.edit_rows(|rows| {
+ let before: Vec<(u64, String, String)> = rows.clone();
let mut current = locals(std::mem::take(rows));
let skipped = apply_local(&mut current, &snapshot, &plan);
*rows = current.into_iter().map(|l| (l.ts, l.url, l.title)).collect();
+ for (_, url, title) in rows.iter() {
+ match before.iter().find(|b| b.1 == *url) {
+ None => events.push(("added here".into(), url.clone(), title.clone())),
+ Some(b) if b.2 != *title => {
+ events.push(("renamed here".into(), url.clone(), format!("{} → {title}", b.2)))
+ }
+ Some(_) => {}
+ }
+ }
+ for (_, url, title) in &before {
+ if !rows.iter().any(|r| r.1 == *url) {
+ events.push(("removed here".into(), url.clone(), title.clone()));
+ }
+ }
skipped
})
} else {
super::Skipped::default()
};
+ let link_of = |id: &super::RaindropId| {
+ remote.iter().find(|r| r.id == *id).map(|r| (r.link.clone(), r.title.clone())).unwrap_or_default()
+ };
+ for (url, _) in &applied.created {
+ let title = plan.create_remote.iter().find(|l| l.url == *url).map(|l| l.title.clone()).unwrap_or_default();
+ events.push(("added to Raindrop".into(), url.clone(), title));
+ }
+ for id in plan.trash_remote.iter().filter(|id| !applied.failed_trash.contains(id)) {
+ let (link, title) = link_of(id);
+ events.push(("moved to Raindrop's trash".into(), link, title));
+ }
+ for (id, new) in plan.rename_remote.iter().filter(|(id, _)| !applied.failed_renames.contains(id)) {
+ let (link, old) = link_of(id);
+ events.push(("renamed in Raindrop".into(), link, format!("{old} → {new}")));
+ }
+ for e in &applied.errors {
+ events.push(("failed".into(), String::new(), e.clone()));
+ }
+ append_log(&log, &events);
// Last: a pass that dies before this point leaves the old base, and the
// next pass redoes the work rather than misreading it.
let new_base = settle_base(&plan, &base, &applied, &skipped);
@@ -173,6 +254,7 @@ fn pass(bookmarks: &Bookmarks, force: bool) {
}
Err(e) => {
log::warn!("raindrop: {e}");
+ append_log(&log_path(&super::state_path()), &[("not synced".into(), String::new(), e.clone())]);
note(format!("not synced: {e}"), None);
}
}
@@ -230,6 +312,21 @@ pub fn spawn(bookmarks: Arc<Bookmarks>, enabled: Arc<AtomicBool>) {
mod tests {
use super::*;
use crate::accounts::Secret;
+
+ #[test]
+ fn the_log_keeps_its_newer_half() {
+ let dir = std::env::temp_dir().join(format!("cce-raindrop-log-{}", std::process::id()));
+ let path = dir.join("raindrop-sync.log");
+ let long = "x".repeat(1000);
+ for i in 0..700 {
+ append_log(&path, &[("added here".into(), format!("https://{i}.test/"), long.clone())]);
+ }
+ let text = std::fs::read_to_string(&path).unwrap();
+ assert!(text.len() <= LOG_MAX);
+ assert!(text.contains("https://699.test/") && !text.contains("https://0.test/"));
+ assert!(text.lines().all(|l| l.split('\t').count() == 4), "trimmed at a line boundary");
+ let _ = std::fs::remove_dir_all(dir);
+ }
use api::tests::{items, server};
fn scratch(name: &str) -> std::path::PathBuf {
@@ -261,6 +358,13 @@ mod tests {
assert_eq!(saved.len(), 3, "both imports and the create are paired");
assert!(saved.iter().any(|b| b.id == 90 && b.url == "https://local.test/"));
assert_eq!(seen.lock().unwrap().len(), 2);
+ // The log names each bookmark that moved, and what happened to it.
+ let log = std::fs::read_to_string(log_path(&base_path)).unwrap();
+ let lines: Vec<Vec<&str>> = log.lines().map(|l| l.split('\t').collect()).collect();
+ assert_eq!(lines.len(), 3, "{log}");
+ assert!(lines.iter().all(|l| l.len() == 4 && l[0].ends_with('Z')));
+ assert!(lines.iter().any(|l| l[1] == "added here" && l[2] == "https://1.test/" && l[3] == "t1"));
+ assert!(lines.iter().any(|l| l[1] == "added to Raindrop" && l[2] == "https://local.test/" && l[3] == "Mine"));
let _ = std::fs::remove_dir_all(dir);
}
@@ -285,12 +389,15 @@ mod tests {
assert!(matches!(out, Outcome::Refused(ref r) if r.contains("every bookmark in Raindrop")), "{out:?}");
assert_eq!(seen.lock().unwrap().len(), 1, "only the fetch went out");
assert_eq!(load_base(&base_path).len(), 3, "the base is untouched");
+ assert!(std::fs::read_to_string(log_path(&base_path)).unwrap().contains("\trefused\t\tthis would delete every bookmark in Raindrop"));
let Outcome::Synced(s) = run_pass(&client, &bookmarks, &base_path, true).unwrap() else {
panic!("forced pass refused")
};
assert_eq!(s.trashed_there, 3);
assert!(load_base(&base_path).is_empty());
+ let log = std::fs::read_to_string(log_path(&base_path)).unwrap();
+ assert_eq!(log.matches("\tmoved to Raindrop's trash\thttps://").count(), 3, "{log}");
let _ = std::fs::remove_dir_all(dir);
}
@@ -305,6 +412,7 @@ mod tests {
assert_eq!(out, Outcome::Synced(Summary::default()));
assert_eq!(Summary::default().text(), "in sync");
assert!(!path.exists(), "nothing to write, nothing written");
+ assert!(!log_path(&dir.join("raindrop-sync.tsv")).exists(), "an idle pass logs nothing");
let _ = std::fs::remove_dir_all(dir);
}
}