A checklist is something a note has, not something a note is
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 8s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 13s
Android / Kotlin + Rust (APK) (push) Failing after 1m43s

`kind` was never a type. A plain TEXT column with no enum and no CHECK behind
it, compared against a hardcoded ("text", "list") tuple in six places;
`note_items` was always an ordinary child table keyed by note_id; serialization
already emitted `items` whatever the kind; and the Android editor already
toggled between the two losslessly, saying so in a comment. The storage has
modelled "a body plus optional checkable items" the whole time. This deletes the
gates that forbade it.

Every surface: the create/PATCH gates, the ?kind= filter and its saved-filter
facet, the three import/export branches, the column (alembic 0025); the core's
`kind` field, its SQLite column (user_version 6), the sync wire, push and pull;
the FFI records and `NoteEdit::Kind`; and on Android `NoteKind.kt`, `DraftKind`,
the compose sheet's Note/List switch, and the branches in the card, the editor
and the chrome.

The editor's note⇄list toggle becomes "Add a checklist" — on both the web and
Android. It is not a conversion any more: nothing moves, nothing is swapped, the
body stays exactly where it is and the note gains somewhere to put items. The
card renders both, in order.

Two things that fell out of the merge rather than being aimed at:

- The Keep importer was DISCARDING `textContent` whenever a note also had
  `listContent`, because the target could only hold one. Both survive now, and
  the test says so.
- Markdown export wrote the body OR the checklist. It writes both.

Protocol goes to v2, floor included: dropping a field a v1 client sends and
expects back is breaking. `title` leaves in step 3 and lands in the same
generation, so it needs no further bump. This is the change that will make the
0.1.227 build on the operator's phone refuse to sync — the in-app updater is
independent of the handshake and remains the recovery path.

The V1 SQLite schema deliberately KEEPS the kind column. V1 is the historical
schema and every later block alters it, so removing it there would make a fresh
database run V1 without the column and then v6's DROP COLUMN against a column
that never existed — "no such column: kind" on every new install.
This commit is contained in:
2026-08-22 12:53:53 -04:00
parent 229076c82d
commit c46a4a7709
34 changed files with 240 additions and 323 deletions
+2 -3
View File
@@ -10,7 +10,7 @@
// stays in the stores — the repo is data access only.
import type { NoteColor } from "../notes/colors";
import type { Note, NoteFacets, NoteView, NoteKind, NoteRevision } from "../stores/notes";
import type { Note, NoteFacets, NoteView, NoteRevision } from "../stores/notes";
import type { Label } from "../stores/labels";
import type { SavedFilter } from "../stores/savedFilters";
import type { Device } from "../stores/devices";
@@ -35,13 +35,12 @@ export interface NoteCreateInput {
title: string;
body: string;
color: NoteColor;
kind?: NoteKind;
items?: string[];
}
// The mutable subset of a note (PATCH /api/notes/:id).
export type NoteChanges = Partial<
Pick<Note, "title" | "body" | "color" | "kind" | "pinned" | "archived" | "remind_at" | "recurrence">
Pick<Note, "title" | "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
>;
export interface ChecklistItemChanges {
-1
View File
@@ -32,7 +32,6 @@ function notesQuery(q: NoteListQuery): string {
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?.kind) params.set("kind", q.facets.kind);
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 -10
View File
@@ -11,7 +11,7 @@ import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor
import Icon from "./Icon.vue";
// A dead-simple facet bar over the board: text search + color + labels + has-reminder
// + has-attachment + kind + created-date range. The URL query IS the state, so a
// + has-attachment + created-date range. The URL query IS the state, so a
// filtered board is a shareable lens and a saved view is just a link.
const route = useRoute();
const router = useRouter();
@@ -35,9 +35,6 @@ function clearAll() {
function setColor(c: NoteColor) {
patch({ color: facets.value.color === c ? undefined : c });
}
function setKind(k: "text" | "list") {
patch({ kind: facets.value.kind === k ? undefined : k });
}
function toggleLabel(id: string) {
const cur = facets.value.label ?? [];
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
@@ -164,12 +161,6 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
<button type="button" :class="[chipBase, facets.has_attachment ? chipOn : chipOff]" @click="toggleAttachment">
Has attachment
</button>
<button type="button" :class="[chipBase, facets.kind === 'list' ? chipOn : chipOff]" @click="setKind('list')">
Lists
</button>
<button type="button" :class="[chipBase, facets.kind === 'text' ? chipOn : chipOff]" @click="setKind('text')">
Notes
</button>
</div>
<div class="flex flex-wrap items-center gap-2">
+21 -23
View File
@@ -210,28 +210,16 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" />
</div>
<!-- Checklist notes can't nest interactive controls in a <button>, so use a
focusable div; text notes keep a semantic button. -->
<template v-if="note.kind === 'list'">
<div
role="button"
tabindex="0"
class="rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
@click="emit('open', note)"
@keydown.enter="emit('open', note)"
>
<h3 v-if="note.title" class="mb-1 break-words text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{{ note.title }}
</h3>
</div>
<NoteChecklist class="mt-1" :note-id="note.id" :items="note.items" @click="emit('open', note)" />
</template>
<button
v-else
type="button"
class="block w-full cursor-text rounded text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
<!-- One render path: every note is a body plus, maybe, checkable items.
A focusable div rather than a <button>, because a checklist nests interactive
controls and those cannot live inside a button and the card is the same
shape whether or not it happens to carry items today. -->
<div
role="button"
tabindex="0"
class="cursor-text rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
@click="emit('open', note)"
@keydown.enter="emit('open', note)"
>
<h3 v-if="note.title" class="mb-1 break-words text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{{ note.title }}
@@ -239,10 +227,20 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<div v-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText :text="note.body" />
</div>
<p v-if="!note.title && !note.body && !note.attachments.length" class="text-sm italic text-neutral-400">
<p
v-if="!note.title && !note.body && !note.items.length && !note.attachments.length"
class="text-sm italic text-neutral-400"
>
Empty note
</p>
</button>
</div>
<NoteChecklist
v-if="note.items.length"
:class="note.body || note.title ? 'mt-2' : ''"
:note-id="note.id"
:items="note.items"
@click="emit('open', note)"
/>
<div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1">
<span
+39 -52
View File
@@ -31,7 +31,10 @@ const title = ref(props.note?.title ?? "");
const body = ref(props.note?.body ?? props.initialBody);
const color = ref<NoteColor>(props.note?.color ?? "default");
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
const createKind = ref<"text" | "list">("text"); // compose-only list toggle
// 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
// on when the note already carries items, and when someone asks for one.
const checklistOpen = ref(false);
const saving = ref(false);
const root = ref<HTMLElement | null>(null);
const bodyInput = ref<HTMLTextAreaElement | null>(null);
@@ -51,14 +54,13 @@ const hasContent = computed(() => title.value.trim() !== "" || body.value.trim()
const richEnabled = computed(() => !isCreate.value || hasContent.value);
// A synthetic note for compose mode (before anything is persisted), so the shared
// template can read attachments/items/kind/remind_at uniformly.
// template can read attachments/items/remind_at uniformly.
const draftNote = computed<Note>(() => ({
id: "",
title: title.value.trim() || null,
display_title: "",
body: body.value,
color: color.value,
kind: createKind.value,
position: 0,
pinned: false,
archived: false,
@@ -78,13 +80,16 @@ const liveNote = computed<Note>(() =>
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value)
: draftNote.value,
);
// Only edit-mode list notes render the interactive checklist; compose-list types
// lines into the textarea (they become items on create).
const showChecklist = computed(() => !isCreate.value && liveNote.value.kind === "list");
const isListMode = computed(() => (isCreate.value ? createKind.value === "list" : liveNote.value.kind === "list"));
const bodyPlaceholder = computed(() =>
isCreate.value && createKind.value === "list" ? "One item per line…" : "Take a note… ([[ to link a note)",
// The checklist renders once the note has items, or once someone has asked for one.
// It sits BELOW the body rather than instead of it — a note can carry both, which is
// the whole point of the merge.
//
// Items need a persisted note to hang off, so this is a rich action like attaching a
// file: in compose it waits for the draft to be saved.
const showChecklist = computed(
() => !isCreate.value && (liveNote.value.items.length > 0 || checklistOpen.value),
);
const bodyPlaceholder = "Take a note…";
// Keep local state in sync when the edited note changes (modal reused for another note).
watch(
@@ -101,17 +106,7 @@ watch(
// ---- persistence ----
async function createFromFields(): Promise<void> {
let created: Note;
if (createKind.value === "list") {
const items = body.value
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
created = await notes.create({ title: title.value, body: "", color: color.value, kind: "list", items });
body.value = ""; // the lines moved into checklist items
} else {
created = await notes.create({ title: title.value, body: body.value, color: color.value });
}
const created = await notes.create({ title: title.value, body: body.value, color: color.value });
noteId.value = created.id;
baseline.value = { title: created.title, body: created.body, color: created.color as NoteColor };
}
@@ -139,7 +134,7 @@ async function flush(): Promise<void> {
return;
}
const b = baseline.value;
const nextBody = showChecklist.value ? b.body : body.value;
const nextBody = body.value;
const changed = (title.value.trim() || null) !== b.title || nextBody !== b.body || color.value !== b.color;
if (!changed) return;
saving.value = true;
@@ -157,7 +152,7 @@ function resetCompose(): void {
body.value = "";
color.value = "default";
labelList.value = [];
createKind.value = "text";
checklistOpen.value = false;
baseline.value = { title: null, body: "", color: "default" };
uploadError.value = "";
}
@@ -301,29 +296,16 @@ function labelChip(c: string): string {
return LABEL_CHIP_CLASSES[c as NoteColor] ?? LABEL_CHIP_CLASSES.default;
}
// ---- kind toggle: compose = local flag, edit = convert the existing note ----
async function toggleKind() {
if (isCreate.value) {
createKind.value = createKind.value === "list" ? "text" : "list";
bodyInput.value?.focus();
return;
}
const id = noteId.value as string;
if (liveNote.value.kind === "list") {
await notes.setKind(id, "text");
return;
}
const lines = body.value
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (const line of lines) await notes.addItem(id, line);
if (lines.length > 0) {
body.value = "";
await notes.saveEdit(id, { title: title.value, body: "", color: color.value });
baseline.value = { title: title.value.trim() || null, body: "", color: color.value };
}
await notes.setKind(id, "list");
// ---- add a checklist ----
//
// Not a conversion any more. Nothing is moved, nothing is swapped: the note keeps its
// body and gains a place to put items. Persists the draft first for the same reason
// attaching a file does — an item needs a note to belong to.
async function addChecklist() {
if (checklistOpen.value) return;
const id = await ensureDraft();
if (!id) return;
checklistOpen.value = true;
}
// ---- attachments ----
@@ -563,7 +545,6 @@ function revPreview(rev: NoteRevision): string {
/>
<textarea
v-if="!showChecklist"
ref="bodyInput"
v-model="body"
rows="8"
@@ -571,7 +552,14 @@ function revPreview(rev: NoteRevision): string {
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@keydown="onBodyKeydown"
/>
<NoteChecklist v-else class="py-1" :note-id="liveNote.id" :items="liveNote.items" editable />
<!-- Below the body, not instead of it. -->
<NoteChecklist
v-if="showChecklist"
class="py-1"
:note-id="liveNote.id"
:items="liveNote.items"
editable
/>
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
<span
@@ -681,13 +669,12 @@ function revPreview(rev: NoteRevision): string {
</button>
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
<button
v-if="!liveNote.trashed"
v-if="richEnabled && !liveNote.trashed && !showChecklist"
type="button"
class="icon-btn"
:class="isListMode ? 'text-brand-700 dark:text-brand' : ''"
:title="isListMode ? 'Switch to a note' : 'Make a checklist'"
:aria-pressed="isListMode"
@click="toggleKind"
title="Add a checklist"
aria-label="Add a checklist"
@click="addChecklist"
>
<Icon name="checkbox" />
</button>
-4
View File
@@ -17,8 +17,6 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
if (text) f.q = text;
const color = one(q.color);
if (color) f.color = color;
const kind = one(q.kind);
if (kind === "text" || kind === "list") f.kind = kind;
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;
@@ -33,7 +31,6 @@ 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.kind) q.kind = f.kind;
if (f.label?.length) q.label = f.label;
if (f.has_reminder) q.has_reminder = "true";
if (f.has_attachment) q.has_attachment = "true";
@@ -47,7 +44,6 @@ export function facetCount(f: NoteFacets): number {
let n = 0;
if (f.q) n++;
if (f.color) n++;
if (f.kind) n++;
n += f.label?.length ?? 0;
if (f.has_reminder) n++;
if (f.has_attachment) n++;
+2 -9
View File
@@ -5,14 +5,11 @@ import { useUiStore } from "./ui";
import type { NoteColor } from "../notes/colors";
export type NoteView = "active" | "archived" | "trash";
export type NoteKind = "text" | "list";
// 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;
kind?: NoteKind;
label?: string[];
has_reminder?: boolean;
has_attachment?: boolean;
@@ -71,7 +68,6 @@ export interface Note {
display_title: string;
body: string;
color: NoteColor;
kind: NoteKind;
position: number;
pinned: boolean;
archived: boolean;
@@ -141,8 +137,7 @@ export const useNotesStore = defineStore("notes", () => {
title: string;
body: string;
color: NoteColor;
kind?: NoteKind;
items?: string[];
items?: string[];
}): Promise<Note> {
const note = await repo.notes.create(input);
reconcile(note);
@@ -152,7 +147,7 @@ export const useNotesStore = defineStore("notes", () => {
async function mutate(
id: string,
changes: Partial<
Pick<Note, "title" | "body" | "color" | "kind" | "pinned" | "archived" | "remind_at" | "recurrence">
Pick<Note, "title" | "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
>,
): Promise<void> {
reconcile(await repo.notes.update(id, changes));
@@ -165,7 +160,6 @@ export const useNotesStore = defineStore("notes", () => {
useUiStore().showToast("Note archived", { label: "Undo", run: () => void setArchived(id, false) });
};
const setColor = (id: string, color: NoteColor) => mutate(id, { color });
const setKind = (id: string, kind: NoteKind) => mutate(id, { kind });
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: { title: string; body: string; color: NoteColor }) => mutate(id, changes);
@@ -284,7 +278,6 @@ export const useNotesStore = defineStore("notes", () => {
setPinned,
setArchived,
setColor,
setKind,
setReminder,
setRecurrence,
completeReminder,