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:
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"):
|
||||
|
||||
+15
-9
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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) == {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user