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>
72 lines
3.9 KiB
Python
72 lines
3.9 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, Text, func
|
|
from sqlalchemy.dialects.postgresql import UUID
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from . import Base
|
|
from ..common import iso
|
|
|
|
class Note(Base):
|
|
__tablename__ = "notes"
|
|
__table_args__ = (
|
|
# Covers the board queries: a user's notes filtered by trash/archive state,
|
|
# pinned first.
|
|
Index("ix_notes_owner_board", "owner_id", "deleted_at", "archived", "pinned"),
|
|
)
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
owner_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
# The note's NAME: its first non-empty body line, else its first checklist item
|
|
# (see notes.derive_display_title). There is no title field to prefer — a note is
|
|
# a body plus optional items, and this is simply the first thing written in it.
|
|
# Persisted so search results and export filenames have something to say, and so
|
|
# the full-text vector can weight it above the rest of the body.
|
|
display_title: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
|
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
|
# Manual drag order (higher = earlier); 0 until the user reorders.
|
|
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
|
|
pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
|
archived: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
|
# Soft delete: non-null => in Trash. Restore sets it back to null.
|
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
# Optional reminder time (surfaced in the Reminders view + foreground delivery).
|
|
remind_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
# Optional recurrence for the reminder: daily | weekly | monthly | yearly (else null).
|
|
# On "complete", a recurring reminder advances remind_at to its next occurrence.
|
|
recurrence: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
|
)
|
|
# Sync (M8): a monotonic per-row revision drawn from sync_revision_seq and assigned
|
|
# by a DB trigger on every insert/update — the delta cursor native clients pull
|
|
# against. Nullable in the ORM because the trigger populates it server-side.
|
|
sync_revision: Mapped[int | None] = mapped_column(BigInteger(), nullable=True)
|
|
# Hard-delete tombstone: non-null => permanently deleted (content cleared), kept so
|
|
# offline clients learn the row is gone. Distinct from deleted_at (= recoverable trash).
|
|
purged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
|
|
def serialize(self) -> dict:
|
|
return {
|
|
"id": str(self.id),
|
|
"display_title": self.display_title,
|
|
"body": self.body,
|
|
"position": self.position,
|
|
"pinned": self.pinned,
|
|
"archived": self.archived,
|
|
"trashed": self.deleted_at is not None,
|
|
# WHEN it was trashed, not just that it was: clients count the retention
|
|
# window from here to show how long a note has left before it's purged.
|
|
"deleted_at": iso(self.deleted_at),
|
|
"remind_at": iso(self.remind_at),
|
|
"recurrence": self.recurrence,
|
|
"created_at": iso(self.created_at),
|
|
"updated_at": iso(self.updated_at),
|
|
}
|