notes: color leaves the model, the wire and all three surfaces
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
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>
This commit is contained in:
@@ -1,16 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .models.note import NOTE_COLORS
|
||||
|
||||
# Notes and labels share one colour palette (their sets were identical). NOTE_COLORS
|
||||
# is the canonical vocabulary (defined on the model); this module is the single home
|
||||
# for the "clamp to the palette" normalizer so notes.py, labels.py and sync.py stop
|
||||
# each carrying their own copy.
|
||||
# The colour palette, and the one place that clamps to it.
|
||||
#
|
||||
# It lived on `models/note.py` until M315, when a note stopped having a colour. A
|
||||
# palette defined on the model that lost one would be a standing invitation to put the
|
||||
# column back; here it reads as what it now is — a LABEL's vocabulary, shared with the
|
||||
# saved-filter and import paths that still name a colour.
|
||||
#
|
||||
# Keys, not tints. The actual colours live in each client (frontend/src/notes/colors.ts
|
||||
# and NoteTint.kt), so they can be retuned without a schema migration — which M315 spent
|
||||
# two steps doing.
|
||||
NOTE_COLORS = {
|
||||
"default",
|
||||
"red",
|
||||
"orange",
|
||||
"yellow",
|
||||
"green",
|
||||
"teal",
|
||||
"blue",
|
||||
"purple",
|
||||
"pink",
|
||||
"gray",
|
||||
}
|
||||
|
||||
__all__ = ["NOTE_COLORS", "normalize_color"]
|
||||
|
||||
|
||||
def normalize_color(color: object) -> str:
|
||||
"""Return `color` if it's a known palette key, else the default. One definition
|
||||
for both notes and labels."""
|
||||
"""Return `color` if it's a known palette key, else the default.
|
||||
|
||||
`default` is no longer something anybody can CHOOSE — nothing offers a colour
|
||||
picker since M315 — but it is still where unrecognised input has to land, so this
|
||||
fallback is unreachable by choice rather than dead.
|
||||
"""
|
||||
return color if color in NOTE_COLORS else "default"
|
||||
|
||||
@@ -10,22 +10,6 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
from . import Base
|
||||
from ..common import iso
|
||||
|
||||
# The Keep-style palette. Stored as a key string, so the actual tints live in the
|
||||
# frontend and can change without a schema migration.
|
||||
NOTE_COLORS = {
|
||||
"default",
|
||||
"red",
|
||||
"orange",
|
||||
"yellow",
|
||||
"green",
|
||||
"teal",
|
||||
"blue",
|
||||
"purple",
|
||||
"pink",
|
||||
"gray",
|
||||
}
|
||||
|
||||
|
||||
class Note(Base):
|
||||
__tablename__ = "notes"
|
||||
__table_args__ = (
|
||||
@@ -45,7 +29,6 @@ class Note(Base):
|
||||
# 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="")
|
||||
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="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())
|
||||
@@ -74,7 +57,6 @@ class Note(Base):
|
||||
"id": str(self.id),
|
||||
"display_title": self.display_title,
|
||||
"body": self.body,
|
||||
"color": self.color,
|
||||
"position": self.position,
|
||||
"pinned": self.pinned,
|
||||
"archived": self.archived,
|
||||
|
||||
@@ -12,8 +12,9 @@ from . import Base
|
||||
|
||||
class SavedFilter(Base):
|
||||
"""A named, saved facet combination (a 'view'/lens) the user can re-apply in one
|
||||
click — e.g. "Yellow + #ideas". `params` is a JSON-encoded facet dict matching the
|
||||
GET /api/notes query (q/color/labels/has_reminder/has_attachment/date range)."""
|
||||
click — e.g. "#ideas with a reminder". `params` is a JSON-encoded facet dict
|
||||
matching the GET /api/notes query (q/labels/has_reminder/has_attachment/date
|
||||
range). Colour was a facet until M315; 0029 swept the key out of stored rows."""
|
||||
|
||||
__tablename__ = "saved_filters"
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from sqlalchemy import func, literal_column, select
|
||||
|
||||
from ..acl import visible_to_user
|
||||
from ..auth import login_required
|
||||
from ..colors import NOTE_COLORS, normalize_color
|
||||
from ..colors import normalize_color
|
||||
from ..common import coerce_bool, iso, parse_dt
|
||||
from ..config import Config
|
||||
from ..db import session_scope
|
||||
@@ -108,7 +108,6 @@ async def list_notes():
|
||||
# Combinable facet filters (all optional, AND-ed together) — the rich-search /
|
||||
# saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them.
|
||||
label_params = request.args.getlist("label")
|
||||
color = request.args.get("color")
|
||||
has_reminder = coerce_bool(request.args.get("has_reminder"))
|
||||
has_attachment = coerce_bool(request.args.get("has_attachment"))
|
||||
query_text = (request.args.get("q") or "").strip()
|
||||
@@ -128,10 +127,6 @@ async def list_notes():
|
||||
if lid is None:
|
||||
return json_error("invalid label", 400)
|
||||
stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid)))
|
||||
if color is not None:
|
||||
if color not in NOTE_COLORS:
|
||||
return json_error("invalid color", 400)
|
||||
stmt = stmt.where(Note.color == color)
|
||||
if has_reminder:
|
||||
stmt = stmt.where(Note.remind_at.is_not(None))
|
||||
if has_attachment:
|
||||
@@ -259,7 +254,6 @@ async def export_notes():
|
||||
"id": str(n.id),
|
||||
"display_title": n.display_title,
|
||||
"body": n.body,
|
||||
"color": n.color,
|
||||
"pinned": n.pinned,
|
||||
"archived": n.archived,
|
||||
"remind_at": n.remind_at.isoformat() if n.remind_at else None,
|
||||
@@ -412,7 +406,6 @@ async def create_note():
|
||||
owner_id=g.user_id,
|
||||
display_title=derive_display_title(body),
|
||||
body=body,
|
||||
color=normalize_color(data.get("color")),
|
||||
position=int(max_pos) + 1,
|
||||
)
|
||||
db.add(note)
|
||||
@@ -453,8 +446,6 @@ async def update_note(note_id: str):
|
||||
old_body = note.body
|
||||
if "body" in data and isinstance(data["body"], str):
|
||||
note.body = data["body"]
|
||||
if "color" in data:
|
||||
note.color = normalize_color(data["color"])
|
||||
if "pinned" in data:
|
||||
note.pinned = bool(data["pinned"])
|
||||
if "archived" in data:
|
||||
|
||||
@@ -14,7 +14,6 @@ from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..colors import normalize_color
|
||||
from ..common import parse_dt
|
||||
from ..config import Config
|
||||
from ..models.label import NoteLabel
|
||||
@@ -39,7 +38,6 @@ def _note_markdown(note: Note, labels: list) -> str:
|
||||
fm.append(f"display_name: {note.display_title}")
|
||||
if labels:
|
||||
fm.append("labels: [" + ", ".join(lb["name"] for lb in labels) + "]")
|
||||
fm.append(f"color: {note.color}")
|
||||
if note.pinned:
|
||||
fm.append("pinned: true")
|
||||
if note.archived:
|
||||
@@ -62,24 +60,6 @@ def _note_markdown(note: Note, labels: list) -> str:
|
||||
|
||||
# --- Import: ThoughtSync's own export (round-trip) OR a Google Keep Takeout zip ---
|
||||
|
||||
# Google Keep (Takeout) color enum → our palette. Keep has a few hues we don't
|
||||
# (BROWN/DARKBLUE/CERULEAN); map each to the nearest. Unknowns fall back to default.
|
||||
_KEEP_COLOR_MAP = {
|
||||
"DEFAULT": "default",
|
||||
"RED": "red",
|
||||
"ORANGE": "orange",
|
||||
"YELLOW": "yellow",
|
||||
"GREEN": "green",
|
||||
"TEAL": "teal",
|
||||
"CERULEAN": "teal",
|
||||
"BLUE": "blue",
|
||||
"DARKBLUE": "blue",
|
||||
"PURPLE": "purple",
|
||||
"PINK": "pink",
|
||||
"BROWN": "orange",
|
||||
"GRAY": "gray",
|
||||
}
|
||||
|
||||
# Reverse of ALLOWED_IMAGE_MIMES, for inferring an attachment's mime from its
|
||||
# filename when the source didn't record one (Keep usually does; be defensive).
|
||||
_EXT_MIME = {ext: mime for mime, ext in ALLOWED_IMAGE_MIMES.items()}
|
||||
@@ -100,7 +80,6 @@ def _native_spec(n: dict) -> dict:
|
||||
return {
|
||||
"title": n.get("title"),
|
||||
"body": n.get("body") or "",
|
||||
"color": n.get("color"),
|
||||
"pinned": bool(n.get("pinned")),
|
||||
"archived": bool(n.get("archived")),
|
||||
"trashed": False, # export only includes live notes
|
||||
@@ -156,7 +135,6 @@ def _keep_spec(kn: dict, keep_dir: str) -> dict:
|
||||
return {
|
||||
"title": kn.get("title"),
|
||||
"body": body,
|
||||
"color": _KEEP_COLOR_MAP.get(str(kn.get("color") or "DEFAULT").upper(), "default"),
|
||||
"pinned": bool(kn.get("isPinned")),
|
||||
"archived": bool(kn.get("isArchived")),
|
||||
"trashed": bool(kn.get("isTrashed")),
|
||||
@@ -304,7 +282,6 @@ async def _create_imported_note(
|
||||
owner_id=owner_id,
|
||||
display_title=derive_display_title(body),
|
||||
body=body,
|
||||
color=normalize_color(spec.get("color")),
|
||||
pinned=bool(spec.get("pinned")),
|
||||
archived=bool(spec.get("archived")),
|
||||
position=position,
|
||||
|
||||
@@ -16,9 +16,11 @@ bp = Blueprint("saved_filters", __name__, url_prefix="/api/saved-filters")
|
||||
|
||||
NAME_CAP = 100
|
||||
# Facet keys allowed in a saved view (must match the GET /api/notes query surface).
|
||||
# `color` was here until M315. A note has no colour to filter on, and `clean_params`
|
||||
# drops the key on the way in — the migration that dropped the column sweeps it out of
|
||||
# the views already stored.
|
||||
_ALLOWED_PARAM_KEYS = {
|
||||
"q",
|
||||
"color",
|
||||
"label", # matches the repeatable ?label= query param (stored as an array)
|
||||
"has_reminder",
|
||||
"has_attachment",
|
||||
|
||||
+10
-2
@@ -62,7 +62,16 @@ MAX_PUSH = 1000 # per-batch change cap
|
||||
#
|
||||
# One bump for the pair: they landed in the same protocol generation, and nothing ever
|
||||
# ran against a half-applied v2.
|
||||
SYNC_PROTOCOL_VERSION = 3
|
||||
# v4 (M315): `color` left the note. The FLOOR DELIBERATELY DOES NOT MOVE, and v2 is
|
||||
# the precedent that makes saying so worthwhile — it dropped `kind` and `title` and did
|
||||
# raise the floor, on the rule that dropping a field a client sends and expects back is
|
||||
# breaking. `color` fails the second half of that: a v3 client reading a v4 note gets
|
||||
# `"default"` from its own serde default and draws the colour it derives locally, which
|
||||
# is 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 loses anything visible. `title` was the note's
|
||||
# NAME; this is a field that no longer renders anywhere.
|
||||
SYNC_PROTOCOL_VERSION = 4
|
||||
MIN_CLIENT_PROTOCOL_VERSION = 3
|
||||
|
||||
# Named capabilities beyond the base protocol. An ADDITIVE change earns a name
|
||||
@@ -198,7 +207,6 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
|
||||
"""Overwrite a note's scalar fields from a client's FULL-state change (sync is
|
||||
whole-note, not a partial patch — the client sends its authoritative version)."""
|
||||
note.body = ch["body"] if isinstance(ch.get("body"), str) else ""
|
||||
note.color = normalize_color(ch.get("color"))
|
||||
note.pinned = bool(ch.get("pinned"))
|
||||
note.archived = bool(ch.get("archived"))
|
||||
if ch.get("trashed"):
|
||||
|
||||
Reference in New Issue
Block a user