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
@@ -652,4 +652,7 @@ private fun blankDraft(): Note =
previews = emptyList(),
createdAt = null,
updatedAt = null,
permission = "owner",
shared = false,
sharedBy = null,
)
+52 -3
View File
@@ -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<Vec<Member>, 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<Vec<NoteShare>, CoreError> {
self.credentials()?;
let shares = sharing::list(&self.db, &note_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<Vec<NoteShare>, CoreError> {
self.credentials()?;
let shares = sharing::share(&self.db, &note_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<Vec<NoteShare>, CoreError> {
self.credentials()?;
let shares = sharing::unshare(&self.db, &note_id, &share_id)
.await
.map_err(CoreError::network)?;
Ok(shares.into_iter().map(NoteShare::from).collect())
}
}
// ── checklist text, as pure functions ───────────────────────────────────────
+75
View File
@@ -46,6 +46,20 @@ pub struct Note {
pub previews: Vec<LinkPreview>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
/// 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<SharedBy>,
}
/// 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<core_models::Note> for Note {
previews,
created_at,
updated_at,
permission,
shared,
shared_by,
} = value;
Note {
id,
@@ -219,6 +236,12 @@ impl From<core_models::Note> 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<bool>,
pub created_after: Option<String>,
pub created_before: Option<String>,
/// "with_me": only notes someone else shared with this account.
pub shared: Option<String>,
}
impl From<NoteQuery> for core_models::ListQuery {
@@ -378,6 +403,7 @@ impl From<NoteFacets> for core_models::Facets {
has_attachment,
created_after,
created_before,
shared,
} = value;
core_models::Facets {
q,
@@ -386,6 +412,7 @@ impl From<NoteFacets> for core_models::Facets {
has_attachment,
created_after,
created_before,
shared,
}
}
}
@@ -586,6 +613,54 @@ impl From<core_client::Identity> 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<core_client::Member> 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<core_client::NoteShare> 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
+17
View File
@@ -28,6 +28,20 @@ pub struct Note {
pub previews: Vec<LinkPreview>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
/// 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<SharedBy>,
}
#[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<String>,
#[serde(default)]
pub created_before: Option<String>,
/// `with_me`: only notes someone else shared with this account.
#[serde(default)]
pub shared: Option<String>,
}
+2 -1
View File
@@ -37,7 +37,8 @@ pub fn sweep_expired_trash(
let mut expired: Vec<String> = 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()? {
+25 -1
View File
@@ -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
+223 -7
View File
@@ -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]
+125 -1
View File
@@ -265,8 +265,15 @@ pub async fn fetch_changes(
base_url: &str,
token: &str,
since: i64,
shares: bool,
) -> Result<wire::ChangesPage, String> {
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<T: Serialize>(
.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<String>,
}
#[derive(Deserialize)]
struct MembersReply {
members: Vec<Member>,
}
#[derive(Deserialize)]
struct SharesReply {
shares: Vec<NoteShare>,
}
/// 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<T: serde::de::DeserializeOwned>(
base_url: &str,
request: RequestBuilder,
) -> Result<T, String> {
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::<serde_json::Value>()
.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<Vec<Member>, 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<Vec<NoteShare>, 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<Vec<NoteShare>, 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<Vec<NoteShare>, 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")
+11 -1
View File
@@ -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
+2
View File
@@ -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;
+120 -14
View File
@@ -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 &note.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<PullSummary, String> {
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<String> = {
let mut stmt = conn.prepare("SELECT id FROM notes").unwrap();
let rows = stmt.query_map([], |r| r.get(0)).unwrap();
rows.collect::<rusqlite::Result<_>>().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.
+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(),
+124
View File
@@ -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<Vec<Member>, String> {
let (url, token) = link(db)?;
client::directory(&url, &token).await
}
pub async fn list(db: &Db, note_id: &str) -> Result<Vec<NoteShare>, 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<Vec<NoteShare>, 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<Vec<NoteShare>, 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));
}
}
+76 -5
View File
@@ -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<bool> {
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<String> {
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::<rusqlite::Result<_>>().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
+22
View File
@@ -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<String>,
}
#[derive(Debug, Clone, Deserialize)]
@@ -59,6 +64,23 @@ pub struct Note {
pub attachments: Vec<Attachment>,
#[serde(default)]
pub previews: Vec<Preview>,
/// 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<String>,
/// 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<SharedBy>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SharedBy {
pub id: String,
#[serde(default)]
pub display_name: String,
}
impl Note {
+36
View File
@@ -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<bool, String> {
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<Vec<Member>, String> {
sharing::directory(&db).await
}
#[tauri::command]
pub async fn shares_list(note_id: String, db: State<'_, Db>) -> Result<Vec<NoteShare>, String> {
sharing::list(&db, &note_id).await
}
#[tauri::command]
pub async fn shares_share(
note_id: String,
user_id: String,
permission: String,
db: State<'_, Db>,
) -> Result<Vec<NoteShare>, String> {
sharing::share(&db, &note_id, &user_id, &permission).await
}
#[tauri::command]
pub async fn shares_unshare(
note_id: String,
share_id: String,
db: State<'_, Db>,
) -> Result<Vec<NoteShare>, String> {
sharing::unshare(&db, &note_id, &share_id).await
}
+4
View File
@@ -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,
+6 -6
View File
@@ -92,12 +92,12 @@ export const local: Repo = {
rename: (id, name) => invoke<SavedFilter>("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<Member[]>([]),
list: () => Promise.resolve<NoteShare[]>([]),
share: () => Promise.reject<NoteShare[]>(new Error(NEEDS_SERVER)),
unshare: () => Promise.reject<NoteShare[]>(new Error(NEEDS_SERVER)),
directory: () => invoke<Member[]>("shares_directory"),
list: (noteId) => invoke<NoteShare[]>("shares_list", { noteId }),
share: (noteId, userId, permission) => invoke<NoteShare[]>("shares_share", { noteId, userId, permission }),
unshare: (noteId, shareId) => invoke<NoteShare[]>("shares_unshare", { noteId, shareId }),
},
};
-5
View File
@@ -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
</button>
<button
v-if="sharingAvailable"
type="button"
:class="[chipBase, facets.shared ? chipOn : chipOff]"
@click="toggleShared"
+4 -4
View File
@@ -18,7 +18,6 @@ import { formatReminder, formatTrashCountdown, isOverdue, trashDaysLeft } from "
import { useConfigStore } from "../stores/config";
import { useUiStore } from "../stores/ui";
import { canEditText, isOwnNote } from "../notes/sharing";
import { isDesktop } from "../desktop/bridge";
const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>();
const emit = defineEmits<{
@@ -38,11 +37,12 @@ const config = useConfigStore();
const ui = useUiStore();
// --- Sharing (#5174). Someone else's note shows who shared it and offers none of
// the owner's controls; at `view` its checkboxes are inert too. Sharing needs a
// server, so the offline desktop has no Share button (its notes are all its own). ---
// the owner's controls; at `view` its checkboxes are inert too. The desktop offers
// Share as well: linked it goes through the server, unlinked the dialog says sharing
// needs one (#5175). ---
const own = computed(() => isOwnNote(props.note));
const editable = computed(() => canEditText(props.note));
const canShare = computed(() => own.value && !isDesktop());
const canShare = own;
const sharedLine = computed(() => {
if (!own.value) {
const who = props.note.shared_by?.display_name || "someone";
+3 -2
View File
@@ -13,7 +13,6 @@ import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
import { labelChipClasses } from "../notes/colors";
import { canEditText, isOwnNote } from "../notes/sharing";
import { useUiStore } from "../stores/ui";
import { isDesktop } from "../desktop/bridge";
import {
afterEnter,
type EditorBlock,
@@ -123,7 +122,9 @@ const bodyPlaceholder = "Take a note…";
const ui = useUiStore();
const own = computed(() => isOwnNote(liveNote.value));
const editable = computed(() => canEditText(liveNote.value));
const canShare = computed(() => own.value && !isCreate.value && !isDesktop());
// On the desktop too: linked, it shares through the server; unlinked, the dialog
// says sharing needs one (#5175).
const canShare = computed(() => own.value && !isCreate.value);
const sharedLine = computed(() => {
if (own.value) return "";
const who = liveNote.value.shared_by?.display_name || "someone";