core: shared notes on the desktop and phone, and Share from the desktop
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m14s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 9m20s

The core pulls with shares from a server offering them (protocol 6): a note
says how it is held (owner, edit, view) and who shared it, and a revoked note
leaves the device. The first such pull starts the feed over once, so notes
shared before this build arrive. The store refuses what a share doesn't allow
(view: everything; edit: anything but the text), push sends only the text of
someone else's note, and their notes stay out of trash, reminders and
reordering. Unlinking drops them.

The Share dialog's calls go to the linked server over the device token, as
Tauri commands and through the FFI. The desktop now offers Share and "Shared
with me"; unlinked, the dialog says sharing needs a server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 15:33:16 -04:00
co-authored by Claude Opus 5.5
parent 75928c7afd
commit aa36b43dc3
21 changed files with 996 additions and 53 deletions
+66 -3
View File
@@ -236,8 +236,13 @@ fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rus
fn collect_notes(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rusqlite::Result<()> {
let remaining = limit.saturating_sub(out.len());
let ids: Vec<String> = {
let mut stmt =
conn.prepare("SELECT id FROM notes WHERE dirty = 1 ORDER BY updated_at LIMIT ?1")?;
// 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.
let mut stmt = conn.prepare(
"SELECT id FROM notes WHERE dirty = 1 AND permission <> 'view'
ORDER BY updated_at LIMIT ?1",
)?;
let rows = stmt.query_map(params![remaining as i64], |r| r.get::<_, String>(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
};
@@ -259,12 +264,14 @@ struct NoteRow {
recurrence: Option<String>,
created_at: String,
updated_at: String,
/// `owner`, or `edit` for a note someone shared with us (#5175).
permission: 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
remind_at, recurrence, created_at, updated_at, permission
FROM notes WHERE id = ?1",
params![id],
|r| {
@@ -278,6 +285,7 @@ fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
recurrence: r.get(6)?,
created_at: r.get(7)?,
updated_at: r.get(8)?,
permission: r.get(9)?,
})
},
)
@@ -286,6 +294,29 @@ fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
fn note_change(conn: &Connection, id: &str) -> 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.
if row.permission != "owner" {
return Ok(Change {
entity: "note",
id: id.to_string(),
op: "upsert",
edited_at: row.updated_at,
body: Some(row.body),
color: None,
pinned: None,
archived: None,
trashed: None,
remind_at: None,
recurrence: None,
position: None,
label_ids: None,
created_at: None,
name: None,
});
}
// MANUAL memberships only. Tag-sourced ones (`via_tag = 1`) are re-derived by the
// server from the body; sending them as label_ids would convert them into manual
// assignments that no longer disappear when the #tag is removed from the text.
@@ -858,6 +889,34 @@ mod tests {
assert_eq!(dirty_count(&conn), 1, "the note's label set changed");
}
#[test]
fn a_note_shared_to_edit_sends_its_text_and_nothing_else() {
let conn = db();
seed_note(&conn, "n1", 1);
conn.execute("UPDATE notes SET permission = 'edit' WHERE id = 'n1'", [])
.unwrap();
let changes = collect(&conn, 10, true).unwrap();
assert_eq!(changes.len(), 1);
let wire = serde_json::to_value(&changes[0]).unwrap();
let mut keys: Vec<&str> = wire
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(keys, ["body", "edited_at", "entity", "id", "op"]);
}
#[test]
fn a_note_shared_to_view_is_never_pushed() {
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());
}
#[test]
fn has_pending_is_false_on_a_clean_store() {
let conn = db();
@@ -988,12 +1047,16 @@ mod tests {
labels: vec![],
attachments,
previews: vec![],
permission: None,
shared: false,
shared_by: None,
};
let page = |note: wire::Note, cursor: i64| wire::ChangesPage {
notes: vec![note],
labels: vec![],
cursor,
has_more: false,
revoked: vec![],
};
let a1 = wire::Attachment {
id: "a1".into(),