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
+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 {