M3 wiki-links: [[links]] + backlinks
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 38s

- Migration 0009: note_links (source_id, target_norm). Parse [[...]] from body on
  create/update and rewrite the source's links. GET /api/notes/titles (owner
  {id,title} index for client-side resolution); GET /api/notes/<id>/backlinks.
- Frontend: titles store; LinkedText renders [[Title]] styled on cards; editor
  shows Links (outgoing, resolve/create-on-click) + Linked-from (backlinks),
  clicking navigates the editor to the target note (board + search).
- notes store: fetchOne, createTitled. DB-free link-parser tests.

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 08:00:08 -04:00
co-authored by Claude Opus 4.8
parent 339cc5c2d2
commit 2d72dcc7cb
12 changed files with 352 additions and 9 deletions
+1 -1
View File
@@ -3,4 +3,4 @@
Imported for side effects only (model registration on Base.metadata).
"""
from . import group, label, note, note_attachment, note_item, settings, share, user # noqa: F401
from . import group, label, note, note_attachment, note_item, note_link, settings, share, user # noqa: F401
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, Index, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from . import Base
class NoteLink(Base):
"""A [[wiki-link]] from a source note to a target title (normalized). Resolved
to a target note by matching target_norm against lower(trim(note.title))."""
__tablename__ = "note_links"
__table_args__ = (Index("ix_note_links_target", "target_norm"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
source_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
)
target_norm: Mapped[str] = mapped_column(Text(), nullable=False)
+78
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
import re
import uuid
from datetime import datetime, timezone
@@ -15,9 +16,24 @@ from .models.label import Label, NoteLabel
from .models.note import NOTE_COLORS, Note
from .models.note_attachment import NoteAttachment
from .models.note_item import NoteItem
from .models.note_link import NoteLink
ALLOWED_IMAGE_MIMES = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp"}
_LINK_RE = re.compile(r"\[\[([^\[\]]+)\]\]")
def parse_link_titles(body: str | None) -> list[str]:
"""Extract distinct normalized [[wiki-link]] titles from a note body."""
if not body:
return []
out: list[str] = []
for match in _LINK_RE.finditer(body):
norm = match.group(1).strip().lower()
if norm and norm not in out:
out.append(norm)
return out
bp = Blueprint("notes", __name__, url_prefix="/api/notes")
VALID_FILTERS = {"active", "archived", "trash"}
@@ -130,6 +146,13 @@ async def _get_owned(db, note_id: str) -> Note | None:
return await db.scalar(select(Note).where(Note.id == nid, Note.owner_id == g.user_id))
async def _rewrite_links(db, note: Note) -> None:
"""Replace a note's outgoing wiki-links from its current body."""
await db.execute(delete(NoteLink).where(NoteLink.source_id == note.id))
for norm in parse_link_titles(note.body):
db.add(NoteLink(source_id=note.id, target_norm=norm))
@bp.get("")
@login_required
async def list_notes():
@@ -175,6 +198,57 @@ async def search_notes():
return jsonify({"notes": await _serialize_notes(db, notes)})
@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.
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))
)
).all()
return jsonify({"titles": [{"id": str(n.id), "title": n.title} for n in rows]})
@bp.get("/<note_id>/backlinks")
@login_required
async def note_backlinks(note_id: str):
try:
nid = uuid.UUID(note_id)
except (ValueError, TypeError):
return jsonify({"error": "not found"}), 404
async with session_scope() as db:
note = await db.scalar(
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
)
if note is None:
return jsonify({"error": "not found"}), 404
if not note.title:
return jsonify({"backlinks": []})
norm = note.title.strip().lower()
sources = (
await db.scalars(
select(Note)
.join(NoteLink, NoteLink.source_id == Note.id)
.where(
NoteLink.target_norm == norm,
Note.owner_id == g.user_id,
Note.deleted_at.is_(None),
Note.id != nid,
)
)
).all()
seen: set = set()
out = []
for n in sources:
if n.id not in seen:
seen.add(n.id)
out.append({"id": str(n.id), "title": n.title})
return jsonify({"backlinks": out})
@bp.post("/reorder")
@login_required
async def reorder_notes():
@@ -225,6 +299,8 @@ async def create_note():
position=int(max_pos) + 1,
)
db.add(note)
await db.flush() # assign note.id before writing links
await _rewrite_links(db, note)
await db.commit()
await db.refresh(note)
return jsonify(await _serialize_note(db, note)), 201
@@ -267,6 +343,8 @@ async def update_note(note_id: str):
note.pinned = bool(data["pinned"])
if "archived" in data:
note.archived = bool(data["archived"])
if "body" in data:
await _rewrite_links(db, note)
await db.commit()
await db.refresh(note)
return jsonify(await _serialize_note(db, note))