diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt index 52becf5..47d072f 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt @@ -652,4 +652,7 @@ private fun blankDraft(): Note = previews = emptyList(), createdAt = null, updatedAt = null, + permission = "owner", + shared = false, + sharedBy = null, ) diff --git a/android/ffi/src/lib.rs b/android/ffi/src/lib.rs index 2033599..b7307fb 100644 --- a/android/ffi/src/lib.rs +++ b/android/ffi/src/lib.rs @@ -40,11 +40,11 @@ use std::sync::Arc; use inkwell_core::local::{self, Db}; use inkwell_core::sync::blobs::BlobStore; -use inkwell_core::sync::{client, compat, engine, push, state}; +use inkwell_core::sync::{client, compat, engine, push, sharing, state}; use models::{ - patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, - NoteQuery, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus, + patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Member, Note, NoteDraft, + NoteEdit, NoteQuery, NoteShare, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus, }; uniffi::setup_scaffolding!(); @@ -606,6 +606,55 @@ impl Inkwell { .map(SyncOutcome::from) .map_err(CoreError::network) } + + // ────────────────────────────── sharing ────────────────────────────── + // + // The Share dialog asks the server directly (#5175). Unlinked, each answers + // `NotLinked`, which the dialog turns into "sharing needs a server". + + /// Everyone on the instance a note can be shared with. + pub async fn share_directory(&self) -> Result, CoreError> { + self.credentials()?; + let members = sharing::directory(&self.db) + .await + .map_err(CoreError::network)?; + Ok(members.into_iter().map(Member::from).collect()) + } + + /// Who this note is shared with. + pub async fn note_shares(&self, note_id: String) -> Result, CoreError> { + self.credentials()?; + let shares = sharing::list(&self.db, ¬e_id) + .await + .map_err(CoreError::network)?; + Ok(shares.into_iter().map(NoteShare::from).collect()) + } + + /// Share with one member at "view" or "edit", or change their permission. + pub async fn share_note( + &self, + note_id: String, + user_id: String, + permission: String, + ) -> Result, CoreError> { + self.credentials()?; + let shares = sharing::share(&self.db, ¬e_id, &user_id, &permission) + .await + .map_err(CoreError::network)?; + Ok(shares.into_iter().map(NoteShare::from).collect()) + } + + pub async fn unshare_note( + &self, + note_id: String, + share_id: String, + ) -> Result, CoreError> { + self.credentials()?; + let shares = sharing::unshare(&self.db, ¬e_id, &share_id) + .await + .map_err(CoreError::network)?; + Ok(shares.into_iter().map(NoteShare::from).collect()) + } } // ── checklist text, as pure functions ─────────────────────────────────────── diff --git a/android/ffi/src/models.rs b/android/ffi/src/models.rs index 9967788..e10f2be 100644 --- a/android/ffi/src/models.rs +++ b/android/ffi/src/models.rs @@ -46,6 +46,20 @@ pub struct Note { pub previews: Vec, pub created_at: Option, pub updated_at: Option, + /// How this account holds the note (#5175): "owner", or "edit"/"view" for one + /// someone shared with it. + pub permission: String, + /// Whether the owner has shared it with anyone. + pub shared: bool, + /// Who shared it with us; absent on our own notes. + pub shared_by: Option, +} + +/// The owner of a note shared with this account. +#[derive(Debug, Clone, uniffi::Record)] +pub struct SharedBy { + pub id: String, + pub display_name: String, } /// A checklist item as it sits in a note's body. @@ -201,6 +215,9 @@ impl From for Note { previews, created_at, updated_at, + permission, + shared, + shared_by, } = value; Note { id, @@ -219,6 +236,12 @@ impl From for Note { previews: previews.into_iter().map(LinkPreview::from).collect(), created_at, updated_at, + permission, + shared, + shared_by: shared_by.map(|by| SharedBy { + id: by.id, + display_name: by.display_name, + }), } } } @@ -350,6 +373,8 @@ pub struct NoteFacets { pub has_attachment: Option, pub created_after: Option, pub created_before: Option, + /// "with_me": only notes someone else shared with this account. + pub shared: Option, } impl From for core_models::ListQuery { @@ -378,6 +403,7 @@ impl From for core_models::Facets { has_attachment, created_after, created_before, + shared, } = value; core_models::Facets { q, @@ -386,6 +412,7 @@ impl From for core_models::Facets { has_attachment, created_after, created_before, + shared, } } } @@ -586,6 +613,54 @@ impl From for Identity { } } +/// Someone on the instance a note can be shared with (#5175). +#[derive(Debug, Clone, uniffi::Record)] +pub struct Member { + pub id: String, + pub display_name: String, + pub email: String, +} + +impl From for Member { + fn from(value: core_client::Member) -> Self { + let core_client::Member { + id, + display_name, + email, + } = value; + Member { + id, + display_name, + email, + } + } +} + +/// One person a note is shared with, at "view" or "edit". +#[derive(Debug, Clone, uniffi::Record)] +pub struct NoteShare { + pub id: String, + pub member: Member, + pub permission: String, +} + +impl From for NoteShare { + fn from(value: core_client::NoteShare) -> Self { + // `created_at` stays behind: nothing on the phone orders or shows by it. + let core_client::NoteShare { + id, + member, + permission, + created_at: _, + } = value; + NoteShare { + id, + member: member.into(), + permission, + } + } +} + /// What became of this device's token on the server during an unlink. /// /// Separate from the local result because the local half always succeeds and the diff --git a/core/src/local/models.rs b/core/src/local/models.rs index f3d7280..21a8c8c 100644 --- a/core/src/local/models.rs +++ b/core/src/local/models.rs @@ -28,6 +28,20 @@ pub struct Note { pub previews: Vec, pub created_at: Option, pub updated_at: Option, + /// How this account holds the note (#5175): `owner`, or `edit`/`view` for one + /// someone shared with it. Named and valued as the server's, so the shared + /// frontend gates the editor identically either way. + pub permission: String, + /// Whether the owner has shared it with anyone. + pub shared: bool, + /// Who shared it with us; null on our own notes. + pub shared_by: Option, +} + +#[derive(Serialize)] +pub struct SharedBy { + pub id: String, + pub display_name: String, } #[derive(Serialize)] @@ -173,4 +187,7 @@ pub struct Facets { pub created_after: Option, #[serde(default)] pub created_before: Option, + /// `with_me`: only notes someone else shared with this account. + #[serde(default)] + pub shared: Option, } diff --git a/core/src/local/retention.rs b/core/src/local/retention.rs index 0a8ca82..137a5c5 100644 --- a/core/src/local/retention.rs +++ b/core/src/local/retention.rs @@ -37,7 +37,8 @@ pub fn sweep_expired_trash( let mut expired: Vec = Vec::new(); { let mut stmt = conn.prepare( - "SELECT id, trashed_at FROM notes WHERE trashed = 1 AND trashed_at IS NOT NULL", + "SELECT id, trashed_at FROM notes + WHERE trashed = 1 AND trashed_at IS NOT NULL AND permission = 'owner'", )?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { diff --git a/core/src/local/schema.rs b/core/src/local/schema.rs index 221fc40..3c4a295 100644 --- a/core/src/local/schema.rs +++ b/core/src/local/schema.rs @@ -291,6 +291,26 @@ ALTER TABLE attachments ADD COLUMN uploaded INTEGER NOT NULL DEFAULT 1; ALTER TABLE attachments ADD COLUMN upload_error TEXT; "#; +// v11 (#5175): notes other people shared with this account. +// +// The feed now carries them, so a note says how it is held: `permission` is `owner`, +// `edit` (its text may change here) or `view`. Every note already on a device is the +// account's own, which is why the default is `owner`. `shared` is whether the owner +// has shared it with anyone, and `shared_by_*` names the owner of one shared with us. +// +// `shares_synced` is whether this device has pulled with shares since it was linked. +// The notes shared before this build sit below the cursor it already holds, so the +// first pull from a server offering `shares` starts again from zero (see +// `engine::run_cycle`). A pull applies idempotently, so starting over costs a +// download and nothing else. +const SCHEMA_V11: &str = r#" +ALTER TABLE notes ADD COLUMN permission TEXT NOT NULL DEFAULT 'owner'; +ALTER TABLE notes ADD COLUMN shared INTEGER NOT NULL DEFAULT 0; +ALTER TABLE notes ADD COLUMN shared_by_id TEXT; +ALTER TABLE notes ADD COLUMN shared_by_name TEXT; +ALTER TABLE sync_state ADD COLUMN shares_synced INTEGER NOT NULL DEFAULT 0; +"#; + 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))?; @@ -334,6 +354,10 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch(SCHEMA_V10)?; conn.execute_batch("PRAGMA user_version = 10;")?; } + if version < 11 { + conn.execute_batch(SCHEMA_V11)?; + conn.execute_batch("PRAGMA user_version = 11;")?; + } Ok(()) } @@ -448,7 +472,7 @@ mod tests { let version: i64 = conn .query_row("PRAGMA user_version", [], |r| r.get(0)) .expect("version"); - assert_eq!(version, 10); + assert_eq!(version, 11); } /// Every attachment that predates v10 came down the feed, so it is already on the diff --git a/core/src/local/store.rs b/core/src/local/store.rs index a0a4d48..df8785b 100644 --- a/core/src/local/store.rs +++ b/core/src/local/store.rs @@ -142,7 +142,8 @@ fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result rusqlite::Result { let mut note = conn.query_row( - "SELECT id, body, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at + "SELECT id, body, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at, + permission, shared, shared_by_id, shared_by_name FROM notes WHERE id = ?1", [id], // By NAME, not by position. Dropping `color` shifted every column after it @@ -167,6 +168,17 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result { previews: Vec::new(), created_at: r.get("created_at")?, updated_at: r.get("updated_at")?, + permission: r.get("permission")?, + shared: r.get("shared")?, + shared_by: match r.get::<_, Option>("shared_by_id")? { + Some(id) => Some(SharedBy { + id, + display_name: r + .get::<_, Option>("shared_by_name")? + .unwrap_or_default(), + }), + None => None, + }, }) }, )?; @@ -178,6 +190,58 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result { Ok(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; +// 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 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` +/// like every other store failure, and prints as exactly the message. +#[derive(Debug)] +struct Refused(&'static str); + +impl std::fmt::Display for Refused { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0) + } +} + +impl std::error::Error for Refused {} + +fn refuse(message: &'static str) -> rusqlite::Error { + rusqlite::Error::ToSqlConversionFailure(Box::new(Refused(message))) +} + +fn permission_of(conn: &Connection, id: &str) -> rusqlite::Result { + conn.query_row("SELECT permission FROM notes WHERE id = ?1", [id], |r| { + r.get(0) + }) +} + +/// The note's text may change here: it is ours, or shared with us to edit. +fn require_text(conn: &Connection, id: &str) -> rusqlite::Result { + let permission = permission_of(conn, id)?; + if permission == "view" { + return Err(refuse(VIEW_ONLY)); + } + Ok(permission) +} + +/// Anything but the text: only the owner. +fn require_owner(conn: &Connection, id: &str) -> rusqlite::Result<()> { + match permission_of(conn, id)?.as_str() { + "owner" => Ok(()), + "view" => Err(refuse(VIEW_ONLY)), + _ => Err(refuse(OWNER_ONLY)), + } +} + fn touch(conn: &Connection, id: &str) -> rusqlite::Result<()> { conn.execute( "UPDATE notes SET updated_at = ?1, dirty = 1 WHERE id = ?2", @@ -299,7 +363,9 @@ fn lift_and_sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite: pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result> { let mut sql = String::from("SELECT id FROM notes WHERE "); sql.push_str(match q.view.as_str() { - "trash" => "trashed = 1", + // Trash is the owner's: a note its owner trashed leaves a recipient's board + // without turning up in their Trash, where they could do nothing with it. + "trash" => "trashed = 1 AND permission = 'owner'", "archived" => "trashed = 0 AND archived = 1", _ => "trashed = 0 AND archived = 0", }); @@ -337,6 +403,9 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result 'owner'"); + } if let Some(a) = f.created_after.as_deref().filter(|s| !s.is_empty()) { sql.push_str(" AND created_at >= ?"); binds.push(a.to_string()); @@ -368,7 +437,9 @@ pub fn get_note(conn: &Connection, id: &str) -> rusqlite::Result { pub fn reminders(conn: &Connection) -> rusqlite::Result> { let ids: Vec = { let mut stmt = - conn.prepare("SELECT id FROM notes WHERE trashed = 0 AND remind_at IS NOT NULL ORDER BY remind_at ASC")?; + // The owner's reminders: a note shared with us neither alerts us nor is ours + // to clear, the same as on the server. + conn.prepare("SELECT id FROM notes WHERE trashed = 0 AND remind_at IS NOT NULL AND permission = 'owner' ORDER BY remind_at ASC")?; let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; rows.collect::>>()? }; @@ -383,7 +454,9 @@ pub fn reminders(conn: &Connection) -> rusqlite::Result> { pub fn due_reminders(conn: &Connection, now_ms: i64) -> rusqlite::Result> { let set: Vec<(String, String)> = { let mut stmt = conn.prepare( - "SELECT id, remind_at FROM notes WHERE trashed = 0 AND remind_at IS NOT NULL ORDER BY remind_at ASC", + "SELECT id, remind_at FROM notes + WHERE trashed = 0 AND remind_at IS NOT NULL AND permission = 'owner' + ORDER BY remind_at ASC", )?; let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; rows.collect::>()? @@ -523,6 +596,11 @@ 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 owned = permission == "owner"; + if !owned && obj.keys().any(|k| k != "body") { + return Err(refuse(OWNER_ONLY)); + } // Snapshot the pre-edit body before changing it (version history) — but only // once per editing session, and only if it actually changed. See should_snapshot. @@ -540,7 +618,12 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re "UPDATE notes SET body = ?1 WHERE id = ?2", params![body, id], )?; - lift_and_sync_tags(conn, id, body)?; + // A #tag typed into someone else's note files it under THEIR labels, + // which the server does when the text arrives. Lifting it here would + // file it under ours. + if owned { + lift_and_sync_tags(conn, id, body)?; + } } "pinned" => { if let Some(b) = v.as_bool() { @@ -589,6 +672,7 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re /// the same note can be completed from a browser or from a client, and a /// disagreement here would move a reminder depending on which one you used. pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result { + require_owner(conn, id)?; let note = load_note(conn, id)?; let next = note .remind_at @@ -614,6 +698,7 @@ pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result } pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::Result { + require_owner(conn, id)?; let t = (Utc::now() + Duration::minutes(minutes)).to_rfc3339_opts(SecondsFormat::Millis, true); conn.execute( "UPDATE notes SET remind_at = ?1 WHERE id = ?2", @@ -624,6 +709,7 @@ pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::R } pub fn set_labels(conn: &Connection, id: &str, label_ids: &[String]) -> rusqlite::Result { + require_owner(conn, id)?; // Manual labels are replaced wholesale; #tag (via_tag) labels are managed by text. conn.execute( "DELETE FROM note_labels WHERE note_id = ?1 AND via_tag = 0", @@ -746,6 +832,7 @@ pub fn add_attachment( if exists.is_none() { return Err("That note no longer exists.".to_string()); } + require_owner(conn, note_id).map_err(|e| e.to_string())?; let sha256 = blobs.put(bytes)?; let id = new_id(); conn.execute( @@ -771,6 +858,7 @@ pub fn add_attachment( } pub fn delete_attachment(conn: &Connection, id: &str, att_id: &str) -> rusqlite::Result { + require_owner(conn, id)?; let uploaded: Option = conn .query_row( "SELECT uploaded FROM attachments WHERE id = ?1 AND note_id = ?2", @@ -792,6 +880,7 @@ pub fn delete_attachment(conn: &Connection, id: &str, att_id: &str) -> rusqlite: } pub fn delete_preview(conn: &Connection, id: &str, preview_id: &str) -> rusqlite::Result { + require_owner(conn, id)?; let removed = conn.execute( "DELETE FROM link_previews WHERE id = ?1 AND note_id = ?2", params![preview_id, id], @@ -807,8 +896,9 @@ 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; for (i, id) in ordered_ids.iter().enumerate() { + // Order is the owner's; a shared note keeps its place on their board. conn.execute( - "UPDATE notes SET position = ?1, dirty = 1 WHERE id = ?2", + "UPDATE notes SET position = ?1, dirty = 1 WHERE id = ?2 AND permission = 'owner'", params![total - i as i64, id], )?; } @@ -816,6 +906,7 @@ pub fn reorder(conn: &Connection, ordered_ids: &[String]) -> rusqlite::Result<() } pub fn trash(conn: &Connection, id: &str) -> rusqlite::Result { + require_owner(conn, id)?; // COALESCE, so trashing an already-trashed note doesn't restart its retention // clock. The server keeps its `deleted_at` the same way — a note shouldn't earn // another 30 days because something touched it twice. @@ -828,6 +919,7 @@ pub fn trash(conn: &Connection, id: &str) -> rusqlite::Result { } pub fn restore(conn: &Connection, id: &str) -> rusqlite::Result { + require_owner(conn, id)?; conn.execute( "UPDATE notes SET trashed = 0, trashed_at = NULL WHERE id = ?1", [id], @@ -837,6 +929,14 @@ pub fn restore(conn: &Connection, id: &str) -> rusqlite::Result { } pub fn delete_forever(conn: &Connection, id: &str) -> rusqlite::Result<()> { + // A note that is already gone is still fine to delete, as it always was; one that + // is someone else's is not ours to delete. + if permission_of(conn, id) + .optional()? + .is_some_and(|p| p != "owner") + { + return Err(refuse(OWNER_ONLY)); + } record_pending_delete(conn, "note", id)?; conn.execute("DELETE FROM notes WHERE id = ?1", [id])?; Ok(()) @@ -891,6 +991,7 @@ pub fn revisions(conn: &Connection, id: &str) -> rusqlite::Result rusqlite::Result { + let owned = require_text(conn, id)? == "owner"; let body: String = conn.query_row( "SELECT body FROM note_revisions WHERE id = ?1 AND note_id = ?2", params![rev_id, id], @@ -901,7 +1002,9 @@ pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite:: "UPDATE notes SET body = ?1 WHERE id = ?2", params![body, id], )?; - lift_and_sync_tags(conn, id, &body)?; + if owned { + lift_and_sync_tags(conn, id, &body)?; + } touch(conn, id)?; load_note(conn, id) } @@ -1165,6 +1268,119 @@ mod tests { notes.iter().map(|n| n.id.as_str()).collect() } + // ---- notes shared with this account (#5175) -------------------------------- + + /// A note someone else owns, as the feed leaves it: clean, and held at `permission`. + fn shared(conn: &Connection, id: &str, permission: &str) { + stamped( + conn, + id, + "their words", + "2026-01-01T00:00:00.000Z", + "2026-01-01T00:00:00.000Z", + ); + conn.execute( + "UPDATE notes SET permission = ?2, shared = 1, shared_by_id = 'u-robin', + shared_by_name = 'Robin', dirty = 0 + WHERE id = ?1", + params![id, permission], + ) + .expect("share"); + } + + fn dirty(conn: &Connection, id: &str) -> bool { + conn.query_row("SELECT dirty FROM notes WHERE id = ?1", [id], |r| r.get(0)) + .expect("dirty") + } + + #[test] + fn a_shared_note_says_who_shared_it_and_how() { + let conn = db(); + shared(&conn, "n", "view"); + let n = get_note(&conn, "n").expect("get"); + assert_eq!(n.permission, "view"); + assert!(n.shared); + let by = n.shared_by.expect("shared_by"); + assert_eq!( + (by.id.as_str(), by.display_name.as_str()), + ("u-robin", "Robin") + ); + let own = note(&conn, "mine"); + assert_eq!((own.permission.as_str(), own.shared), ("owner", false)); + assert!(own.shared_by.is_none()); + } + + #[test] + fn a_view_share_changes_nothing_here() { + let conn = db(); + shared(&conn, "n", "view"); + let refused = update_note(&conn, "n", &json!({ "body": "mine now" })).unwrap_err(); + // The words the person sees, exactly: the refusal prints as its message. + assert_eq!(refused.to_string(), VIEW_ONLY); + assert!(add_item(&conn, "n", "eggs").is_err()); + assert!(trash(&conn, "n").is_err()); + assert!(snooze_reminder(&conn, "n", 10).is_err()); + assert!(set_labels(&conn, "n", &[]).is_err()); + assert!(delete_forever(&conn, "n").is_err()); + assert_eq!(get_note(&conn, "n").expect("get").body, "their words"); + assert!(!dirty(&conn, "n")); + } + + #[test] + fn an_edit_share_changes_the_text_and_nothing_else() { + let conn = db(); + shared(&conn, "n", "edit"); + let edited = update_note(&conn, "n", &json!({ "body": "their words, edited\n#mine" })) + .expect("body"); + // The tag is the server's to file, under the owner's labels; not ours. + assert!(edited.labels.is_empty()); + assert_eq!(edited.body, "their words, edited\n#mine"); + assert!(dirty(&conn, "n")); + add_item(&conn, "n", "eggs").expect("an item is text"); + + let pinned = update_note(&conn, "n", &json!({ "pinned": true })).unwrap_err(); + assert_eq!(pinned.to_string(), OWNER_ONLY); + assert!(update_note(&conn, "n", &json!({ "body": "x", "archived": true })).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() { + let conn = db(); + shared(&conn, "theirs", "edit"); + conn.execute( + "UPDATE notes SET trashed = 1, trashed_at = '2026-01-02T00:00:00.000Z', + remind_at = '2026-01-03T00:00:00.000Z' + WHERE id = 'theirs'", + [], + ) + .expect("owner trashed it"); + assert!(ids(&list(&conn, json!({ "view": "trash" }))).is_empty()); + assert!(ids(&list(&conn, json!({ "view": "notes" }))).is_empty()); + + conn.execute("UPDATE notes SET trashed = 0 WHERE id = 'theirs'", []) + .expect("restored"); + assert!(reminders(&conn).expect("reminders").is_empty()); + assert!(due_reminders(&conn, i64::MAX).expect("due").is_empty()); + + reorder(&conn, &["theirs".to_string()]).expect("reorder"); + assert!(!dirty(&conn, "theirs"), "order is the owner's"); + } + + #[test] + fn shared_with_me_narrows_the_board_to_other_peoples_notes() { + let conn = db(); + shared(&conn, "theirs", "view"); + note(&conn, "mine"); + let narrowed = list( + &conn, + json!({ "view": "notes", "facets": { "shared": "with_me" } }), + ); + assert_eq!(ids(&narrowed), ["theirs"]); + assert_eq!(list(&conn, json!({ "view": "notes" })).len(), 2); + } + // ---- reading a note -------------------------------------------------------- #[test] diff --git a/core/src/sync/client.rs b/core/src/sync/client.rs index 81fe10a..62015b8 100644 --- a/core/src/sync/client.rs +++ b/core/src/sync/client.rs @@ -265,8 +265,15 @@ pub async fn fetch_changes( base_url: &str, token: &str, since: i64, + shares: bool, ) -> Result { - let url = format!("{base_url}/api/sync/changes?since={since}"); + // `shares=1` only to a server advertising `shares`: it asks for the notes shared + // with this account and the `revoked` list, which an older server would ignore. + let url = if shares { + format!("{base_url}/api/sync/changes?since={since}&shares=1") + } else { + format!("{base_url}/api/sync/changes?since={since}") + }; let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token)); let response = request .send() @@ -416,6 +423,123 @@ pub async fn push_changes( .map_err(|e| format!("Couldn't read the push reply from {base_url}: {e}")) } +// --- sharing (#5175) ----------------------------------------------------------- +// +// The Share dialog on a linked device asks the server directly, over the device +// token: who is on the instance, and who a note is shared with. Shares are the +// server's, so there is nothing to keep offline and nothing to queue. + +/// Someone on the instance a note can be shared with. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct Member { + pub id: String, + #[serde(default)] + pub display_name: String, + #[serde(default)] + pub email: String, +} + +/// One person a note is shared with, and at what level (`view` or `edit`). +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct NoteShare { + pub id: String, + pub member: Member, + pub permission: String, + #[serde(default)] + pub created_at: Option, +} + +#[derive(Deserialize)] +struct MembersReply { + members: Vec, +} + +#[derive(Deserialize)] +struct SharesReply { + shares: Vec, +} + +/// A note made on this device that hasn't been pushed yet is a 404 to the server, +/// and so is one this account doesn't own. +const SHARE_NOT_FOUND: &str = "The server doesn't have this note yet. Sync, then share it."; + +async fn read_reply( + base_url: &str, + request: RequestBuilder, +) -> Result { + let response = request + .send() + .await + .map_err(|e| describe_transport_error(base_url, &e))?; + let status = response.status(); + if status == StatusCode::UNAUTHORIZED { + return Err(TOKEN_REJECTED.to_string()); + } + if status == StatusCode::NOT_FOUND { + return Err(SHARE_NOT_FOUND.to_string()); + } + if !status.is_success() { + // The server's own words when it gave some ("choose someone to share with"). + let reason = response + .json::() + .await + .ok() + .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from)); + return Err(reason.unwrap_or_else(|| unexpected_status(base_url, status))); + } + response + .json() + .await + .map_err(|e| format!("Couldn't read the reply from {base_url}: {e}")) +} + +/// Everyone on the instance but this account. +pub async fn directory(base_url: &str, token: &str) -> Result, String> { + let url = format!("{base_url}/api/users/directory"); + let reply: MembersReply = read_reply(base_url, prepare(http()?.get(url), Some(token))).await?; + Ok(reply.members) +} + +pub async fn list_shares( + base_url: &str, + token: &str, + note_id: &str, +) -> Result, String> { + let url = format!("{base_url}/api/notes/{note_id}/shares"); + let reply: SharesReply = read_reply(base_url, prepare(http()?.get(url), Some(token))).await?; + Ok(reply.shares) +} + +/// Share with one member, or change their permission. Answers the note's shares. +pub async fn share_note( + base_url: &str, + token: &str, + note_id: &str, + user_id: &str, + permission: &str, +) -> Result, String> { + let url = format!("{base_url}/api/notes/{note_id}/shares"); + let body = serde_json::json!({ "user_id": user_id, "permission": permission }); + let reply: SharesReply = read_reply( + base_url, + prepare(http()?.post(url), Some(token)).json(&body), + ) + .await?; + Ok(reply.shares) +} + +pub async fn unshare_note( + base_url: &str, + token: &str, + note_id: &str, + share_id: &str, +) -> Result, String> { + let url = format!("{base_url}/api/notes/{note_id}/shares/{share_id}"); + let reply: SharesReply = + read_reply(base_url, prepare(http()?.delete(url), Some(token))).await?; + Ok(reply.shares) +} + /// The public, unauthenticated endpoint carrying the handshake. fn config_url(base_url: &str) -> String { format!("{base_url}/api/config") diff --git a/core/src/sync/engine.rs b/core/src/sync/engine.rs index 483928c..bde59d9 100644 --- a/core/src/sync/engine.rs +++ b/core/src/sync/engine.rs @@ -47,9 +47,19 @@ pub async fn run_cycle( let attachment_sync = server .as_ref() .is_some_and(|s| s.has_feature("attachment_sync")); + // Notes shared with this account come only from a server offering `shares`, and + // 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 push = push::run(db, blobs, base_url, token, attachment_sync).await?; - let pull = pull::run(db, blobs, base_url, token).await?; + if shares { + // After the push, so nothing unsent is waiting when the full pull lands. + let conn = db.0.lock().map_err(|e| e.to_string())?; + if state::begin_shares(&conn).map_err(|e| e.to_string())? { + log::info!("the server shares notes now; pulling everything once to find them"); + } + } + let pull = pull::run(db, blobs, base_url, token, shares).await?; if pull.clobbered_dirty > 0 { // Push ran first and reported success, so nothing should still have been diff --git a/core/src/sync/mod.rs b/core/src/sync/mod.rs index a8477f7..22c265c 100644 --- a/core/src/sync/mod.rs +++ b/core/src/sync/mod.rs @@ -8,6 +8,7 @@ //! - `client` — HTTP transport: the handshake call and device-token auth. //! - `state` — the persisted link record (server, token, change-feed cursor). //! - `engine` — one full cycle: push local changes, then pull the server's. +//! - `sharing` — the Share dialog's calls, straight to the server (#5175). //! //! The UI surface that drives this lives in whichever client is wrapping the crate, //! not here. @@ -18,5 +19,6 @@ pub mod compat; pub mod engine; pub mod pull; pub mod push; +pub mod sharing; pub mod state; pub mod wire; diff --git a/core/src/sync/pull.rs b/core/src/sync/pull.rs index ea45168..4789a50 100644 --- a/core/src/sync/pull.rs +++ b/core/src/sync/pull.rs @@ -149,6 +149,18 @@ pub fn apply_page(conn: &Connection, page: &wire::ChangesPage) -> rusqlite::Resu summary.notes_applied += 1; } + // After the notes, never before: a page can carry a note AND its revocation only + // when the revocation is the newer of the two (sharing again deletes an older + // one on the server), so the revocation is the one that has to win. Only a note + // someone else owns can be revoked; this account's own are never touched. + for id in &page.revoked { + let removed = tx.execute( + "DELETE FROM notes WHERE id = ?1 AND permission <> 'owner'", + params![id], + )?; + summary.notes_deleted += removed; + } + state::set_cursor(&tx, page.cursor)?; tx.commit()?; Ok(summary) @@ -239,23 +251,38 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> { }; // `created_at` is deliberately absent from the UPDATE clause: a note's birth time // never changes, and the server's copy is the same value anyway. + // A server without `shares` sends no permission, and every note it sends is ours. + let permission = match note.permission.as_deref() { + Some("edit") => "edit", + Some("view") => "view", + _ => "owner", + }; + let (shared_by_id, shared_by_name) = match ¬e.shared_by { + Some(by) => (Some(by.id.as_str()), Some(by.display_name.as_str())), + None => (None, None), + }; conn.execute( "INSERT INTO notes (id, body, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, - sync_revision, trashed_at, dirty) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0) + sync_revision, trashed_at, dirty, + permission, shared, shared_by_id, shared_by_name) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0, ?13, ?14, ?15, ?16) ON CONFLICT(id) DO UPDATE SET - body = excluded.body, - position = excluded.position, - pinned = excluded.pinned, - archived = excluded.archived, - trashed = excluded.trashed, - remind_at = excluded.remind_at, - recurrence = excluded.recurrence, - updated_at = excluded.updated_at, - sync_revision = excluded.sync_revision, - trashed_at = excluded.trashed_at, - dirty = 0", + body = excluded.body, + position = excluded.position, + pinned = excluded.pinned, + archived = excluded.archived, + trashed = excluded.trashed, + remind_at = excluded.remind_at, + recurrence = excluded.recurrence, + updated_at = excluded.updated_at, + sync_revision = excluded.sync_revision, + trashed_at = excluded.trashed_at, + dirty = 0, + permission = excluded.permission, + shared = excluded.shared, + shared_by_id = excluded.shared_by_id, + shared_by_name = excluded.shared_by_name", params![ note.id, note.body, @@ -269,6 +296,10 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> { updated, note.sync_revision, trashed_at, + permission, + note.shared, + shared_by_id, + shared_by_name, ], )?; @@ -409,6 +440,7 @@ pub async fn run( blobs: &BlobStore, base_url: &str, token: &str, + shares: bool, ) -> Result { let mut total = PullSummary::default(); @@ -418,7 +450,7 @@ pub async fn run( state::read(&conn).map_err(|e| e.to_string())?.last_cursor }; - let page = client::fetch_changes(base_url, token, since).await?; + let page = client::fetch_changes(base_url, token, since, shares).await?; // Trust the data over the flag: a server that claims more pages without // advancing the cursor would spin this loop forever. @@ -505,6 +537,9 @@ mod tests { labels: vec![], attachments: vec![], previews: vec![], + permission: None, + shared: false, + shared_by: None, } } @@ -525,6 +560,7 @@ mod tests { labels, cursor, has_more: false, + revoked: vec![], } } @@ -565,6 +601,76 @@ mod tests { assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0); } + fn shared_note(id: &str, revision: i64, permission: &str) -> wire::Note { + let mut n = note(id, revision); + n.permission = Some(permission.into()); + n.shared = true; + n.shared_by = Some(wire::SharedBy { + id: "u-owner".into(), + display_name: "Robin".into(), + }); + n + } + + #[test] + fn a_shared_note_lands_saying_who_shared_it_and_how() { + let conn = db(); + apply_page(&conn, &page(vec![shared_note("n1", 1, "edit")], vec![], 1)).expect("apply"); + let held: (String, i64, String) = conn + .query_row( + "SELECT permission, shared, shared_by_name FROM notes WHERE id = 'n1'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .expect("row"); + assert_eq!(held, ("edit".to_string(), 1, "Robin".to_string())); + + // A server without shares sends no permission: the note is this account's own. + apply_page(&conn, &page(vec![note("n2", 2)], vec![], 2)).expect("apply"); + assert_eq!( + count( + &conn, + "SELECT COUNT(*) FROM notes WHERE id = 'n2' AND permission = 'owner'" + ), + 1 + ); + } + + #[test] + fn a_revoked_note_leaves_and_an_owned_one_never_does() { + let conn = db(); + apply_page( + &conn, + &page( + vec![shared_note("theirs", 1, "view"), note("mine", 2)], + vec![], + 2, + ), + ) + .expect("apply"); + let mut revoked = page(vec![], vec![], 3); + revoked.revoked = vec!["theirs".into(), "mine".into(), "unknown".into()]; + let summary = apply_page(&conn, &revoked).expect("apply"); + assert_eq!(summary.notes_deleted, 1); + let left: Vec = { + let mut stmt = conn.prepare("SELECT id FROM notes").unwrap(); + let rows = stmt.query_map([], |r| r.get(0)).unwrap(); + rows.collect::>().unwrap() + }; + assert_eq!(left, ["mine"]); + } + + #[test] + fn a_revocation_beside_its_note_in_one_page_wins() { + // The server deletes an older revocation when it shares again, so a page holds + // both only when the revocation is the newer: the note must not survive it. + let conn = db(); + let mut both = page(vec![shared_note("n1", 4, "view")], vec![], 5); + both.revoked = vec!["n1".into()]; + apply_page(&conn, &both).expect("apply"); + assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0); + } + #[test] fn trashed_is_not_a_tombstone() { // `trashed` is ordinary state that keeps syncing; only `purged_at` deletes. diff --git a/core/src/sync/push.rs b/core/src/sync/push.rs index 314d8e5..74ee7d1 100644 --- a/core/src/sync/push.rs +++ b/core/src/sync/push.rs @@ -236,8 +236,13 @@ fn collect_labels(conn: &Connection, out: &mut Vec, limit: usize) -> rus fn collect_notes(conn: &Connection, out: &mut Vec, limit: usize) -> rusqlite::Result<()> { let remaining = limit.saturating_sub(out.len()); let ids: Vec = { - 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::>>()? }; @@ -259,12 +264,14 @@ struct NoteRow { recurrence: Option, 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 { 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 { 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 { fn note_change(conn: &Connection, id: &str) -> rusqlite::Result { 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(), diff --git a/core/src/sync/sharing.rs b/core/src/sync/sharing.rs new file mode 100644 index 0000000..9755b1f --- /dev/null +++ b/core/src/sync/sharing.rs @@ -0,0 +1,124 @@ +//! Sharing a note from a linked device (#5175). +//! +//! Shares belong to the server: who is on the instance and who a note is shared +//! with are never kept here, so each call goes straight to the server over the +//! device token. The one thing kept locally is the note's `shared` flag, set from +//! the server's answer so the card's chip changes at once rather than at the next +//! sync (which brings the same value). + +use rusqlite::params; + +use super::client::{self, Member, NoteShare}; +use super::state; +use crate::local::Db; + +/// What an unlinked device says when asked to share. Sharing is between accounts on +/// a server, so there is nothing to do offline and nothing worth queueing. +pub const NEEDS_SERVER: &str = + "Sharing is between people on a server. Link this device to one in Sync to share notes."; + +fn link(db: &Db) -> Result<(String, String), String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + let link = state::read(&conn).map_err(|e| e.to_string())?; + match (link.server_url, link.device_token) { + (Some(url), Some(token)) => Ok((url, token)), + _ => Err(NEEDS_SERVER.to_string()), + } +} + +/// Set the local note's `shared` flag from the server's list of its shares. Not a +/// local edit, so it leaves `dirty` and `updated_at` alone. +fn mark_shared(db: &Db, note_id: &str, shares: &[NoteShare]) -> Result<(), String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + conn.execute( + "UPDATE notes SET shared = ?2 WHERE id = ?1 AND permission = 'owner'", + params![note_id, !shares.is_empty()], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +pub async fn directory(db: &Db) -> Result, String> { + let (url, token) = link(db)?; + client::directory(&url, &token).await +} + +pub async fn list(db: &Db, note_id: &str) -> Result, String> { + let (url, token) = link(db)?; + let shares = client::list_shares(&url, &token, note_id).await?; + mark_shared(db, note_id, &shares)?; + Ok(shares) +} + +pub async fn share( + db: &Db, + note_id: &str, + user_id: &str, + permission: &str, +) -> Result, String> { + let (url, token) = link(db)?; + let shares = client::share_note(&url, &token, note_id, user_id, permission).await?; + mark_shared(db, note_id, &shares)?; + Ok(shares) +} + +pub async fn unshare(db: &Db, note_id: &str, share_id: &str) -> Result, String> { + let (url, token) = link(db)?; + let shares = client::unshare_note(&url, &token, note_id, share_id).await?; + mark_shared(db, note_id, &shares)?; + Ok(shares) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::local::schema; + use rusqlite::Connection; + use std::sync::Mutex; + + fn db() -> Db { + let conn = Connection::open_in_memory().expect("in-memory db"); + schema::migrate(&conn).expect("migrate"); + Db(Mutex::new(conn)) + } + + #[test] + fn an_unlinked_device_explains_that_sharing_needs_a_server() { + assert_eq!(link(&db()).unwrap_err(), NEEDS_SERVER); + } + + #[test] + fn the_shared_flag_follows_the_servers_answer_without_dirtying_the_note() { + let db = db(); + { + let conn = db.0.lock().unwrap(); + conn.execute( + "INSERT INTO notes (id, body, created_at, updated_at, dirty) + VALUES ('n1', 'x', '2026-01-01', '2026-01-01', 0)", + [], + ) + .unwrap(); + } + let one = NoteShare { + id: "s1".into(), + member: Member { + id: "u2".into(), + display_name: "Sam".into(), + email: "sam@example.test".into(), + }, + permission: "view".into(), + created_at: None, + }; + let read = |db: &Db| -> (bool, i64) { + let conn = db.0.lock().unwrap(); + conn.query_row("SELECT shared, dirty FROM notes WHERE id = 'n1'", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .unwrap() + }; + mark_shared(&db, "n1", &[one]).unwrap(); + assert_eq!(read(&db), (true, 0)); + mark_shared(&db, "n1", &[]).unwrap(); + assert_eq!(read(&db), (false, 0)); + } +} diff --git a/core/src/sync/state.rs b/core/src/sync/state.rs index aa94fae..3d42340 100644 --- a/core/src/sync/state.rs +++ b/core/src/sync/state.rs @@ -94,15 +94,42 @@ pub fn set_link(conn: &Connection, server_url: &str, device_token: &str) -> rusq let keep_cursor = read(conn)?.server_url.as_deref() == Some(server_url); conn.execute( "UPDATE sync_state - SET server_url = ?1, - device_token = ?2, - last_cursor = CASE WHEN ?3 THEN last_cursor ELSE NULL END + SET server_url = ?1, + device_token = ?2, + last_cursor = CASE WHEN ?3 THEN last_cursor ELSE NULL END, + shares_synced = CASE WHEN ?3 THEN shares_synced ELSE 0 END WHERE id = 1", params![server_url, device_token, keep_cursor], )?; + if !keep_cursor { + forget_shared_notes(conn)?; + } Ok(()) } +/// Drop the notes other people shared with the account this device was linked to. +/// They were only ever here through that link: kept after it ends, they would sit +/// on the board as notes nobody here can edit and nothing would ever update. +fn forget_shared_notes(conn: &Connection) -> rusqlite::Result<()> { + conn.execute("DELETE FROM notes WHERE permission <> 'owner'", [])?; + Ok(()) +} + +/// Start the change feed over the first time this device pulls with shares (#5175). +/// +/// Notes shared before this build sit below the cursor the device already holds, so +/// without a restart they would only arrive once something next changed them. +/// Returns whether it restarted. Once per link: `set_link` to another server and +/// `clear_link` both reset the flag. +pub fn begin_shares(conn: &Connection) -> rusqlite::Result { + let restarted = conn.execute( + "UPDATE sync_state SET last_cursor = NULL, shares_synced = 1 + WHERE id = 1 AND shares_synced = 0", + [], + )?; + Ok(restarted > 0) +} + /// Forget the server entirely. /// /// Clears the cursor as well as the credentials: a cursor left behind would, on the @@ -111,11 +138,11 @@ pub fn clear_link(conn: &Connection) -> rusqlite::Result<()> { conn.execute( "UPDATE sync_state SET server_url = NULL, device_token = NULL, last_cursor = NULL, - last_sync_at = NULL, server_retention_days = NULL + last_sync_at = NULL, server_retention_days = NULL, shares_synced = 0 WHERE id = 1", [], )?; - Ok(()) + forget_shared_notes(conn) } /// Remember the linked server's trash-retention window (0 = it never purges). @@ -281,6 +308,50 @@ mod tests { assert!(state.device_token.is_none()); } + fn seed(conn: &Connection, id: &str, permission: &str) { + conn.execute( + "INSERT INTO notes (id, body, created_at, updated_at, permission) + VALUES (?1, 'x', '2026-01-01', '2026-01-01', ?2)", + params![id, permission], + ) + .expect("seed"); + } + + fn note_ids(conn: &Connection) -> Vec { + let mut stmt = conn.prepare("SELECT id FROM notes ORDER BY id").unwrap(); + let rows = stmt.query_map([], |r| r.get(0)).unwrap(); + rows.collect::>().unwrap() + } + + #[test] + fn unlinking_drops_the_notes_others_shared_and_keeps_our_own() { + let conn = db(); + set_link(&conn, "https://a.example.com", "tok-1").expect("link"); + seed(&conn, "mine", "owner"); + seed(&conn, "theirs", "view"); + seed(&conn, "editable", "edit"); + // Re-linking the same server keeps everything. + set_link(&conn, "https://a.example.com", "tok-2").expect("relink"); + assert_eq!(note_ids(&conn), ["editable", "mine", "theirs"]); + clear_link(&conn).expect("unlink"); + assert_eq!(note_ids(&conn), ["mine"]); + } + + #[test] + fn the_first_pull_with_shares_starts_over_once_per_link() { + let conn = db(); + set_link(&conn, "https://a.example.com", "tok-1").expect("link"); + set_cursor(&conn, 500).expect("cursor"); + assert!(begin_shares(&conn).expect("begin")); + assert_eq!(read(&conn).expect("read").last_cursor, 0); + set_cursor(&conn, 600).expect("cursor"); + assert!(!begin_shares(&conn).expect("again"), "only the first time"); + assert_eq!(read(&conn).expect("read").last_cursor, 600); + // Another server is a fresh start, shares included. + set_link(&conn, "https://b.example.com", "tok-2").expect("relink"); + assert!(begin_shares(&conn).expect("new server")); + } + #[test] fn unlink_clears_the_last_sync_stamp() { // Otherwise a freshly-linked server would claim it synced at a time that diff --git a/core/src/sync/wire.rs b/core/src/sync/wire.rs index 8ad2d77..d1dc94b 100644 --- a/core/src/sync/wire.rs +++ b/core/src/sync/wire.rs @@ -18,6 +18,11 @@ pub struct ChangesPage { pub cursor: i64, #[serde(default)] pub has_more: bool, + /// Notes shared with this account that stopped being shared, or that their owner + /// deleted (`shares`, protocol 6). The note no longer reaches this account's feed, + /// so this is the only word its devices get to delete their copy. + #[serde(default)] + pub revoked: Vec, } #[derive(Debug, Clone, Deserialize)] @@ -59,6 +64,23 @@ pub struct Note { pub attachments: Vec, #[serde(default)] pub previews: Vec, + /// How this account holds the note: `owner`, `edit` or `view` (`shares`). Absent + /// from a server without shares, where every note in the feed is the caller's own. + #[serde(default)] + pub permission: Option, + /// Whether the owner has shared it with anyone. + #[serde(default)] + pub shared: bool, + /// Who shared it with us; absent on our own notes. + #[serde(default)] + pub shared_by: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SharedBy { + pub id: String, + #[serde(default)] + pub display_name: String, } impl Note { diff --git a/desktop/src-tauri/src/commands/sync.rs b/desktop/src-tauri/src/commands/sync.rs index e188868..b6c6ebc 100644 --- a/desktop/src-tauri/src/commands/sync.rs +++ b/desktop/src-tauri/src/commands/sync.rs @@ -8,9 +8,11 @@ use tauri::{AppHandle, State}; use inkwell_core::local::Db; use inkwell_core::sync::client::{self, Identity, ProbeResult}; +use inkwell_core::sync::client::{Member, NoteShare}; use inkwell_core::sync::compat::Compatibility; use inkwell_core::sync::engine; use inkwell_core::sync::push; +use inkwell_core::sync::sharing; use inkwell_core::sync::state; use crate::autosync::{self, AutoSync, LastCycle}; @@ -184,3 +186,37 @@ pub fn sync_has_pending(db: State<'_, Db>) -> Result { let conn = db.0.lock().map_err(|e| e.to_string())?; push::has_pending(&conn).map_err(|e| e.to_string()) } + +// --- sharing (#5175) ----------------------------------------------------------- +// +// The Share dialog's calls, straight to the linked server. Unlinked, each answers +// `sharing::NEEDS_SERVER`, which the dialog shows as it is. + +#[tauri::command] +pub async fn shares_directory(db: State<'_, Db>) -> Result, String> { + sharing::directory(&db).await +} + +#[tauri::command] +pub async fn shares_list(note_id: String, db: State<'_, Db>) -> Result, String> { + sharing::list(&db, ¬e_id).await +} + +#[tauri::command] +pub async fn shares_share( + note_id: String, + user_id: String, + permission: String, + db: State<'_, Db>, +) -> Result, String> { + sharing::share(&db, ¬e_id, &user_id, &permission).await +} + +#[tauri::command] +pub async fn shares_unshare( + note_id: String, + share_id: String, + db: State<'_, Db>, +) -> Result, String> { + sharing::unshare(&db, ¬e_id, &share_id).await +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 71daf8b..0782323 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -213,6 +213,10 @@ pub fn run() { commands::sync::sync_now, commands::sync::sync_last, commands::sync::sync_has_pending, + commands::sync::shares_directory, + commands::sync::shares_list, + commands::sync::shares_share, + commands::sync::shares_unshare, update::update_channel_get, update::update_channel_set, update::update_check, diff --git a/frontend/src/adapters/local.ts b/frontend/src/adapters/local.ts index e5ccee6..851cb34 100644 --- a/frontend/src/adapters/local.ts +++ b/frontend/src/adapters/local.ts @@ -92,12 +92,12 @@ export const local: Repo = { rename: (id, name) => invoke("saved_filters_rename", { id, name }), }, - // Sharing is between accounts on a server; the desktop gets it with sync (#5175). - // The Share controls are not shown here, so these only answer a stray call. + // Sharing is between accounts on a server, so these go to the linked one (#5175). + // Unlinked, each rejects with the core's explanation, which the dialog shows. shares: { - directory: () => Promise.resolve([]), - list: () => Promise.resolve([]), - share: () => Promise.reject(new Error(NEEDS_SERVER)), - unshare: () => Promise.reject(new Error(NEEDS_SERVER)), + directory: () => invoke("shares_directory"), + list: (noteId) => invoke("shares_list", { noteId }), + share: (noteId, userId, permission) => invoke("shares_share", { noteId, userId, permission }), + unshare: (noteId, shareId) => invoke("shares_unshare", { noteId, shareId }), }, }; diff --git a/frontend/src/components/FilterBar.vue b/frontend/src/components/FilterBar.vue index f23d7f5..53d8922 100644 --- a/frontend/src/components/FilterBar.vue +++ b/frontend/src/components/FilterBar.vue @@ -9,7 +9,6 @@ import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets"; import { addLocalDays, formatLocalDay, parseLocalDate } from "../notes/datetime"; import Icon from "./Icon.vue"; import { errorMessage } from "../api/errors"; -import { isDesktop } from "../desktop/bridge"; // A dead-simple facet bar over the board: color + labels + has-reminder // + has-attachment + created-date range. The URL query IS the state, so a @@ -44,9 +43,6 @@ function toggleReminder() { function toggleAttachment() { patch({ has_attachment: facets.value.has_attachment ? undefined : true }); } -// Sharing is between accounts on a server, so the offline desktop has no shared notes -// to narrow to (#5174). -const sharingAvailable = !isDesktop(); function toggleShared() { patch({ shared: facets.value.shared ? undefined : "with_me" }); } @@ -137,7 +133,6 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b Has attachment