web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
feat(raindrop): bookmark sync, phase 1 — the merge
The pure half of syncing bookmarks with one Raindrop.io collection
(Unsorted), with no network: a three-way merge of the local bookmarks,
the collection and the base (raindrop-sync.tsv, the pairs as of the last
successful pass).
Identity is the Raindrop id, URLs pair only what the base has never
seen, and only link and title are ever sent. A first run never deletes;
more than 10 deletions on a side, emptying a side, or Raindrop answering
empty against a non-empty base is refused, with the plan kept for a
person to force. apply_local skips operations on entries edited since
the snapshot the plan was made from, a failed create retries rather
than reading as a deletion, and the base is written atomically.
Not wired into the browser yet; RAINDROP-SYNC.md has the design and the
remaining phases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RAINDROP-SYNC.md | 91 +++++++++
src/main.rs | 1 +
src/raindrop.rs | 576 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 668 insertions(+)
diff --git a/RAINDROP-SYNC.md b/RAINDROP-SYNC.md
new file mode 100644
index 0000000..ed2f5f5
--- /dev/null
+++ b/RAINDROP-SYNC.md
@@ -0,0 +1,91 @@
+# Bookmark sync with Raindrop.io
+
+Status: **phase 1 done** (2026-10-02) — the merge, the deletion guard, the sync
+state file and applying a plan locally, all in `src/raindrop.rs` with no
+network, under 15 unit tests. Nothing is wired into the browser yet.
+
+## Decisions
+
+- **One Raindrop collection — Unsorted (id `-1`) — mirrors the browser's
+ bookmarks**, and new bookmarks from the browser land there. It is also where
+ Raindrop's phone app and extensions save by default, so a bookmark saved
+ anywhere shows up here. The cost, accepted: filing a bookmark into another
+ collection in Raindrop moves it out of the mirror, and the next pass removes
+ it here (not from Raindrop). Mirroring one collection and creating in another
+ was ruled out — every bookmark made here would read as deleted there.
+- **Deleting here moves the Raindrop copy to its trash** (recoverable there).
+- **Favorites stay local.** A dedicated Raindrop collection is the upgrade path
+ (phase 4), using its manual order for strip order.
+
+## Shape
+
+A worker thread **inside the browser**, not a separate tool like
+cce-keyring-sync: only the browser edits bookmarks locally, and it holds them
+in memory (`pages::Bookmarks`, rewriting `bookmarks.tsv` on each edit), so a
+second process writing that file would race it. It syncs at launch, a few
+seconds after a local edit (debounced), and every ~10 minutes while running.
+The menu and `cce://bookmarks` keep reading the local store and never wait on
+the network.
+
+## The merge (phase 1, `src/raindrop.rs`)
+
+`bookmarks.tsv` is unchanged. `raindrop-sync.tsv` holds the **base** — `id \t
+local url \t title` per pair, as of the last successful pass — and `plan(local,
+remote, base)` is a three-way merge:
+
+| here | Raindrop | in base | action |
+| --- | --- | --- | --- |
+| ✓ | ✓ | ✓ | title: the side that moved off the base wins; both → Raindrop |
+| ✓ | – | no | create in Raindrop |
+| ✓ | – | ✓ | delete here (deleted there, or moved out of the collection) |
+| – | ✓ | no | add here, at its `created` time |
+| – | ✓ | ✓ | move to Raindrop's trash |
+
+Rules, each with a test:
+
+- **Identity is the Raindrop id.** URLs pair only what the base has never seen
+ (`pair_key`: scheme/host case and a trailing slash don't matter). A link
+ edited in Raindrop is followed locally (`relink_local`).
+- **Only link and title are ever sent** — tags, notes and collections set
+ elsewhere cannot be clobbered (the cce-secrets attribute-wipe lesson).
+- **A first run never deletes** (empty base: union only).
+- **The guard refuses** more than 10 deletions on a side, emptying a side of 3
+ or more, or Raindrop answering empty when the base is not. The refused plan
+ is returned so a person can force it.
+- **Local edits made during a pass survive it**: `apply_local` skips any
+ operation on an entry that changed since the snapshot the plan was made from.
+- **A failed create retries next pass**, never reads as a deletion
+ (`base_after` only records ids Raindrop actually returned).
+- **The base is written atomically** (temp file + rename): half a base would
+ read as half the bookmarks deleted.
+- Raindrop's own duplicates are neither imported nor removed; `file:`
+ bookmarks stay local.
+- A pass settles: re-planning a synced state is a no-op (tested end to end).
+
+## Phase 2 — the API client
+
+To confirm against Raindrop's API docs first: REST at
+`api.raindrop.io/rest/v1`; a personal **test token** (integration settings) as
+`Authorization: Bearer`, so no OAuth; `GET /raindrops/{collection}` paged 50 at
+a time (a full fetch each pass — ~20 requests per 1000 bookmarks, inside the
+~120/min limit); `POST /raindrop` to create, `PUT /raindrop/{id}` to rename
+(title only), `DELETE /raindrop/{id}` to trash. Blocking `reqwest` on the
+worker (already a dependency). A dry-run mode logs the plan and applies
+nothing — run it against the real account before anything writes.
+
+**The token** lives in the keyring as an entry with no `UserName` (attribute
+`service=raindrop.io`), so cce-keyring-sync — which skips entries without
+`UserName` — never sends it to 1Password. A locked keyring skips the pass; it
+never prompts.
+
+## Phase 3 — wiring
+
+The worker, the debounce, `apply_local` against `pages::Bookmarks` under its
+lock, `save_base` last. A one-line status on `cce://bookmarks` ("synced 3m
+ago", or why not, including a refused plan with a way to force it). Setting
+`browser.raindrop` (off by default); its writing side belongs to
+cce-system-interface's Browser page — keep the key names in sync.
+
+## Phase 4 — favorites (optional)
+
+A dedicated collection, ordered by Raindrop's manual sort.
diff --git a/src/main.rs b/src/main.rs
index 731a680..8e73cdf 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -13,6 +13,7 @@ mod downloads;
mod instance;
mod lineedit;
mod pages;
+mod raindrop;
mod session;
mod settings;
/// The retired Servo backend; compiled only under `--features servo`.
diff --git a/src/raindrop.rs b/src/raindrop.rs
new file mode 100644
index 0000000..3337477
--- /dev/null
+++ b/src/raindrop.rs
@@ -0,0 +1,576 @@
+//! Bookmark sync with Raindrop.io — phase 1: the merge, with no network.
+//!
+//! The design is in `RAINDROP-SYNC.md`. In short: the local `bookmarks.tsv`
+//! stays the live store the chrome reads, and a sync pass reconciles it with
+//! one Raindrop collection by a **three-way merge** against `base` — the set
+//! as it stood after the last successful sync, kept in `raindrop-sync.tsv`.
+//! Only a base can tell "deleted over there" from "new over here".
+//!
+//! Everything in this file is a pure function of its inputs, because that is
+//! where a sync goes wrong: the API client (phase 2) only fetches and sends,
+//! and the browser (phase 3) only applies. Rules the merge keeps:
+//!
+//! * **Identity is the Raindrop `_id`, not the URL.** A base entry pairs a
+//! local URL with a remote id; Raindrop may tidy a link, and keying on it
+//! would read every tidied link as one deletion plus one creation, forever.
+//! URLs pair only things the base has never seen (the first run, or the
+//! same page saved on both sides in between).
+//! * **The browser owns the link and the title, nothing else.** A plan never
+//! carries tags, notes or collections, so it cannot clobber them.
+//! * **A first run never deletes**: with an empty base nothing was ever
+//! synced, so nothing can have been deleted since.
+//! * **Mass deletions are refused** (`guard`): an emptied file, the wrong
+//! collection or an API answer that came back empty all look like "delete
+//! everything", and that must take a person to confirm.
+//! * **Raindrop's duplicates are left alone.** Locally a URL is a key; a second
+//! Raindrop entry for a link already here is neither imported nor removed.
+
+// Not wired into the browser until phase 3; the tests exercise all of it.
+#![allow(dead_code)]
+
+use std::collections::{HashMap, HashSet};
+use std::path::{Path, PathBuf};
+
+/// A Raindrop bookmark's id (`_id` in the API).
+pub type RaindropId = u64;
+
+/// A local bookmark: a row of `bookmarks.tsv`.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Local {
+ pub url: String,
+ pub title: String,
+ /// When it was bookmarked, in seconds — the local order.
+ pub ts: u64,
+}
+
+/// A Raindrop bookmark, as much of it as the merge reads.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Remote {
+ pub id: RaindropId,
+ pub link: String,
+ pub title: String,
+ /// `created`, in seconds — becomes `ts` when imported.
+ pub created: u64,
+}
+
+/// One pair as it stood after the last successful sync.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Synced {
+ pub id: RaindropId,
+ /// The local URL this id is paired with.
+ pub url: String,
+ /// The title both sides agreed on, which is what says *which side* changed
+ /// it since.
+ pub title: String,
+}
+
+/// What one sync pass will do. Applied in field order on each side.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct Plan {
+ /// New here: create in Raindrop (link and title only).
+ pub create_remote: Vec<Local>,
+ pub rename_remote: Vec<(RaindropId, String)>,
+ /// Deleted here: move to Raindrop's trash, where it stays recoverable.
+ pub trash_remote: Vec<RaindropId>,
+ /// The link was edited in Raindrop: `(old local URL, new URL)`.
+ pub relink_local: Vec<(String, String)>,
+ /// `(local URL, new title)` — by the URL *after* any relink.
+ pub rename_local: Vec<(String, String)>,
+ pub delete_local: Vec<String>,
+ /// New in Raindrop: add here.
+ pub add_local: Vec<Local>,
+ /// The base after this plan, except the pairs `create_remote` makes: their
+ /// ids exist only once Raindrop answers. See [`Plan::base_after`].
+ pub base: Vec<Synced>,
+}
+
+impl Plan {
+ /// Whether this pass changes nothing on either side.
+ pub fn is_noop(&self) -> bool {
+ self.create_remote.is_empty()
+ && self.rename_remote.is_empty()
+ && self.trash_remote.is_empty()
+ && self.relink_local.is_empty()
+ && self.rename_local.is_empty()
+ && self.delete_local.is_empty()
+ && self.add_local.is_empty()
+ }
+
+ /// The base to save once the remote side has been applied. `created` maps
+ /// each local URL Raindrop accepted to the id it gave it; a create that
+ /// failed is simply absent, stays out of the base, and is tried again
+ /// next pass as "new here" — never misread as a deletion.
+ pub fn base_after(&self, created: &[(String, RaindropId)]) -> Vec<Synced> {
+ let mut base = self.base.clone();
+ for (url, id) in created {
+ if let Some(l) = self.create_remote.iter().find(|l| l.url == *url) {
+ base.push(Synced { id: *id, url: l.url.clone(), title: l.title.clone() });
+ }
+ }
+ base
+ }
+}
+
+/// A pass refused by [`guard`], with the plan it would have run, so a person
+/// can look at it and force it.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Refusal {
+ pub reason: String,
+ pub plan: Plan,
+}
+
+/// More deletions than this on one side in one pass needs a person.
+pub const MAX_DELETES: usize = 10;
+/// A side this large being emptied outright needs a person.
+pub const WIPE_MIN: usize = 3;
+
+/// The key two URLs are paired on when the base does not know them: scheme
+/// and host case-folded (the URL parser does that) and one trailing slash
+/// dropped. Only http(s) syncs — Raindrop stores web links, and a `file:`
+/// bookmark means nothing on another machine. Not an identity: once paired,
+/// the id is.
+pub fn pair_key(url: &str) -> Option<String> {
+ let u = url::Url::parse(url.trim()).ok()?;
+ if !matches!(u.scheme(), "http" | "https") {
+ return None;
+ }
+ let mut s = u.to_string();
+ if s.ends_with('/') {
+ s.pop();
+ }
+ Some(s)
+}
+
+/// Work out one pass. `Err` when the pass trips the deletion guard.
+pub fn plan(local: &[Local], remote: &[Remote], base: &[Synced]) -> Result<Plan, Refusal> {
+ let mut plan = Plan::default();
+ let local_by_url: HashMap<&str, &Local> = local.iter().map(|l| (l.url.as_str(), l)).collect();
+ let remote_by_id: HashMap<RaindropId, &Remote> = remote.iter().map(|r| (r.id, r)).collect();
+ // Every local URL's pairing key, so nothing imported duplicates one.
+ let local_keys: HashSet<String> = local.iter().filter_map(|l| pair_key(&l.url)).collect();
+ let mut used_local: HashSet<&str> = HashSet::new();
+ let mut used_remote: HashSet<RaindropId> = HashSet::new();
+
+ // 1. What the base knows: three-way.
+ for b in base {
+ let l = local_by_url.get(b.url.as_str()).copied();
+ let r = remote_by_id.get(&b.id).copied();
+ if let Some(l) = l {
+ used_local.insert(l.url.as_str());
+ }
+ if let Some(r) = r {
+ used_remote.insert(r.id);
+ }
+ match (l, r) {
+ (Some(l), Some(r)) => {
+ // Raindrop's link edited since: follow it, unless the new
+ // link is already a bookmark here — then the pair stays put.
+ let mut url = l.url.clone();
+ if pair_key(&r.link) != pair_key(&l.url) {
+ match pair_key(&r.link) {
+ Some(k) if !local_keys.contains(&k) => {
+ plan.relink_local.push((l.url.clone(), r.link.clone()));
+ url = r.link.clone();
+ }
+ _ => {}
+ }
+ }
+ // Title: whichever side moved off the base wins; both moved,
+ // Raindrop wins (the browser has no rename for bookmarks, so
+ // a local change only comes from a hand-edited file).
+ let title = if r.title != b.title {
+ if l.title != r.title {
+ plan.rename_local.push((url.clone(), r.title.clone()));
+ }
+ r.title.clone()
+ } else {
+ if l.title != b.title {
+ plan.rename_remote.push((r.id, l.title.clone()));
+ }
+ l.title.clone()
+ };
+ plan.base.push(Synced { id: r.id, url, title });
+ }
+ // Gone from the collection — deleted there, or moved out of it.
+ (Some(l), None) => plan.delete_local.push(l.url.clone()),
+ (None, Some(r)) => plan.trash_remote.push(r.id),
+ // Gone on both sides: nothing to do, and it leaves the base.
+ (None, None) => {}
+ }
+ }
+
+ // 2. What it does not: pair by URL, else it is new on its own side.
+ let mut unpaired_remote: Vec<(String, &Remote)> = Vec::new();
+ let mut seen: HashSet<String> = HashSet::new();
+ for r in remote.iter().filter(|r| !used_remote.contains(&r.id)) {
+ if let Some(k) = pair_key(&r.link) {
+ // The first of Raindrop's duplicates speaks for the link.
+ if seen.insert(k.clone()) {
+ unpaired_remote.push((k, r));
+ }
+ }
+ }
+ let mut taken: HashSet<RaindropId> = HashSet::new();
+ for l in local.iter().filter(|l| !used_local.contains(l.url.as_str())) {
+ let Some(k) = pair_key(&l.url) else { continue };
+ match unpaired_remote.iter().find(|(rk, r)| *rk == k && !taken.contains(&r.id)) {
+ Some((_, r)) => {
+ // Paired for the first time: Raindrop's title wins, since
+ // that is where titles get edited.
+ taken.insert(r.id);
+ if l.title != r.title {
+ plan.rename_local.push((l.url.clone(), r.title.clone()));
+ }
+ plan.base.push(Synced { id: r.id, url: l.url.clone(), title: r.title.clone() });
+ }
+ None => plan.create_remote.push(l.clone()),
+ }
+ }
+ // A link being deleted here this pass is free again: Raindrop's entry
+ // for it is a re-save (deleted and saved anew there), not a duplicate.
+ let leaving: HashSet<String> = plan.delete_local.iter().filter_map(|u| pair_key(u)).collect();
+ for (k, r) in &unpaired_remote {
+ if taken.contains(&r.id) || (local_keys.contains(k) && !leaving.contains(k)) {
+ continue;
+ }
+ plan.add_local.push(Local { url: r.link.clone(), title: r.title.clone(), ts: r.created });
+ plan.base.push(Synced { id: r.id, url: r.link.clone(), title: r.title.clone() });
+ }
+
+ match guard(&plan, local.len(), remote.len(), base.len()) {
+ Some(reason) => Err(Refusal { reason, plan }),
+ None => Ok(plan),
+ }
+}
+
+/// Why a plan must not run unattended, if it must not.
+pub fn guard(plan: &Plan, local_len: usize, remote_len: usize, base_len: usize) -> Option<String> {
+ if remote_len == 0 && base_len > 0 {
+ return Some(format!(
+ "Raindrop returned no bookmarks, but {base_len} were synced before — \
+ the wrong collection, or a token for another account?"
+ ));
+ }
+ for (side, deletes, size) in [
+ ("here", plan.delete_local.len(), local_len),
+ ("in Raindrop", plan.trash_remote.len(), remote_len),
+ ] {
+ if deletes > MAX_DELETES {
+ return Some(format!("this would delete {deletes} bookmarks {side} at once"));
+ }
+ if size >= WIPE_MIN && deletes == size {
+ return Some(format!("this would delete every bookmark {side} ({size})"));
+ }
+ }
+ None
+}
+
+/// Apply a plan's local half to the bookmarks as they are **now**.
+///
+/// The plan was made from a `snapshot`, and the person may have bookmarked or
+/// removed something while Raindrop was answering. An operation on a URL whose
+/// entry is not what the snapshot had is skipped — the next pass sees the new
+/// state and plans again — so a local edit is never overwritten by a stale
+/// plan. Returns how many were skipped.
+pub fn apply_local(current: &mut Vec<Local>, snapshot: &[Local], plan: &Plan) -> usize {
+ let unchanged = |current: &Vec<Local>, url: &str| {
+ current.iter().find(|l| l.url == url) == snapshot.iter().find(|l| l.url == url)
+ };
+ let mut skipped = 0;
+ for (old, new) in &plan.relink_local {
+ if unchanged(current, old) && !current.iter().any(|l| l.url == *new) {
+ if let Some(l) = current.iter_mut().find(|l| l.url == *old) {
+ l.url = new.clone();
+ }
+ } else {
+ skipped += 1;
+ }
+ }
+ for (url, title) in &plan.rename_local {
+ // Renames address the post-relink URL; a relinked entry was checked
+ // against the snapshot by its old one above.
+ let relinked = plan.relink_local.iter().any(|(_, n)| n == url);
+ if relinked || unchanged(current, url) {
+ if let Some(l) = current.iter_mut().find(|l| l.url == *url) {
+ l.title = title.clone();
+ continue;
+ }
+ }
+ skipped += 1;
+ }
+ for url in &plan.delete_local {
+ if unchanged(current, url) {
+ current.retain(|l| l.url != *url);
+ } else {
+ skipped += 1;
+ }
+ }
+ for add in &plan.add_local {
+ if current.iter().any(|l| l.url == add.url) {
+ skipped += 1;
+ } else {
+ current.push(add.clone());
+ }
+ }
+ // Local order is bookmarking time; an import lands where it was made.
+ current.sort_by_key(|l| l.ts);
+ skipped
+}
+
+/// `~/.local/state/cce/browser/raindrop-sync.tsv`.
+pub fn state_path() -> PathBuf {
+ crate::pages::state_dir().join("raindrop-sync.tsv")
+}
+
+/// Read the base: `id \t url \t title` per line. A malformed line is dropped,
+/// and so is a repeat of an id or a URL — a pair is one-to-one, and a second
+/// claim on either half can only be damage.
+pub fn load_base(path: &Path) -> Vec<Synced> {
+ let Ok(text) = std::fs::read_to_string(path) else { return Vec::new() };
+ let mut ids = HashSet::new();
+ let mut urls = HashSet::new();
+ let mut out = Vec::new();
+ for line in text.lines() {
+ let mut parts = line.splitn(3, '\t');
+ let (Some(id), Some(url), Some(title)) = (parts.next(), parts.next(), parts.next()) else {
+ continue;
+ };
+ let Ok(id) = id.parse::<RaindropId>() else { continue };
+ if url.is_empty() || !ids.insert(id) || !urls.insert(url.to_string()) {
+ continue;
+ }
+ out.push(Synced { id, url: url.to_string(), title: title.to_string() });
+ }
+ out
+}
+
+/// Write the base, atomically: a pass that dies mid-write must leave the old
+/// base, not half a new one — half a base reads as half the bookmarks deleted.
+pub fn save_base(path: &Path, base: &[Synced]) -> std::io::Result<()> {
+ if let Some(dir) = path.parent() {
+ std::fs::create_dir_all(dir)?;
+ }
+ let field = |s: &str| s.replace(['\t', '\n', '\r'], " ");
+ let mut out = String::new();
+ for b in base {
+ out.push_str(&format!("{}\t{}\t{}\n", b.id, field(&b.url), field(&b.title)));
+ }
+ let tmp = path.with_extension("tsv.tmp");
+ std::fs::write(&tmp, out)?;
+ std::fs::rename(&tmp, path)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn l(url: &str, title: &str, ts: u64) -> Local {
+ Local { url: url.into(), title: title.into(), ts }
+ }
+ fn r(id: RaindropId, link: &str, title: &str) -> Remote {
+ Remote { id, link: link.into(), title: title.into(), created: 1000 + id }
+ }
+ fn s(id: RaindropId, url: &str, title: &str) -> Synced {
+ Synced { id, url: url.into(), title: title.into() }
+ }
+
+ /// Run a whole pass the way phases 2 and 3 will: plan, apply to Raindrop
+ /// (handing out ids), apply locally, save the base. Returns the new state.
+ fn run(
+ local: &[Local],
+ remote: &[Remote],
+ base: &[Synced],
+ ) -> (Vec<Local>, Vec<Remote>, Vec<Synced>) {
+ let p = plan(local, remote, base).expect("not refused");
+ let mut remote = remote.to_vec();
+ remote.retain(|x| !p.trash_remote.contains(&x.id));
+ for (id, t) in &p.rename_remote {
+ remote.iter_mut().find(|x| x.id == *id).unwrap().title = t.clone();
+ }
+ let mut created = Vec::new();
+ for c in &p.create_remote {
+ let id = 500 + remote.len() as RaindropId + created.len() as RaindropId;
+ remote.push(Remote { id, link: c.url.clone(), title: c.title.clone(), created: c.ts });
+ created.push((c.url.clone(), id));
+ }
+ let mut local_now = local.to_vec();
+ assert_eq!(apply_local(&mut local_now, local, &p), 0);
+ (local_now, remote, p.base_after(&created))
+ }
+
+ #[test]
+ fn a_first_run_unions_and_pairs_by_link() {
+ let local = [l("https://Example.com/", "mine", 1), l("https://only-here.test/a", "A", 2)];
+ let remote = [r(1, "https://example.com", "theirs"), r(2, "https://only-there.test/", "B")];
+ let p = plan(&local, &remote, &[]).unwrap();
+ assert!(p.delete_local.is_empty() && p.trash_remote.is_empty(), "a first run never deletes");
+ assert_eq!(p.rename_local, vec![("https://Example.com/".into(), "theirs".into())]);
+ assert_eq!(p.create_remote, vec![l("https://only-here.test/a", "A", 2)]);
+ assert_eq!(p.add_local, vec![l("https://only-there.test/", "B", 1002)]);
+ assert!(p.base.contains(&s(1, "https://Example.com/", "theirs")));
+ }
+
+ #[test]
+ fn deletions_cross_over_through_the_base() {
+ let base = [s(1, "https://a.test/", "A"), s(2, "https://b.test/", "B")];
+ // a.test deleted here, b.test deleted in Raindrop.
+ let local = [l("https://b.test/", "B", 1)];
+ let remote = [r(1, "https://a.test/", "A")];
+ let p = plan(&local, &remote, &base).unwrap();
+ assert_eq!(p.trash_remote, vec![1]);
+ assert_eq!(p.delete_local, vec!["https://b.test/".to_string()]);
+ assert!(p.base.is_empty());
+ assert!(p.add_local.is_empty() && p.create_remote.is_empty(), "no resurrection");
+ }
+
+ #[test]
+ fn titles_follow_whichever_side_moved() {
+ let base = [s(1, "https://a.test/", "A"), s(2, "https://b.test/", "B"), s(3, "https://c.test/", "C")];
+ let local = [l("https://a.test/", "A", 1), l("https://b.test/", "B local", 2), l("https://c.test/", "C local", 3)];
+ let remote = [r(1, "https://a.test/", "A remote"), r(2, "https://b.test/", "B"), r(3, "https://c.test/", "C remote")];
+ let p = plan(&local, &remote, &base).unwrap();
+ assert_eq!(
+ p.rename_local,
+ vec![("https://a.test/".into(), "A remote".into()), ("https://c.test/".into(), "C remote".into())],
+ "Raindrop's edit lands here, and wins when both moved"
+ );
+ assert_eq!(p.rename_remote, vec![(2, "B local".into())]);
+ }
+
+ #[test]
+ fn a_link_edited_in_raindrop_is_followed() {
+ let base = [s(1, "http://old.test/page", "P")];
+ let local = [l("http://old.test/page", "P", 1)];
+ let remote = [r(1, "https://new.test/page", "P")];
+ let p = plan(&local, &remote, &base).unwrap();
+ assert_eq!(p.relink_local, vec![("http://old.test/page".into(), "https://new.test/page".into())]);
+ assert!(p.delete_local.is_empty() && p.add_local.is_empty());
+ assert_eq!(p.base, vec![s(1, "https://new.test/page", "P")]);
+ }
+
+ #[test]
+ fn a_tidied_link_is_not_a_change() {
+ let base = [s(1, "https://Example.com/x/", "X")];
+ let local = [l("https://Example.com/x/", "X", 1)];
+ let remote = [r(1, "https://example.com/x", "X")];
+ assert!(plan(&local, &remote, &base).unwrap().is_noop());
+ }
+
+ #[test]
+ fn raindrop_duplicates_are_left_alone() {
+ let base = [s(1, "https://a.test/", "A")];
+ let local = [l("https://a.test/", "A", 1)];
+ let remote = [r(1, "https://a.test/", "A"), r(2, "https://a.test", "A again"), r(3, "https://b.test/", "B"), r(4, "https://b.test/", "B again")];
+ let p = plan(&local, &remote, &base).unwrap();
+ assert_eq!(p.add_local, vec![l("https://b.test/", "B", 1003)], "one import per link");
+ assert!(p.trash_remote.is_empty(), "and nothing of theirs is removed");
+ }
+
+ #[test]
+ fn a_link_re_saved_in_raindrop_settles_in_one_pass() {
+ // Deleted and saved again in Raindrop: same link, a new id.
+ let base = [s(1, "https://a.test/", "A")];
+ let local = [l("https://a.test/", "A", 1)];
+ let remote = [r(7, "https://a.test/", "A anew")];
+ let (local, remote, base) = run(&local, &remote, &base);
+ assert_eq!(local, vec![l("https://a.test/", "A anew", 1007)]);
+ assert!(plan(&local, &remote, &base).unwrap().is_noop());
+ }
+
+ #[test]
+ fn only_web_links_sync() {
+ let local = [l("file:///home/me/notes.html", "notes", 1)];
+ let p = plan(&local, &[r(1, "https://a.test/", "A")], &[]).unwrap();
+ assert!(p.create_remote.is_empty(), "a file: bookmark stays here");
+ }
+
+ #[test]
+ fn mass_deletion_is_refused() {
+ let n = MAX_DELETES as RaindropId + 1;
+ let base: Vec<_> = (1..=n).map(|i| s(i, &format!("https://{i}.test/"), "t")).collect();
+ let mut remote: Vec<_> = (1..=n).map(|i| r(i, &format!("https://{i}.test/"), "t")).collect();
+ remote.push(r(99, "https://keep.test/", "k"));
+ let err = plan(&[], &remote, &base).unwrap_err();
+ assert!(err.reason.contains("11 bookmarks in Raindrop"), "{}", err.reason);
+ assert_eq!(err.plan.trash_remote.len(), 11, "the refused plan is kept for a person to force");
+ }
+
+ #[test]
+ fn emptying_a_side_is_refused() {
+ let base: Vec<_> = (1..=3).map(|i| s(i, &format!("https://{i}.test/"), "t")).collect();
+ let local: Vec<_> = (1..=3).map(|i| l(&format!("https://{i}.test/"), "t", i)).collect();
+ let remote: Vec<_> = (1..=3).map(|i| r(i, &format!("https://{i}.test/"), "t")).collect();
+ assert!(plan(&[], &remote, &base).unwrap_err().reason.contains("every bookmark in Raindrop"));
+ assert!(plan(&local, &[], &base).unwrap_err().reason.contains("returned no bookmarks"));
+ }
+
+ #[test]
+ fn ordinary_deletion_is_allowed() {
+ let base = [s(1, "https://a.test/", "A"), s(2, "https://b.test/", "B")];
+ let local = [l("https://a.test/", "A", 1)];
+ let remote = [r(1, "https://a.test/", "A"), r(2, "https://b.test/", "B")];
+ assert_eq!(plan(&local, &remote, &base).unwrap().trash_remote, vec![2]);
+ // Deleting the only bookmark is a person's choice, not a wipe.
+ let p = plan(&[], &[r(1, "https://a.test/", "A")], &[s(1, "https://a.test/", "A")]).unwrap();
+ assert_eq!(p.trash_remote, vec![1]);
+ }
+
+ #[test]
+ fn a_pass_settles_and_the_next_one_is_a_noop() {
+ let local = vec![l("https://a.test/", "A", 1), l("https://here.test/", "H", 2)];
+ let remote = vec![r(1, "https://a.test", "A (theirs)"), r(2, "https://there.test/", "T")];
+ let (local, remote, base) = run(&local, &remote, &[]);
+ assert_eq!(local.len(), 3);
+ assert_eq!(remote.len(), 3);
+ assert!(plan(&local, &remote, &base).unwrap().is_noop(), "a synced state plans nothing");
+
+ // A deletion on each side, then a rename in Raindrop, each settles.
+ let local: Vec<_> = local.into_iter().filter(|x| x.url != "https://here.test/").collect();
+ let (local, mut remote, base) = run(&local, &remote, &base);
+ assert_eq!(remote.len(), 2);
+ remote.iter_mut().find(|x| x.link.contains("there")).unwrap().title = "T2".into();
+ let (local, remote, base) = run(&local, &remote, &base);
+ assert!(local.iter().any(|x| x.title == "T2"));
+ assert!(plan(&local, &remote, &base).unwrap().is_noop());
+ }
+
+ #[test]
+ fn a_failed_create_is_retried_not_deleted() {
+ let local = [l("https://new.test/", "N", 1)];
+ let p = plan(&local, &[], &[]).unwrap();
+ let base = p.base_after(&[]); // Raindrop refused the create
+ let again = plan(&local, &[], &base).unwrap();
+ assert_eq!(again.create_remote.len(), 1);
+ assert!(again.delete_local.is_empty());
+ }
+
+ #[test]
+ fn edits_made_during_a_pass_survive_it() {
+ let snapshot = vec![l("https://a.test/", "A", 1), l("https://b.test/", "B", 2)];
+ let p = Plan {
+ delete_local: vec!["https://a.test/".into()],
+ rename_local: vec![("https://b.test/".into(), "B2".into())],
+ add_local: vec![l("https://c.test/", "C", 3)],
+ ..Plan::default()
+ };
+ // Meanwhile: a.test re-bookmarked with a new title, c.test bookmarked.
+ let mut current = vec![l("https://a.test/", "A again", 5), l("https://b.test/", "B", 2), l("https://c.test/", "mine", 6)];
+ assert_eq!(apply_local(&mut current, &snapshot, &p), 2);
+ assert!(current.iter().any(|x| x.url == "https://a.test/" && x.title == "A again"));
+ assert!(current.iter().any(|x| x.url == "https://b.test/" && x.title == "B2"));
+ assert!(current.iter().any(|x| x.url == "https://c.test/" && x.title == "mine"));
+ }
+
+ #[test]
+ fn the_base_round_trips_and_drops_damage() {
+ let dir = std::env::temp_dir().join(format!("cce-raindrop-{}", std::process::id()));
+ let path = dir.join("raindrop-sync.tsv");
+ save_base(&path, &[s(1, "https://a.test/", "tab\there"), s(2, "https://b.test/", "B")]).unwrap();
+ let mut text = std::fs::read_to_string(&path).unwrap();
+ text.push_str("3\thttps://a.test/\tsame url\n1\thttps://c.test/\tsame id\nnot a line\n");
+ std::fs::write(&path, text).unwrap();
+ assert_eq!(load_base(&path), vec![s(1, "https://a.test/", "tab here"), s(2, "https://b.test/", "B")]);
+ assert!(!path.with_extension("tsv.tmp").exists());
+ let _ = std::fs::remove_dir_all(dir);
+ }
+}