web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
feat(raindrop): bookmark sync, phase 2 — the client and a dry run
The Raindrop.io REST client (checked against developer.raindrop.io):
pages Unsorted 50 at a time, creates with link and title only, renames
with a title-only partial update, trashes only ids just fetched from
the live collection (DELETE on an item already in Trash is permanent).
The fetch is checked against Raindrop's reported count and refused on
a mismatch: paging is by position, so a deletion between pages would
skip an item, and a missing item reads as a deletion. One failed item
does not stop the rest, and base_after now records what happened rather
than what was planned, so a failed trash is retried instead of
re-imported and a failed rename retried instead of reversed. A 401
stops the pass; one 429 is waited out.
The token lives in the keyring under service=raindrop.io with no
UserName, so cce-keyring-sync never sends it to 1Password and the
account index never offers it. cce-browser --raindrop-plan fetches,
plans and prints, changing nothing.
reqwest becomes a plain dependency (it was Servo-only), the same
blocking build cce-map and cce-calendar use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cargo.toml | 7 +-
RAINDROP-SYNC.md | 61 +++--
src/accounts.rs | 36 +++
src/main.rs | 13 +
src/pages.rs | 11 +
src/raindrop/api.rs | 465 +++++++++++++++++++++++++++++++++++
src/{raindrop.rs => raindrop/mod.rs} | 86 ++++++-
7 files changed, 651 insertions(+), 28 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index 7ab5d39..8d5b88c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,7 +16,7 @@ default = ["wpe"]
wpe = ["dep:rustix"]
# The retired Servo backend, kept buildable for comparison:
# cargo build --release -p cce-browser --no-default-features --features servo
-servo = ["dep:servo", "dep:dpi", "dep:euclid", "dep:rustls", "dep:reqwest", "dep:http"]
+servo = ["dep:servo", "dep:dpi", "dep:euclid", "dep:rustls", "dep:http"]
[dependencies]
cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "6634ba3d690bb2125aad371eb5ef4cf10962999a" }
@@ -25,7 +25,10 @@ wayland-client = { version = "0.31", features = ["system"] }
servo = { version = "0.4", optional = true }
url = "2"
http = { version = "1", optional = true }
-reqwest = { version = "0.12", features = ["blocking"], optional = true }
+# Downloads on the Servo backend, and Raindrop bookmark sync on both. The same
+# version and features cce-map and cce-calendar build, so the shared target/
+# already holds it.
+reqwest = { version = "0.12", features = ["blocking"] }
dpi = { version = "0.1", optional = true }
euclid = { version = "0.22", optional = true }
rustls = { version = "0.23", features = ["aws-lc-rs"], optional = true }
diff --git a/RAINDROP-SYNC.md b/RAINDROP-SYNC.md
index ed2f5f5..88394f6 100644
--- a/RAINDROP-SYNC.md
+++ b/RAINDROP-SYNC.md
@@ -1,8 +1,10 @@
# 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.
+Status: **phase 2 done** (2026-10-02). Phase 1 is the merge, the deletion
+guard, the sync state file and applying a plan locally (`src/raindrop/mod.rs`);
+phase 2 is the REST client, the keyring token and a read-only dry run
+(`src/raindrop/api.rs`, `cce-browser --raindrop-plan`). 25 unit tests, the
+client's against a stand-in server. Nothing syncs on its own yet — phase 3.
## Decisions
@@ -62,21 +64,44 @@ Rules, each with a test:
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 2 — the API client (done)
+
+Checked against developer.raindrop.io on 2026-10-02: REST at
+`api.raindrop.io/rest/v1`, `Authorization: Bearer <test token>` (from the
+integration settings; test tokens do not expire), 120 requests/minute with
+`429` past it, ISO 8601 timestamps. `GET /raindrops/-1` pages Unsorted 50 at a
+time; `POST /raindrop` creates; `PUT /raindrop/{id}` is a **partial** update;
+`DELETE /raindrop/{id}` moves to Trash — and is **permanent** on an item
+already in Trash, so only ids just fetched from the live collection are ever
+trashed. Choices:
+
+- **The fetch is checked against Raindrop's `count`.** Paging is by position,
+ so a deletion between pages shifts an item past the fetch, and a missing
+ item reads as "deleted in Raindrop". Sorted oldest-first, an *addition*
+ lands on the last page; a mismatch refuses the whole pass.
+- **A failure on one item does not stop the rest**, and `base_after` records
+ what *happened*: a failed create stays out of the base (retried as new), a
+ failed trash keeps its pair (retried, not re-imported), a failed rename keeps
+ its old title (retried, not reversed). A `401` stops the pass at once.
+- **One `429` is waited out** (until `X-RateLimit-Reset`, at most a minute).
+- `reqwest` is now a plain dependency (it was Servo-only) — `blocking`, the
+ same build cce-map and cce-calendar use; JSON bodies are serialized by hand
+ rather than turning on its `json` feature, to keep it the same build.
+
+**The token** lives in the keyring as an entry with no `UserName`, found by
+`service=raindrop.io`:
+
+```sh
+secret-tool store --label='Raindrop.io token' service raindrop.io
+```
+
+cce-keyring-sync skips entries without `UserName`, so it stays on this machine
+and never goes to 1Password; the account index skips it too, so it is never
+offered to a login form. A locked keyring is an error, never a prompt.
+
+**`cce-browser --raindrop-plan`** fetches Unsorted, plans a pass against
+`bookmarks.tsv` and the base, prints it, and changes nothing. It runs ahead of
+the single-instance hand-off, so it works while the browser is open.
## Phase 3 — wiring
diff --git a/src/accounts.rs b/src/accounts.rs
index e93ed34..4c67a60 100644
--- a/src/accounts.rs
+++ b/src/accounts.rs
@@ -388,6 +388,42 @@ fn save(ss: &secret_service::blocking::SecretService, login: &NewLogin) -> Resul
.map_err(|e| format!("could not save to the keyring: {e}"))
}
+/// The attribute the Raindrop.io token's keyring entry is found by.
+pub const RAINDROP_TOKEN_ATTR: (&str, &str) = ("service", "raindrop.io");
+
+/// The Raindrop.io test token, from the keyring.
+///
+/// Stored as an entry with **no `UserName`**, found by `service=raindrop.io`:
+/// cce-keyring-sync skips entries without a `UserName`, so the token stays on
+/// this machine rather than travelling to 1Password, and the account index
+/// above skips it too (no username, no URL), so it is never offered to a
+/// login form. A locked keyring is an error, never an unlock prompt — the
+/// same rule as everything else here.
+pub fn raindrop_token() -> Result<Secret, String> {
+ use secret_service::blocking::SecretService;
+ use secret_service::EncryptionType;
+ let ss = SecretService::connect(EncryptionType::Dh)
+ .map_err(|e| format!("no secret service: {e}"))?;
+ let found = ss
+ .search_items(std::collections::HashMap::from([RAINDROP_TOKEN_ATTR]))
+ .map_err(|e| format!("searching the keyring failed: {e}"))?;
+ let Some(item) = found.unlocked.first() else {
+ return Err(if found.locked.is_empty() {
+ "no Raindrop token in the keyring — store one with: \
+ secret-tool store --label='Raindrop.io token' service raindrop.io"
+ .to_string()
+ } else {
+ "the keyring is locked — unlock it in cce-secrets".to_string()
+ });
+ };
+ let bytes = item.get_secret().map_err(|_| "could not read the Raindrop token".to_string())?;
+ let token = String::from_utf8_lossy(&bytes).trim().to_string();
+ if token.is_empty() {
+ return Err("the Raindrop token in the keyring is empty".to_string());
+ }
+ Ok(Secret(token))
+}
+
/// Hosts the person has said never to offer saving on. One host per line in
/// `~/.local/state/cce/browser/never-save.txt`, beside history and bookmarks.
pub struct NeverSave {
diff --git a/src/main.rs b/src/main.rs
index 8e73cdf..c8d17d1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3553,6 +3553,19 @@ impl Application for BrowserApp {
fn main() {
env_logger::init();
+ // A read-only look at what a Raindrop sync would do (RAINDROP-SYNC.md).
+ // Ahead of the instance hand-off: it is a tool, not a launch, and must
+ // work while the browser is running.
+ if std::env::args().nth(1).as_deref() == Some("--raindrop-plan") {
+ match raindrop::dry_run() {
+ Ok(report) => print!("{report}"),
+ Err(e) => {
+ eprintln!("raindrop: {e}");
+ std::process::exit(1);
+ }
+ }
+ return;
+ }
// Hand the launch to a running instance before any engine work: an
// external open (`xdg-open` → `cce-browser %u`) becomes a tab there,
// and this process never touches Wayland or the shared profile dir.
diff --git a/src/pages.rs b/src/pages.rs
index 2171076..0947118 100644
--- a/src/pages.rs
+++ b/src/pages.rs
@@ -229,6 +229,17 @@ impl Bookmarks {
write_tsv(&self.path, &entries);
}
+ /// Every bookmark as stored — `(ts, url, title)` in file order — for the
+ /// Raindrop sync, which needs the timestamps the menu does not.
+ pub fn rows(&self) -> Vec<(u64, String, String)> {
+ self.entries
+ .lock()
+ .unwrap()
+ .iter()
+ .map(|e| (e.ts, e.url.clone(), e.title.clone()))
+ .collect()
+ }
+
/// The bookmarks as the chrome's menu lists them: newest first, the
/// same order the `cce://bookmarks` page renders.
pub fn snapshot(&self) -> Vec<Link> {
diff --git a/src/raindrop/api.rs b/src/raindrop/api.rs
new file mode 100644
index 0000000..57f2a3f
--- /dev/null
+++ b/src/raindrop/api.rs
@@ -0,0 +1,465 @@
+//! The Raindrop.io REST client — phase 2. It fetches and it sends; deciding
+//! what to send is the merge's job (`super::plan`), and nothing here has an
+//! opinion about it.
+//!
+//! Checked against developer.raindrop.io on 2026-10-02: REST under
+//! `api.raindrop.io/rest/v1`, `Authorization: Bearer <token>` (a personal
+//! *test token* from the integration settings, which does not expire), 120
+//! requests a minute with `429` past that, timestamps in ISO 8601, and:
+//!
+//! * `GET /raindrops/{collection}` — `-1` is Unsorted — 50 a page at most;
+//! * `POST /raindrop` creates, `PUT /raindrop/{id}` is a **partial** update
+//! (only the fields sent change), `DELETE /raindrop/{id}` moves to Trash —
+//! and deletes **permanently** when the item is already in Trash, which is
+//! why only ids just fetched from a live collection are ever trashed.
+//!
+//! The token is held as an `accounts::Secret` and only ever goes into the
+//! `Authorization` header; reqwest's errors carry the URL, never headers.
+
+use super::{Local, Plan, RaindropId, Remote, Synced};
+use crate::accounts::Secret;
+
+const BASE: &str = "https://api.raindrop.io/rest/v1";
+/// The Unsorted collection: the one this browser mirrors (RAINDROP-SYNC.md).
+pub const UNSORTED: i64 = -1;
+const PER_PAGE: usize = 50;
+/// A fetch that runs past this many pages is not a bookmark collection.
+const MAX_PAGES: usize = 400;
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum ApiError {
+ /// 401/403: the token is wrong, revoked, or for nothing.
+ Unauthorized,
+ /// Still 429 after waiting once.
+ RateLimited,
+ /// The collection changed while it was being paged through, so the list
+ /// may be missing an item — and a missing item reads as a deletion.
+ Changed { fetched: usize, count: usize },
+ Http(u16, String),
+ Network(String),
+ Parse(String),
+}
+
+impl std::fmt::Display for ApiError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ApiError::Unauthorized => write!(f, "Raindrop refused the token"),
+ ApiError::RateLimited => write!(f, "Raindrop's rate limit; try again in a minute"),
+ ApiError::Changed { fetched, count } => write!(
+ f,
+ "the collection changed while it was read ({fetched} fetched, {count} reported); \
+ try again"
+ ),
+ ApiError::Http(code, body) => write!(f, "Raindrop answered {code}: {body}"),
+ ApiError::Network(e) => write!(f, "could not reach Raindrop: {e}"),
+ ApiError::Parse(e) => write!(f, "unexpected answer from Raindrop: {e}"),
+ }
+ }
+}
+
+pub struct Client {
+ http: reqwest::blocking::Client,
+ token: Secret,
+ base: String,
+}
+
+impl Client {
+ pub fn new(token: Secret) -> Self {
+ Self::with_base(token, BASE)
+ }
+
+ /// Against another server — the tests' stand-in.
+ pub fn with_base(token: Secret, base: &str) -> Self {
+ let http = reqwest::blocking::Client::builder()
+ .user_agent(concat!("cce-browser/", env!("CARGO_PKG_VERSION")))
+ .timeout(std::time::Duration::from_secs(30))
+ .build()
+ .expect("an HTTP client with default TLS");
+ Self { http, token, base: base.trim_end_matches('/').to_string() }
+ }
+
+ /// Send one request, waiting out a single `429` (Raindrop says when its
+ /// window resets; never longer than a minute), and return the JSON body.
+ fn call(
+ &self,
+ method: reqwest::Method,
+ path: &str,
+ body: Option<&serde_json::Value>,
+ ) -> Result<serde_json::Value, ApiError> {
+ for attempt in 0..2 {
+ let mut req = self
+ .http
+ .request(method.clone(), format!("{}{path}", self.base))
+ .bearer_auth(self.token.expose());
+ if let Some(b) = body {
+ // By hand rather than reqwest's `json` feature, to build the
+ // same reqwest the sibling crates already compile.
+ req = req
+ .header(reqwest::header::CONTENT_TYPE, "application/json")
+ .body(b.to_string());
+ }
+ let resp = req.send().map_err(|e| ApiError::Network(e.without_url().to_string()))?;
+ let status = resp.status().as_u16();
+ if status == 429 && attempt == 0 {
+ let now = super::unix_now();
+ let reset = resp
+ .headers()
+ .get("x-ratelimit-reset")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|v| v.parse::<u64>().ok())
+ .unwrap_or(now + 60);
+ std::thread::sleep(std::time::Duration::from_secs(reset.saturating_sub(now).clamp(1, 60)));
+ continue;
+ }
+ let text = resp.text().map_err(|e| ApiError::Network(e.without_url().to_string()))?;
+ return match status {
+ 200..=299 => serde_json::from_str(&text).map_err(|e| ApiError::Parse(e.to_string())),
+ 401 | 403 => Err(ApiError::Unauthorized),
+ 429 => Err(ApiError::RateLimited),
+ _ => Err(ApiError::Http(status, text.chars().take(200).collect())),
+ };
+ }
+ Err(ApiError::RateLimited)
+ }
+
+ /// Every bookmark in `collection`, oldest first.
+ ///
+ /// Sorted by creation time ascending, so a bookmark added mid-fetch lands
+ /// on the last page instead of shifting the others. A deletion still
+ /// shifts them, which is why the total is checked against the `count`
+ /// Raindrop reports and the whole fetch refused on a mismatch.
+ pub fn fetch(&self, collection: i64) -> Result<Vec<Remote>, ApiError> {
+ let mut out = Vec::new();
+ let mut reported = None;
+ for page in 0..MAX_PAGES {
+ let v = self.call(
+ reqwest::Method::GET,
+ &format!("/raindrops/{collection}?perpage={PER_PAGE}&page={page}&sort=created"),
+ None,
+ )?;
+ if let Some(c) = v["count"].as_u64() {
+ reported = Some(c as usize);
+ }
+ let items = v["items"].as_array().ok_or_else(|| ApiError::Parse("no items".into()))?;
+ for item in items {
+ out.push(parse_item(item)?);
+ }
+ if items.len() < PER_PAGE {
+ break;
+ }
+ }
+ if let Some(count) = reported {
+ if count != out.len() {
+ return Err(ApiError::Changed { fetched: out.len(), count });
+ }
+ }
+ Ok(out)
+ }
+
+ /// Create a bookmark; returns its new id. Link and title only — Raindrop
+ /// fills in the rest itself.
+ pub fn create(&self, collection: i64, link: &str, title: &str) -> Result<RaindropId, ApiError> {
+ let body = serde_json::json!({
+ "link": link,
+ "title": title,
+ "collection": { "$id": collection },
+ });
+ let v = self.call(reqwest::Method::POST, "/raindrop", Some(&body))?;
+ v["item"]["_id"].as_u64().ok_or_else(|| ApiError::Parse("created item has no _id".into()))
+ }
+
+ /// Change a title. A partial update: nothing else on the item is touched.
+ pub fn rename(&self, id: RaindropId, title: &str) -> Result<(), ApiError> {
+ let body = serde_json::json!({ "title": title });
+ self.call(reqwest::Method::PUT, &format!("/raindrop/{id}"), Some(&body)).map(|_| ())
+ }
+
+ /// Move to Trash. Only ever called with ids fetched from the live
+ /// collection this pass — on an item already in Trash this is permanent.
+ pub fn trash(&self, id: RaindropId) -> Result<(), ApiError> {
+ self.call(reqwest::Method::DELETE, &format!("/raindrop/{id}"), None).map(|_| ())
+ }
+}
+
+fn parse_item(v: &serde_json::Value) -> Result<Remote, ApiError> {
+ let id = v["_id"].as_u64().ok_or_else(|| ApiError::Parse("an item has no _id".into()))?;
+ let link = v["link"].as_str().ok_or_else(|| ApiError::Parse(format!("item {id} has no link")))?;
+ Ok(Remote {
+ id,
+ link: link.to_string(),
+ title: v["title"].as_str().unwrap_or_default().to_string(),
+ created: v["created"].as_str().and_then(iso8601_secs).unwrap_or(0),
+ })
+}
+
+/// `2026-10-02T16:04:05.123Z` → seconds since the epoch. Just the shape
+/// Raindrop sends (UTC, `Z`, optional fraction); anything else is `None`.
+pub fn iso8601_secs(s: &str) -> Option<u64> {
+ let b = s.as_bytes();
+ if b.len() < 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' {
+ return None;
+ }
+ if !s.ends_with('Z') {
+ return None;
+ }
+ let num = |r: std::ops::Range<usize>| s.get(r)?.parse::<i64>().ok();
+ let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?);
+ let (h, mi, se) = (num(11..13)?, num(14..16)?, num(17..19)?);
+ if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || se > 60 {
+ return None;
+ }
+ // Days from the civil date (Howard Hinnant's algorithm).
+ let y2 = if mo <= 2 { y - 1 } else { y };
+ let era = y2.div_euclid(400);
+ let yoe = y2 - era * 400;
+ let doy = (153 * (if mo > 2 { mo - 3 } else { mo + 9 }) + 2) / 5 + d - 1;
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
+ let days = era * 146_097 + doe - 719_468;
+ u64::try_from(days * 86_400 + h * 3600 + mi * 60 + se).ok()
+}
+
+/// 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)]
+pub struct Applied {
+ /// `(local URL, new id)` for each create Raindrop accepted.
+ pub created: Vec<(String, RaindropId)>,
+ pub failed_renames: Vec<RaindropId>,
+ pub failed_trash: Vec<RaindropId>,
+ /// One line per failure, for the status.
+ pub errors: Vec<String>,
+}
+
+/// Apply a plan's remote half. A failure on one item does not stop the rest;
+/// it is recorded, and the base built from the result leaves that item to be
+/// retried. Unauthorized stops everything — every other call would fail too.
+pub fn apply_remote(client: &Client, collection: i64, plan: &Plan) -> Result<Applied, ApiError> {
+ let mut out = Applied::default();
+ let mut note = |what: String, e: ApiError| -> Result<(), ApiError> {
+ if e == ApiError::Unauthorized {
+ return Err(e);
+ }
+ out.errors.push(format!("{what}: {e}"));
+ Ok(())
+ };
+ let mut created = Vec::new();
+ let mut failed_renames = Vec::new();
+ let mut failed_trash = Vec::new();
+ for l in &plan.create_remote {
+ match client.create(collection, &l.url, &l.title) {
+ Ok(id) => created.push((l.url.clone(), id)),
+ Err(e) => note(format!("create {}", l.url), e)?,
+ }
+ }
+ for (id, title) in &plan.rename_remote {
+ if let Err(e) = client.rename(*id, title) {
+ failed_renames.push(*id);
+ note(format!("rename {id}"), e)?;
+ }
+ }
+ for id in &plan.trash_remote {
+ if let Err(e) = client.trash(*id) {
+ failed_trash.push(*id);
+ note(format!("trash {id}"), e)?;
+ }
+ }
+ out.created = created;
+ out.failed_renames = failed_renames;
+ out.failed_trash = failed_trash;
+ Ok(out)
+}
+
+/// The plan, readably — what the dry run prints.
+pub fn describe(plan: &Plan, local: &[Local], remote: &[Remote], base: &[Synced]) -> String {
+ let mut s = format!(
+ "here {} · Raindrop {} · synced before {}\n",
+ local.len(),
+ remote.len(),
+ base.len()
+ );
+ let title_of = |id: &RaindropId| {
+ remote.iter().find(|r| r.id == *id).map(|r| r.link.as_str()).unwrap_or("?")
+ };
+ let mut line = |label: &str, items: Vec<String>| {
+ if !items.is_empty() {
+ s.push_str(&format!("\n{label} ({}):\n", items.len()));
+ for i in items {
+ s.push_str(&format!(" {i}\n"));
+ }
+ }
+ };
+ line("create in Raindrop", plan.create_remote.iter().map(|l| format!("{} {}", l.url, l.title)).collect());
+ line("rename in Raindrop", plan.rename_remote.iter().map(|(id, t)| format!("{} → {t}", title_of(id))).collect());
+ line("move to Raindrop's trash", plan.trash_remote.iter().map(|id| title_of(id).to_string()).collect());
+ line("relink here", plan.relink_local.iter().map(|(a, b)| format!("{a} → {b}")).collect());
+ line("rename here", plan.rename_local.iter().map(|(u, t)| format!("{u} → {t}")).collect());
+ line("delete here", plan.delete_local.clone());
+ line("add here", plan.add_local.iter().map(|l| format!("{} {}", l.url, l.title)).collect());
+ if plan.is_noop() {
+ s.push_str("\nnothing to do — in sync\n");
+ }
+ s
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::{BufRead, BufReader, Read, Write};
+ use std::sync::{Arc, Mutex};
+
+ /// A stand-in Raindrop: answers each request with the next scripted
+ /// `(status, extra headers, body)` and records `(request line, body)`.
+ fn server(script: Vec<(u16, &'static str, String)>) -> (String, Arc<Mutex<Vec<(String, String)>>>) {
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
+ let base = format!("http://{}", listener.local_addr().unwrap());
+ let seen = Arc::new(Mutex::new(Vec::new()));
+ let log = seen.clone();
+ std::thread::spawn(move || {
+ for (status, headers, body) in script {
+ let Ok((stream, _)) = listener.accept() else { return };
+ let mut reader = BufReader::new(stream);
+ let mut first = String::new();
+ reader.read_line(&mut first).unwrap();
+ let mut len = 0;
+ let mut auth = String::new();
+ loop {
+ let mut h = String::new();
+ reader.read_line(&mut h).unwrap();
+ if h == "\r\n" || h.is_empty() {
+ break;
+ }
+ let lower = h.to_lowercase();
+ if let Some(v) = lower.strip_prefix("content-length:") {
+ len = v.trim().parse().unwrap();
+ }
+ if lower.starts_with("authorization:") {
+ auth = h.trim().to_string();
+ }
+ }
+ let mut req_body = vec![0; len];
+ reader.read_exact(&mut req_body).unwrap();
+ log.lock().unwrap().push((
+ format!("{} | {auth}", first.trim()),
+ String::from_utf8_lossy(&req_body).into_owned(),
+ ));
+ let mut stream = reader.into_inner();
+ let _ = write!(
+ stream,
+ "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\n{headers}\
+ Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
+ body.len()
+ );
+ }
+ });
+ (base, seen)
+ }
+
+ fn items(range: std::ops::Range<u64>, count: usize) -> String {
+ let items: Vec<_> = range
+ .map(|i| serde_json::json!({
+ "_id": i, "link": format!("https://{i}.test/"), "title": format!("t{i}"),
+ "created": "2026-10-02T16:04:05.123Z", "collection": {"$id": -1},
+ }))
+ .collect();
+ serde_json::json!({ "result": true, "items": items, "count": count }).to_string()
+ }
+
+ fn client(base: &str) -> Client {
+ Client::with_base(Secret::from("tok-123".to_string()), base)
+ }
+
+ #[test]
+ fn fetch_pages_until_a_short_page_and_checks_the_count() {
+ let (base, seen) = server(vec![(200, "", items(1..51, 53)), (200, "", items(51..54, 53))]);
+ let all = client(&base).fetch(UNSORTED).unwrap();
+ assert_eq!(all.len(), 53);
+ assert_eq!(all[0], Remote { id: 1, link: "https://1.test/".into(), title: "t1".into(), created: 1_790_957_045 });
+ let seen = seen.lock().unwrap();
+ assert!(seen[0].0.starts_with("GET /raindrops/-1?perpage=50&page=0&sort=created"));
+ assert!(seen[1].0.contains("page=1"));
+ assert!(seen[0].0.ends_with("authorization: Bearer tok-123") || seen[0].0.ends_with("Authorization: Bearer tok-123"));
+ }
+
+ #[test]
+ fn a_collection_that_shifts_mid_fetch_is_refused() {
+ // A deletion between pages: 50 + 2 fetched, but 53 reported.
+ let (base, _) = server(vec![(200, "", items(1..51, 53)), (200, "", items(52..54, 53))]);
+ assert_eq!(client(&base).fetch(UNSORTED), Err(ApiError::Changed { fetched: 52, count: 53 }));
+ }
+
+ #[test]
+ fn create_sends_only_link_title_and_collection() {
+ let (base, seen) = server(vec![(200, "", r#"{"result":true,"item":{"_id":77}}"#.into())]);
+ assert_eq!(client(&base).create(UNSORTED, "https://a.test/", "A").unwrap(), 77);
+ let body: serde_json::Value = serde_json::from_str(&seen.lock().unwrap()[0].1).unwrap();
+ assert_eq!(body, serde_json::json!({"link": "https://a.test/", "title": "A", "collection": {"$id": -1}}));
+ }
+
+ #[test]
+ fn rename_is_a_title_only_partial_update() {
+ let (base, seen) = server(vec![(200, "", r#"{"result":true,"item":{}}"#.into())]);
+ client(&base).rename(5, "New").unwrap();
+ let (line, body) = seen.lock().unwrap()[0].clone();
+ assert!(line.starts_with("PUT /raindrop/5 "));
+ assert_eq!(body, r#"{"title":"New"}"#);
+ }
+
+ #[test]
+ fn a_rate_limit_is_waited_out_once() {
+ let (base, seen) = server(vec![
+ (429, "X-RateLimit-Reset: 0\r\n", "{}".into()),
+ (200, "", r#"{"result":true}"#.into()),
+ ]);
+ client(&base).trash(9).unwrap();
+ assert_eq!(seen.lock().unwrap().len(), 2);
+ }
+
+ #[test]
+ fn a_bad_token_stops_the_pass() {
+ let plan = Plan {
+ create_remote: vec![Local { url: "https://a.test/".into(), title: "A".into(), ts: 1 }],
+ trash_remote: vec![3],
+ ..Plan::default()
+ };
+ let (base, seen) = server(vec![(401, "", "{}".into()), (200, "", "{}".into())]);
+ assert_eq!(apply_remote(&client(&base), UNSORTED, &plan), Err(ApiError::Unauthorized));
+ assert_eq!(seen.lock().unwrap().len(), 1, "nothing more is sent after a 401");
+ }
+
+ #[test]
+ fn one_failure_does_not_stop_the_rest() {
+ let plan = Plan {
+ create_remote: vec![
+ Local { url: "https://a.test/".into(), title: "A".into(), ts: 1 },
+ Local { url: "https://b.test/".into(), title: "B".into(), ts: 2 },
+ ],
+ trash_remote: vec![3],
+ ..Plan::default()
+ };
+ let (base, _) = server(vec![
+ (500, "", "boom".into()),
+ (200, "", r#"{"result":true,"item":{"_id":8}}"#.into()),
+ (404, "", "gone".into()),
+ ]);
+ let applied = apply_remote(&client(&base), UNSORTED, &plan).unwrap();
+ assert_eq!(applied.created, vec![("https://b.test/".to_string(), 8)]);
+ assert_eq!(applied.failed_trash, vec![3]);
+ assert_eq!(applied.errors.len(), 2);
+ }
+
+ #[test]
+ fn timestamps_parse() {
+ assert_eq!(iso8601_secs("1970-01-01T00:00:00Z"), Some(0));
+ assert_eq!(iso8601_secs("2000-03-01T00:00:00.000Z"), Some(951_868_800));
+ assert_eq!(iso8601_secs("2026-10-02T16:04:05Z"), Some(1_790_957_045));
+ assert_eq!(iso8601_secs("2026-10-02 16:04:05"), None);
+ assert_eq!(iso8601_secs("2026-13-02T16:04:05Z"), None);
+ }
+
+ #[test]
+ fn errors_never_carry_the_token() {
+ let e = client("http://127.0.0.1:1").fetch(UNSORTED).unwrap_err();
+ assert!(matches!(e, ApiError::Network(_)));
+ assert!(!e.to_string().contains("tok-123"));
+ }
+}
diff --git a/src/raindrop.rs b/src/raindrop/mod.rs
similarity index 87%
rename from src/raindrop.rs
rename to src/raindrop/mod.rs
index 3337477..7da15ff 100644
--- a/src/raindrop.rs
+++ b/src/raindrop/mod.rs
@@ -28,9 +28,18 @@
// Not wired into the browser until phase 3; the tests exercise all of it.
#![allow(dead_code)]
+pub mod api;
+
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
+pub(crate) fn unix_now() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+}
+
/// A Raindrop bookmark's id (`_id` in the API).
pub type RaindropId = u64;
@@ -96,13 +105,31 @@ impl Plan {
&& 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> {
+ /// The base to save once the remote side has been applied (`prior` is the
+ /// base the plan was made from). It must describe what *happened*, not
+ /// what was planned, or the next pass misreads a failure:
+ ///
+ /// * a create that failed stays out of the base, so it is tried again as
+ /// "new here" — never read as deleted in Raindrop;
+ /// * a trash that failed keeps its old pair, so it is tried again — not
+ /// re-imported as "new in Raindrop";
+ /// * a rename that failed keeps its old title, so Raindrop's unchanged
+ /// title is not read as Raindrop renaming it back.
+ pub fn base_after(&self, prior: &[Synced], applied: &api::Applied) -> Vec<Synced> {
let mut base = self.base.clone();
- for (url, id) in created {
+ for id in &applied.failed_renames {
+ if let (Some(b), Some(old)) =
+ (base.iter_mut().find(|b| b.id == *id), prior.iter().find(|p| p.id == *id))
+ {
+ b.title = old.title.clone();
+ }
+ }
+ for id in &applied.failed_trash {
+ if let Some(old) = prior.iter().find(|p| p.id == *id) {
+ base.push(old.clone());
+ }
+ }
+ for (url, id) in &applied.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() });
}
@@ -317,6 +344,30 @@ pub fn apply_local(current: &mut Vec<Local>, snapshot: &[Local], plan: &Plan) ->
skipped
}
+/// `cce-browser --raindrop-plan`: fetch Unsorted, plan a pass against the
+/// local bookmarks and the base, and describe it — changing nothing on either
+/// side. The way to look at a real account before anything is allowed to
+/// write to it.
+pub fn dry_run() -> Result<String, String> {
+ let local: Vec<Local> = crate::pages::Bookmarks::load()
+ .rows()
+ .into_iter()
+ .map(|(ts, url, title)| Local { url, title, ts })
+ .collect();
+ let base = load_base(&state_path());
+ let token = crate::accounts::raindrop_token()?;
+ let remote = api::Client::new(token).fetch(api::UNSORTED).map_err(|e| e.to_string())?;
+ let mut out = String::from("dry run: nothing has been changed\n\n");
+ match plan(&local, &remote, &base) {
+ Ok(p) => out.push_str(&api::describe(&p, &local, &remote, &base)),
+ Err(refused) => {
+ out.push_str(&format!("REFUSED: {}\n\n", refused.reason));
+ out.push_str(&api::describe(&refused.plan, &local, &remote, &base));
+ }
+ }
+ Ok(out)
+}
+
/// `~/.local/state/cce/browser/raindrop-sync.tsv`.
pub fn state_path() -> PathBuf {
crate::pages::state_dir().join("raindrop-sync.tsv")
@@ -395,7 +446,8 @@ mod tests {
}
let mut local_now = local.to_vec();
assert_eq!(apply_local(&mut local_now, local, &p), 0);
- (local_now, remote, p.base_after(&created))
+ let applied = api::Applied { created, ..Default::default() };
+ (local_now, remote, p.base_after(base, &applied))
}
#[test]
@@ -538,12 +590,30 @@ mod tests {
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 base = p.base_after(&[], &api::Applied::default()); // 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 failed_trash_and_rename_are_retried_not_reversed() {
+ let prior = [s(1, "https://a.test/", "A"), s(2, "https://b.test/", "B")];
+ // a.test deleted here; b.test renamed here.
+ let local = [l("https://b.test/", "B new", 2)];
+ let remote = [r(1, "https://a.test/", "A"), r(2, "https://b.test/", "B")];
+ let p = plan(&local, &remote, &prior).unwrap();
+ assert_eq!((p.trash_remote.clone(), p.rename_remote.clone()), (vec![1], vec![(2, "B new".to_string())]));
+ // Both calls fail.
+ let applied = api::Applied { failed_trash: vec![1], failed_renames: vec![2], ..Default::default() };
+ let base = p.base_after(&prior, &applied);
+ let again = plan(&local, &remote, &base).unwrap();
+ assert_eq!(again.trash_remote, vec![1], "the trash is retried");
+ assert!(again.add_local.is_empty(), "not re-imported");
+ assert_eq!(again.rename_remote, vec![(2, "B new".to_string())], "the rename is retried");
+ assert!(again.rename_local.is_empty(), "not reversed");
+ }
+
#[test]
fn edits_made_during_a_pass_survive_it() {
let snapshot = vec![l("https://a.test/", "A", 1), l("https://b.test/", "B", 2)];