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>
295 lines
9.1 KiB
TypeScript
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,
|
|
};
|
|
});
|