Files
thoughtsync/android/ffi/src/models.rs
T
bvandeusen 95aa10c2c3
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
Remove the title field — a note is named by its first line
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.
2026-08-22 19:33:57 -04:00

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());
}
}