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:
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user