Remove the title field — a note is named by its first line
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s

Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME
stays — search results, export filenames and the command palette all need one —
but nothing is typed into it any more. `display_title` is now the first non-empty
line of the body, falling back to the first checklist item.

That fallback is what step 2 bought, and the reason this could not go first: a
checklist had no body to be named from, so the title was its only name. Now every
note has a body, and a note that is only a checklist is named by its first item.

Gone everywhere: the column and note_revisions.title (0026), the field on the
core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7),
`normalize_title`, the wire field, the FFI record and `NoteEdit::Title` /
`ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and
the Android title field in both the compose sheet and the editor.

**The search vector had to be rebuilt, not just left alone.** `notes.search_vector`
is a STORED GENERATED column whose expression names `title` — Postgres refuses to
drop a column another generated column depends on. It is dropped and recreated over
`display_title` at weight A, which keeps the original intent: a note's NAME ranks
above the rest of its body.

**An imported title becomes the note's first body line.** Keep notes carry one, and
so does any ThoughtSync export taken before this. Dropping it would silently lose
text someone wrote; folding it in puts it exactly where a name now lives, so the
note arrives named as it was. Skipped when the body already opens with that line,
so re-importing an export this code produced doesn't stack duplicates.

Two smaller things fell out. The Android editor loses its bold first field — one
weight throughout, because the first line is the note's name but not a different
KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s
justification comment moved to `ClearRemindAt`, which is now the surviving example
of why NoteEdit is a list rather than a struct of options.

Protocol note corrected to say what actually shipped: v2 is "no kind, no title",
one bump for the pair.

Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests
all green before pushing. It caught four things — orphaned serde attributes where
fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview
still has one), nine retention fixtures inserting a dropped column, and four
rustfmt diffs.
This commit is contained in:
2026-08-22 19:33:57 -04:00
parent 6d778f26a7
commit 95aa10c2c3
29 changed files with 420 additions and 435 deletions
+2 -5
View File
@@ -221,14 +221,11 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
@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 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.items.length && !note.attachments.length"
v-if="!note.body && !note.items.length && !note.attachments.length"
class="text-sm italic text-neutral-400"
>
Empty note
@@ -236,7 +233,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
</div>
<NoteChecklist
v-if="note.items.length"
:class="note.body || note.title ? 'mt-2' : ''"
:class="note.body ? 'mt-2' : ''"
:note-id="note.id"
:items="note.items"
@click="emit('open', note)"
+11 -33
View File
@@ -27,7 +27,6 @@ const notes = useNotesStore();
const config = useConfigStore();
const noteId = ref<string | null>(props.note?.id ?? null);
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] : []);
@@ -42,14 +41,13 @@ const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref("");
// Baseline for edit-mode change detection (save only when text actually changed).
const baseline = ref<{ title: string | null; body: string; color: NoteColor }>({
title: props.note?.title ?? null,
const baseline = ref<{ body: string; color: NoteColor }>({
body: props.note?.body ?? "",
color: (props.note?.color ?? "default") as NoteColor,
});
const isCreate = computed(() => noteId.value === null);
const hasContent = computed(() => title.value.trim() !== "" || body.value.trim() !== "");
const hasContent = computed(() => body.value.trim() !== "");
// Rich features need a saved note; in compose they light up once there's content.
const richEnabled = computed(() => !isCreate.value || hasContent.value);
@@ -57,7 +55,6 @@ const richEnabled = computed(() => !isCreate.value || hasContent.value);
// 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,
@@ -96,19 +93,18 @@ watch(
() => props.note,
(n) => {
noteId.value = n?.id ?? null;
title.value = n?.title ?? "";
body.value = n?.body ?? "";
color.value = (n?.color ?? "default") as NoteColor;
labelList.value = n ? [...n.labels] : [];
baseline.value = { title: n?.title ?? null, body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
},
);
// ---- persistence ----
async function createFromFields(): Promise<void> {
const created = await notes.create({ title: title.value, body: body.value, color: color.value });
const created = await notes.create({ body: body.value, color: color.value });
noteId.value = created.id;
baseline.value = { title: created.title, body: created.body, color: created.color as NoteColor };
baseline.value = { body: created.body, color: created.color as NoteColor };
}
// Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or
@@ -135,12 +131,12 @@ async function flush(): Promise<void> {
}
const b = baseline.value;
const nextBody = body.value;
const changed = (title.value.trim() || null) !== b.title || nextBody !== b.body || color.value !== b.color;
const changed = nextBody !== b.body || color.value !== b.color;
if (!changed) return;
saving.value = true;
try {
await notes.saveEdit(noteId.value as string, { title: title.value, body: nextBody, color: color.value });
baseline.value = { title: title.value.trim() || null, body: nextBody, color: color.value };
await notes.saveEdit(noteId.value as string, { body: nextBody, color: color.value });
baseline.value = { body: nextBody, color: color.value };
} finally {
saving.value = false;
}
@@ -148,12 +144,11 @@ async function flush(): Promise<void> {
function resetCompose(): void {
noteId.value = null;
title.value = "";
body.value = "";
color.value = "default";
labelList.value = [];
checklistOpen.value = false;
baseline.value = { title: null, body: "", color: "default" };
baseline.value = { body: "", color: "default" };
uploadError.value = "";
}
@@ -249,12 +244,6 @@ function onBodyKeydown(e: KeyboardEvent) {
}
}
function onTitleEnter(e: KeyboardEvent) {
e.preventDefault();
if (e.shiftKey && isCreate.value) void commitAndContinue();
else bodyInput.value?.focus();
}
// ---- reminder ----
const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at));
async function onReminderChange(e: Event) {
@@ -413,10 +402,9 @@ async function restoreRevisionAt(revId: string) {
const id = noteId.value;
if (!id) return;
const updated = await notes.restoreRevision(id, revId);
title.value = updated.title ?? "";
body.value = updated.body;
color.value = updated.color;
baseline.value = { title: updated.title, body: updated.body, color: updated.color };
baseline.value = { body: updated.body, color: updated.color };
void loadRevisions(); // the pre-restore state became a new revision
}
function revLabel(iso: string | null): string {
@@ -424,9 +412,7 @@ function revLabel(iso: string | null): string {
return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}
function revPreview(rev: NoteRevision): string {
const t = (rev.title ?? "").trim();
const b = rev.body.trim().replace(/\s+/g, " ");
const s = t && b ? `${t}${b}` : t || b;
const s = rev.body.trim().replace(/\s+/g, " ");
if (!s) return "(empty)";
return s.length > 80 ? `${s.slice(0, 80)}` : s;
}
@@ -536,14 +522,6 @@ function revPreview(rev: NoteRevision): string {
</div>
<p v-if="unfurlError" class="text-xs text-red-600 dark:text-red-400">{{ unfurlError }}</p>
<input
v-model="title"
type="text"
placeholder="Title (optional)"
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
@keydown.enter="onTitleEnter"
/>
<textarea
ref="bodyInput"
v-model="body"