core, web: pin, archive and reorder a note someone shared with you
Schema v12 adds notes.state_at: a recipient's own pin, archive and order are stamped there instead of on updated_at, which stays the text's time. Push sends them only to a server advertising `shared_state` (push::Accepts), a view share included; the first pull at that level starts the feed over once so held copies drop their owner's pins. The client speaks protocol 7 and lists `shares` and `shared_state` among the features a server may lack. The web card and editor offer pin, archive and drag on shared notes; share and trash stay the owner's, and the board's trash key skips notes you don't own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+112
-49
@@ -95,6 +95,12 @@ pub struct Change {
|
||||
pub created_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// When this account last pinned, archived or moved a note someone else owns
|
||||
/// (#5176): `pinned`, `archived` and `position` on such a note are its own, and
|
||||
/// the server weighs them against this rather than `edited_at`, which stays the
|
||||
/// time of the text.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub state_at: Option<String>,
|
||||
}
|
||||
|
||||
impl Change {
|
||||
@@ -115,6 +121,7 @@ impl Change {
|
||||
label_ids: None,
|
||||
created_at: None,
|
||||
name: None,
|
||||
state_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,24 +150,38 @@ pub struct PushResult {
|
||||
|
||||
// --- collecting --------------------------------------------------------------
|
||||
|
||||
/// What the server takes beyond the base protocol, read from the features it
|
||||
/// advertises. What it doesn't take stays queued here, untouched, until a server
|
||||
/// that does: an older one would refuse it, or worse, accept it and keep nothing.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Accepts {
|
||||
/// Attachment and preview removals, and file uploads (`attachment_sync`). An
|
||||
/// older server would answer "unknown entity" and the removal would read as a
|
||||
/// failure.
|
||||
pub attachment_sync: bool,
|
||||
/// This account's own pin, archive and position on a note someone else owns
|
||||
/// (`shared_state`, #5176).
|
||||
pub shared_state: bool,
|
||||
}
|
||||
|
||||
impl Accepts {
|
||||
/// Everything this client can send.
|
||||
pub const ALL: Accepts = Accepts {
|
||||
attachment_sync: true,
|
||||
shared_state: true,
|
||||
};
|
||||
}
|
||||
|
||||
/// Everything waiting to go up, oldest edit first so a truncated batch still makes
|
||||
/// forward progress in a sensible order.
|
||||
///
|
||||
/// `attachment_sync` is whether the server takes attachment and preview removals.
|
||||
/// Without it they stay queued here, untouched, until a server that does — an older
|
||||
/// one would answer "unknown entity" and the removal would read as a failure.
|
||||
pub fn collect(
|
||||
conn: &Connection,
|
||||
limit: usize,
|
||||
attachment_sync: bool,
|
||||
) -> rusqlite::Result<Vec<Change>> {
|
||||
pub fn collect(conn: &Connection, limit: usize, accepts: Accepts) -> rusqlite::Result<Vec<Change>> {
|
||||
let mut out = Vec::new();
|
||||
collect_deletes(conn, &mut out, limit, attachment_sync)?;
|
||||
collect_deletes(conn, &mut out, limit, accepts.attachment_sync)?;
|
||||
if out.len() < limit {
|
||||
collect_labels(conn, &mut out, limit)?;
|
||||
}
|
||||
if out.len() < limit {
|
||||
collect_notes(conn, &mut out, limit)?;
|
||||
collect_notes(conn, &mut out, limit, accepts.shared_state)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -225,6 +246,7 @@ fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rus
|
||||
position: None,
|
||||
label_ids: None,
|
||||
created_at: None,
|
||||
state_at: None,
|
||||
})
|
||||
})?;
|
||||
for row in rows {
|
||||
@@ -233,21 +255,31 @@ fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rus
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_notes(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rusqlite::Result<()> {
|
||||
fn collect_notes(
|
||||
conn: &Connection,
|
||||
out: &mut Vec<Change>,
|
||||
limit: usize,
|
||||
shared_state: bool,
|
||||
) -> rusqlite::Result<()> {
|
||||
let remaining = limit.saturating_sub(out.len());
|
||||
let ids: Vec<String> = {
|
||||
// A note shared with us to view can't be changed here, so one that is dirty
|
||||
// anyway (its share was narrowed while an edit waited) has nothing the server
|
||||
// would take. The next pull puts the server's copy back over it.
|
||||
// A note shared with us to view has only this account's own pin, archive and
|
||||
// place to send, and only to a server that keeps them. Without one, a dirty
|
||||
// view note has nothing the server would take, and the next pull puts the
|
||||
// server's copy back over it.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id FROM notes WHERE dirty = 1 AND permission <> 'view'
|
||||
"SELECT id FROM notes
|
||||
WHERE dirty = 1
|
||||
AND (permission <> 'view' OR (?2 AND state_at IS NOT NULL))
|
||||
ORDER BY updated_at LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![remaining as i64], |r| r.get::<_, String>(0))?;
|
||||
let rows = stmt.query_map(params![remaining as i64, shared_state], |r| {
|
||||
r.get::<_, String>(0)
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||
};
|
||||
for id in ids {
|
||||
out.push(note_change(conn, &id)?);
|
||||
out.push(note_change(conn, &id, shared_state)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -264,14 +296,16 @@ struct NoteRow {
|
||||
recurrence: Option<String>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
/// `owner`, or `edit` for a note someone shared with us (#5175).
|
||||
/// `owner`, or `edit` or `view` for a note someone shared with us (#5175).
|
||||
permission: String,
|
||||
/// When this account last pinned, archived or moved it, if it isn't ours (#5176).
|
||||
state_at: Option<String>,
|
||||
}
|
||||
|
||||
fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
|
||||
conn.query_row(
|
||||
"SELECT body, position, pinned, archived, trashed,
|
||||
remind_at, recurrence, created_at, updated_at, permission
|
||||
remind_at, recurrence, created_at, updated_at, permission, state_at
|
||||
FROM notes WHERE id = ?1",
|
||||
params![id],
|
||||
|r| {
|
||||
@@ -286,34 +320,39 @@ fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
|
||||
created_at: r.get(7)?,
|
||||
updated_at: r.get(8)?,
|
||||
permission: r.get(9)?,
|
||||
state_at: r.get(10)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
|
||||
fn note_change(conn: &Connection, id: &str, shared_state: bool) -> rusqlite::Result<Change> {
|
||||
let row = note_row(conn, id)?;
|
||||
|
||||
// Someone else's note: only its text is ours to change, so only its text goes.
|
||||
// Pin, archive, trash, reminders, order and labels are the owner's, and this
|
||||
// account's labels were never on it.
|
||||
// Someone else's note: its text if it was shared to edit, and this account's own
|
||||
// pin, archive and place once they have been changed here, to a server that keeps
|
||||
// them. Trash, reminders and labels are the owner's, and this account's labels
|
||||
// were never on it.
|
||||
if row.permission != "owner" {
|
||||
let state_at = row.state_at.filter(|_| shared_state);
|
||||
let own = state_at.is_some();
|
||||
return Ok(Change {
|
||||
entity: "note",
|
||||
id: id.to_string(),
|
||||
op: "upsert",
|
||||
edited_at: row.updated_at,
|
||||
body: Some(row.body),
|
||||
body: (row.permission == "edit").then_some(row.body),
|
||||
color: None,
|
||||
pinned: None,
|
||||
archived: None,
|
||||
pinned: own.then_some(row.pinned),
|
||||
archived: own.then_some(row.archived),
|
||||
trashed: None,
|
||||
remind_at: None,
|
||||
recurrence: None,
|
||||
position: None,
|
||||
position: own.then_some(row.position),
|
||||
label_ids: None,
|
||||
created_at: None,
|
||||
name: None,
|
||||
state_at,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -346,6 +385,7 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
|
||||
label_ids: Some(label_ids),
|
||||
created_at: Some(row.created_at),
|
||||
name: None,
|
||||
state_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -616,21 +656,21 @@ async fn upload_pending(
|
||||
/// Send everything pending, in batches, applying each batch's results before the
|
||||
/// next is collected — then the files, once their notes are there to hold them.
|
||||
///
|
||||
/// `attachment_sync`: whether the server advertises it (see [`collect`]). Without
|
||||
/// it, uploads wait too.
|
||||
/// `accepts`: what the server advertises (see [`Accepts`]). Without
|
||||
/// `attachment_sync`, uploads wait too.
|
||||
pub async fn run(
|
||||
db: &Db,
|
||||
blobs: &BlobStore,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
attachment_sync: bool,
|
||||
accepts: Accepts,
|
||||
) -> Result<PushSummary, String> {
|
||||
let mut total = PushSummary::default();
|
||||
|
||||
loop {
|
||||
let batch = {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
collect(&conn, BATCH, attachment_sync).map_err(|e| e.to_string())?
|
||||
collect(&conn, BATCH, accepts).map_err(|e| e.to_string())?
|
||||
};
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
@@ -658,7 +698,7 @@ pub async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
if attachment_sync {
|
||||
if accepts.attachment_sync {
|
||||
total.absorb(upload_pending(db, blobs, base_url, token).await?);
|
||||
}
|
||||
|
||||
@@ -736,7 +776,7 @@ mod tests {
|
||||
let conn = db();
|
||||
seed_note(&conn, "clean", 0);
|
||||
seed_note(&conn, "dirty", 1);
|
||||
let batch = collect(&conn, 100, true).expect("collect");
|
||||
let batch = collect(&conn, 100, Accepts::ALL).expect("collect");
|
||||
assert_eq!(batch.len(), 1);
|
||||
assert_eq!(batch[0].id, "dirty");
|
||||
assert_eq!(batch[0].op, "upsert");
|
||||
@@ -761,7 +801,7 @@ mod tests {
|
||||
)
|
||||
.expect("seed membership");
|
||||
}
|
||||
let batch = collect(&conn, 100, true).expect("collect");
|
||||
let batch = collect(&conn, 100, Accepts::ALL).expect("collect");
|
||||
let note = batch.iter().find(|c| c.entity == "note").expect("note");
|
||||
assert_eq!(note.label_ids.as_deref(), Some(&["manual".to_string()][..]));
|
||||
}
|
||||
@@ -771,7 +811,7 @@ mod tests {
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 0);
|
||||
store::delete_forever(&conn, "n1").expect("delete");
|
||||
let batch = collect(&conn, 100, true).expect("collect");
|
||||
let batch = collect(&conn, 100, Accepts::ALL).expect("collect");
|
||||
assert_eq!(batch.len(), 1);
|
||||
assert_eq!(batch[0].op, "delete");
|
||||
assert_eq!(batch[0].entity, "note");
|
||||
@@ -782,7 +822,7 @@ mod tests {
|
||||
fn applied_clears_dirty_and_records_the_revision() {
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 1);
|
||||
let batch = collect(&conn, 100, true).expect("collect");
|
||||
let batch = collect(&conn, 100, Accepts::ALL).expect("collect");
|
||||
apply_results(&conn, &batch, &[ok("applied", Some(42))]).expect("apply");
|
||||
assert_eq!(dirty_count(&conn), 0);
|
||||
let rev: i64 = conn
|
||||
@@ -799,7 +839,7 @@ mod tests {
|
||||
// every time; the following pull adopts the server's version instead.
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 1);
|
||||
let batch = collect(&conn, 100, true).expect("collect");
|
||||
let batch = collect(&conn, 100, Accepts::ALL).expect("collect");
|
||||
let summary = apply_results(&conn, &batch, &[ok("kept", Some(99))]).expect("apply");
|
||||
assert_eq!(summary.kept, 1);
|
||||
assert_eq!(dirty_count(&conn), 0);
|
||||
@@ -813,7 +853,7 @@ mod tests {
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 1);
|
||||
state::set_cursor(&conn, 100).expect("cursor");
|
||||
let batch = collect(&conn, 100, true).expect("collect");
|
||||
let batch = collect(&conn, 100, Accepts::ALL).expect("collect");
|
||||
apply_results(&conn, &batch, &[ok("kept", Some(40))]).expect("apply");
|
||||
assert_eq!(state::read(&conn).expect("state").last_cursor, 39);
|
||||
}
|
||||
@@ -823,7 +863,7 @@ mod tests {
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 1);
|
||||
state::set_cursor(&conn, 10).expect("cursor");
|
||||
let batch = collect(&conn, 100, true).expect("collect");
|
||||
let batch = collect(&conn, 100, Accepts::ALL).expect("collect");
|
||||
apply_results(&conn, &batch, &[ok("kept", Some(40))]).expect("apply");
|
||||
assert_eq!(
|
||||
state::read(&conn).expect("state").last_cursor,
|
||||
@@ -836,7 +876,7 @@ mod tests {
|
||||
fn rejected_stays_dirty_and_is_reported() {
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 1);
|
||||
let batch = collect(&conn, 100, true).expect("collect");
|
||||
let batch = collect(&conn, 100, Accepts::ALL).expect("collect");
|
||||
let mut bad = ok("rejected", None);
|
||||
bad.error = Some("name in use".into());
|
||||
let summary = apply_results(&conn, &batch, &[bad]).expect("apply");
|
||||
@@ -850,7 +890,7 @@ mod tests {
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 0);
|
||||
store::delete_forever(&conn, "n1").expect("delete");
|
||||
let batch = collect(&conn, 100, true).expect("collect");
|
||||
let batch = collect(&conn, 100, Accepts::ALL).expect("collect");
|
||||
apply_results(&conn, &batch, &[ok("applied", Some(7))]).expect("apply");
|
||||
assert!(!has_pending(&conn).expect("pending"));
|
||||
}
|
||||
@@ -861,7 +901,7 @@ mod tests {
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 1);
|
||||
store::delete_forever(&conn, "n1").expect("delete");
|
||||
let batch = collect(&conn, 100, true).expect("collect");
|
||||
let batch = collect(&conn, 100, Accepts::ALL).expect("collect");
|
||||
apply_results(&conn, &batch, &[ok("noop", None)]).expect("apply");
|
||||
assert!(!has_pending(&conn).expect("pending"));
|
||||
}
|
||||
@@ -895,7 +935,7 @@ mod tests {
|
||||
seed_note(&conn, "n1", 1);
|
||||
conn.execute("UPDATE notes SET permission = 'edit' WHERE id = 'n1'", [])
|
||||
.unwrap();
|
||||
let changes = collect(&conn, 10, true).unwrap();
|
||||
let changes = collect(&conn, 10, Accepts::ALL).unwrap();
|
||||
assert_eq!(changes.len(), 1);
|
||||
let wire = serde_json::to_value(&changes[0]).unwrap();
|
||||
let mut keys: Vec<&str> = wire
|
||||
@@ -909,12 +949,35 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_note_shared_to_view_is_never_pushed() {
|
||||
fn a_note_shared_to_view_sends_only_this_accounts_own_state() {
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 1);
|
||||
conn.execute("UPDATE notes SET permission = 'view' WHERE id = 'n1'", [])
|
||||
.unwrap();
|
||||
assert!(collect(&conn, 10, true).unwrap().is_empty());
|
||||
// Dirty with nothing of ours changed on it: nothing to send.
|
||||
assert!(collect(&conn, 10, Accepts::ALL).unwrap().is_empty());
|
||||
|
||||
conn.execute(
|
||||
"UPDATE notes SET pinned = 1, position = 4,
|
||||
state_at = '2026-07-27T00:00:00.000Z' WHERE id = 'n1'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let changes = collect(&conn, 10, Accepts::ALL).unwrap();
|
||||
let wire = serde_json::to_value(&changes[0]).unwrap();
|
||||
assert!(wire.get("body").is_none(), "a view share sends no text");
|
||||
assert_eq!(wire["pinned"], true);
|
||||
assert_eq!(wire["archived"], false);
|
||||
assert_eq!(wire["position"], 4);
|
||||
assert_eq!(wire["state_at"], "2026-07-27T00:00:00.000Z");
|
||||
assert_eq!(wire["edited_at"], "2026-07-26T00:00:00.000Z");
|
||||
|
||||
// A server that doesn't keep them gets nothing, and it waits here.
|
||||
let older = Accepts {
|
||||
shared_state: false,
|
||||
..Accepts::ALL
|
||||
};
|
||||
assert!(collect(&conn, 10, older).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1003,14 +1066,14 @@ mod tests {
|
||||
store::record_pending_delete(&conn, "note", "n9").expect("tombstone");
|
||||
|
||||
// An older server would answer "unknown entity" to each of them.
|
||||
let older: Vec<_> = collect(&conn, 100, false)
|
||||
let older: Vec<_> = collect(&conn, 100, Accepts::default())
|
||||
.expect("collect")
|
||||
.iter()
|
||||
.map(|c| c.entity)
|
||||
.collect();
|
||||
assert_eq!(older, vec!["note"]);
|
||||
|
||||
let mut newer: Vec<_> = collect(&conn, 100, true)
|
||||
let mut newer: Vec<_> = collect(&conn, 100, Accepts::ALL)
|
||||
.expect("collect")
|
||||
.iter()
|
||||
.map(|c| (c.entity, c.op))
|
||||
@@ -1071,7 +1134,7 @@ mod tests {
|
||||
store::delete_attachment(&conn, "n1", "a1").expect("remove");
|
||||
|
||||
// Push: the removal and the touched note go up, and the server applies both.
|
||||
let batch = collect(&conn, 100, true).expect("collect");
|
||||
let batch = collect(&conn, 100, Accepts::ALL).expect("collect");
|
||||
let results: Vec<PushResult> = batch
|
||||
.iter()
|
||||
.map(|c| ok("applied", (c.entity == "note").then_some(5)))
|
||||
|
||||
Reference in New Issue
Block a user