diff --git a/src/thoughtsync/notes.py b/src/thoughtsync/notes.py index c4a35b7..4d3f70b 100644 --- a/src/thoughtsync/notes.py +++ b/src/thoughtsync/notes.py @@ -153,6 +153,41 @@ async def _rewrite_links(db, note: Note) -> None: db.add(NoteLink(source_id=note.id, target_norm=norm)) +def rewrite_link_title(body: str | None, old_norm: str, new_title: str) -> str: + """Repoint every [[token]] whose normalized form == old_norm to [[new_title]].""" + if not body: + return body or "" + + def _sub(match: re.Match) -> str: + return f"[[{new_title}]]" if match.group(1).strip().lower() == old_norm else match.group(0) + + return _LINK_RE.sub(_sub, body) + + +async def _rename_inbound_links(db, renamed: Note, old_title: str, new_title: str) -> None: + """Rewrite [[old title]] references (and their link rows) in every note that + links to the renamed note, so its backlinks survive the title change.""" + old_norm = old_title.strip().lower() + sources = ( + await db.scalars( + select(Note) + .join(NoteLink, NoteLink.source_id == Note.id) + .where( + NoteLink.target_norm == old_norm, + Note.owner_id == renamed.owner_id, + Note.deleted_at.is_(None), + ) + ) + ).all() + seen: set = set() + for source in sources: + if source.id in seen: + continue + seen.add(source.id) + source.body = rewrite_link_title(source.body, old_norm, new_title) + await _rewrite_links(db, source) + + @bp.get("") @login_required async def list_notes(): @@ -347,6 +382,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 if "title" in data: title = data["title"] if isinstance(data["title"], str) else "" note.title = title.strip() or None @@ -371,6 +407,15 @@ async def update_note(note_id: str): return jsonify({"error": "invalid remind_at"}), 400 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) 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 4601e7f..0e6d8bb 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -2,7 +2,7 @@ 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 +from thoughtsync.notes import is_empty_note, normalize_color, parse_link_titles, rewrite_link_title @pytest.fixture @@ -86,6 +86,17 @@ def test_parse_link_titles_empty(): assert parse_link_titles("no links here") == [] +def test_rewrite_link_title(): + body = "see [[Alpha]] and [[ alpha ]] and [[Beta]]" + assert rewrite_link_title(body, "alpha", "Gamma") == "see [[Gamma]] and [[Gamma]] and [[Beta]]" + + +def test_rewrite_link_title_noop(): + assert rewrite_link_title("", "alpha", "Gamma") == "" + assert rewrite_link_title(None, "alpha", "Gamma") == "" + assert rewrite_link_title("no links here", "alpha", "Gamma") == "no links here" + + async def test_titles_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes/titles")