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

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:
2026-08-28 14:07:03 -04:00
co-authored by Claude Opus 5
parent 13a88179b8
commit fa89da1fab
36 changed files with 278 additions and 441 deletions
+93
View File
@@ -0,0 +1,93 @@
"""drop notes.color — a card is one neutral surface, colour lives on the tag
Revision ID: 0029
Revises: 0028
Create Date: 2026-08-28
M315 step 3. A note's colour was set by a picker and read by three card renderers.
Steps 1 and 2 stopped every one of those reads: the card is one neutral per theme and
the only coloured thing on a board is a tag. This drops the column that nothing has
been reading since, and the picker goes with it.
`labels.color` is untouched. That is the colour that survived, and the one the whole
milestone was about keeping.
## What is lost, and why that is the change rather than a cost of it
Any colour a note was explicitly given. There is nowhere to preserve it TO — the field
it would be preserved in is the one being dropped — and nothing renders it, so a
preserved value would be a column kept warm for a feature that was deliberately
removed. A note that had a colour now takes its identity from its tags, which is what
the operator asked for: "strip color from the cards ... and keep the color for tags
just on the tag."
The palette itself is not lost. `NOTE_COLORS` moved from `models/note.py` to
`colors.py` in the same change — labels still name a colour, and leaving the vocabulary
defined on the model that lost one would be an invitation to put the column back.
## The saved-filter sweep is not optional
`saved_filters.params` is opaque JSON mirroring the `GET /api/notes` facet query, and
a stored view could carry `"color": "teal"`. With the facet gone that key would sit
there forever, and `clean_params` only guards what is written FROM here on. A view that
silently filters on a field the app no longer has is worse than one that visibly lost a
criterion, so the stored rows are swept too.
Done in Python rather than as `params::jsonb - 'color'`, deliberately. Postgres has no
try-cast: one malformed blob would abort the whole migration, and these rows are
somebody's saved views. `json.loads` in a try/except lets a corrupt row keep whatever it
holds and lets every other row be fixed.
## Search is not affected
`notes.search_vector` is a stored generated column over `display_title` and `body`
(rebuilt in 0026). It never named `color`, so unlike the title drop there is nothing
here to tear down and recreate.
## Downgrade
Restores the column, empty, at its old default. The values are not recoverable — see
above. It is the schema that comes back, not the data.
"""
import json
from alembic import op
import sqlalchemy as sa
revision = "0029"
down_revision = "0028"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("notes", "color")
bind = op.get_bind()
rows = bind.execute(
sa.text("SELECT id, params FROM saved_filters WHERE params LIKE '%color%'")
).fetchall()
for sf_id, params in rows:
try:
parsed = json.loads(params)
except (ValueError, TypeError):
# A blob that does not parse cannot be edited safely. Leaving it is
# correct: it was already unreadable by the app, and this migration is not
# the place to decide what it should have said.
continue
if not isinstance(parsed, dict) or "color" not in parsed:
continue
parsed.pop("color")
bind.execute(
sa.text("UPDATE saved_filters SET params = :p WHERE id = :id"),
{"p": json.dumps(parsed), "id": sf_id},
)
def downgrade() -> None:
# Comes back at the default every note would have had anyway. Which notes once
# carried a chosen colour is not recorded anywhere after the upgrade.
op.add_column(
"notes",
sa.Column("color", sa.Text(), nullable=False, server_default="default"),
)
@@ -354,7 +354,6 @@ class BoardViewModel(
is EditorAction.SaveText ->
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color))
// Pinning re-sorts the board rather than emptying it, and on a phone
// you often pin while still reading — so unlike the three below, it
@@ -511,9 +510,6 @@ class BoardViewModel(
}
}
/** The palette key a note starts on, matching the web and the desktop. */
private const val DEFAULT_COLOR = "default"
// ── pure builders ───────────────────────────────────────────────────────────
//
// Neither of these reads or writes view-model state; they only shape a core input
@@ -529,7 +525,7 @@ private fun draft(content: String): NoteDraft =
// The core names the note from the body's first line, so a captured thought is
// findable without anyone being asked to name it. A checklist is added afterwards,
// in the editor — it is something a note HAS, not a different thing to capture.
NoteDraft(body = content, color = DEFAULT_COLOR, items = null)
NoteDraft(body = content, items = null)
/**
* The id a note has before it has been saved.
@@ -545,7 +541,6 @@ private fun blankDraft(): Note =
id = DRAFT_ID,
displayTitle = "",
body = "",
color = DEFAULT_COLOR,
position = 0,
pinned = false,
archived = false,
@@ -25,10 +25,6 @@ sealed interface EditorAction {
val body: String,
) : EditorAction
data class SetColor(
val color: String,
) : EditorAction
data class SetPinned(
val pinned: Boolean,
) : EditorAction
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -76,9 +75,6 @@ import com.fabledsword.thoughtsync.core.Note
fun EditorTopBar(
note: Note,
readOnly: Boolean,
/** What the colour PICKER is set to — the swatch dot's fill, and nothing else.
* Since M315 no surface paints with a note's colour; see [noteCardSurface]. */
picked: NoteTint,
onClose: () -> Unit,
onStartChecklist: () -> Unit,
onPicker: (Picker) -> Unit,
@@ -101,18 +97,6 @@ fun EditorTopBar(
},
actions = {
if (!readOnly) {
// A dot in the picker's CURRENT colour rather than a palette icon: it
// shows what the setting is as well as what the button does.
IconButton(onClick = { onPicker(Picker.COLOR) }) {
Box(
modifier =
Modifier
.size(SWATCH_DOT)
.clip(CircleShape)
.background(picked.chipBackground(dark))
.border(1.dp, picked.border(dark), CircleShape),
)
}
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
Icon(
Icons.Filled.Notifications,
@@ -410,6 +394,5 @@ fun EditorReminderRow(
}
}
private val SWATCH_DOT = 22.dp
private const val SNOOZE_HOUR = 60L
private const val SNOOZE_DAY = 1440L
@@ -1,11 +1,7 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -13,21 +9,16 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
@@ -42,7 +33,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
@@ -57,80 +47,17 @@ import java.time.LocalTime
import java.time.ZoneId
import java.time.temporal.TemporalAdjusters
// The three things you pick rather than type: a colour, a set of labels, a time.
// The two things you pick rather than type: a set of labels, and a time.
//
// It was three. The colour sheet went with `note.color` in M315 — a card is one neutral
// surface now and colour lives on the tag, so the swatch grid was a control with nothing
// behind it.
//
// All bottom sheets rather than dialogs. A dialog takes the middle of the screen
// and asks to be dismissed; a sheet rises from the bottom, under the thumb, with
// the note still visible above it — which matters when the choice you are making
// is about the thing you are looking at.
/** The note palette, as swatches. Order and colours come from [NOTE_TINTS]. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ColorSheet(
selected: String,
onPick: (String) -> Unit,
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.navigationBarsPadding(),
) {
SheetTitle(R.string.color_picker_title)
// Chunked into fixed rows rather than a flow layout: ten swatches
// always lay out as two rows of five on every phone width, and a flow
// would reshuffle them between devices for no gain.
NOTE_TINTS.entries.chunked(SWATCHES_PER_ROW).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
row.forEach { (key, tint) ->
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.size(SWATCH_SIZE)
.clip(CircleShape)
.background(tint.background(dark))
.border(
// The selected swatch gets a heavier ring
// as well as a tick: on the pale tints the
// tick alone is nearly invisible.
if (key == selected) 2.dp else 1.dp,
if (key == selected) {
MaterialTheme.colorScheme.primary
} else {
tint.border(dark)
},
CircleShape,
).clickable(onClickLabel = tint.label) { onPick(key) },
) {
if (key == selected) {
Icon(
Icons.Filled.Check,
contentDescription = tint.label,
modifier = Modifier.size(18.dp),
)
}
}
}
// Pad a short final row so its swatches line up with the row
// above instead of spreading across the full width.
repeat(SWATCHES_PER_ROW - row.size) {
Box(modifier = Modifier.size(SWATCH_SIZE))
}
}
}
}
}
}
/**
* Every label, ticked where it is on the note.
*
@@ -461,8 +388,6 @@ private val RECURRENCE_RULES: List<Pair<String?, Int>> =
"yearly" to R.string.recurrence_yearly,
)
private const val SWATCHES_PER_ROW = 5
private const val EVENING_HOUR = 18
private const val MORNING_HOUR = 8
private val SWATCH_SIZE = 44.dp
private val LABEL_LIST_MAX_HEIGHT = 320.dp
@@ -179,10 +179,10 @@ fun NoteCard(
* whatever matches. A menu that offered "Move to trash" on a note already in the
* trash would be offering to do something twice.
*
* **Colour is deliberately absent**, though #2946 suggested it. `note.color` is
* scheduled for removal along with the whole picker (#3041, the last step of M309) —
* colour comes from the note's tags now. Building a swatch row here would be building
* the one control in this menu already known to be coming out.
* **Colour is absent**, though #2946 suggested it. It was left out because `note.color`
* was already scheduled for removal; M315 removed it. There is no colour to set on a
* note any more — a card is one neutral surface and the only coloured thing on a board
* is a tag — so the row this menu never grew is a row that could not exist.
*
* Labels are absent too, for a duller reason: the picker they open is editor state,
* and hoisting it to the board is a bigger change than the friction actually reported.
@@ -59,10 +59,6 @@ fun NoteEditorScreen(
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
// The colour the PICKER is set to, which since M315 is all `note.color` still is:
// nothing paints with it any more. It feeds the swatch dot so the control shows its
// own state; both go together in #3041.
val picked = noteTint(note.color)
// Keyed by the SESSION, not by note.id: the editor is reused across notes, so it
// needs a key — but a draft's id changes the moment it is first saved, and
@@ -171,7 +167,6 @@ fun NoteEditorScreen(
EditorTopBar(
note = note,
readOnly = readOnly,
picked = picked,
onClose = leave,
onStartChecklist = {
val (next, id) = blocks.plusTask()
@@ -273,7 +268,7 @@ fun NoteEditorScreen(
}
/** Which overlay is open. One at a time, so they cannot stack on a phone screen. */
enum class Picker { NONE, COLOR, LABELS, REMINDER }
enum class Picker { NONE, LABELS, REMINDER }
/** The pickers, hoisted out so the screen above reads as a layout rather than a switch. */
@Composable
@@ -287,15 +282,6 @@ private fun EditorOverlays(
val dismiss = { onPicker(Picker.NONE) }
when (picker) {
Picker.NONE -> Unit
Picker.COLOR ->
ColorSheet(
selected = note.color,
onPick = {
onAction(EditorAction.SetColor(it))
dismiss()
},
onDismiss = dismiss,
)
Picker.LABELS ->
LabelSheet(
note = note,
@@ -67,7 +67,6 @@
<string name="editor_delete_forever_confirm">Delete</string>
<!-- Pickers -->
<string name="color_picker_title">Color</string>
<string name="label_picker_title">Labels</string>
<string name="label_new_hint">Type a label and press enter</string>
<string name="label_from_tag">from #tag</string>
-3
View File
@@ -617,7 +617,6 @@ mod tests {
fn draft(body: &str) -> NoteDraft {
NoteDraft {
body: body.to_string(),
color: "default".to_string(),
items: None,
}
}
@@ -671,7 +670,6 @@ mod tests {
let created = app
.create_note(NoteDraft {
body: String::new(),
color: "default".to_string(),
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
})
.expect("create should succeed");
@@ -707,7 +705,6 @@ mod tests {
let note = app
.create_note(NoteDraft {
body: "Packing".to_string(),
color: "default".to_string(),
items: Some(vec!["socks".to_string()]),
})
.expect("create");
+2 -12
View File
@@ -33,7 +33,6 @@ pub struct Note {
/// Always present. Derived by the core, never stored.
pub display_title: String,
pub body: String,
pub color: String,
pub position: i64,
pub pinned: bool,
pub archived: bool,
@@ -187,7 +186,6 @@ impl From<core_models::Note> for Note {
id,
display_title,
body,
color,
position,
pinned,
archived,
@@ -206,7 +204,6 @@ impl From<core_models::Note> for Note {
id,
display_title,
body,
color,
position,
pinned,
archived,
@@ -344,7 +341,6 @@ pub struct NoteQuery {
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteFacets {
pub q: Option<String>,
pub color: Option<String>,
pub label: Option<Vec<String>>,
pub has_reminder: Option<bool>,
pub has_attachment: Option<bool>,
@@ -373,7 +369,6 @@ impl From<NoteFacets> for core_models::Facets {
fn from(value: NoteFacets) -> Self {
let NoteFacets {
q,
color,
label,
has_reminder,
has_attachment,
@@ -382,7 +377,6 @@ impl From<NoteFacets> for core_models::Facets {
} = value;
core_models::Facets {
q,
color,
label,
has_reminder,
has_attachment,
@@ -396,8 +390,6 @@ impl From<NoteFacets> for core_models::Facets {
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteDraft {
pub body: String,
/// "default" unless the user picked a colour.
pub color: String,
/// Checklist lines. A note can carry both a body and items (M13 step 2), so this
/// is not an alternative to `body` — it is an addition to it.
pub items: Option<Vec<String>>,
@@ -405,8 +397,8 @@ pub struct NoteDraft {
impl From<NoteDraft> for core_models::NoteCreateInput {
fn from(value: NoteDraft) -> Self {
let NoteDraft { body, color, items } = value;
core_models::NoteCreateInput { body, color, items }
let NoteDraft { body, items } = value;
core_models::NoteCreateInput { body, items }
}
}
@@ -421,7 +413,6 @@ impl From<NoteDraft> for core_models::NoteCreateInput {
#[derive(Debug, Clone, uniffi::Enum)]
pub enum NoteEdit {
Body { value: String },
Color { value: String },
Pinned { value: bool },
Archived { value: bool },
RemindAt { value: String },
@@ -441,7 +432,6 @@ impl NoteEdit {
use serde_json::Value;
match self {
NoteEdit::Body { value } => ("body", Value::String(value)),
NoteEdit::Color { value } => ("color", Value::String(value)),
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
NoteEdit::Archived { value } => ("archived", Value::Bool(value)),
NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)),
-9
View File
@@ -13,7 +13,6 @@ pub struct Note {
/// never stored.
pub display_title: String,
pub body: String,
pub color: String,
pub position: i64,
pub pinned: bool,
pub archived: bool,
@@ -121,16 +120,10 @@ pub struct User {
pub is_admin: bool,
}
fn default_color() -> String {
"default".to_string()
}
#[derive(Deserialize)]
pub struct NoteCreateInput {
#[serde(default)]
pub body: String,
#[serde(default = "default_color")]
pub color: String,
#[serde(default)]
pub items: Option<Vec<String>>,
}
@@ -154,8 +147,6 @@ pub struct Facets {
#[serde(default)]
pub q: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub label: Option<Vec<String>>,
#[serde(default)]
pub has_reminder: Option<bool>,
+23 -1
View File
@@ -15,7 +15,7 @@ CREATE TABLE notes (
id TEXT PRIMARY KEY,
title TEXT,
body TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default',
color TEXT NOT NULL DEFAULT 'default', -- dropped in v9; kept so DROP COLUMN has something to drop
kind TEXT NOT NULL DEFAULT 'text', -- dropped in v6; kept so DROP COLUMN has something to drop
position INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
@@ -250,6 +250,24 @@ fn migrate_v8(conn: &Connection) -> rusqlite::Result<()> {
}
/// Bring the database up to the latest schema. Idempotent.
// v9 (M315): `notes.color` is gone. A card is one neutral surface now and colour lives
// only on a tag, so the column was written by a picker nothing read and read by nothing
// at all. `labels.color` is untouched — that is the colour that survived.
//
// The saved-filter sweep is the second half and not optional. `params` is opaque JSON
// and a stored view could carry `"color": "teal"`; with the facet gone that key would
// sit there forever, and a view that silently filters on a field the app no longer has
// is worse than one that lost a criterion. Guarded on `json_valid` because a corrupt
// blob must lose a key, not become NULL.
const SCHEMA_V9: &str = r#"
ALTER TABLE notes DROP COLUMN color;
UPDATE saved_filters
SET params = json_remove(params, '$.color')
WHERE json_valid(params)
AND json_extract(params, '$.color') IS NOT NULL;
"#;
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
@@ -285,6 +303,10 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
migrate_v8(conn)?;
conn.execute_batch("PRAGMA user_version = 8;")?;
}
if version < 9 {
conn.execute_batch(SCHEMA_V9)?;
conn.execute_batch("PRAGMA user_version = 9;")?;
}
Ok(())
}
+11 -21
View File
@@ -141,7 +141,7 @@ fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<LinkP
fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
let mut note = conn.query_row(
"SELECT id, body, color, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
"SELECT id, body, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
FROM notes WHERE id = ?1",
[id],
|r| {
@@ -150,14 +150,13 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
id: r.get(0)?,
display_title: String::new(), // filled below — it may need a query
body,
color: r.get(2)?,
position: r.get(3)?,
pinned: r.get(4)?,
archived: r.get(5)?,
trashed: r.get(6)?,
deleted_at: r.get(11)?,
remind_at: r.get(7)?,
recurrence: r.get(8)?,
position: r.get(2)?,
pinned: r.get(3)?,
archived: r.get(4)?,
trashed: r.get(5)?,
deleted_at: r.get(10)?,
remind_at: r.get(6)?,
recurrence: r.get(7)?,
labels: Vec::new(),
items: Vec::new(),
attachments: Vec::new(),
@@ -327,10 +326,6 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
binds.push(pat.clone());
binds.push(pat);
}
if let Some(c) = f.color.as_deref().filter(|s| !s.is_empty()) {
sql.push_str(" AND color = ?");
binds.push(c.to_string());
}
if f.has_reminder == Some(true) {
sql.push_str(" AND remind_at IS NOT NULL");
}
@@ -428,9 +423,9 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
}
}
conn.execute(
"INSERT INTO notes (id, body, color, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?5, 1)",
params![id, body, input.color, position, ts],
"INSERT INTO notes (id, body, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?4, 1)",
params![id, body, position, ts],
)?;
// The FOLDED body, not the input one: an item can carry a #tag too.
lift_and_sync_tags(conn, &id, &body)?;
@@ -508,11 +503,6 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
)?;
lift_and_sync_tags(conn, id, body)?;
}
"color" => {
if let Some(s) = v.as_str() {
conn.execute("UPDATE notes SET color = ?1 WHERE id = ?2", params![s, id])?;
}
}
"pinned" => {
if let Some(b) = v.as_bool() {
conn.execute("UPDATE notes SET pinned = ?1 WHERE id = ?2", params![b, id])?;
+15 -1
View File
@@ -19,10 +19,24 @@
use serde::{Deserialize, Serialize};
/// The sync wire protocol this client speaks.
pub const CLIENT_PROTOCOL_VERSION: u32 = 3;
///
/// v4 (M315): `color` left the note. NOT a floor raise on either side — see the note
/// on [`MIN_SERVER_PROTOCOL_VERSION`].
pub const CLIENT_PROTOCOL_VERSION: u32 = 4;
/// The oldest server protocol this client can drive — the symmetric half of the
/// server's `min_client_protocol_version`.
///
/// STAYS AT 3 ACROSS v4, and the v2 precedent is the reason to say why rather than
/// leave it looking like an oversight. v2 dropped `kind` and `title` and DID move both
/// floors, on the rule that "dropping a field a client sends and expects back is
/// breaking". `color` fails that test on the second half: a v3 client reading a v4
/// server gets `"default"` from serde's default and draws the colour it derives
/// locally, which is a board that looks exactly like the one it drew yesterday. A v3
/// client PUSHING `color` to a v4 server has the key ignored — the server reads its
/// payload key by key and never validates the shape. Neither direction errors, and
/// neither loses anything a person can see; `title` was the note's NAME, and this is a
/// field that no longer renders anywhere.
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 3;
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the
+2 -5
View File
@@ -240,13 +240,12 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
// `created_at` is deliberately absent from the UPDATE clause: a note's birth time
// never changes, and the server's copy is the same value anyway.
conn.execute(
"INSERT INTO notes (id, body, color, position, pinned, archived,
"INSERT INTO notes (id, body, position, pinned, archived,
trashed, remind_at, recurrence, created_at, updated_at,
sync_revision, trashed_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0)
ON CONFLICT(id) DO UPDATE SET
body = excluded.body,
color = excluded.color,
position = excluded.position,
pinned = excluded.pinned,
archived = excluded.archived,
@@ -260,7 +259,6 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
params![
note.id,
note.body,
note.color,
note.position,
note.pinned,
note.archived,
@@ -463,7 +461,6 @@ mod tests {
wire::Note {
id: id.to_string(),
body: "Body".into(),
color: "default".into(),
position: 0,
pinned: false,
archived: false,
+15 -14
View File
@@ -64,6 +64,8 @@ pub struct Change {
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
/// A LABEL's colour. A note has none since M315, so a note change leaves this
/// `None` and the key never reaches the wire.
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -220,7 +222,6 @@ fn collect_notes(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rusq
/// field-to-column mapping stays readable at the call site.
struct NoteRow {
body: String,
color: String,
position: i64,
pinned: bool,
archived: bool,
@@ -233,22 +234,21 @@ struct NoteRow {
fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
conn.query_row(
"SELECT body, color, position, pinned, archived, trashed,
"SELECT body, position, pinned, archived, trashed,
remind_at, recurrence, created_at, updated_at
FROM notes WHERE id = ?1",
params![id],
|r| {
Ok(NoteRow {
body: r.get(0)?,
color: r.get(1)?,
position: r.get(2)?,
pinned: r.get::<_, i64>(3)? != 0,
archived: r.get::<_, i64>(4)? != 0,
trashed: r.get::<_, i64>(5)? != 0,
remind_at: r.get(6)?,
recurrence: r.get(7)?,
created_at: r.get(8)?,
updated_at: r.get(9)?,
position: r.get(1)?,
pinned: r.get::<_, i64>(2)? != 0,
archived: r.get::<_, i64>(3)? != 0,
trashed: r.get::<_, i64>(4)? != 0,
remind_at: r.get(5)?,
recurrence: r.get(6)?,
created_at: r.get(7)?,
updated_at: r.get(8)?,
})
},
)
@@ -275,7 +275,8 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
// server's last-write-wins comparison runs against.
edited_at: row.updated_at,
body: Some(row.body),
color: Some(row.color),
// A note has no colour to send. See the field on `Change`.
color: None,
pinned: Some(row.pinned),
archived: Some(row.archived),
trashed: Some(row.trashed),
@@ -497,9 +498,9 @@ mod tests {
fn seed_note(conn: &Connection, id: &str, dirty: i64) {
conn.execute(
"INSERT INTO notes (id, body, color, position, pinned, archived,
"INSERT INTO notes (id, body, position, pinned, archived,
trashed, created_at, updated_at, sync_revision, dirty)
VALUES (?1, 'B', 'default', 0, 0, 0, 0,
VALUES (?1, 'B', 0, 0, 0, 0,
'2026-07-26T00:00:00.000Z', '2026-07-26T00:00:00.000Z', 3, ?2)",
params![id, dirty],
)
-2
View File
@@ -25,8 +25,6 @@ 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)]
+20 -5
View File
@@ -52,6 +52,15 @@ syncs everything else.
### The policy
- **Any wire change** → bump `SYNC_PROTOCOL_VERSION`.
- v2 (M13): `kind` and `title` left the wire; **floor raised**, because a v1
client kept pushing both and read back notes carrying neither — and `title` was
the note's NAME, so an old client showed nameless notes.
- v3: attachments/tombstones/revisions.
- v4 (M315): `color` left the note; **floor NOT raised**. Both directions degrade
in silence and neither loses anything visible — an old client reading a v4 note
falls back to the colour it derives locally, and one pushing `color` has the key
ignored. The test is not "did a field leave" but "does either side end up
showing something wrong".
- **Additive change** (a new field, a new capability) → add a `sync_features`
name. Do **not** raise a minimum. Old clients keep working.
- **Breaking change only** → raise `MIN_CLIENT_PROTOCOL_VERSION` (or the client's
@@ -190,18 +199,24 @@ Body: `{ "changes": [ ... ] }` (max 1000 per batch). Each change:
```json
{ "entity": "note", "id": "<uuid>", "op": "upsert", "edited_at": "<iso8601>",
"title": "...", "body": "...", "color": "blue", "kind": "text",
"body": "...",
"pinned": false, "archived": false, "trashed": false, "remind_at": null,
"position": 0, "items": [ {"text": "...", "checked": false} ],
"recurrence": null, "position": 0,
"label_ids": ["<uuid>", ...], "created_at": "<iso8601, on create>" }
```
- **Client-generated ids.** Notes/labels are UUIDs; the client mints the id when
it creates the row offline and sends it here. Create-if-absent, else update.
- **Whole-note semantics.** A note upsert carries the client's *full* current
state (not a partial patch) — the server overwrites all scalar fields, replaces
items, and sets manual label memberships from `label_ids` (tag-sourced labels
are re-derived from the body). `#tags` are recomputed server-side.
state (not a partial patch) — the server overwrites all scalar fields and sets
manual label memberships from `label_ids` (tag-sourced labels are re-derived
from the body). `#tags` are recomputed server-side. A checklist is `- [ ] ` lines
inside `body` (M304), so there is no separate `items` array.
- **Fields a change may still carry, and the server reads past.** `title` and
`kind` (removed in v2), `items` (M304) and `color` (v4, M315). The server reads
its payload key by key and never validates the shape, which is exactly what lets
an older client keep pushing a field this one has stopped storing — see the
version policy above for why none of those needed a floor raise on their own.
- **`op: "delete"`** purges (tombstones) the row. Trashing is just an upsert with
`trashed: true`.
- **Labels:** `{entity: "label", op: "upsert"|"delete", id, edited_at, name,
+1 -3
View File
@@ -9,7 +9,6 @@
// consume. Client-side logic (list reconciliation, optimistic updates, toasts)
// stays in the stores — the repo is data access only.
import type { NoteColor } from "../notes/colors";
import type { Note, NoteFacets, NoteView, NoteRevision } from "../stores/notes";
import type { Label } from "../stores/labels";
import type { SavedFilter } from "../stores/savedFilters";
@@ -33,13 +32,12 @@ export interface NoteListQuery {
export interface NoteCreateInput {
body: string;
color: NoteColor;
items?: string[];
}
// The mutable subset of a note (PATCH /api/notes/:id).
export type NoteChanges = Partial<
Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
Pick<Note, "body" | "pinned" | "archived" | "remind_at" | "recurrence">
>;
export interface ChecklistItemChanges {
-1
View File
@@ -31,7 +31,6 @@ function notesQuery(q: NoteListQuery): string {
if (q.labelId) params.append("label", q.labelId);
for (const id of q.facets?.label ?? []) if (id) params.append("label", id);
if (q.facets?.q) params.set("q", q.facets.q);
if (q.facets?.color) params.set("color", q.facets.color);
if (q.facets?.has_reminder) params.set("has_reminder", "true");
if (q.facets?.has_attachment) params.set("has_attachment", "true");
if (q.facets?.created_after) params.set("created_after", q.facets.created_after);
-24
View File
@@ -1,24 +0,0 @@
<script setup lang="ts">
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
defineProps<{ modelValue: NoteColor }>();
defineEmits<{ (e: "update:modelValue", value: NoteColor): void }>();
</script>
<template>
<div class="flex flex-wrap items-center gap-1.5">
<button
v-for="key in NOTE_COLOR_KEYS"
:key="key"
type="button"
:title="NOTE_COLOR_LABELS[key]"
:aria-label="NOTE_COLOR_LABELS[key]"
:aria-pressed="modelValue === key"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110
focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-1
focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-900"
:class="[NOTE_SWATCH_CLASSES[key], modelValue === key ? 'ring-2 ring-brand ring-offset-1' : '']"
@click="$emit('update:modelValue', key)"
/>
</div>
</template>
-18
View File
@@ -7,7 +7,6 @@ import { useUiStore } from "../stores/ui";
import type { NoteFacets } from "../stores/notes";
import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
import { addLocalDays, formatLocalDay, parseLocalDate } from "../notes/datetime";
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
import Icon from "./Icon.vue";
// A dead-simple facet bar over the board: color + labels + has-reminder
@@ -32,9 +31,6 @@ function patch(p: Partial<NoteFacets>) {
function clearAll() {
void router.replace({ path: "/", query: {} });
}
function setColor(c: NoteColor) {
patch({ color: facets.value.color === c ? undefined : c });
}
function toggleLabel(id: string) {
const cur = facets.value.label ?? [];
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
@@ -111,20 +107,6 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
v-if="open"
class="mt-2 flex flex-col gap-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800"
>
<div class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Color</span>
<button
v-for="c in NOTE_COLOR_KEYS"
:key="c"
type="button"
:title="NOTE_COLOR_LABELS[c]"
:aria-label="NOTE_COLOR_LABELS[c]"
class="h-6 w-6 rounded-full border border-black/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/10"
:class="[NOTE_SWATCH_CLASSES[c], facets.color === c ? 'ring-2 ring-brand ring-offset-1' : '']"
@click="setColor(c)"
/>
</div>
<div v-if="labels.items.length" class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Labels</span>
<button
+2 -61
View File
@@ -1,14 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import {
NOTE_CARD_SURFACE,
NOTE_COLOR_KEYS,
NOTE_COLOR_LABELS,
NOTE_SWATCH_CLASSES,
labelChipClasses,
type NoteColor,
} from "../notes/colors";
import { NOTE_CARD_SURFACE, labelChipClasses } from "../notes/colors";
import type { Note } from "../stores/notes";
import Icon from "./Icon.vue";
import LinkPreview from "./LinkPreview.vue";
@@ -206,27 +199,6 @@ const tagColors = computed<Record<string, string>>(() => {
return map;
});
// Per-card color popover (recolor without opening the editor).
const colorOpen = ref(false);
function swatch(color: string): string {
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
}
function pickColor(color: NoteColor) {
colorOpen.value = false;
void notes.setColor(props.note.id, color);
}
function onDocMousedown(e: MouseEvent) {
if (colorOpen.value && root.value && !root.value.contains(e.target as Node)) colorOpen.value = false;
}
// Only listen for outside clicks while the popover is actually open.
watch(colorOpen, (open) => {
if (open) document.addEventListener("mousedown", onDocMousedown);
else document.removeEventListener("mousedown", onDocMousedown);
});
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown));
</script>
<template>
@@ -469,19 +441,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
</button>
</template>
<template v-else>
<button
type="button"
class="icon-btn"
title="Change color"
aria-label="Change color"
:aria-expanded="colorOpen"
@click.stop="colorOpen = !colorOpen"
>
<span
class="h-4 w-4 rounded-full border border-black/10 dark:border-white/20"
:class="swatch(note.color)"
></span>
</button>
<button
type="button"
class="icon-btn"
@@ -512,24 +471,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<Icon name="trash" />
</button>
</template>
<!-- Inside the action set rather than beside it, so it follows the set to
whichever corner or footer the device put it in. -->
<div
v-if="colorOpen"
class="note-swatches flex w-40 flex-wrap gap-1.5 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<button
v-for="key in NOTE_COLOR_KEYS"
:key="key"
type="button"
:title="NOTE_COLOR_LABELS[key]"
:aria-label="NOTE_COLOR_LABELS[key]"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
:class="[NOTE_SWATCH_CLASSES[key], note.color === key ? 'ring-2 ring-brand' : '']"
@click.stop="pickColor(key)"
/>
</div>
</div>
</div>
</div>
+14 -21
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue";
import LinkPreview from "./LinkPreview.vue";
@@ -9,7 +8,7 @@ import { fromLocalInput, toLocalInput } from "../notes/datetime";
import { takeMorphOrigin } from "../composables/useEditorMorph";
import { prefersReducedMotion } from "../composables/useReducedMotion";
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
import { labelChipClasses, type NoteColor } from "../notes/colors";
import { labelChipClasses } from "../notes/colors";
import {
afterEnter,
type EditorBlock,
@@ -41,7 +40,6 @@ const body = computed(() => joinBlocks(blocks.value));
function setBody(text: string): void {
blocks.value = splitBlocks(text);
}
const color = ref<NoteColor>(props.note?.color ?? "default");
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
// Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2)
// rather than BEING one, so this is a view flag, not a property of the note: it turns
@@ -80,10 +78,7 @@ const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref("");
// Baseline for edit-mode change detection (save only when text actually changed).
const baseline = ref<{ body: string; color: NoteColor }>({
body: props.note?.body ?? "",
color: (props.note?.color ?? "default") as NoteColor,
});
const baseline = ref<{ body: string }>({ body: props.note?.body ?? "" });
const isCreate = computed(() => noteId.value === null);
const hasContent = computed(() => body.value.trim() !== "");
@@ -96,7 +91,6 @@ const draftNote = computed<Note>(() => ({
id: "",
display_title: "",
body: body.value,
color: color.value,
position: 0,
pinned: false,
archived: false,
@@ -124,17 +118,16 @@ watch(
(n) => {
noteId.value = n?.id ?? null;
setBody(n?.body ?? "");
color.value = (n?.color ?? "default") as NoteColor;
labelList.value = n ? [...n.labels] : [];
baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
baseline.value = { body: n?.body ?? "" };
},
);
// ---- persistence ----
async function createFromFields(): Promise<void> {
const created = await notes.create({ body: body.value, color: color.value });
const created = await notes.create({ body: body.value });
noteId.value = created.id;
baseline.value = { body: created.body, color: created.color as NoteColor };
baseline.value = { body: created.body };
}
// Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or
@@ -161,12 +154,12 @@ async function flush(): Promise<void> {
}
const b = baseline.value;
const nextBody = body.value;
const changed = nextBody !== b.body || color.value !== b.color;
const changed = nextBody !== b.body;
if (!changed) return;
saving.value = true;
try {
await notes.saveEdit(noteId.value as string, { body: nextBody, color: color.value });
baseline.value = { body: nextBody, color: color.value };
await notes.saveEdit(noteId.value as string, { body: nextBody });
baseline.value = { body: nextBody };
} finally {
saving.value = false;
}
@@ -175,9 +168,8 @@ async function flush(): Promise<void> {
function resetCompose(): void {
noteId.value = null;
setBody("");
color.value = "default";
labelList.value = [];
baseline.value = { body: "", color: "default" };
baseline.value = { body: "" };
uploadError.value = "";
}
@@ -468,8 +460,7 @@ async function restoreRevisionAt(revId: string) {
if (!id) return;
const updated = await notes.restoreRevision(id, revId);
setBody(updated.body);
color.value = updated.color;
baseline.value = { body: updated.body, color: updated.color };
baseline.value = { body: updated.body };
void loadRevisions(); // the pre-restore state became a new revision
}
function revLabel(iso: string | null): string {
@@ -713,8 +704,10 @@ function revPreview(rev: NoteRevision): string {
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<ColorPicker v-model="color" />
<!-- `justify-end`, not `justify-between`: the colour picker sat on the left of
this row until M315 and the row was balanced around it. With one child left,
`between` would push the actions to the far left of a full-width bar. -->
<div class="flex items-center justify-end gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<div class="flex items-center gap-0.5">
<button
v-if="richEnabled && !liveNote.trashed"
-4
View File
@@ -15,8 +15,6 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
const f: NoteFacets = {};
const text = one(q.q);
if (text) f.q = text;
const color = one(q.color);
if (color) f.color = color;
if (labels.length) f.label = labels;
if (one(q.has_reminder) === "true") f.has_reminder = true;
if (one(q.has_attachment) === "true") f.has_attachment = true;
@@ -30,7 +28,6 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
const q: LocationQueryRaw = {};
if (f.q) q.q = f.q;
if (f.color) q.color = f.color;
if (f.label?.length) q.label = f.label;
if (f.has_reminder) q.has_reminder = "true";
if (f.has_attachment) q.has_attachment = "true";
@@ -43,7 +40,6 @@ export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
export function facetCount(f: NoteFacets): number {
let n = 0;
if (f.q) n++;
if (f.color) n++;
n += f.label?.length ?? 0;
if (f.has_reminder) n++;
if (f.has_attachment) n++;
+3 -14
View File
@@ -2,14 +2,12 @@ import { defineStore } from "pinia";
import { ref } from "vue";
import { repo } from "../adapters";
import { useUiStore } from "./ui";
import type { NoteColor } from "../notes/colors";
export type NoteView = "active" | "archived" | "trash";
// Combinable facet filters for the board (mirrors the GET /api/notes query + a saved
// view's stored params). All optional; empty = the plain, unfiltered board.
export interface NoteFacets {
q?: string;
color?: string;
label?: string[];
has_reminder?: boolean;
has_attachment?: boolean;
@@ -70,7 +68,6 @@ export interface Note {
// (server-derived). Every note has one, so every note has something to be called.
display_title: string;
body: string;
color: NoteColor;
position: number;
pinned: boolean;
archived: boolean;
@@ -136,11 +133,7 @@ export const useNotesStore = defineStore("notes", () => {
}
}
async function create(input: {
body: string;
color: NoteColor;
items?: string[];
}): Promise<Note> {
async function create(input: { body: string; items?: string[] }): Promise<Note> {
const note = await repo.notes.create(input);
reconcile(note);
return note;
@@ -148,9 +141,7 @@ export const useNotesStore = defineStore("notes", () => {
async function mutate(
id: string,
changes: Partial<
Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
>,
changes: Partial<Pick<Note, "body" | "pinned" | "archived" | "remind_at" | "recurrence">>,
): Promise<void> {
reconcile(await repo.notes.update(id, changes));
}
@@ -161,10 +152,9 @@ export const useNotesStore = defineStore("notes", () => {
if (archived)
useUiStore().showToast("Note archived", { label: "Undo", run: () => void setArchived(id, false) });
};
const setColor = (id: string, color: NoteColor) => mutate(id, { color });
const setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt });
const setRecurrence = (id: string, recurrence: string | null) => mutate(id, { recurrence });
const saveEdit = (id: string, changes: { body: string; color: NoteColor }) => mutate(id, changes);
const saveEdit = (id: string, changes: { body: string }) => mutate(id, changes);
async function completeReminder(id: string): Promise<void> {
reconcile(await repo.notes.completeReminder(id));
@@ -279,7 +269,6 @@ export const useNotesStore = defineStore("notes", () => {
create,
setPinned,
setArchived,
setColor,
setReminder,
setRecurrence,
completeReminder,
-19
View File
@@ -143,25 +143,6 @@ body {
}
}
/* The per-card colour popover, anchored to whichever end of the card the action set
* currently occupies: it opens DOWNWARD from a floating top-corner pill, and UPWARD
* from a footer row, so in both cases it grows into the card rather than off it. */
.note-swatches {
position: absolute;
right: 0;
bottom: 100%;
margin-bottom: 0.375rem;
z-index: 20;
}
@media (hover: hover) {
.note-swatches {
top: 100%;
bottom: auto;
margin-top: 0.375rem;
margin-bottom: 0;
}
}
/* Board motion (M7). Defined once here rather than three times in BoardView's
* markup, because "how the board moves" is one idea even though the pinned, other
* and non-board grids are three TransitionGroups.
+28 -8
View File
@@ -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"
-18
View File
@@ -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,
+3 -2
View File
@@ -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"
+1 -10
View File
@@ -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:
-23
View File
@@ -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,
+3 -1
View File
@@ -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
View File
@@ -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"):
+15 -9
View File
@@ -4,7 +4,8 @@ import pytest
from thoughtsync.app import create_app
from thoughtsync.common import coerce_bool, parse_dt
from thoughtsync.models.note import NOTE_COLORS, Note
from thoughtsync.colors import NOTE_COLORS
from thoughtsync.models.note import Note
from thoughtsync.notes.checklist import (
append_item,
parse_items,
@@ -76,16 +77,16 @@ def test_normalize_color():
def test_palette_has_core_colors():
# A LABEL's vocabulary since M315 — a note has no colour to be one of these.
for c in ("default", "red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"):
assert c in NOTE_COLORS
def test_serialize_shape():
n = Note(body="b", color="blue", pinned=True, archived=False)
n = Note(body="b", pinned=True, archived=False)
s = n.serialize()
assert "title" not in s # there is no title field any more (M13 step 3)
assert s["body"] == "b"
assert s["color"] == "blue"
assert s["pinned"] is True
assert s["archived"] is False
assert s["trashed"] is False
@@ -442,6 +443,8 @@ def test_keep_spec_list_note_keeps_its_text_too():
"textContent": "for the weekend",
"listContent": [{"text": "Milk", "isChecked": False}, {"text": "Eggs", "isChecked": True}],
"labels": [{"name": "shopping"}],
# Keep's own colour, which the importer now reads past: there is nothing on a
# note for it to land on, and a spec carrying a key nobody applies is a lie.
"color": "TEAL",
"isPinned": True,
"isArchived": False,
@@ -451,7 +454,7 @@ def test_keep_spec_list_note_keeps_its_text_too():
}
spec = _keep_spec(kn, "Takeout/Keep")
assert spec["body"] == "for the weekend"
assert spec["color"] == "teal"
assert "color" not in spec
assert spec["pinned"] is True
assert spec["archived"] is False
assert spec["trashed"] is False
@@ -460,16 +463,18 @@ def test_keep_spec_list_note_keeps_its_text_too():
assert spec["created_at"].year == 2020
def test_keep_spec_text_note_folds_annotation_urls_and_maps_color():
def test_keep_spec_text_note_folds_annotation_urls_and_drops_color():
kn = {
"textContent": "Read this later",
"annotations": [{"url": "https://example.com"}],
"color": "BROWN", # no brown in our palette → nearest (orange)
"color": "BROWN",
"attachments": [{"filePath": "img.jpg", "mimetype": "image/jpeg"}],
}
spec = _keep_spec(kn, "Takeout/Keep")
assert "https://example.com" in spec["body"]
assert spec["color"] == "orange"
# BROWN used to map to the nearest hue we had. There is no hue to map TO now, so a
# Keep import brings across everything except the one thing this app stopped having.
assert "color" not in spec
# attachment path is resolved relative to the note JSON's folder
assert spec["attachments"] == [{"file": "Takeout/Keep/img.jpg", "mime": "image/jpeg"}]
@@ -478,6 +483,8 @@ def test_native_spec_roundtrip_fields():
n = {
"title": "T",
"body": "b",
# An export taken before M315 still carries this. Reading past it rather than
# rejecting the file is the whole point — old exports must still import.
"color": "blue",
"pinned": True,
"archived": False,
@@ -491,7 +498,7 @@ def test_native_spec_roundtrip_fields():
# _create_imported_note folds it into the body rather than dropping it.
assert spec["title"] == "T"
assert spec["body"] == "b"
assert spec["color"] == "blue"
assert "color" not in spec
assert spec["pinned"] is True
assert spec["trashed"] is False # exports only carry live notes
assert spec["created_at"].year == 2026
@@ -642,7 +649,6 @@ def test_note_markdown_writes_a_checklist_once():
note = Note(
display_title="shopping",
body="shopping\n\n- [ ] milk\n- [x] eggs",
color="default",
pinned=False,
archived=False,
)
+4 -1
View File
@@ -12,13 +12,16 @@ def app():
def test_clean_params_whitelists_facet_keys():
raw = {
"q": "hi",
# A facet until M315. It is junk now, and has to be dropped like any other —
# a stored view that still filtered on a field the app lost would return
# nothing and never say why.
"color": "yellow",
"label": ["a"],
"has_reminder": True,
"junk": 1,
"__proto__": 2,
}
assert clean_params(raw) == {"q": "hi", "color": "yellow", "label": ["a"], "has_reminder": True}
assert clean_params(raw) == {"q": "hi", "label": ["a"], "has_reminder": True}
assert clean_params("nope") == {}
assert clean_params(None) == {}