CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m28s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m1s
Step 3 of M315, and the destructive half. Steps 1 and 2 stopped every read of this field: a card is one neutral surface per theme, and the only coloured thing on a board is a tag. What was left was a column written by a picker and read by nothing. Rule 22 — the old path comes out completely. No flag, no fallback, no "override if set". Server: the column, the `?color=` facet, the create/update/serialise paths, the sync assignment, the front-matter line, and Keep's colour map. Alembic 0029 drops it and sweeps `"color"` out of stored saved-filter params — a view that silently filtered on a field the app no longer has would return nothing and never say why. That sweep is Python, not `params::jsonb - 'color'`, because Postgres has no try-cast and one malformed blob would abort a migration that is running over somebody's saved views. `NOTE_COLORS` moves from `models/note.py` to `colors.py`. A palette defined on the model that lost one is an invitation to put the column back; labels still name a colour, so the vocabulary belongs where the normalizer already is. Core: the field, the facet, the `NoteCreateInput`, and every read and write in store/push/pull. Local schema v9 drops the column and does the same saved-filter sweep, guarded on `json_valid` so a corrupt blob loses a key rather than becoming NULL. The uniffi layer drops `NoteEdit::Color` and `NoteDraft.color` with it. Web: `ColorPicker.vue`, the per-card swatch popover and its stylesheet rule, the FilterBar colour row, the facet in the query round-trip, and the colour half of the editor's baseline-and-save. Android: the `ColorSheet`, the `Picker.COLOR` case, the toolbar's swatch dot, `EditorAction.SetColor`. ## The protocol: v4, and the floor deliberately stays at 3 Checked against `compat.rs` and the push handler rather than trusting the `#[serde(default)]` annotation, because the v2 precedent points the other way: v2 dropped `kind` and `title` and DID raise both floors, on the rule that dropping a field a client sends and expects back is breaking. `color` fails the second half of that test. A v3 client reading a v4 note gets `"default"` from its own serde default and draws the colour it derives locally — the board it drew yesterday. A v3 client pushing `color` has the key ignored, since `_assign_note_fields` reads its payload key by key and never validates the shape. Neither direction errors and neither shows anything wrong. `title` was the note's NAME; this is a field that no longer renders. So `SYNC_PROTOCOL_VERSION` and `CLIENT_PROTOCOL_VERSION` go to 4, and both floors stay at 3. `docs/sync.md` carries the reasoning and the per-version history, and its push example is brought back in line — it still listed `title`, `kind` and `items`, all gone before this. Import stays tolerant: a pre-M315 export or a Keep takeout carrying `color:` imports fine, the key simply read past. Old exports must still import. #3041 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
760 lines
23 KiB
Rust
760 lines
23 KiB
Rust
//! 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<String>,
|
|
pub remind_at: Option<String>,
|
|
pub recurrence: Option<String>,
|
|
pub labels: Vec<NoteLabel>,
|
|
pub items: Vec<ChecklistItem>,
|
|
pub attachments: Vec<Attachment>,
|
|
pub previews: Vec<LinkPreview>,
|
|
pub created_at: Option<String>,
|
|
pub updated_at: Option<String>,
|
|
}
|
|
|
|
/// 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<thoughtsync_core::local::derive::DerivedItem> 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<thoughtsync_core::local::derive::DerivedTag> 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<thoughtsync_core::sync::client::ClientRelease> 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<String>,
|
|
pub mime: String,
|
|
pub size: Option<i64>,
|
|
pub sha256: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, uniffi::Record)]
|
|
pub struct LinkPreview {
|
|
pub id: String,
|
|
pub url: String,
|
|
pub title: Option<String>,
|
|
pub description: Option<String>,
|
|
pub image_url: Option<String>,
|
|
pub site_name: Option<String>,
|
|
}
|
|
|
|
impl From<core_models::Note> 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<core_models::NoteLabel> 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<core_models::ChecklistItem> 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<core_models::Attachment> 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<core_models::LinkPreview> 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<i64>,
|
|
}
|
|
|
|
impl From<core_models::Label> 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<String>,
|
|
pub sort: Option<String>,
|
|
pub facets: Option<NoteFacets>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, uniffi::Record)]
|
|
pub struct NoteFacets {
|
|
pub q: Option<String>,
|
|
pub label: Option<Vec<String>>,
|
|
pub has_reminder: Option<bool>,
|
|
pub has_attachment: Option<bool>,
|
|
pub created_after: Option<String>,
|
|
pub created_before: Option<String>,
|
|
}
|
|
|
|
impl From<NoteQuery> 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<NoteFacets> 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<Vec<String>>,
|
|
}
|
|
|
|
impl From<NoteDraft> 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<NoteEdit>) -> 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<String>,
|
|
pub last_cursor: i64,
|
|
pub last_sync_at: Option<String>,
|
|
}
|
|
|
|
impl From<core_state::Status> 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<String>,
|
|
pub version: Option<String>,
|
|
pub trash_retention_days: Option<u32>,
|
|
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<String>,
|
|
},
|
|
/// 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<core_compat::Compatibility> 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<core_client::ProbeResult> 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<core_client::Identity> 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<core_client::RevokeOutcome> 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<String>,
|
|
}
|
|
|
|
#[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<core_push::PushSummary> 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<core_pull::PullSummary> 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<core_engine::SyncOutcome> 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());
|
|
}
|
|
}
|