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.
159 lines
4.3 KiB
Rust
159 lines
4.3 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 = "default_color")]
|
|
pub color: 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 items: Vec<Item>,
|
|
#[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 Item {
|
|
pub id: String,
|
|
#[serde(default)]
|
|
pub text: String,
|
|
#[serde(default)]
|
|
pub checked: bool,
|
|
#[serde(default)]
|
|
pub position: i64,
|
|
}
|
|
|
|
#[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()
|
|
}
|