Files
inkwell/core/src/sync/wire.rs
T
bvandeusenandClaude Opus 5.5 aa36b43dc3
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
core: shared notes on the desktop and phone, and Share from the desktop
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>
2026-10-07 15:33:16 -04:00

166 lines
4.9 KiB
Rust

//! The delta-feed JSON shapes, exactly as `GET /api/sync/changes` sends them.
//!
//! Mirrors the server's serializers (`notes/serialize.py` + `serialize.py`) — see
//! `docs/sync.md` for the contract. Every field is `#[serde(default)]` or `Option`
//! so a NEWER server adding fields, or an older one omitting one, degrades to a
//! partial note rather than failing the whole page. Losing one attribute is
//! recoverable; refusing a page stalls sync permanently at that cursor.
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ChangesPage {
#[serde(default)]
pub notes: Vec<Note>,
#[serde(default)]
pub labels: Vec<Label>,
#[serde(default)]
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)]
pub struct Note {
pub id: String,
#[serde(default)]
pub body: String,
#[serde(default)]
pub position: i64,
#[serde(default)]
pub pinned: bool,
#[serde(default)]
pub archived: bool,
/// The server derives this from `deleted_at` — trash, NOT a tombstone.
#[serde(default)]
pub trashed: bool,
/// WHEN it was trashed. The trash-retention clock runs from here, so it has to be
/// the server's timestamp rather than anything this device invents. Absent from an
/// older server, which is why it's optional rather than required.
#[serde(default)]
pub deleted_at: Option<String>,
#[serde(default)]
pub remind_at: Option<String>,
#[serde(default)]
pub recurrence: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub sync_revision: i64,
/// Set means the row was permanently purged: a content-less tombstone whose only
/// job is to tell clients to delete their copy.
#[serde(default)]
pub purged_at: Option<String>,
#[serde(default)]
pub labels: Vec<NoteLabel>,
#[serde(default)]
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 {
pub fn is_tombstone(&self) -> bool {
self.purged_at.is_some()
}
}
/// A label as it appears attached to a note. Carries enough to materialize the label
/// row itself, which is what lets a membership be applied even if the label's own
/// delta hasn't arrived (see `pull::apply_page`).
#[derive(Debug, Clone, Deserialize)]
pub struct NoteLabel {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default = "default_color")]
pub color: String,
/// True when the membership came from a `#tag` in the body rather than a manual
/// assignment. Applied verbatim rather than re-derived — see `pull::apply_page`.
#[serde(default)]
pub via_tag: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Attachment {
pub id: String,
#[serde(default)]
pub url: String,
#[serde(default)]
pub filename: Option<String>,
#[serde(default = "default_mime")]
pub mime: String,
#[serde(default)]
pub size: Option<i64>,
#[serde(default)]
pub sha256: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Preview {
pub id: String,
#[serde(default)]
pub url: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub image_url: Option<String>,
#[serde(default)]
pub site_name: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Label {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default = "default_color")]
pub color: String,
#[serde(default)]
pub sync_revision: i64,
#[serde(default)]
pub purged_at: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
}
impl Label {
pub fn is_tombstone(&self) -> bool {
self.purged_at.is_some()
}
}
fn default_color() -> String {
"default".to_string()
}
fn default_mime() -> String {
"application/octet-stream".to_string()
}