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
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:
+223
-7
@@ -142,7 +142,8 @@ fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<LinkP
|
||||
|
||||
fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
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<Note> {
|
||||
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<String>>("shared_by_id")? {
|
||||
Some(id) => Some(SharedBy {
|
||||
id,
|
||||
display_name: r
|
||||
.get::<_, Option<String>>("shared_by_name")?
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
None => None,
|
||||
},
|
||||
})
|
||||
},
|
||||
)?;
|
||||
@@ -178,6 +190,58 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<Vec<Note>> {
|
||||
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<Vec<Note
|
||||
if f.has_attachment == Some(true) {
|
||||
sql.push_str(" AND EXISTS (SELECT 1 FROM attachments a WHERE a.note_id = notes.id)");
|
||||
}
|
||||
if f.shared.as_deref() == Some("with_me") {
|
||||
sql.push_str(" AND permission <> '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<Note> {
|
||||
pub fn reminders(conn: &Connection) -> rusqlite::Result<Vec<Note>> {
|
||||
let ids: Vec<String> = {
|
||||
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::<rusqlite::Result<Vec<String>>>()?
|
||||
};
|
||||
@@ -383,7 +454,9 @@ pub fn reminders(conn: &Connection) -> rusqlite::Result<Vec<Note>> {
|
||||
pub fn due_reminders(conn: &Connection, now_ms: i64) -> rusqlite::Result<Vec<DueReminder>> {
|
||||
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::<rusqlite::Result<_>>()?
|
||||
@@ -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<Note> {
|
||||
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<Note>
|
||||
}
|
||||
|
||||
pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::Result<Note> {
|
||||
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<Note> {
|
||||
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<Note> {
|
||||
require_owner(conn, id)?;
|
||||
let uploaded: Option<bool> = 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<Note> {
|
||||
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<Note> {
|
||||
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<Note> {
|
||||
}
|
||||
|
||||
pub fn restore(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
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<Note> {
|
||||
}
|
||||
|
||||
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<Vec<NoteRevisi
|
||||
}
|
||||
|
||||
pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::Result<Note> {
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user