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>
144 lines
4.0 KiB
Rust
144 lines
4.0 KiB
Rust
//! The delta-feed JSON shapes, exactly as `GET /api/sync/changes` sends them.
|
|
//!
|
|
//! Mirrors the server's serializers (`notes/serialize.py` + `serialize.py`) — see
|
|
//! `docs/sync.md` for the contract. Every field is `#[serde(default)]` or `Option`
|
|
//! so a NEWER server adding fields, or an older one omitting one, degrades to a
|
|
//! partial note rather than failing the whole page. Losing one attribute is
|
|
//! recoverable; refusing a page stalls sync permanently at that cursor.
|
|
|
|
use serde::Deserialize;
|
|
|
|
#[derive(Debug, Clone, Deserialize, Default)]
|
|
pub struct ChangesPage {
|
|
#[serde(default)]
|
|
pub notes: Vec<Note>,
|
|
#[serde(default)]
|
|
pub labels: Vec<Label>,
|
|
#[serde(default)]
|
|
pub cursor: i64,
|
|
#[serde(default)]
|
|
pub has_more: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct Note {
|
|
pub id: String,
|
|
#[serde(default)]
|
|
pub body: String,
|
|
#[serde(default)]
|
|
pub position: i64,
|
|
#[serde(default)]
|
|
pub pinned: bool,
|
|
#[serde(default)]
|
|
pub archived: bool,
|
|
/// The server derives this from `deleted_at` — trash, NOT a tombstone.
|
|
#[serde(default)]
|
|
pub trashed: bool,
|
|
/// WHEN it was trashed. The trash-retention clock runs from here, so it has to be
|
|
/// the server's timestamp rather than anything this device invents. Absent from an
|
|
/// older server, which is why it's optional rather than required.
|
|
#[serde(default)]
|
|
pub deleted_at: Option<String>,
|
|
#[serde(default)]
|
|
pub remind_at: Option<String>,
|
|
#[serde(default)]
|
|
pub recurrence: Option<String>,
|
|
#[serde(default)]
|
|
pub created_at: Option<String>,
|
|
#[serde(default)]
|
|
pub updated_at: Option<String>,
|
|
#[serde(default)]
|
|
pub sync_revision: i64,
|
|
/// Set means the row was permanently purged: a content-less tombstone whose only
|
|
/// job is to tell clients to delete their copy.
|
|
#[serde(default)]
|
|
pub purged_at: Option<String>,
|
|
#[serde(default)]
|
|
pub labels: Vec<NoteLabel>,
|
|
#[serde(default)]
|
|
pub attachments: Vec<Attachment>,
|
|
#[serde(default)]
|
|
pub previews: Vec<Preview>,
|
|
}
|
|
|
|
impl Note {
|
|
pub fn is_tombstone(&self) -> bool {
|
|
self.purged_at.is_some()
|
|
}
|
|
}
|
|
|
|
/// A label as it appears attached to a note. Carries enough to materialize the label
|
|
/// row itself, which is what lets a membership be applied even if the label's own
|
|
/// delta hasn't arrived (see `pull::apply_page`).
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct NoteLabel {
|
|
pub id: String,
|
|
#[serde(default)]
|
|
pub name: String,
|
|
#[serde(default = "default_color")]
|
|
pub color: String,
|
|
/// True when the membership came from a `#tag` in the body rather than a manual
|
|
/// assignment. Applied verbatim rather than re-derived — see `pull::apply_page`.
|
|
#[serde(default)]
|
|
pub via_tag: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct Attachment {
|
|
pub id: String,
|
|
#[serde(default)]
|
|
pub url: String,
|
|
#[serde(default)]
|
|
pub filename: Option<String>,
|
|
#[serde(default = "default_mime")]
|
|
pub mime: String,
|
|
#[serde(default)]
|
|
pub size: Option<i64>,
|
|
#[serde(default)]
|
|
pub sha256: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct Preview {
|
|
pub id: String,
|
|
#[serde(default)]
|
|
pub url: String,
|
|
#[serde(default)]
|
|
pub title: Option<String>,
|
|
#[serde(default)]
|
|
pub description: Option<String>,
|
|
#[serde(default)]
|
|
pub image_url: Option<String>,
|
|
#[serde(default)]
|
|
pub site_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct Label {
|
|
pub id: String,
|
|
#[serde(default)]
|
|
pub name: String,
|
|
#[serde(default = "default_color")]
|
|
pub color: String,
|
|
#[serde(default)]
|
|
pub sync_revision: i64,
|
|
#[serde(default)]
|
|
pub purged_at: Option<String>,
|
|
#[serde(default)]
|
|
pub created_at: Option<String>,
|
|
}
|
|
|
|
impl Label {
|
|
pub fn is_tombstone(&self) -> bool {
|
|
self.purged_at.is_some()
|
|
}
|
|
}
|
|
|
|
fn default_color() -> String {
|
|
"default".to_string()
|
|
}
|
|
|
|
fn default_mime() -> String {
|
|
"application/octet-stream".to_string()
|
|
}
|