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:
@@ -38,11 +38,11 @@ class Note(Base):
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||
# The note's display NAME: explicit title if set, else the first non-empty body
|
||||
# line (see notes.derive_display_title). Persisted so every note — even a body-only
|
||||
# one — has something to be called in search results and in an export filename,
|
||||
# without forcing the user to type a title.
|
||||
# The note's NAME: its first non-empty body line, else its first checklist item
|
||||
# (see notes.derive_display_title). There is no title field to prefer — a note is
|
||||
# a body plus optional items, and this is simply the first thing written in it.
|
||||
# Persisted so search results and export filenames have something to say, and so
|
||||
# the full-text vector can weight it above the rest of the body.
|
||||
display_title: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
||||
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
||||
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
|
||||
@@ -73,7 +73,6 @@ class Note(Base):
|
||||
def serialize(self) -> dict:
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"title": self.title,
|
||||
"display_title": self.display_title,
|
||||
"body": self.body,
|
||||
"color": self.color,
|
||||
|
||||
@@ -11,9 +11,9 @@ from . import Base
|
||||
|
||||
|
||||
class NoteRevision(Base):
|
||||
"""A point-in-time snapshot of a note's title+body, written on each edit that
|
||||
changes either — so an accidental overwrite can be viewed and restored. Only
|
||||
title+body are versioned in v1 (not items/attachments/labels)."""
|
||||
"""A point-in-time snapshot of a note's body, written on each edit that changes
|
||||
it — so an accidental overwrite can be viewed and restored. Only the body is
|
||||
versioned (not items/attachments/labels)."""
|
||||
|
||||
__tablename__ = "note_revisions"
|
||||
__table_args__ = (Index("ix_note_revisions_note_created", "note_id", "created_at"),)
|
||||
@@ -22,6 +22,5 @@ class NoteRevision(Base):
|
||||
note_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -89,7 +89,6 @@ async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None:
|
||||
await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id))
|
||||
await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))
|
||||
await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id))
|
||||
note.title = None
|
||||
note.body = ""
|
||||
note.display_title = ""
|
||||
# `deleted_at` deliberately SURVIVES. It's still true — that is when the note was
|
||||
|
||||
+28
-13
@@ -55,13 +55,12 @@ MAX_PUSH = 1000 # per-batch change cap
|
||||
# Bump SYNC_PROTOCOL_VERSION for ANY wire change. Raise
|
||||
# MIN_CLIENT_PROTOCOL_VERSION only for a genuinely BREAKING one: it is the switch
|
||||
# that hard-blocks older clients, so additive changes must leave it alone.
|
||||
# v2 (M13): `kind` left the wire. Dropping a field a v1 client sends and expects back
|
||||
# is breaking, so the FLOOR moves too — a v1 client would keep pushing a `kind` the
|
||||
# server no longer stores, and would read back notes without one.
|
||||
# v2 (M13): `kind` and `title` both left the wire. Dropping a field a v1 client sends
|
||||
# and expects back is breaking, so the FLOOR moves too — a v1 client would keep pushing
|
||||
# both and would read back notes carrying neither.
|
||||
#
|
||||
# `title` goes the same way in step 3. It lands in this same protocol generation, so
|
||||
# it needs no further bump — v2 means "no kind, no title", and nothing has run against
|
||||
# a half-applied v2.
|
||||
# One bump for the pair: they landed in the same protocol generation, and nothing ever
|
||||
# ran against a half-applied v2.
|
||||
SYNC_PROTOCOL_VERSION = 2
|
||||
MIN_CLIENT_PROTOCOL_VERSION = 2
|
||||
|
||||
@@ -197,8 +196,6 @@ def client_wins(client_edited_at: datetime | None, server_edited_at: datetime |
|
||||
def _assign_note_fields(note: Note, ch: dict) -> None:
|
||||
"""Overwrite a note's scalar fields from a client's FULL-state change (sync is
|
||||
whole-note, not a partial patch — the client sends its authoritative version)."""
|
||||
title = ch.get("title")
|
||||
note.title = (title or "").strip() or None if isinstance(title, str) else None
|
||||
note.body = ch["body"] if isinstance(ch.get("body"), str) else ""
|
||||
note.color = normalize_color(ch.get("color"))
|
||||
note.pinned = bool(ch.get("pinned"))
|
||||
@@ -214,6 +211,24 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
|
||||
note.position = ch["position"]
|
||||
|
||||
|
||||
def _first_item_text(ch: dict) -> str:
|
||||
"""The first non-blank checklist item in a pushed change, or "".
|
||||
|
||||
Read straight from the payload rather than the database because the note's name is
|
||||
computed BEFORE `_apply_note_items` has written anything — and a note whose body is
|
||||
empty is named by its first item (M13 step 3).
|
||||
"""
|
||||
items = ch.get("items")
|
||||
if not isinstance(items, list):
|
||||
return ""
|
||||
for it in items:
|
||||
if isinstance(it, dict):
|
||||
text = (it.get("text") or "").strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
async def _apply_note_items(db, note: Note, ch: dict) -> None:
|
||||
"""Replace the note's checklist items with the client's (items sync inline).
|
||||
|
||||
@@ -296,14 +311,14 @@ async def _apply_note(db, ch: dict) -> dict:
|
||||
elif note.purged_at is not None:
|
||||
note.purged_at = None # client re-created/edited → clear the tombstone
|
||||
|
||||
old_title, old_body = note.title, note.body
|
||||
old_body = note.body
|
||||
_assign_note_fields(note, ch)
|
||||
note.display_title = derive_display_title(note.title, note.body)
|
||||
note.display_title = derive_display_title(note.body, _first_item_text(ch))
|
||||
if edited_at is not None:
|
||||
note.updated_at = edited_at
|
||||
# Non-destructive LWW: snapshot the overwritten server title+body into history.
|
||||
if not creating and (note.title != old_title or note.body != old_body):
|
||||
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
|
||||
# Non-destructive LWW: snapshot the overwritten server body into history.
|
||||
if not creating and note.body != old_body:
|
||||
db.add(NoteRevision(note_id=note.id, body=old_body))
|
||||
await db.flush() # assign note.id before items/labels/links
|
||||
await _apply_note_items(db, note, ch)
|
||||
await _reconcile_tags(db, note)
|
||||
|
||||
Reference in New Issue
Block a user