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;
|
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<()> {
|
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||||
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
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(SCHEMA_V11)?;
|
||||||
conn.execute_batch("PRAGMA user_version = 11;")?;
|
conn.execute_batch("PRAGMA user_version = 11;")?;
|
||||||
}
|
}
|
||||||
|
if version < 12 {
|
||||||
|
conn.execute_batch(SCHEMA_V12)?;
|
||||||
|
conn.execute_batch("PRAGMA user_version = 12;")?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,7 +490,7 @@ mod tests {
|
|||||||
let version: i64 = conn
|
let version: i64 = conn
|
||||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||||
.expect("version");
|
.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
|
/// 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) ---------------------------------
|
// ---- notes shared with this account (#5175) ---------------------------------
|
||||||
//
|
//
|
||||||
// A note someone else owns arrives with `permission` `edit` (its text may change
|
// 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,
|
// here) or `view` (it may not). Its pin, archive and place on this board are this
|
||||||
// reminders, labels, files — stays its owner's. The server enforces the same split;
|
// 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,
|
// 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.
|
// 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.";
|
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`
|
/// 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)
|
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<()> {
|
fn require_owner(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||||
match permission_of(conn, id)?.as_str() {
|
match permission_of(conn, id)?.as_str() {
|
||||||
"owner" => Ok(()),
|
"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<()> {
|
fn touch(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE notes SET updated_at = ?1, dirty = 1 WHERE id = ?2",
|
"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
|
let obj = changes
|
||||||
.as_object()
|
.as_object()
|
||||||
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
|
.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";
|
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));
|
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)
|
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<()> {
|
pub fn reorder(conn: &Connection, ordered_ids: &[String]) -> rusqlite::Result<()> {
|
||||||
let total = ordered_ids.len() as i64;
|
let total = ordered_ids.len() as i64;
|
||||||
|
let at = now();
|
||||||
for (i, id) in ordered_ids.iter().enumerate() {
|
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(
|
conn.execute(
|
||||||
"UPDATE notes SET position = ?1, dirty = 1 WHERE id = ?2 AND permission = 'owner'",
|
"UPDATE notes
|
||||||
params![total - i as i64, id],
|
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(())
|
Ok(())
|
||||||
@@ -1340,17 +1376,21 @@ mod tests {
|
|||||||
assert!(dirty(&conn, "n"));
|
assert!(dirty(&conn, "n"));
|
||||||
add_item(&conn, "n", "eggs").expect("an item is text");
|
add_item(&conn, "n", "eggs").expect("an item is text");
|
||||||
|
|
||||||
let pinned = update_note(&conn, "n", &json!({ "pinned": true }))
|
let reminded = update_note(
|
||||||
.err()
|
&conn,
|
||||||
.expect("refused");
|
"n",
|
||||||
assert_eq!(pinned.to_string(), OWNER_ONLY);
|
&json!({ "remind_at": "2026-02-01T00:00:00.000Z" }),
|
||||||
assert!(update_note(&conn, "n", &json!({ "body": "x", "archived": true })).is_err());
|
)
|
||||||
|
.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!(trash(&conn, "n").is_err());
|
||||||
assert!(complete_reminder(&conn, "n").is_err());
|
assert!(complete_reminder(&conn, "n").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shared_notes_stay_out_of_trash_reminders_and_reordering() {
|
fn shared_notes_stay_out_of_trash_and_reminders() {
|
||||||
let conn = db();
|
let conn = db();
|
||||||
shared(&conn, "theirs", "edit");
|
shared(&conn, "theirs", "edit");
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -1367,9 +1407,52 @@ mod tests {
|
|||||||
.expect("restored");
|
.expect("restored");
|
||||||
assert!(reminders(&conn).expect("reminders").is_empty());
|
assert!(reminders(&conn).expect("reminders").is_empty());
|
||||||
assert!(due_reminders(&conn, i64::MAX).expect("due").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");
|
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]
|
#[test]
|
||||||
|
|||||||
+12
-3
@@ -25,7 +25,10 @@ use std::sync::OnceLock;
|
|||||||
/// on [`MIN_SERVER_PROTOCOL_VERSION`].
|
/// on [`MIN_SERVER_PROTOCOL_VERSION`].
|
||||||
/// v5 (#5168): files attached here upload, and removed attachments and previews are
|
/// v5 (#5168): files attached here upload, and removed attachments and previews are
|
||||||
/// pushed. Additive: both go only to a server advertising `attachment_sync`.
|
/// pushed. Additive: both go only to a server advertising `attachment_sync`.
|
||||||
pub const CLIENT_PROTOCOL_VERSION: u32 = 5;
|
/// v6 (#5175): notes shared with this account, asked for with `shares`.
|
||||||
|
/// v7 (#5176): this account's own pin, archive and position on a shared note, sent
|
||||||
|
/// only to a server advertising `shared_state`.
|
||||||
|
pub const CLIENT_PROTOCOL_VERSION: u32 = 7;
|
||||||
|
|
||||||
/// The oldest server protocol this client can drive — the symmetric half of the
|
/// The oldest server protocol this client can drive — the symmetric half of the
|
||||||
/// server's `min_client_protocol_version`.
|
/// server's `min_client_protocol_version`.
|
||||||
@@ -49,8 +52,14 @@ pub const REQUIRED_FEATURES: &[&str] = &["notes", "labels"];
|
|||||||
/// Capabilities whose absence costs a feature but not the link. Listing these
|
/// Capabilities whose absence costs a feature but not the link. Listing these
|
||||||
/// explicitly (rather than diffing against whatever the server happens to send) is
|
/// explicitly (rather than diffing against whatever the server happens to send) is
|
||||||
/// what lets the UI name exactly what the user will be missing.
|
/// what lets the UI name exactly what the user will be missing.
|
||||||
pub const OPTIONAL_FEATURES: &[&str] =
|
pub const OPTIONAL_FEATURES: &[&str] = &[
|
||||||
&["attachments", "tombstones", "revisions", "attachment_sync"];
|
"attachments",
|
||||||
|
"tombstones",
|
||||||
|
"revisions",
|
||||||
|
"attachment_sync",
|
||||||
|
"shares",
|
||||||
|
"shared_state",
|
||||||
|
];
|
||||||
|
|
||||||
/// The handshake fields of `GET /api/config`.
|
/// The handshake fields of `GET /api/config`.
|
||||||
///
|
///
|
||||||
|
|||||||
+16
-3
@@ -50,13 +50,26 @@ pub async fn run_cycle(
|
|||||||
// Notes shared with this account come only from a server offering `shares`, and
|
// Notes shared with this account come only from a server offering `shares`, and
|
||||||
// an unanswered probe reads as "not offered", like `attachment_sync` above.
|
// an unanswered probe reads as "not offered", like `attachment_sync` above.
|
||||||
let shares = server.as_ref().is_some_and(|s| s.has_feature("shares"));
|
let shares = server.as_ref().is_some_and(|s| s.has_feature("shares"));
|
||||||
|
// A recipient's own pin, archive and order on a shared note go only to a server
|
||||||
|
// that keeps them per person; an older one would apply them to nobody.
|
||||||
|
let accepts = push::Accepts {
|
||||||
|
attachment_sync,
|
||||||
|
shared_state: server
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|s| s.has_feature("shared_state")),
|
||||||
|
};
|
||||||
|
|
||||||
let push = push::run(db, blobs, base_url, token, attachment_sync).await?;
|
let push = push::run(db, blobs, base_url, token, accepts).await?;
|
||||||
if shares {
|
if shares {
|
||||||
// After the push, so nothing unsent is waiting when the full pull lands.
|
// After the push, so nothing unsent is waiting when the full pull lands.
|
||||||
|
let level = if accepts.shared_state {
|
||||||
|
state::SHARED_STATE
|
||||||
|
} else {
|
||||||
|
state::SHARES
|
||||||
|
};
|
||||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
if state::begin_shares(&conn).map_err(|e| e.to_string())? {
|
if state::begin_shares(&conn, level).map_err(|e| e.to_string())? {
|
||||||
log::info!("the server shares notes now; pulling everything once to find them");
|
log::info!("the server shares more of notes now; pulling everything once");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let pull = pull::run(db, blobs, base_url, token, shares).await?;
|
let pull = pull::run(db, blobs, base_url, token, shares).await?;
|
||||||
|
|||||||
+112
-49
@@ -95,6 +95,12 @@ pub struct Change {
|
|||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub name: Option<String>,
|
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 {
|
impl Change {
|
||||||
@@ -115,6 +121,7 @@ impl Change {
|
|||||||
label_ids: None,
|
label_ids: None,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
name: None,
|
name: None,
|
||||||
|
state_at: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,24 +150,38 @@ pub struct PushResult {
|
|||||||
|
|
||||||
// --- collecting --------------------------------------------------------------
|
// --- 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
|
/// Everything waiting to go up, oldest edit first so a truncated batch still makes
|
||||||
/// forward progress in a sensible order.
|
/// forward progress in a sensible order.
|
||||||
///
|
pub fn collect(conn: &Connection, limit: usize, accepts: Accepts) -> rusqlite::Result<Vec<Change>> {
|
||||||
/// `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>> {
|
|
||||||
let mut out = Vec::new();
|
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 {
|
if out.len() < limit {
|
||||||
collect_labels(conn, &mut out, limit)?;
|
collect_labels(conn, &mut out, limit)?;
|
||||||
}
|
}
|
||||||
if out.len() < limit {
|
if out.len() < limit {
|
||||||
collect_notes(conn, &mut out, limit)?;
|
collect_notes(conn, &mut out, limit, accepts.shared_state)?;
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
@@ -225,6 +246,7 @@ fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rus
|
|||||||
position: None,
|
position: None,
|
||||||
label_ids: None,
|
label_ids: None,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
|
state_at: None,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@@ -233,21 +255,31 @@ fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rus
|
|||||||
Ok(())
|
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 remaining = limit.saturating_sub(out.len());
|
||||||
let ids: Vec<String> = {
|
let ids: Vec<String> = {
|
||||||
// A note shared with us to view can't be changed here, so one that is dirty
|
// A note shared with us to view has only this account's own pin, archive and
|
||||||
// anyway (its share was narrowed while an edit waited) has nothing the server
|
// place to send, and only to a server that keeps them. Without one, a dirty
|
||||||
// would take. The next pull puts the server's copy back over it.
|
// 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(
|
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",
|
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>>>()?
|
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||||
};
|
};
|
||||||
for id in ids {
|
for id in ids {
|
||||||
out.push(note_change(conn, &id)?);
|
out.push(note_change(conn, &id, shared_state)?);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -264,14 +296,16 @@ struct NoteRow {
|
|||||||
recurrence: Option<String>,
|
recurrence: Option<String>,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
updated_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,
|
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> {
|
fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT body, position, pinned, archived, trashed,
|
"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",
|
FROM notes WHERE id = ?1",
|
||||||
params![id],
|
params![id],
|
||||||
|r| {
|
|r| {
|
||||||
@@ -286,34 +320,39 @@ fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
|
|||||||
created_at: r.get(7)?,
|
created_at: r.get(7)?,
|
||||||
updated_at: r.get(8)?,
|
updated_at: r.get(8)?,
|
||||||
permission: r.get(9)?,
|
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)?;
|
let row = note_row(conn, id)?;
|
||||||
|
|
||||||
// Someone else's note: only its text is ours to change, so only its text goes.
|
// Someone else's note: its text if it was shared to edit, and this account's own
|
||||||
// Pin, archive, trash, reminders, order and labels are the owner's, and this
|
// pin, archive and place once they have been changed here, to a server that keeps
|
||||||
// account's labels were never on it.
|
// them. Trash, reminders and labels are the owner's, and this account's labels
|
||||||
|
// were never on it.
|
||||||
if row.permission != "owner" {
|
if row.permission != "owner" {
|
||||||
|
let state_at = row.state_at.filter(|_| shared_state);
|
||||||
|
let own = state_at.is_some();
|
||||||
return Ok(Change {
|
return Ok(Change {
|
||||||
entity: "note",
|
entity: "note",
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
op: "upsert",
|
op: "upsert",
|
||||||
edited_at: row.updated_at,
|
edited_at: row.updated_at,
|
||||||
body: Some(row.body),
|
body: (row.permission == "edit").then_some(row.body),
|
||||||
color: None,
|
color: None,
|
||||||
pinned: None,
|
pinned: own.then_some(row.pinned),
|
||||||
archived: None,
|
archived: own.then_some(row.archived),
|
||||||
trashed: None,
|
trashed: None,
|
||||||
remind_at: None,
|
remind_at: None,
|
||||||
recurrence: None,
|
recurrence: None,
|
||||||
position: None,
|
position: own.then_some(row.position),
|
||||||
label_ids: None,
|
label_ids: None,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
name: None,
|
name: None,
|
||||||
|
state_at,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +385,7 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
|
|||||||
label_ids: Some(label_ids),
|
label_ids: Some(label_ids),
|
||||||
created_at: Some(row.created_at),
|
created_at: Some(row.created_at),
|
||||||
name: None,
|
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
|
/// 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.
|
/// next is collected — then the files, once their notes are there to hold them.
|
||||||
///
|
///
|
||||||
/// `attachment_sync`: whether the server advertises it (see [`collect`]). Without
|
/// `accepts`: what the server advertises (see [`Accepts`]). Without
|
||||||
/// it, uploads wait too.
|
/// `attachment_sync`, uploads wait too.
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
db: &Db,
|
db: &Db,
|
||||||
blobs: &BlobStore,
|
blobs: &BlobStore,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
token: &str,
|
token: &str,
|
||||||
attachment_sync: bool,
|
accepts: Accepts,
|
||||||
) -> Result<PushSummary, String> {
|
) -> Result<PushSummary, String> {
|
||||||
let mut total = PushSummary::default();
|
let mut total = PushSummary::default();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let batch = {
|
let batch = {
|
||||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
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() {
|
if batch.is_empty() {
|
||||||
break;
|
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?);
|
total.absorb(upload_pending(db, blobs, base_url, token).await?);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,7 +776,7 @@ mod tests {
|
|||||||
let conn = db();
|
let conn = db();
|
||||||
seed_note(&conn, "clean", 0);
|
seed_note(&conn, "clean", 0);
|
||||||
seed_note(&conn, "dirty", 1);
|
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.len(), 1);
|
||||||
assert_eq!(batch[0].id, "dirty");
|
assert_eq!(batch[0].id, "dirty");
|
||||||
assert_eq!(batch[0].op, "upsert");
|
assert_eq!(batch[0].op, "upsert");
|
||||||
@@ -761,7 +801,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("seed membership");
|
.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");
|
let note = batch.iter().find(|c| c.entity == "note").expect("note");
|
||||||
assert_eq!(note.label_ids.as_deref(), Some(&["manual".to_string()][..]));
|
assert_eq!(note.label_ids.as_deref(), Some(&["manual".to_string()][..]));
|
||||||
}
|
}
|
||||||
@@ -771,7 +811,7 @@ mod tests {
|
|||||||
let conn = db();
|
let conn = db();
|
||||||
seed_note(&conn, "n1", 0);
|
seed_note(&conn, "n1", 0);
|
||||||
store::delete_forever(&conn, "n1").expect("delete");
|
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.len(), 1);
|
||||||
assert_eq!(batch[0].op, "delete");
|
assert_eq!(batch[0].op, "delete");
|
||||||
assert_eq!(batch[0].entity, "note");
|
assert_eq!(batch[0].entity, "note");
|
||||||
@@ -782,7 +822,7 @@ mod tests {
|
|||||||
fn applied_clears_dirty_and_records_the_revision() {
|
fn applied_clears_dirty_and_records_the_revision() {
|
||||||
let conn = db();
|
let conn = db();
|
||||||
seed_note(&conn, "n1", 1);
|
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");
|
apply_results(&conn, &batch, &[ok("applied", Some(42))]).expect("apply");
|
||||||
assert_eq!(dirty_count(&conn), 0);
|
assert_eq!(dirty_count(&conn), 0);
|
||||||
let rev: i64 = conn
|
let rev: i64 = conn
|
||||||
@@ -799,7 +839,7 @@ mod tests {
|
|||||||
// every time; the following pull adopts the server's version instead.
|
// every time; the following pull adopts the server's version instead.
|
||||||
let conn = db();
|
let conn = db();
|
||||||
seed_note(&conn, "n1", 1);
|
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");
|
let summary = apply_results(&conn, &batch, &[ok("kept", Some(99))]).expect("apply");
|
||||||
assert_eq!(summary.kept, 1);
|
assert_eq!(summary.kept, 1);
|
||||||
assert_eq!(dirty_count(&conn), 0);
|
assert_eq!(dirty_count(&conn), 0);
|
||||||
@@ -813,7 +853,7 @@ mod tests {
|
|||||||
let conn = db();
|
let conn = db();
|
||||||
seed_note(&conn, "n1", 1);
|
seed_note(&conn, "n1", 1);
|
||||||
state::set_cursor(&conn, 100).expect("cursor");
|
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");
|
apply_results(&conn, &batch, &[ok("kept", Some(40))]).expect("apply");
|
||||||
assert_eq!(state::read(&conn).expect("state").last_cursor, 39);
|
assert_eq!(state::read(&conn).expect("state").last_cursor, 39);
|
||||||
}
|
}
|
||||||
@@ -823,7 +863,7 @@ mod tests {
|
|||||||
let conn = db();
|
let conn = db();
|
||||||
seed_note(&conn, "n1", 1);
|
seed_note(&conn, "n1", 1);
|
||||||
state::set_cursor(&conn, 10).expect("cursor");
|
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");
|
apply_results(&conn, &batch, &[ok("kept", Some(40))]).expect("apply");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state::read(&conn).expect("state").last_cursor,
|
state::read(&conn).expect("state").last_cursor,
|
||||||
@@ -836,7 +876,7 @@ mod tests {
|
|||||||
fn rejected_stays_dirty_and_is_reported() {
|
fn rejected_stays_dirty_and_is_reported() {
|
||||||
let conn = db();
|
let conn = db();
|
||||||
seed_note(&conn, "n1", 1);
|
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);
|
let mut bad = ok("rejected", None);
|
||||||
bad.error = Some("name in use".into());
|
bad.error = Some("name in use".into());
|
||||||
let summary = apply_results(&conn, &batch, &[bad]).expect("apply");
|
let summary = apply_results(&conn, &batch, &[bad]).expect("apply");
|
||||||
@@ -850,7 +890,7 @@ mod tests {
|
|||||||
let conn = db();
|
let conn = db();
|
||||||
seed_note(&conn, "n1", 0);
|
seed_note(&conn, "n1", 0);
|
||||||
store::delete_forever(&conn, "n1").expect("delete");
|
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");
|
apply_results(&conn, &batch, &[ok("applied", Some(7))]).expect("apply");
|
||||||
assert!(!has_pending(&conn).expect("pending"));
|
assert!(!has_pending(&conn).expect("pending"));
|
||||||
}
|
}
|
||||||
@@ -861,7 +901,7 @@ mod tests {
|
|||||||
let conn = db();
|
let conn = db();
|
||||||
seed_note(&conn, "n1", 1);
|
seed_note(&conn, "n1", 1);
|
||||||
store::delete_forever(&conn, "n1").expect("delete");
|
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");
|
apply_results(&conn, &batch, &[ok("noop", None)]).expect("apply");
|
||||||
assert!(!has_pending(&conn).expect("pending"));
|
assert!(!has_pending(&conn).expect("pending"));
|
||||||
}
|
}
|
||||||
@@ -895,7 +935,7 @@ mod tests {
|
|||||||
seed_note(&conn, "n1", 1);
|
seed_note(&conn, "n1", 1);
|
||||||
conn.execute("UPDATE notes SET permission = 'edit' WHERE id = 'n1'", [])
|
conn.execute("UPDATE notes SET permission = 'edit' WHERE id = 'n1'", [])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let changes = collect(&conn, 10, true).unwrap();
|
let changes = collect(&conn, 10, Accepts::ALL).unwrap();
|
||||||
assert_eq!(changes.len(), 1);
|
assert_eq!(changes.len(), 1);
|
||||||
let wire = serde_json::to_value(&changes[0]).unwrap();
|
let wire = serde_json::to_value(&changes[0]).unwrap();
|
||||||
let mut keys: Vec<&str> = wire
|
let mut keys: Vec<&str> = wire
|
||||||
@@ -909,12 +949,35 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let conn = db();
|
||||||
seed_note(&conn, "n1", 1);
|
seed_note(&conn, "n1", 1);
|
||||||
conn.execute("UPDATE notes SET permission = 'view' WHERE id = 'n1'", [])
|
conn.execute("UPDATE notes SET permission = 'view' WHERE id = 'n1'", [])
|
||||||
.unwrap();
|
.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]
|
#[test]
|
||||||
@@ -1003,14 +1066,14 @@ mod tests {
|
|||||||
store::record_pending_delete(&conn, "note", "n9").expect("tombstone");
|
store::record_pending_delete(&conn, "note", "n9").expect("tombstone");
|
||||||
|
|
||||||
// An older server would answer "unknown entity" to each of them.
|
// 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")
|
.expect("collect")
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| c.entity)
|
.map(|c| c.entity)
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(older, vec!["note"]);
|
assert_eq!(older, vec!["note"]);
|
||||||
|
|
||||||
let mut newer: Vec<_> = collect(&conn, 100, true)
|
let mut newer: Vec<_> = collect(&conn, 100, Accepts::ALL)
|
||||||
.expect("collect")
|
.expect("collect")
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| (c.entity, c.op))
|
.map(|c| (c.entity, c.op))
|
||||||
@@ -1071,7 +1134,7 @@ mod tests {
|
|||||||
store::delete_attachment(&conn, "n1", "a1").expect("remove");
|
store::delete_attachment(&conn, "n1", "a1").expect("remove");
|
||||||
|
|
||||||
// Push: the removal and the touched note go up, and the server applies both.
|
// 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
|
let results: Vec<PushResult> = batch
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| ok("applied", (c.entity == "note").then_some(5)))
|
.map(|c| ok("applied", (c.entity == "note").then_some(5)))
|
||||||
|
|||||||
+32
-12
@@ -115,17 +115,27 @@ fn forget_shared_notes(conn: &Connection) -> rusqlite::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the change feed over the first time this device pulls with shares (#5175).
|
/// How much of sharing this device's copy of the feed reflects, kept in
|
||||||
|
/// `sync_state.shares_synced`. Each level changed what the feed says about notes a
|
||||||
|
/// device may already hold, below the cursor it already has.
|
||||||
///
|
///
|
||||||
/// Notes shared before this build sit below the cursor the device already holds, so
|
/// Shared notes arrive at all (#5175).
|
||||||
/// without a restart they would only arrive once something next changed them.
|
pub const SHARES: i64 = 1;
|
||||||
/// Returns whether it restarted. Once per link: `set_link` to another server and
|
/// A shared note's pin, archive and position are this account's own (#5176). Before,
|
||||||
/// `clear_link` both reset the flag.
|
/// the feed sent the owner's, and a device holding those would keep showing them
|
||||||
pub fn begin_shares(conn: &Connection) -> rusqlite::Result<bool> {
|
/// until something next changed the note.
|
||||||
|
pub const SHARED_STATE: i64 = 2;
|
||||||
|
|
||||||
|
/// Start the change feed over the first time this device pulls at a sharing `level`
|
||||||
|
/// the server offers, so what it already holds is re-read in that level's terms.
|
||||||
|
///
|
||||||
|
/// Returns whether it restarted. Once per level per link: `set_link` to another
|
||||||
|
/// server and `clear_link` both go back to none.
|
||||||
|
pub fn begin_shares(conn: &Connection, level: i64) -> rusqlite::Result<bool> {
|
||||||
let restarted = conn.execute(
|
let restarted = conn.execute(
|
||||||
"UPDATE sync_state SET last_cursor = NULL, shares_synced = 1
|
"UPDATE sync_state SET last_cursor = NULL, shares_synced = ?1
|
||||||
WHERE id = 1 AND shares_synced = 0",
|
WHERE id = 1 AND shares_synced < ?1",
|
||||||
[],
|
params![level],
|
||||||
)?;
|
)?;
|
||||||
Ok(restarted > 0)
|
Ok(restarted > 0)
|
||||||
}
|
}
|
||||||
@@ -342,14 +352,24 @@ mod tests {
|
|||||||
let conn = db();
|
let conn = db();
|
||||||
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
||||||
set_cursor(&conn, 500).expect("cursor");
|
set_cursor(&conn, 500).expect("cursor");
|
||||||
assert!(begin_shares(&conn).expect("begin"));
|
assert!(begin_shares(&conn, SHARES).expect("begin"));
|
||||||
assert_eq!(read(&conn).expect("read").last_cursor, 0);
|
assert_eq!(read(&conn).expect("read").last_cursor, 0);
|
||||||
set_cursor(&conn, 600).expect("cursor");
|
set_cursor(&conn, 600).expect("cursor");
|
||||||
assert!(!begin_shares(&conn).expect("again"), "only the first time");
|
assert!(
|
||||||
|
!begin_shares(&conn, SHARES).expect("again"),
|
||||||
|
"only the first time"
|
||||||
|
);
|
||||||
assert_eq!(read(&conn).expect("read").last_cursor, 600);
|
assert_eq!(read(&conn).expect("read").last_cursor, 600);
|
||||||
|
// A server offering more of sharing starts it over once more, and only once.
|
||||||
|
assert!(begin_shares(&conn, SHARED_STATE).expect("a newer server"));
|
||||||
|
assert_eq!(read(&conn).expect("read").last_cursor, 0);
|
||||||
|
set_cursor(&conn, 700).expect("cursor");
|
||||||
|
assert!(!begin_shares(&conn, SHARED_STATE).expect("again"));
|
||||||
|
assert!(!begin_shares(&conn, SHARES).expect("an older level"));
|
||||||
|
assert_eq!(read(&conn).expect("read").last_cursor, 700);
|
||||||
// Another server is a fresh start, shares included.
|
// Another server is a fresh start, shares included.
|
||||||
set_link(&conn, "https://b.example.com", "tok-2").expect("relink");
|
set_link(&conn, "https://b.example.com", "tok-2").expect("relink");
|
||||||
assert!(begin_shares(&conn).expect("new server"));
|
assert!(begin_shares(&conn, SHARES).expect("new server"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ const root = ref<HTMLElement | null>(null);
|
|||||||
// touch — on a phone this did nothing at all. One code path now covers mouse, touch
|
// touch — on a phone this did nothing at all. One code path now covers mouse, touch
|
||||||
// and stylus. See composables/useCardDrag.ts for why the state is shared. ---
|
// and stylus. See composables/useCardDrag.ts for why the state is shared. ---
|
||||||
// Board order is the owner's; a shared note's place is the recipient's from #5176.
|
// Board order is the owner's; a shared note's place is the recipient's from #5176.
|
||||||
const canDrag = () => !!props.reorderable && !props.note.trashed && own.value;
|
// A shared note moves on the recipient's board only (#5176), so anyone may drag it.
|
||||||
|
const canDrag = () => !!props.reorderable && !props.note.trashed;
|
||||||
const dragging = computed(() => draggingId.value === props.note.id);
|
const dragging = computed(() => draggingId.value === props.note.id);
|
||||||
const dragOver = computed(() => overId.value === props.note.id);
|
const dragOver = computed(() => overId.value === props.note.id);
|
||||||
|
|
||||||
@@ -458,9 +459,10 @@ const tagColors = computed<Record<string, string>>(() => {
|
|||||||
<Icon name="grip" />
|
<Icon name="grip" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Every action here is the owner's, so a shared note has no action set. -->
|
<!-- Pin and archive are everyone's own (#5176); share and trash are the owner's.
|
||||||
|
A note shared with you is never in your Trash, so the trashed set is too. -->
|
||||||
<div
|
<div
|
||||||
v-if="own"
|
v-if="own || !note.trashed"
|
||||||
class="note-actions-set hover-reveal pointer-events-none flex items-center gap-0.5 rounded-full bg-white/85 p-0.5 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition focus-within:pointer-events-auto focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:ring-white/10"
|
class="note-actions-set hover-reveal pointer-events-none flex items-center gap-0.5 rounded-full bg-white/85 p-0.5 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition focus-within:pointer-events-auto focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:ring-white/10"
|
||||||
>
|
>
|
||||||
<template v-if="note.trashed">
|
<template v-if="note.trashed">
|
||||||
@@ -477,7 +479,7 @@ const tagColors = computed<Record<string, string>>(() => {
|
|||||||
<Icon name="trash" />
|
<Icon name="trash" />
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="own">
|
<template v-else>
|
||||||
<button
|
<button
|
||||||
v-if="canShare"
|
v-if="canShare"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -509,6 +511,7 @@ const tagColors = computed<Record<string, string>>(() => {
|
|||||||
<Icon name="archive" />
|
<Icon name="archive" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="own"
|
||||||
type="button"
|
type="button"
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
title="Move to trash"
|
title="Move to trash"
|
||||||
|
|||||||
@@ -865,7 +865,8 @@ function revPreview(rev: NoteRevision): string {
|
|||||||
>
|
>
|
||||||
<Icon name="history" />
|
<Icon name="history" />
|
||||||
</button>
|
</button>
|
||||||
<template v-if="!isCreate && !liveNote.trashed && own">
|
<!-- Pin and archive are everyone's own on a shared note (#5176); trash is the owner's. -->
|
||||||
|
<template v-if="!isCreate && !liveNote.trashed">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
@@ -884,6 +885,7 @@ function revPreview(rev: NoteRevision): string {
|
|||||||
<Icon name="archive" />
|
<Icon name="archive" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="own"
|
||||||
type="button"
|
type="button"
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
title="Move to trash"
|
title="Move to trash"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useNotesStore, type Note, type NoteView } from "../stores/notes";
|
|||||||
import { useConfigStore } from "../stores/config";
|
import { useConfigStore } from "../stores/config";
|
||||||
import { useUiStore } from "../stores/ui";
|
import { useUiStore } from "../stores/ui";
|
||||||
import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
|
import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
|
||||||
|
import { isOwnNote } from "../notes/sharing";
|
||||||
import { useNoteEditor } from "../composables/useNoteEditor";
|
import { useNoteEditor } from "../composables/useNoteEditor";
|
||||||
import AsyncState from "../components/AsyncState.vue";
|
import AsyncState from "../components/AsyncState.vue";
|
||||||
import EmptyState from "../components/EmptyState.vue";
|
import EmptyState from "../components/EmptyState.vue";
|
||||||
@@ -127,7 +128,8 @@ function onBoardKey(e: KeyboardEvent) {
|
|||||||
if (key === "Enter") {
|
if (key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openEditor(note);
|
openEditor(note);
|
||||||
} else if (key === "x" && !inTrash.value) {
|
} else if (key === "x" && !inTrash.value && isOwnNote(note)) {
|
||||||
|
// Trash is the owner's; archive and pin below are everyone's own (#5176).
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void notes.trash(note.id);
|
void notes.trash(note.id);
|
||||||
} else if (key === "e" && !inTrash.value) {
|
} else if (key === "e" && !inTrash.value) {
|
||||||
|
|||||||
Reference in New Issue
Block a user