diff --git a/alembic/versions/0012_note_display_title.py b/alembic/versions/0012_note_display_title.py new file mode 100644 index 0000000..507f93c --- /dev/null +++ b/alembic/versions/0012_note_display_title.py @@ -0,0 +1,36 @@ +"""notes.display_title + +Revision ID: 0012 +Revises: 0011 +Create Date: 2026-07-22 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0012" +down_revision = "0011" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # The note's display NAME: explicit title if set, else the first body line. + op.add_column("notes", sa.Column("display_title", sa.Text(), nullable=False, server_default="")) + # Best-effort backfill (kept intentionally simple + unambiguous — CI has no + # Postgres lane, so this only runs on a real deploy). The app recomputes the + # precise value via derive_display_title on the next save (which additionally + # skips leading blank lines). Capped at 200 chars. + op.execute( + r""" + UPDATE notes SET display_title = LEFT(btrim( + CASE + WHEN title IS NOT NULL AND btrim(title) <> '' THEN title + ELSE split_part(body, E'\n', 1) + END + ), 200) + """ + ) + + +def downgrade() -> None: + op.drop_column("notes", "display_title") diff --git a/frontend/src/components/NoteEditor.vue b/frontend/src/components/NoteEditor.vue index 7457a16..c8d5b1f 100644 --- a/frontend/src/components/NoteEditor.vue +++ b/frontend/src/components/NoteEditor.vue @@ -279,7 +279,7 @@ async function act(fn: () => Promise) {
diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index d42239e..2e4e506 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -29,6 +29,9 @@ export interface Attachment { export interface Note { id: string; title: string | null; + // The note's display NAME: explicit title, else its first body line (server-derived). + // Every note has one, so body-only notes are still nameable + [[link]]-able. + display_title: string; body: string; color: NoteColor; kind: NoteKind; diff --git a/src/thoughtsync/graph.py b/src/thoughtsync/graph.py index f480fe6..053e4eb 100644 --- a/src/thoughtsync/graph.py +++ b/src/thoughtsync/graph.py @@ -19,14 +19,14 @@ async def get_graph(): """Wiki-link graph. Nodes are ALL of the owner's non-trashed notes (the frontend toggles whether to show unlinked ones); each carries its first label's color for clustering. Edges are resolved [[links]] (note_links.target_norm matched to a - note's normalized title).""" + note's normalized display_title — its explicit title or first body line).""" source = aliased(Note) target = aliased(Note) edge_stmt = ( select(source.id, target.id) .select_from(NoteLink) .join(source, source.id == NoteLink.source_id) - .join(target, func.lower(func.trim(target.title)) == NoteLink.target_norm) + .join(target, func.lower(func.trim(target.display_title)) == NoteLink.target_norm) .where( source.owner_id == g.user_id, source.deleted_at.is_(None), @@ -63,7 +63,7 @@ async def get_graph(): await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None))) ).all() nodes = [ - {"id": str(n.id), "title": n.title or "Untitled", "color": first_color.get(n.id, "default")} + {"id": str(n.id), "title": n.display_title or "Untitled", "color": first_color.get(n.id, "default")} for n in note_rows ] return jsonify({"nodes": nodes, "edges": edges}) diff --git a/src/thoughtsync/models/note.py b/src/thoughtsync/models/note.py index 3884028..6563adc 100644 --- a/src/thoughtsync/models/note.py +++ b/src/thoughtsync/models/note.py @@ -38,6 +38,11 @@ class Note(Base): 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 + normalized-matched so every + # note — even a body-only one — is nameable, searchable, graphable, and + # [[wiki-link]]-able without forcing the user to type a title. + 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") # 'text' (freeform body) or 'list' (a checklist of note_items). @@ -59,6 +64,7 @@ class Note(Base): return { "id": str(self.id), "title": self.title, + "display_title": self.display_title, "body": self.body, "color": self.color, "kind": self.kind, diff --git a/src/thoughtsync/notes.py b/src/thoughtsync/notes.py index f5cf9f8..e10ae4a 100644 --- a/src/thoughtsync/notes.py +++ b/src/thoughtsync/notes.py @@ -38,6 +38,22 @@ bp = Blueprint("notes", __name__, url_prefix="/api/notes") 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] + for line in (body or "").splitlines(): + stripped = line.strip() + if stripped: + return stripped[:DISPLAY_TITLE_CAP] + return "" + def is_empty_note(title: str | None, body: str | None) -> bool: return not (title or "").strip() and not (body or "").strip() @@ -253,15 +269,16 @@ async def list_reminders(): @bp.get("/titles") @login_required async def list_titles(): - # Owner's non-trashed titled notes — the index the frontend uses to resolve - # [[wiki-links]] client-side. + # Owner's non-trashed notes, keyed by their display NAME (explicit title or + # first body line) — the index the frontend uses to resolve + autocomplete + # [[wiki-links]]. Every note has a name now, so body-only notes are linkable too. async with session_scope() as db: rows = ( - await db.scalars( - select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None), Note.title.is_not(None)) - ) + await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None))) ).all() - return jsonify({"titles": [{"id": str(n.id), "title": n.title} for n in rows]}) + return jsonify( + {"titles": [{"id": str(n.id), "title": n.display_title} for n in rows if n.display_title]} + ) @bp.get("//backlinks") @@ -277,9 +294,9 @@ async def note_backlinks(note_id: str): ) if note is None: return jsonify({"error": "not found"}), 404 - if not note.title: + if not note.display_title: return jsonify({"backlinks": []}) - norm = note.title.strip().lower() + norm = note.display_title.strip().lower() sources = ( await db.scalars( select(Note) @@ -297,7 +314,7 @@ async def note_backlinks(note_id: str): for n in sources: if n.id not in seen: seen.add(n.id) - out.append({"id": str(n.id), "title": n.title}) + out.append({"id": str(n.id), "title": n.display_title}) return jsonify({"backlinks": out}) @@ -343,9 +360,11 @@ 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=title.strip() or None, + title=clean_title, + display_title=derive_display_title(clean_title, body), body=body, color=normalize_color(data.get("color")), position=int(max_pos) + 1, @@ -382,7 +401,7 @@ async def update_note(note_id: str): note = await _get_owned(db, note_id) if note is None: return jsonify({"error": "not found"}), 404 - old_title = note.title + old_display = note.display_title if "title" in data: title = data["title"] if isinstance(data["title"], str) else "" note.title = title.strip() or None @@ -405,17 +424,18 @@ async def update_note(note_id: str): note.remind_at = datetime.fromisoformat(str(raw).replace("Z", "+00:00")) except ValueError: return jsonify({"error": "invalid remind_at"}), 400 + # 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: await _rewrite_links(db, note) - # A rename repoints inbound [[Old Title]] references so backlinks survive - # (skip pure case/whitespace changes, which still resolve). - if ( - "title" in data - and old_title - and note.title - and old_title.strip().lower() != note.title.strip().lower() - ): - await _rename_inbound_links(db, note, old_title, note.title) + # The display NAME changing — via an explicit title OR the first body line — + # repoints inbound [[Old Name]] references so backlinks survive (skip pure + # case/whitespace changes, which still resolve). + new_display = note.display_title + if old_display and new_display and old_display.strip().lower() != new_display.strip().lower(): + await _rename_inbound_links(db, note, old_display, new_display) await db.commit() await db.refresh(note) return jsonify(await _serialize_note(db, note)) diff --git a/tests/test_notes.py b/tests/test_notes.py index 0e6d8bb..8ebc49c 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -2,7 +2,13 @@ import pytest from thoughtsync.app import create_app from thoughtsync.models.note import NOTE_COLORS, Note -from thoughtsync.notes import is_empty_note, normalize_color, parse_link_titles, rewrite_link_title +from thoughtsync.notes import ( + derive_display_title, + is_empty_note, + normalize_color, + parse_link_titles, + rewrite_link_title, +) @pytest.fixture @@ -97,6 +103,32 @@ def test_rewrite_link_title_noop(): assert rewrite_link_title("no links here", "alpha", "Gamma") == "no links here" +def test_derive_display_title_explicit_wins(): + assert derive_display_title("My Title", "some body line") == "My Title" + assert derive_display_title(" Padded ", "body") == "Padded" + + +def test_derive_display_title_from_first_body_line(): + assert derive_display_title(None, "first line\nsecond line") == "first line" + assert derive_display_title("", " spaced first \nnext") == "spaced first" + # leading blank/whitespace lines are skipped to the first line with content + assert derive_display_title(None, "\n \nreal line\nmore") == "real line" + # a whitespace-only title falls through to the body + assert derive_display_title(" ", "body wins") == "body wins" + + +def test_derive_display_title_empty(): + assert derive_display_title(None, None) == "" + assert derive_display_title("", "") == "" + assert derive_display_title(" ", " \n ") == "" + + +def test_derive_display_title_caps_length(): + long = "x" * 300 + assert derive_display_title(None, long) == "x" * 200 + assert derive_display_title(long, "body") == "x" * 200 + + async def test_titles_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes/titles")