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:
@@ -139,8 +139,9 @@ async def list_notes():
|
||||
return json_error("invalid created_before", 400)
|
||||
stmt = stmt.where(Note.created_at < before_dt)
|
||||
if query_text:
|
||||
# Full-text match over title+body (generated tsvector, migration 0005),
|
||||
# ranked — so the facet bar's text box searches, not just filters.
|
||||
# Full-text match over the note's name + body (generated tsvector,
|
||||
# migrations 0005/0026), ranked — so the facet bar's text box searches,
|
||||
# not just filters.
|
||||
tsquery = func.websearch_to_tsquery("english", query_text)
|
||||
search_col = literal_column("notes.search_vector")
|
||||
stmt = stmt.where(search_col.op("@@")(tsquery)).order_by(
|
||||
@@ -273,7 +274,6 @@ async def export_notes():
|
||||
payload["notes"].append(
|
||||
{
|
||||
"id": str(n.id),
|
||||
"title": n.title,
|
||||
"display_title": n.display_title,
|
||||
"body": n.body,
|
||||
"color": n.color,
|
||||
@@ -406,17 +406,32 @@ async def reorder_notes():
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
async def _name_for(db, note: Note, item_texts: list[str] | None = None) -> str:
|
||||
"""The note's display name, consulting its checklist only when the body is silent.
|
||||
|
||||
`item_texts` short-circuits the query for callers that already hold the items
|
||||
(create, import). Everyone else pays one narrow SELECT, and only when the body
|
||||
produced nothing — which is the uncommon case.
|
||||
"""
|
||||
name = derive_display_title(note.body)
|
||||
if name:
|
||||
return name
|
||||
if item_texts is not None:
|
||||
return derive_display_title("", item_texts[0] if item_texts else None)
|
||||
first = await db.scalar(
|
||||
select(NoteItem.text).where(NoteItem.note_id == note.id).order_by(NoteItem.position).limit(1)
|
||||
)
|
||||
return derive_display_title("", first)
|
||||
|
||||
|
||||
@bp.post("")
|
||||
@login_required
|
||||
async def create_note():
|
||||
data = await request.get_json(silent=True) or {}
|
||||
title = data.get("title") if isinstance(data.get("title"), str) else ""
|
||||
body = data.get("body") if isinstance(data.get("body"), str) else ""
|
||||
# Items are accepted on ANY note now — a checklist is something a note HAS.
|
||||
# Items are accepted on ANY note — a checklist is something a note HAS.
|
||||
item_texts = parse_list_items(data.get("items"))
|
||||
# "Empty" therefore means all three are empty, not just the two that used to
|
||||
# matter for whichever kind this was.
|
||||
if is_empty_note(title, body) and not item_texts:
|
||||
if is_empty_note(body, item_texts):
|
||||
return json_error("note is empty", 400)
|
||||
async with session_scope() as db:
|
||||
# New notes go to the top of the manual order.
|
||||
@@ -425,11 +440,9 @@ async def create_note():
|
||||
Note.owner_id == g.user_id, Note.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
clean_title = title.strip() or None
|
||||
note = Note(
|
||||
owner_id=g.user_id,
|
||||
title=clean_title,
|
||||
display_title=derive_display_title(clean_title, body),
|
||||
display_title=derive_display_title(body, item_texts[0] if item_texts else None),
|
||||
body=body,
|
||||
color=normalize_color(data.get("color")),
|
||||
position=int(max_pos) + 1,
|
||||
@@ -467,11 +480,7 @@ async def update_note(note_id: str):
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return not_found()
|
||||
old_title = note.title
|
||||
old_body = note.body
|
||||
if "title" in data:
|
||||
title = data["title"] if isinstance(data["title"], str) else ""
|
||||
note.title = title.strip() or None
|
||||
if "body" in data and isinstance(data["body"], str):
|
||||
note.body = data["body"]
|
||||
if "color" in data:
|
||||
@@ -492,15 +501,12 @@ async def update_note(note_id: str):
|
||||
note.remind_at = remind_dt
|
||||
if "recurrence" in data:
|
||||
note.recurrence = normalize_recurrence(data["recurrence"])
|
||||
# Recompute the display name (explicit title, else first body line) whenever
|
||||
# the title or body may have changed.
|
||||
if "title" in data or "body" in data:
|
||||
note.display_title = derive_display_title(note.title, note.body)
|
||||
if "body" in data:
|
||||
note.display_title = await _name_for(db, note)
|
||||
await _reconcile_tags(db, note)
|
||||
# Version history: snapshot the PRE-edit title+body whenever either changed.
|
||||
if note.title != old_title or note.body != old_body:
|
||||
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
|
||||
# Version history: snapshot the PRE-edit body whenever it changed.
|
||||
if note.body != old_body:
|
||||
db.add(NoteRevision(note_id=note.id, body=old_body))
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
return jsonify(await _serialize_note(db, note))
|
||||
@@ -509,7 +515,6 @@ async def update_note(note_id: str):
|
||||
def _serialize_revision(rev: NoteRevision) -> dict:
|
||||
return {
|
||||
"id": str(rev.id),
|
||||
"title": rev.title,
|
||||
"body": rev.body,
|
||||
"created_at": iso(rev.created_at),
|
||||
}
|
||||
@@ -546,14 +551,13 @@ async def restore_revision(note_id: str, rev_id: str):
|
||||
rev = await db.scalar(select(NoteRevision).where(NoteRevision.id == rid, NoteRevision.note_id == note.id))
|
||||
if rev is None:
|
||||
return not_found()
|
||||
if note.title == rev.title and note.body == rev.body:
|
||||
if note.body == rev.body:
|
||||
return jsonify(await _serialize_note(db, note)) # already at this version — no-op
|
||||
# Snapshot the CURRENT state first, so restoring is itself undoable, then apply
|
||||
# the revision — with the same title/body ripple as a normal edit.
|
||||
db.add(NoteRevision(note_id=note.id, title=note.title, body=note.body))
|
||||
note.title = rev.title
|
||||
# the revision — with the same body ripple as a normal edit.
|
||||
db.add(NoteRevision(note_id=note.id, body=note.body))
|
||||
note.body = rev.body
|
||||
note.display_title = derive_display_title(note.title, note.body)
|
||||
note.display_title = await _name_for(db, note)
|
||||
await _reconcile_tags(db, note)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
|
||||
@@ -19,22 +19,30 @@ VALID_FILTERS = {"active", "archived", "trash"}
|
||||
DISPLAY_TITLE_CAP = 200
|
||||
|
||||
|
||||
def derive_display_title(title: str | None, body: str | None) -> str:
|
||||
"""The note's display NAME: the explicit title if set, else the first non-empty
|
||||
line of the body (trimmed, length-capped). Persisted as notes.display_title so a
|
||||
body-only note is still nameable/searchable/linkable — the user never has to type
|
||||
a title. Deterministic (literal first line, no AI)."""
|
||||
if title and title.strip():
|
||||
return title.strip()[:DISPLAY_TITLE_CAP]
|
||||
def derive_display_title(body: str | None, first_item: str | None = None) -> str:
|
||||
"""The note's display NAME: the first non-empty line of the body, else the first
|
||||
checklist item's text (both trimmed and length-capped).
|
||||
|
||||
There is no explicit title to prefer any more (M13 step 3) — a note is a body plus
|
||||
optional items, and its name is simply the first thing written in it. Persisted as
|
||||
notes.display_title so search results and export filenames have something to say.
|
||||
|
||||
The item fallback is what step 2 bought: a note that is only a checklist would
|
||||
otherwise have no name at all, which is exactly the hole that made removing the
|
||||
title unsafe before checklists stopped being their own kind of thing.
|
||||
|
||||
Deterministic — a literal first line, never generated.
|
||||
"""
|
||||
for line in (body or "").splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped:
|
||||
return stripped[:DISPLAY_TITLE_CAP]
|
||||
return ""
|
||||
return (first_item or "").strip()[:DISPLAY_TITLE_CAP]
|
||||
|
||||
|
||||
def is_empty_note(title: str | None, body: str | None) -> bool:
|
||||
return not (title or "").strip() and not (body or "").strip()
|
||||
def is_empty_note(body: str | None, items: list | None = None) -> bool:
|
||||
"""Nothing worth keeping: no body text and no checklist items."""
|
||||
return not (body or "").strip() and not items
|
||||
|
||||
|
||||
def parse_list_items(raw: object) -> list[str]:
|
||||
|
||||
@@ -36,8 +36,6 @@ def _note_markdown(note: Note, labels: list, items: list) -> str:
|
||||
"""One note as a human-readable Markdown file with a small frontmatter block.
|
||||
The authoritative machine format is notes.json; this is for reading/portability."""
|
||||
fm = ["---"]
|
||||
if note.title:
|
||||
fm.append(f"title: {note.title}")
|
||||
fm.append(f"display_name: {note.display_title}")
|
||||
if labels:
|
||||
fm.append("labels: [" + ", ".join(lb["name"] for lb in labels) + "]")
|
||||
@@ -276,19 +274,29 @@ async def _create_imported_note(
|
||||
db, owner_id, spec: dict, zf: zipfile.ZipFile, position: int, budget: _ImportBudget
|
||||
) -> bool:
|
||||
"""Insert one imported note plus its items/labels/attachments, reusing the same
|
||||
display-title derivation + tag/link reconciliation as create_note. Returns False
|
||||
(nothing written) when the spec is empty."""
|
||||
title = (spec.get("title") or "").strip() or None
|
||||
name derivation + tag reconciliation as create_note. Returns False (nothing
|
||||
written) when the spec is empty."""
|
||||
body = spec.get("body") or ""
|
||||
# An imported title becomes the note's FIRST BODY LINE.
|
||||
#
|
||||
# ThoughtSync has no title field any more (M13 step 3), but the things people
|
||||
# import from do — Keep notes carry one, and so does any export taken before this.
|
||||
# Dropping it would silently lose text someone wrote; folding it into the body puts
|
||||
# it exactly where a name now lives, so the note comes in named the way it was.
|
||||
# Skipped when the body already opens with that line, so re-importing an export
|
||||
# this code produced doesn't stack duplicates.
|
||||
title = (spec.get("title") or "").strip()
|
||||
if title and body.lstrip().split("\n", 1)[0].strip() != title:
|
||||
body = f"{title}\n{body}" if body.strip() else title
|
||||
|
||||
items = spec.get("items") or []
|
||||
has_items = any((it.get("text") or "").strip() for it in items)
|
||||
if is_empty_note(title, body) and not has_items:
|
||||
item_texts = [t for t in ((it.get("text") or "").strip() for it in items) if t]
|
||||
if is_empty_note(body, item_texts):
|
||||
return False
|
||||
|
||||
note = Note(
|
||||
owner_id=owner_id,
|
||||
title=title,
|
||||
display_title=derive_display_title(title, body),
|
||||
display_title=derive_display_title(body, item_texts[0] if item_texts else None),
|
||||
body=body,
|
||||
color=normalize_color(spec.get("color")),
|
||||
pinned=bool(spec.get("pinned")),
|
||||
|
||||
Reference in New Issue
Block a user