AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
@@ -652,4 +652,7 @@ private fun blankDraft(): Note =
|
||||
previews = emptyList(),
|
||||
createdAt = null,
|
||||
updatedAt = null,
|
||||
permission = "owner",
|
||||
shared = false,
|
||||
sharedBy = null,
|
||||
)
|
||||
|
||||
+52
-3
@@ -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, ¬e_id)
|
||||
.await
|
||||
.map_err(CoreError::network)?;
|
||||
Ok(shares.into_iter().map(NoteShare::from).collect())
|
||||
}
|
||||
|
||||
/// Share with one member at "view" or "edit", or change their permission.
|
||||
pub async fn share_note(
|
||||
&self,
|
||||
note_id: String,
|
||||
user_id: String,
|
||||
permission: String,
|
||||
) -> Result<Vec<NoteShare>, CoreError> {
|
||||
self.credentials()?;
|
||||
let shares = sharing::share(&self.db, ¬e_id, &user_id, &permission)
|
||||
.await
|
||||
.map_err(CoreError::network)?;
|
||||
Ok(shares.into_iter().map(NoteShare::from).collect())
|
||||
}
|
||||
|
||||
pub async fn unshare_note(
|
||||
&self,
|
||||
note_id: String,
|
||||
share_id: String,
|
||||
) -> Result<Vec<NoteShare>, CoreError> {
|
||||
self.credentials()?;
|
||||
let shares = sharing::unshare(&self.db, ¬e_id, &share_id)
|
||||
.await
|
||||
.map_err(CoreError::network)?;
|
||||
Ok(shares.into_iter().map(NoteShare::from).collect())
|
||||
}
|
||||
}
|
||||
|
||||
// ── checklist text, as pure functions ───────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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()? {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -149,6 +149,18 @@ pub fn apply_page(conn: &Connection, page: &wire::ChangesPage) -> rusqlite::Resu
|
||||
summary.notes_applied += 1;
|
||||
}
|
||||
|
||||
// After the notes, never before: a page can carry a note AND its revocation only
|
||||
// when the revocation is the newer of the two (sharing again deletes an older
|
||||
// one on the server), so the revocation is the one that has to win. Only a note
|
||||
// someone else owns can be revoked; this account's own are never touched.
|
||||
for id in &page.revoked {
|
||||
let removed = tx.execute(
|
||||
"DELETE FROM notes WHERE id = ?1 AND permission <> 'owner'",
|
||||
params![id],
|
||||
)?;
|
||||
summary.notes_deleted += removed;
|
||||
}
|
||||
|
||||
state::set_cursor(&tx, page.cursor)?;
|
||||
tx.commit()?;
|
||||
Ok(summary)
|
||||
@@ -239,23 +251,38 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
};
|
||||
// `created_at` is deliberately absent from the UPDATE clause: a note's birth time
|
||||
// never changes, and the server's copy is the same value anyway.
|
||||
// A server without `shares` sends no permission, and every note it sends is ours.
|
||||
let permission = match note.permission.as_deref() {
|
||||
Some("edit") => "edit",
|
||||
Some("view") => "view",
|
||||
_ => "owner",
|
||||
};
|
||||
let (shared_by_id, shared_by_name) = match ¬e.shared_by {
|
||||
Some(by) => (Some(by.id.as_str()), Some(by.display_name.as_str())),
|
||||
None => (None, None),
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, body, position, pinned, archived,
|
||||
trashed, remind_at, recurrence, created_at, updated_at,
|
||||
sync_revision, trashed_at, dirty)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0)
|
||||
sync_revision, trashed_at, dirty,
|
||||
permission, shared, shared_by_id, shared_by_name)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0, ?13, ?14, ?15, ?16)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
body = excluded.body,
|
||||
position = excluded.position,
|
||||
pinned = excluded.pinned,
|
||||
archived = excluded.archived,
|
||||
trashed = excluded.trashed,
|
||||
remind_at = excluded.remind_at,
|
||||
recurrence = excluded.recurrence,
|
||||
updated_at = excluded.updated_at,
|
||||
sync_revision = excluded.sync_revision,
|
||||
trashed_at = excluded.trashed_at,
|
||||
dirty = 0",
|
||||
body = excluded.body,
|
||||
position = excluded.position,
|
||||
pinned = excluded.pinned,
|
||||
archived = excluded.archived,
|
||||
trashed = excluded.trashed,
|
||||
remind_at = excluded.remind_at,
|
||||
recurrence = excluded.recurrence,
|
||||
updated_at = excluded.updated_at,
|
||||
sync_revision = excluded.sync_revision,
|
||||
trashed_at = excluded.trashed_at,
|
||||
dirty = 0,
|
||||
permission = excluded.permission,
|
||||
shared = excluded.shared,
|
||||
shared_by_id = excluded.shared_by_id,
|
||||
shared_by_name = excluded.shared_by_name",
|
||||
params![
|
||||
note.id,
|
||||
note.body,
|
||||
@@ -269,6 +296,10 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
updated,
|
||||
note.sync_revision,
|
||||
trashed_at,
|
||||
permission,
|
||||
note.shared,
|
||||
shared_by_id,
|
||||
shared_by_name,
|
||||
],
|
||||
)?;
|
||||
|
||||
@@ -409,6 +440,7 @@ pub async fn run(
|
||||
blobs: &BlobStore,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
shares: bool,
|
||||
) -> Result<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
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ¬e_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, ¬e_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, ¬e_id, &share_id).await
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user