//! The types that cross into Kotlin. //! //! These MIRROR `thoughtsync_core::local::models` rather than reusing it. The core's //! shapes are serde structs whose field names and optionality are contracted with the //! shared Vue frontend; hanging uniffi derives on them would couple two very //! different consumers to one definition and put a `serde_json::Value` (which has no //! uniffi representation) in the middle of it. //! //! The cost of mirroring is drift — an Android client quietly missing a field the //! desktop gained. Every conversion below therefore DESTRUCTURES the core struct //! exhaustively instead of reading fields it cares about. Add a field to //! `core::local::models::Note` and this file stops compiling until Android is told //! what to do with it. That is the entire reason for the `let Core { .. } = value` //! style here; please keep it. use thoughtsync_core::local::models as core_models; use thoughtsync_core::sync::client as core_client; use thoughtsync_core::sync::compat as core_compat; use thoughtsync_core::sync::engine as core_engine; use thoughtsync_core::sync::pull as core_pull; use thoughtsync_core::sync::push as core_push; use thoughtsync_core::sync::state as core_state; /// A note, with everything needed to render a card or open the editor. /// /// Timestamps are RFC3339 strings, not a date type: that is what SQLite holds and /// what the server speaks, and converting here would mean this layer picking a /// calendar/timezone policy that belongs to the UI. #[derive(Debug, Clone, uniffi::Record)] pub struct Note { pub id: String, /// The note's NAME: its first non-blank body line, else its first checklist item. /// Always present. Derived by the core, never stored. pub display_title: String, pub body: String, pub position: i64, pub pinned: bool, pub archived: bool, pub trashed: bool, pub deleted_at: Option, pub remind_at: Option, pub recurrence: Option, pub labels: Vec, pub items: Vec, pub attachments: Vec, pub previews: Vec, pub created_at: Option, pub updated_at: Option, } /// A checklist item as it sits in a note's body. /// /// Mirrors `derive::DerivedItem`. Carries the LINE because every renderer that walks /// a body line by line needs the text, the state and the position together — the card /// to draw a box in the right place, the block editor to know where one block ends. #[derive(Debug, Clone, uniffi::Record)] pub struct BodyItem { pub line: u32, pub text: String, pub checked: bool, } impl From for BodyItem { fn from(i: thoughtsync_core::local::derive::DerivedItem) -> Self { let thoughtsync_core::local::derive::DerivedItem { text, checked, line, } = i; BodyItem { line, text, checked, } } } /// One `#tag` and where it sits in a note's body. /// /// Mirrors `derive::DerivedTag`. The card colours the tag where it was typed rather /// than repeating it as a chip, so it needs the SPAN — and the offsets are UTF-16 /// code units precisely because Kotlin's `AnnotatedString` counts that way. #[derive(Debug, Clone, uniffi::Record)] pub struct BodyTag { pub line: u32, pub start: u32, pub end: u32, pub name: String, } impl From for BodyTag { fn from(t: thoughtsync_core::local::derive::DerivedTag) -> Self { let thoughtsync_core::local::derive::DerivedTag { line, start, end, name, } = t; BodyTag { line, start, end, name, } } } /// An Android build the linked server is offering, already judged to be newer. /// /// A mirror rather than a re-export of `client::ClientRelease`, for the same /// reason every other record here is one: the core's shapes are contracted with /// other consumers, and `url` in particular is an implementation detail of how /// the download is fetched — the app never needs it, because it asks the core to /// do the downloading. #[derive(Debug, Clone, uniffi::Record)] pub struct ClientUpdate { /// For people to read. pub version: String, /// For machines to compare. pub version_code: i64, pub size: i64, } impl From for ClientUpdate { fn from(r: thoughtsync_core::sync::client::ClientRelease) -> Self { // Destructured exhaustively, like every other conversion in this file: a // field added upstream stops this compiling until Android is told what to // do with it, which turns silent drift into a build error. let thoughtsync_core::sync::client::ClientRelease { version, version_code, size, sha256: _, url: _, } = r; ClientUpdate { version, version_code, size, } } } #[derive(Debug, Clone, uniffi::Record)] pub struct NoteLabel { pub id: String, pub name: String, pub color: String, /// True when attached because of a `#tag` in the body, so the UI can show it is /// owned by the text and not independently removable. pub via_tag: bool, } #[derive(Debug, Clone, uniffi::Record)] pub struct ChecklistItem { pub id: String, pub text: String, pub checked: bool, pub position: i64, } #[derive(Debug, Clone, uniffi::Record)] pub struct Attachment { pub id: String, pub url: String, pub filename: Option, pub mime: String, pub size: Option, pub sha256: Option, } #[derive(Debug, Clone, uniffi::Record)] pub struct LinkPreview { pub id: String, pub url: String, pub title: Option, pub description: Option, pub image_url: Option, pub site_name: Option, } impl From for Note { fn from(value: core_models::Note) -> Self { // Exhaustive on purpose — see the module header. let core_models::Note { id, display_title, body, position, pinned, archived, trashed, deleted_at, remind_at, recurrence, labels, items, attachments, previews, created_at, updated_at, } = value; Note { id, display_title, body, position, pinned, archived, trashed, deleted_at, remind_at, recurrence, labels: labels.into_iter().map(NoteLabel::from).collect(), items: items.into_iter().map(ChecklistItem::from).collect(), attachments: attachments.into_iter().map(Attachment::from).collect(), previews: previews.into_iter().map(LinkPreview::from).collect(), created_at, updated_at, } } } impl From for NoteLabel { fn from(value: core_models::NoteLabel) -> Self { let core_models::NoteLabel { id, name, color, via_tag, } = value; NoteLabel { id, name, color, via_tag, } } } impl From for ChecklistItem { fn from(value: core_models::ChecklistItem) -> Self { let core_models::ChecklistItem { id, text, checked, position, } = value; ChecklistItem { id, text, checked, position, } } } impl From for Attachment { fn from(value: core_models::Attachment) -> Self { let core_models::Attachment { id, url, filename, mime, size, sha256, } = value; Attachment { id, url, filename, mime, size, sha256, } } } impl From for LinkPreview { fn from(value: core_models::LinkPreview) -> Self { let core_models::LinkPreview { id, url, title, description, image_url, site_name, } = value; LinkPreview { id, url, title, description, image_url, site_name, } } } /// A label, as the sidebar lists them. #[derive(Debug, Clone, uniffi::Record)] pub struct Label { pub id: String, pub name: String, /// Same colour vocabulary as notes, so one palette serves both. pub color: String, /// How many notes carry it. Only populated in listings — `None` elsewhere, /// matching the REST single-label responses. pub count: Option, } impl From for Label { fn from(value: core_models::Label) -> Self { let core_models::Label { id, name, color, count, } = value; Label { id, name, color, count, } } } // ───────────────────────────── queries and edits ───────────────────────────── /// What the board is asking for. Mirrors the core's `ListQuery`. #[derive(Debug, Clone, uniffi::Record)] pub struct NoteQuery { /// "notes" | "archive" | "trash" | "reminders" | "labels" — the core validates. pub view: String, pub label_id: Option, pub sort: Option, pub facets: Option, } #[derive(Debug, Clone, uniffi::Record)] pub struct NoteFacets { pub q: Option, pub label: Option>, pub has_reminder: Option, pub has_attachment: Option, pub created_after: Option, pub created_before: Option, } impl From for core_models::ListQuery { fn from(value: NoteQuery) -> Self { let NoteQuery { view, label_id, sort, facets, } = value; core_models::ListQuery { view, label_id, sort, facets: facets.map(core_models::Facets::from), } } } impl From for core_models::Facets { fn from(value: NoteFacets) -> Self { let NoteFacets { q, label, has_reminder, has_attachment, created_after, created_before, } = value; core_models::Facets { q, label, has_reminder, has_attachment, created_after, created_before, } } } /// A new note. #[derive(Debug, Clone, uniffi::Record)] pub struct NoteDraft { pub body: String, /// Checklist lines. A note can carry both a body and items (M13 step 2), so this /// is not an alternative to `body` — it is an addition to it. pub items: Option>, } impl From for core_models::NoteCreateInput { fn from(value: NoteDraft) -> Self { let NoteDraft { body, items } = value; core_models::NoteCreateInput { body, items } } } /// One field-level change to a note. /// /// A LIST of these rather than a struct of optional fields, because the core's patch /// semantics distinguish three states — leave alone, set to a value, and clear to /// null — and Kotlin has no way to express the third with a nullable field. /// `remindAt: null` in a data class is indistinguishable from `remindAt` unset, so /// the editor could never clear a reminder. Explicit `Clear*` variants say it out /// loud, and Kotlin gets a sealed class it can `when` over exhaustively. #[derive(Debug, Clone, uniffi::Enum)] pub enum NoteEdit { Body { value: String }, Pinned { value: bool }, Archived { value: bool }, RemindAt { value: String }, ClearRemindAt, Recurrence { value: String }, ClearRecurrence, } impl NoteEdit { /// The (key, value) pair this edit contributes to the core's JSON patch. /// /// The core reads a patch object where a present key means "change this" and a /// null value means "clear it" — the shape the REST API and the Tauri commands /// both already speak. Translating here keeps that one patch format in one /// place instead of teaching a second dialect to the store. fn entry(self) -> (&'static str, serde_json::Value) { use serde_json::Value; match self { NoteEdit::Body { value } => ("body", Value::String(value)), NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)), NoteEdit::Archived { value } => ("archived", Value::Bool(value)), NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)), NoteEdit::ClearRemindAt => ("remind_at", Value::Null), NoteEdit::Recurrence { value } => ("recurrence", Value::String(value)), NoteEdit::ClearRecurrence => ("recurrence", Value::Null), } } } /// Fold a list of edits into the single patch object the store applies. /// /// Later edits win on a repeated key, which is what a caller batching "set a /// reminder, then clear it" would expect. pub fn patch_from(edits: Vec) -> serde_json::Value { let mut map = serde_json::Map::new(); for edit in edits { let (key, value) = edit.entry(); map.insert(key.to_string(), value); } serde_json::Value::Object(map) } // ───────────────────────────────── sync ───────────────────────────────── /// What the UI may know about the link. Carries no device token, deliberately — /// the core withholds it from `Status` for the same reason, and a bearer token has /// no business in UI state. #[derive(Debug, Clone, uniffi::Record)] pub struct SyncStatus { pub linked: bool, pub server_url: Option, pub last_cursor: i64, pub last_sync_at: Option, } impl From for SyncStatus { fn from(value: core_state::Status) -> Self { let core_state::Status { linked, server_url, last_cursor, last_sync_at, } = value; SyncStatus { linked, server_url, last_cursor, last_sync_at, } } } /// What a server said about itself, before committing to anything. #[derive(Debug, Clone, uniffi::Record)] pub struct ProbeResult { /// Normalised by the core — this, not what the user typed, is what gets stored. pub base_url: String, pub site_name: Option, pub version: Option, pub trash_retention_days: Option, pub compatibility: Compatibility, } /// Whether this client and that server can sync at all. #[derive(Debug, Clone, uniffi::Enum)] pub enum Compatibility { Ok, /// Safe to sync, but these named capabilities are missing. The UI should say so /// rather than let a feature silently do nothing. Degraded { unavailable: Vec, }, /// Do not sync. `client_must_update` says which side can fix it, so the message /// can be actionable. Incompatible { reason: String, client_must_update: bool, }, } impl From for Compatibility { fn from(value: core_compat::Compatibility) -> Self { match value { core_compat::Compatibility::Ok => Compatibility::Ok, core_compat::Compatibility::Degraded { unavailable } => { Compatibility::Degraded { unavailable } } core_compat::Compatibility::Incompatible { reason, client_must_update, } => Compatibility::Incompatible { reason, client_must_update, }, } } } impl From for ProbeResult { fn from(value: core_client::ProbeResult) -> Self { let core_client::ProbeResult { base_url, server, compatibility, } = value; let core_compat::ServerInfo { site_name, version, // Protocol numbers are the raw material of the compatibility verdict, // which is already carried above in a form the UI can act on. Sending // them too would invite a second, worse judgement being made in Kotlin. sync_protocol_version: _, min_client_protocol_version: _, sync_features: _, trash_retention_days, } = server; ProbeResult { base_url, site_name, version, trash_retention_days, compatibility: compatibility.into(), } } } /// Who the server thinks this device belongs to. #[derive(Debug, Clone, uniffi::Record)] pub struct Identity { pub id: String, pub email: String, pub display_name: String, } impl From for Identity { fn from(value: core_client::Identity) -> Self { let core_client::Identity { id, email, display_name, } = value; Identity { id, email, display_name, } } } /// 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 /// remote half may not — someone unlinking a machine they are selling deserves to be /// told plainly that the token is still live. #[derive(Debug, Clone, uniffi::Enum)] pub enum RevokeOutcome { Revoked, /// This server predates the self-revoke route. Only the web app can retire it. Unsupported, Failed { reason: String, }, /// Nothing to revoke; the app wasn't linked. Skipped, } impl From for RevokeOutcome { fn from(value: core_client::RevokeOutcome) -> Self { match value { core_client::RevokeOutcome::Revoked => RevokeOutcome::Revoked, core_client::RevokeOutcome::Unsupported => RevokeOutcome::Unsupported, core_client::RevokeOutcome::Failed { reason } => RevokeOutcome::Failed { reason }, core_client::RevokeOutcome::Skipped => RevokeOutcome::Skipped, } } } /// The result of one full push-then-pull cycle. #[derive(Debug, Clone, uniffi::Record)] pub struct SyncOutcome { pub push: PushSummary, pub pull: PullSummary, /// The state after the cycle, so the UI refreshes from one call rather than /// following every sync with a status query. pub status: SyncStatus, } /// Counts are `u64` because the core uses `usize`, which has no uniffi /// representation. Widening is lossless on every target we build for; narrowing to /// u32 would be a silent truncation waiting for a very large sync. #[derive(Debug, Clone, uniffi::Record)] pub struct PushSummary { pub batches: u64, pub sent: u64, pub created: u64, pub applied: u64, /// The server had a newer edit and kept it. Not a failure — the local row stops /// being dirty and the following pull adopts the server's version. pub kept: u64, pub noop: u64, /// Still dirty, and surfaced: these need a human (a duplicate label name is the /// realistic case). Silently retrying forever would be the wrong shape. pub rejected: u64, pub errors: Vec, } #[derive(Debug, Clone, uniffi::Record)] pub struct PullSummary { pub pages: u64, pub notes_applied: u64, pub notes_deleted: u64, pub labels_applied: u64, pub labels_deleted: u64, pub cursor: i64, /// Rows that still held unpushed local edits when the server's version landed on /// top. Should be 0 in a normal cycle, because push runs first; anything higher /// means local work was overwritten, which is worth saying out loud. pub clobbered_dirty: u64, pub blobs_downloaded: u64, /// Attachments whose bytes couldn't be fetched or failed verification. Counted /// rather than fatal. pub blobs_failed: u64, } impl From for PushSummary { fn from(value: core_push::PushSummary) -> Self { let core_push::PushSummary { batches, sent, created, applied, kept, noop, rejected, errors, } = value; PushSummary { batches: batches as u64, sent: sent as u64, created: created as u64, applied: applied as u64, kept: kept as u64, noop: noop as u64, rejected: rejected as u64, errors, } } } impl From for PullSummary { fn from(value: core_pull::PullSummary) -> Self { let core_pull::PullSummary { pages, notes_applied, notes_deleted, labels_applied, labels_deleted, cursor, clobbered_dirty, blobs_downloaded, blobs_failed, } = value; PullSummary { pages: pages as u64, notes_applied: notes_applied as u64, notes_deleted: notes_deleted as u64, labels_applied: labels_applied as u64, labels_deleted: labels_deleted as u64, cursor, clobbered_dirty: clobbered_dirty as u64, blobs_downloaded: blobs_downloaded as u64, blobs_failed: blobs_failed as u64, } } } impl From for SyncOutcome { fn from(value: core_engine::SyncOutcome) -> Self { let core_engine::SyncOutcome { push, pull, status } = value; SyncOutcome { push: push.into(), pull: pull.into(), status: status.into(), } } } #[cfg(test)] mod tests { use super::*; #[test] fn a_set_and_a_clear_are_different_patch_entries() { let set = patch_from(vec![NoteEdit::RemindAt { value: "2026-01-01T00:00:00Z".to_string(), }]); assert_eq!(set["remind_at"], serde_json::json!("2026-01-01T00:00:00Z")); let cleared = patch_from(vec![NoteEdit::ClearRemindAt]); assert!( cleared["remind_at"].is_null(), "a clear must reach the store as JSON null — an absent key means \ 'leave alone', which is a different instruction" ); } #[test] fn an_empty_edit_list_is_an_empty_patch() { // Not merely tidy: the core rejects a non-object patch, and a UI that // batches edits may well end up sending none. assert_eq!(patch_from(vec![]), serde_json::json!({})); } #[test] fn later_edits_win_on_a_repeated_field() { let patch = patch_from(vec![ NoteEdit::RemindAt { value: "2026-01-01T00:00:00Z".to_string(), }, NoteEdit::ClearRemindAt, ]); assert!(patch["remind_at"].is_null()); } }