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
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:
@@ -0,0 +1,82 @@
|
||||
"""drop notes.title and note_revisions.title — a note's name is its first line
|
||||
|
||||
Revision ID: 0026
|
||||
Revises: 0025
|
||||
Create Date: 2026-08-22
|
||||
|
||||
M13 step 3. A note is a body plus optional checkable items; its NAME is the first
|
||||
non-empty line of that body, falling back to its first checklist item. There is no
|
||||
separate field to type into, and `display_title` (already persisted, already what
|
||||
search results and export filenames read) carries the name.
|
||||
|
||||
## The search vector has to be rebuilt, not just left alone
|
||||
|
||||
`notes.search_vector` is a STORED GENERATED column whose expression names `title`
|
||||
(migration 0005, weight A) — Postgres will refuse to drop a column another generated
|
||||
column depends on, and even if it didn't, the weighting would be wrong. So it is
|
||||
dropped and recreated over `display_title` instead, which keeps the original
|
||||
intent: the note's NAME ranks above the rest of its body.
|
||||
|
||||
Rebuilding a stored generated column re-computes every row, and the GIN index is
|
||||
rebuilt with it. On a personal instance that is milliseconds; it is worth knowing
|
||||
before running this against something large.
|
||||
|
||||
## What happens to existing titles
|
||||
|
||||
Nothing preserves them, deliberately: `display_title` was already derived from the
|
||||
title when one was set, so every note keeps the NAME it had. What is lost is the
|
||||
distinction between "this note has an explicit title" and "this note's first line is
|
||||
its name" — which is the distinction being removed.
|
||||
|
||||
Imports are the exception and are handled in code, not here: a Keep note's title, or
|
||||
one in an export taken before this, is folded in as the note's first body line rather
|
||||
than dropped (see `_create_imported_note`).
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0026"
|
||||
down_revision = "0025"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Order matters: the generated column depends on `title`, so it goes first.
|
||||
op.execute("DROP INDEX IF EXISTS ix_notes_search")
|
||||
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector")
|
||||
|
||||
op.drop_column("notes", "title")
|
||||
op.drop_column("note_revisions", "title")
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE notes ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
setweight(to_tsvector('english', coalesce(display_title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
|
||||
) STORED
|
||||
"""
|
||||
)
|
||||
op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_notes_search")
|
||||
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector")
|
||||
|
||||
# Comes back empty. The text is not gone — it is the first line of every body —
|
||||
# but which notes once had an explicit title is not recorded anywhere.
|
||||
op.add_column("notes", sa.Column("title", sa.Text(), nullable=True))
|
||||
op.add_column("note_revisions", sa.Column("title", sa.Text(), nullable=True))
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE notes ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
|
||||
) STORED
|
||||
"""
|
||||
)
|
||||
op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)")
|
||||
Reference in New Issue
Block a user