AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
21 changed files with 996 additions and 53 deletions
Showing only changes of commit aa36b43dc3 - Show all commits
@@ -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";