CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME stays — search results, export filenames and the command palette all need one — but nothing is typed into it any more. `display_title` is now the first non-empty line of the body, falling back to the first checklist item. That fallback is what step 2 bought, and the reason this could not go first: a checklist had no body to be named from, so the title was its only name. Now every note has a body, and a note that is only a checklist is named by its first item. Gone everywhere: the column and note_revisions.title (0026), the field on the core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7), `normalize_title`, the wire field, the FFI record and `NoteEdit::Title` / `ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and the Android title field in both the compose sheet and the editor. **The search vector had to be rebuilt, not just left alone.** `notes.search_vector` is a STORED GENERATED column whose expression names `title` — Postgres refuses to drop a column another generated column depends on. It is dropped and recreated over `display_title` at weight A, which keeps the original intent: a note's NAME ranks above the rest of its body. **An imported title becomes the note's first body line.** Keep notes carry one, and so does any ThoughtSync export taken before this. Dropping it would silently lose text someone wrote; folding it in puts it exactly where a name now lives, so the note arrives named as it was. Skipped when the body already opens with that line, so re-importing an export this code produced doesn't stack duplicates. Two smaller things fell out. The Android editor loses its bold first field — one weight throughout, because the first line is the note's name but not a different KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s justification comment moved to `ClearRemindAt`, which is now the surviving example of why NoteEdit is a list rather than a struct of options. Protocol note corrected to say what actually shipped: v2 is "no kind, no title", one bump for the pair. Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests all green before pushing. It caught four things — orphaned serde attributes where fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview still has one), nine retention fixtures inserting a dropped column, and four rustfmt diffs.
713 lines
22 KiB
Rust
713 lines
22 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 color: 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>,
|
|
}
|
|
|
|
/// 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,
|
|
color,
|
|
position,
|
|
pinned,
|
|
archived,
|
|
trashed,
|
|
deleted_at,
|
|
remind_at,
|
|
recurrence,
|
|
labels,
|
|
items,
|
|
attachments,
|
|
previews,
|
|
created_at,
|
|
updated_at,
|
|
} = value;
|
|
Note {
|
|
id,
|
|
display_title,
|
|
body,
|
|
color,
|
|
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 color: 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,
|
|
color,
|
|
label,
|
|
has_reminder,
|
|
has_attachment,
|
|
created_after,
|
|
created_before,
|
|
} = value;
|
|
core_models::Facets {
|
|
q,
|
|
color,
|
|
label,
|
|
has_reminder,
|
|
has_attachment,
|
|
created_after,
|
|
created_before,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A new note.
|
|
#[derive(Debug, Clone, uniffi::Record)]
|
|
pub struct NoteDraft {
|
|
pub body: String,
|
|
/// "default" unless the user picked a colour.
|
|
pub color: 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, color, items } = value;
|
|
core_models::NoteCreateInput { body, color, 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 },
|
|
Color { 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::Color { value } => ("color", 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());
|
|
}
|
|
}
|