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))
|
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("")
|
@bp.get("")
|
||||||
@login_required
|
@login_required
|
||||||
async def list_notes():
|
async def list_notes():
|
||||||
@@ -347,6 +382,7 @@ async def update_note(note_id: str):
|
|||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return jsonify({"error": "not found"}), 404
|
||||||
|
old_title = note.title
|
||||||
if "title" in data:
|
if "title" in data:
|
||||||
title = data["title"] if isinstance(data["title"], str) else ""
|
title = data["title"] if isinstance(data["title"], str) else ""
|
||||||
note.title = title.strip() or None
|
note.title = title.strip() or None
|
||||||
@@ -371,6 +407,15 @@ async def update_note(note_id: str):
|
|||||||
return jsonify({"error": "invalid remind_at"}), 400
|
return jsonify({"error": "invalid remind_at"}), 400
|
||||||
if "body" in data:
|
if "body" in data:
|
||||||
await _rewrite_links(db, note)
|
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.commit()
|
||||||
await db.refresh(note)
|
await db.refresh(note)
|
||||||
return jsonify(await _serialize_note(db, note))
|
return jsonify(await _serialize_note(db, note))
|
||||||
|
|||||||
+12
-1
@@ -2,7 +2,7 @@ import pytest
|
|||||||
|
|
||||||
from thoughtsync.app import create_app
|
from thoughtsync.app import create_app
|
||||||
from thoughtsync.models.note import NOTE_COLORS, Note
|
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
|
@pytest.fixture
|
||||||
@@ -86,6 +86,17 @@ def test_parse_link_titles_empty():
|
|||||||
assert parse_link_titles("no links here") == []
|
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):
|
async def test_titles_requires_auth(app):
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
resp = await client.get("/api/notes/titles")
|
resp = await client.get("/api/notes/titles")
|
||||||
|
|||||||
Reference in New Issue
Block a user