Files
thoughtsync/core/src/local/models.rs
T
bvandeusenandClaude Opus 5 fa89da1fab
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
notes: color leaves the model, the wire and all three surfaces
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>
2026-08-28 14:07:03 -04:00

160 lines
4.2 KiB
Rust

//! Serde shapes for the local store. Output structs mirror the JSON the frontend
//! already consumes (frontend/src/stores/*.ts) so the same components render whether
//! the data comes from REST or from these Tauri commands. Field names/optionality
//! match the TypeScript interfaces exactly.
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct Note {
pub id: String,
/// The note's NAME: its first non-blank body line, else its first checklist item.
/// Always present, so every note has something to be called. Derived at read time,
/// never stored.
pub display_title: String,
pub body: String,
pub position: i64,
pub pinned: bool,
pub archived: bool,
pub trashed: bool,
/// When it was trashed (null unless trashed). Named for the server's field so the
/// shared frontend counts down the retention window identically either way.
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>,
}
#[derive(Serialize)]
pub struct NoteLabel {
pub id: String,
pub name: String,
pub color: String,
/// True when attached because of a `#tag` in the body (kept in sync with the text).
pub via_tag: bool,
}
#[derive(Serialize)]
pub struct ChecklistItem {
pub id: String,
pub text: String,
pub checked: bool,
pub position: i64,
}
#[derive(Serialize)]
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(Serialize)]
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>,
}
#[derive(Serialize)]
pub struct NoteRevision {
pub id: String,
pub body: String,
pub created_at: Option<String>,
}
#[derive(Serialize)]
pub struct Label {
pub id: String,
pub name: String,
pub color: String,
// Note count is only meaningful in listings; omitted elsewhere so the frontend
// preserves the count it already holds (matches the REST single-label responses).
#[serde(skip_serializing_if = "Option::is_none")]
pub count: Option<i64>,
}
#[derive(Serialize)]
pub struct TitleEntry {
pub id: String,
pub title: String,
}
#[derive(Serialize)]
pub struct SavedFilter {
pub id: String,
pub name: String,
/// Mirrors NoteFacets — stored as a JSON blob, round-tripped opaquely.
pub params: serde_json::Value,
pub position: i64,
}
#[derive(Serialize)]
pub struct PublicConfig {
pub site_name: String,
pub allow_registration: bool,
pub version: String,
pub enable_url_unfurl: bool,
pub trash_retention_days: u32,
}
/// The synthetic single user the offline core reports, so the app's auth-gated
/// router resolves without a login. There is no real account offline.
#[derive(Serialize)]
pub struct User {
pub id: String,
pub email: String,
pub display_name: String,
pub email_verified: bool,
pub is_admin: bool,
}
#[derive(Deserialize)]
pub struct NoteCreateInput {
#[serde(default)]
pub body: String,
#[serde(default)]
pub items: Option<Vec<String>>,
}
/// The board query (mirrors adapters/repo.ts NoteListQuery). `labelId` is camelCase
/// on the wire; the facet fields are snake_case, matching NoteFacets.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListQuery {
pub view: String,
#[serde(default)]
pub label_id: Option<String>,
#[serde(default)]
pub facets: Option<Facets>,
#[serde(default)]
pub sort: Option<String>,
}
#[derive(Deserialize)]
pub struct Facets {
#[serde(default)]
pub q: Option<String>,
#[serde(default)]
pub label: Option<Vec<String>>,
#[serde(default)]
pub has_reminder: Option<bool>,
#[serde(default)]
pub has_attachment: Option<bool>,
#[serde(default)]
pub created_after: Option<String>,
#[serde(default)]
pub created_before: Option<String>,
}