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:
2026-10-07 17:13:58 -04:00
co-authored by Claude Opus 5.5
parent 63955bbe97
commit 89cd1c76bb
9 changed files with 304 additions and 91 deletions
+19 -1
View File
@@ -311,6 +311,20 @@ ALTER TABLE notes ADD COLUMN shared_by_name TEXT;
ALTER TABLE sync_state ADD COLUMN shares_synced INTEGER NOT NULL DEFAULT 0;
"#;
// v12 (#5176): a shared note's pin, archive and position are this account's own.
//
// `state_at` is when they last changed here, on a note someone else owns. It is kept
// apart from `updated_at`, which stays the time of the note's TEXT: pinning a copy
// whose text is behind the owner's must not make that text look like the newer
// edit when it is pushed. Null on our own notes, where `updated_at` covers both.
//
// `sync_state.shares_synced` now holds a level rather than a flag (see
// `state::SHARED_STATE`), and a device reaching this level pulls everything once
// more: the shared notes it holds carry their owner's pins until it does.
const SCHEMA_V12: &str = r#"
ALTER TABLE notes ADD COLUMN state_at TEXT;
"#;
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
@@ -358,6 +372,10 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(SCHEMA_V11)?;
conn.execute_batch("PRAGMA user_version = 11;")?;
}
if version < 12 {
conn.execute_batch(SCHEMA_V12)?;
conn.execute_batch("PRAGMA user_version = 12;")?;
}
Ok(())
}
@@ -472,7 +490,7 @@ mod tests {
let version: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.expect("version");
assert_eq!(version, 11);
assert_eq!(version, 12);
}
/// Every attachment that predates v10 came down the feed, so it is already on the
+100 -17
View File
@@ -193,12 +193,14 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
// ---- notes shared with this account (#5175) ---------------------------------
//
// A note someone else owns arrives with `permission` `edit` (its text may change
// here) or `view` (nothing may). Everything else about it — pin, archive, trash,
// reminders, labels, files — stays its owner's. The server enforces the same split;
// here) or `view` (it may not). Its pin, archive and place on this board are this
// account's own either way (#5176). Everything else about it — trash, reminders,
// labels, files — stays its owner's. The server enforces the same split;
// refusing here as well tells the person at once, instead of letting an edit be made,
// queued, and then thrown away by the next sync.
pub const VIEW_ONLY: &str = "This note was shared with you to view, so it can't be changed here.";
pub const VIEW_ONLY: &str =
"This note was shared with you to view, so its text can't be changed here.";
pub const OWNER_ONLY: &str = "Only the note's owner can change that.";
/// A refusal carrying words to show. Wrapped so it travels as a `rusqlite::Error`
@@ -233,7 +235,8 @@ fn require_text(conn: &Connection, id: &str) -> rusqlite::Result<String> {
Ok(permission)
}
/// Anything but the text: only the owner.
/// Anything but the text and this account's own pin, archive and place: only the
/// owner.
fn require_owner(conn: &Connection, id: &str) -> rusqlite::Result<()> {
match permission_of(conn, id)?.as_str() {
"owner" => Ok(()),
@@ -242,6 +245,20 @@ fn require_owner(conn: &Connection, id: &str) -> rusqlite::Result<()> {
}
}
/// This account's own pin, archive or place changed on a note someone else owns.
/// Stamped apart from `updated_at`, which is the time of the note's text; see
/// SCHEMA_V12 for why the two must not share a clock.
fn touch_own_state(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute(
"UPDATE notes SET state_at = ?1, dirty = 1 WHERE id = ?2",
params![now(), id],
)?;
Ok(())
}
/// The fields of a note anyone it is shared with may set: their own.
const OWN_FIELDS: [&str; 2] = ["pinned", "archived"];
fn touch(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute(
"UPDATE notes SET updated_at = ?1, dirty = 1 WHERE id = ?2",
@@ -596,9 +613,18 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
let obj = changes
.as_object()
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
let permission = require_text(conn, id)?;
let text = obj.contains_key("body");
let permission = if text {
require_text(conn, id)?
} else {
permission_of(conn, id)?
};
let owned = permission == "owner";
if !owned && obj.keys().any(|k| k != "body") {
if !owned
&& obj
.keys()
.any(|k| k != "body" && !OWN_FIELDS.contains(&k.as_str()))
{
return Err(refuse(OWNER_ONLY));
}
@@ -656,7 +682,12 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
}
}
touch(conn, id)?;
if owned || text {
touch(conn, id)?;
}
if !owned && OWN_FIELDS.iter().any(|k| obj.contains_key(*k)) {
touch_own_state(conn, id)?;
}
load_note(conn, id)
}
@@ -895,11 +926,16 @@ pub fn delete_preview(conn: &Connection, id: &str, preview_id: &str) -> rusqlite
pub fn reorder(conn: &Connection, ordered_ids: &[String]) -> rusqlite::Result<()> {
let total = ordered_ids.len() as i64;
let at = now();
for (i, id) in ordered_ids.iter().enumerate() {
// Order is the owner's; a shared note keeps its place on their board.
// A note someone shared with us moves on this account's board only (#5176),
// stamped as its own state rather than as an edit.
conn.execute(
"UPDATE notes SET position = ?1, dirty = 1 WHERE id = ?2 AND permission = 'owner'",
params![total - i as i64, id],
"UPDATE notes
SET position = ?1, dirty = 1,
state_at = CASE WHEN permission = 'owner' THEN state_at ELSE ?3 END
WHERE id = ?2",
params![total - i as i64, id, at],
)?;
}
Ok(())
@@ -1340,17 +1376,21 @@ mod tests {
assert!(dirty(&conn, "n"));
add_item(&conn, "n", "eggs").expect("an item is text");
let pinned = update_note(&conn, "n", &json!({ "pinned": true }))
.err()
.expect("refused");
assert_eq!(pinned.to_string(), OWNER_ONLY);
assert!(update_note(&conn, "n", &json!({ "body": "x", "archived": true })).is_err());
let reminded = update_note(
&conn,
"n",
&json!({ "remind_at": "2026-02-01T00:00:00.000Z" }),
)
.err()
.expect("refused");
assert_eq!(reminded.to_string(), OWNER_ONLY);
assert!(update_note(&conn, "n", &json!({ "body": "x", "recurrence": "daily" })).is_err());
assert!(trash(&conn, "n").is_err());
assert!(complete_reminder(&conn, "n").is_err());
}
#[test]
fn shared_notes_stay_out_of_trash_reminders_and_reordering() {
fn shared_notes_stay_out_of_trash_and_reminders() {
let conn = db();
shared(&conn, "theirs", "edit");
conn.execute(
@@ -1367,9 +1407,52 @@ mod tests {
.expect("restored");
assert!(reminders(&conn).expect("reminders").is_empty());
assert!(due_reminders(&conn, i64::MAX).expect("due").is_empty());
}
#[test]
fn a_recipient_pins_archives_and_orders_their_own_copy() {
let conn = db();
shared(&conn, "theirs", "view");
let state_at = |conn: &Connection| -> Option<String> {
conn.query_row("SELECT state_at FROM notes WHERE id = 'theirs'", [], |r| {
r.get(0)
})
.expect("state_at")
};
let pinned = update_note(
&conn,
"theirs",
&json!({ "pinned": true, "archived": true }),
)
.expect("a view share may still be pinned");
assert!(pinned.pinned && pinned.archived);
assert!(dirty(&conn, "theirs"));
// Stamped as this account's own state, never as an edit to the owner's text.
assert_eq!(
pinned.updated_at.as_deref(),
Some("2026-01-01T00:00:00.000Z")
);
let first = state_at(&conn).expect("stamped");
conn.execute("UPDATE notes SET dirty = 0, state_at = NULL", [])
.expect("synced");
reorder(&conn, &["theirs".to_string()]).expect("reorder");
assert!(!dirty(&conn, "theirs"), "order is the owner's");
assert!(dirty(&conn, "theirs"));
assert!(state_at(&conn).is_some_and(|at| at >= first));
assert_eq!(get_note(&conn, "theirs").expect("get").position, 1);
// An own note's order is still its own edit, with no separate stamp.
let mine = note(&conn, "mine");
reorder(&conn, &[mine.id.clone()]).expect("reorder mine");
let own: Option<String> = conn
.query_row(
"SELECT state_at FROM notes WHERE id = ?1",
[&mine.id],
|r| r.get(0),
)
.expect("own state_at");
assert!(own.is_none());
}
#[test]