Files
thoughtsync/frontend/src/stores/notes.ts
T
bvandeusenandClaude Opus 5 fa89da1fab
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
notes: color leaves the model, the wire and all three surfaces
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>
2026-08-28 14:07:03 -04:00

295 lines
9.1 KiB
TypeScript

import { defineStore } from "pinia";
import { ref } from "vue";
import { repo } from "../adapters";
import { useUiStore } from "./ui";
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;
label?: string[];
has_reminder?: boolean;
has_attachment?: boolean;
created_after?: string;
created_before?: string;
}
export interface NoteLabel {
id: string;
name: string;
color: string;
// True when the label is backed by text STILL IN THE BODY — a `#tag` written
// mid-sentence, kept in sync with those words. False covers both a label added
// through the picker and a tag lifted off a line of its own (M311), which is why
// it is also what decides whether a chip can be removed with a cross.
//
// The card reads it the other way round: a true here means the body is already
// showing this tag, so the chip would be the second copy and is not drawn.
via_tag: boolean;
}
export interface ChecklistItem {
id: string;
text: string;
checked: boolean;
position: number;
}
export interface Attachment {
id: string;
url: string;
filename?: string | null;
mime: string;
size?: number;
sha256?: string | null;
}
// A cached OpenGraph/meta preview for a URL in the note (server-fetched, SSRF-guarded).
export interface LinkPreview {
id: string;
url: string;
title: string | null;
description: string | null;
image_url: string | null;
site_name: string | null;
}
// A past version of a note's body (version history).
export interface NoteRevision {
id: string;
body: string;
created_at: string | null;
}
export interface Note {
id: string;
// The note's NAME: its first body line, else its first checklist item
// (server-derived). Every note has one, so every note has something to be called.
display_title: string;
body: string;
position: number;
pinned: boolean;
archived: boolean;
trashed: boolean;
// When it was trashed (null unless trashed). The Trash view counts the retention
// window from here to show how long the note has left before it's purged.
deleted_at: string | null;
remind_at: string | null;
recurrence: string | null;
labels: NoteLabel[];
items: ChecklistItem[];
attachments: Attachment[];
previews: LinkPreview[];
created_at: string | null;
updated_at: string | null;
}
export const useNotesStore = defineStore("notes", () => {
const items = ref<Note[]>([]);
const loading = ref(false);
const view = ref<NoteView>("active");
const activeLabel = ref<string | null>(null);
const activeFacets = ref<NoteFacets>({});
function sortItems(): void {
items.value.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
if (a.position !== b.position) return b.position - a.position;
return (b.updated_at ?? "").localeCompare(a.updated_at ?? "");
});
}
function belongsHere(n: Note): boolean {
const v = view.value;
const inView =
v === "trash" ? n.trashed : v === "archived" ? !n.trashed && n.archived : !n.trashed && !n.archived;
if (!inView) return false;
if (activeLabel.value) return n.labels.some((lb) => lb.id === activeLabel.value);
return true;
}
function reconcile(note: Note): void {
const idx = items.value.findIndex((n) => n.id === note.id);
if (belongsHere(note)) {
if (idx >= 0) items.value[idx] = note;
else items.value.push(note);
sortItems();
} else if (idx >= 0) {
items.value.splice(idx, 1);
}
}
async function load(v: NoteView, labelId: string | null = null, facets: NoteFacets = {}): Promise<void> {
view.value = v;
activeLabel.value = labelId;
activeFacets.value = facets;
loading.value = true;
try {
items.value = await repo.notes.list({ view: v, labelId, facets });
sortItems();
} finally {
loading.value = false;
}
}
async function create(input: { body: string; items?: string[] }): Promise<Note> {
const note = await repo.notes.create(input);
reconcile(note);
return note;
}
async function mutate(
id: string,
changes: Partial<Pick<Note, "body" | "pinned" | "archived" | "remind_at" | "recurrence">>,
): Promise<void> {
reconcile(await repo.notes.update(id, changes));
}
const setPinned = (id: string, pinned: boolean) => mutate(id, { pinned });
const setArchived = async (id: string, archived: boolean): Promise<void> => {
await mutate(id, { archived });
if (archived)
useUiStore().showToast("Note archived", { label: "Undo", run: () => void setArchived(id, false) });
};
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 }) => mutate(id, changes);
async function completeReminder(id: string): Promise<void> {
reconcile(await repo.notes.completeReminder(id));
}
async function snoozeReminder(id: string, minutes: number): Promise<void> {
reconcile(await repo.notes.snoozeReminder(id, minutes));
}
async function setLabels(id: string, labelIds: string[]): Promise<void> {
reconcile(await repo.notes.setLabels(id, labelIds));
}
async function addItem(id: string, text: string): Promise<void> {
reconcile(await repo.notes.addItem(id, text));
}
async function updateItem(id: string, itemId: string, changes: { text?: string; checked?: boolean }): Promise<void> {
reconcile(await repo.notes.updateItem(id, itemId, changes));
}
async function deleteItem(id: string, itemId: string): Promise<void> {
reconcile(await repo.notes.deleteItem(id, itemId));
}
async function uploadAttachment(id: string, file: File): Promise<void> {
reconcile(await repo.notes.uploadAttachment(id, file));
}
async function deleteAttachment(id: string, attId: string): Promise<void> {
reconcile(await repo.notes.deleteAttachment(id, attId));
}
async function unfurl(id: string, url: string): Promise<void> {
reconcile(await repo.notes.unfurl(id, url));
}
async function deletePreview(id: string, previewId: string): Promise<void> {
reconcile(await repo.notes.deletePreview(id, previewId));
}
async function importNotes(file: File): Promise<{ source: string; imported: number; skipped: number }> {
const data = await repo.notes.import(file);
// Refresh the current lens so imported notes appear (labels reloaded by caller).
await load(view.value, activeLabel.value, activeFacets.value);
return data;
}
async function fetchOne(id: string): Promise<Note | null> {
try {
return await repo.notes.get(id);
} catch {
return null;
}
}
async function reorder(orderedIds: string[]): Promise<void> {
// Optimistically assign positions matching the backend (total - index), sort,
// then persist.
const total = orderedIds.length;
orderedIds.forEach((id, index) => {
const n = items.value.find((x) => x.id === id);
if (n) n.position = total - index;
});
sortItems();
await repo.notes.reorder(orderedIds);
}
async function trash(id: string): Promise<void> {
reconcile(await repo.notes.trash(id));
useUiStore().showToast("Note moved to trash", { label: "Undo", run: () => void restore(id) });
}
async function restore(id: string): Promise<void> {
reconcile(await repo.notes.restore(id));
}
async function deleteForever(id: string): Promise<void> {
// Guarded HERE rather than at the call sites (NoteCard and NoteEditor both offer
// it) so the two can't drift on the one action with no undo. Trash is the
// reversible step and already offers Undo; this is the point of no return — and
// since sync propagates a tombstone, it reaches every linked device too.
const note = items.value.find((n) => n.id === id);
const title = note?.display_title.trim();
const subject = title ? `"${title}"` : "this note";
const confirmed = window.confirm(
`Permanently delete ${subject}?\n\n` +
"This can't be undone, and it will be deleted from every device you sync with.",
);
if (!confirmed) return;
await repo.notes.deleteForever(id);
const idx = items.value.findIndex((n) => n.id === id);
if (idx >= 0) items.value.splice(idx, 1);
}
async function fetchRevisions(id: string): Promise<NoteRevision[]> {
return await repo.notes.revisions(id);
}
async function restoreRevision(id: string, revId: string): Promise<Note> {
const note = await repo.notes.restoreRevision(id, revId);
reconcile(note);
return note;
}
return {
items,
loading,
view,
activeLabel,
activeFacets,
load,
create,
setPinned,
setArchived,
setReminder,
setRecurrence,
completeReminder,
snoozeReminder,
saveEdit,
setLabels,
addItem,
updateItem,
deleteItem,
uploadAttachment,
deleteAttachment,
unfurl,
deletePreview,
importNotes,
fetchOne,
reorder,
trash,
restore,
deleteForever,
fetchRevisions,
restoreRevision,
};
});