links: rename repoints inbound backlinks (fix orphaning)
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:
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user