links: rename repoints inbound backlinks (fix orphaning)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 29s

Renaming a note now rewrites [[Old Title]] references (and their note_links
rows) in every note that links to it, so backlinks survive the rename
instead of silently orphaning. Pure/case-only renames are skipped since they
still resolve. New pure helper rewrite_link_title() (DB-free unit tests) does
the token rewrite; _rename_inbound_links() applies it across owner-scoped,
non-trashed sources.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-20 11:26:49 -04:00
co-authored by Claude Opus 4.8
parent 9be8920362
commit f3145a5e3f
2 changed files with 57 additions and 1 deletions
+45
View File
@@ -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))
+12 -1
View File
@@ -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")