diff --git a/alembic/versions/0009_note_links.py b/alembic/versions/0009_note_links.py
new file mode 100644
index 0000000..681716e
--- /dev/null
+++ b/alembic/versions/0009_note_links.py
@@ -0,0 +1,31 @@
+"""note_links (wiki-links)
+
+Revision ID: 0009
+Revises: 0008
+Create Date: 2026-07-20
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects.postgresql import UUID
+
+revision = "0009"
+down_revision = "0008"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "note_links",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("source_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("target_norm", sa.Text(), nullable=False),
+ )
+ op.create_index("ix_note_links_source", "note_links", ["source_id"])
+ op.create_index("ix_note_links_target", "note_links", ["target_norm"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_note_links_target", table_name="note_links")
+ op.drop_index("ix_note_links_source", table_name="note_links")
+ op.drop_table("note_links")
diff --git a/frontend/src/components/LinkedText.vue b/frontend/src/components/LinkedText.vue
new file mode 100644
index 0000000..7c10411
--- /dev/null
+++ b/frontend/src/components/LinkedText.vue
@@ -0,0 +1,36 @@
+
+
+
+ {{ part.text }}{{ part.text }}
+
diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue
index d704d90..5f8abbc 100644
--- a/frontend/src/components/NoteCard.vue
+++ b/frontend/src/components/NoteCard.vue
@@ -3,6 +3,7 @@ import { useNotesStore } from "../stores/notes";
import { NOTE_CARD_CLASSES, type NoteColor } from "../notes/colors";
import type { Note } from "../stores/notes";
import Icon from "./Icon.vue";
+import LinkedText from "./LinkedText.vue";
import NoteChecklist from "./NoteChecklist.vue";
defineProps<{ note: Note; reorderable?: boolean }>();
@@ -61,9 +62,9 @@ function cardClass(color: NoteColor): string {
{{ note.title }}
-
- {{ note.body }}
-
+
+
+
Empty note
diff --git a/frontend/src/components/NoteEditor.vue b/frontend/src/components/NoteEditor.vue
index a2ade27..205f6a0 100644
--- a/frontend/src/components/NoteEditor.vue
+++ b/frontend/src/components/NoteEditor.vue
@@ -1,6 +1,8 @@
@@ -62,6 +72,6 @@ async function closeEditor() {
-
+
diff --git a/src/thoughtsync/models/all.py b/src/thoughtsync/models/all.py
index 9c5bdb6..693ec6f 100644
--- a/src/thoughtsync/models/all.py
+++ b/src/thoughtsync/models/all.py
@@ -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
diff --git a/src/thoughtsync/models/note_link.py b/src/thoughtsync/models/note_link.py
new file mode 100644
index 0000000..0a195e3
--- /dev/null
+++ b/src/thoughtsync/models/note_link.py
@@ -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)
diff --git a/src/thoughtsync/notes.py b/src/thoughtsync/notes.py
index 6fc6afa..dc1b3b6 100644
--- a/src/thoughtsync/notes.py
+++ b/src/thoughtsync/notes.py
@@ -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("//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))
diff --git a/tests/test_notes.py b/tests/test_notes.py
index 6ca3875..bd07487 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
+from thoughtsync.notes import is_empty_note, normalize_color, parse_link_titles
@pytest.fixture
@@ -74,3 +74,19 @@ async def test_reorder_requires_auth(app):
client = app.test_client()
resp = await client.post("/api/notes/reorder", json={"ids": []})
assert resp.status_code == 401
+
+
+def test_parse_link_titles():
+ titles = parse_link_titles("see [[Alpha]] and [[ beta ]] and [[Alpha]] again")
+ assert titles == ["alpha", "beta"]
+
+
+def test_parse_link_titles_empty():
+ assert parse_link_titles(None) == []
+ assert parse_link_titles("no links here") == []
+
+
+async def test_titles_requires_auth(app):
+ client = app.test_client()
+ resp = await client.get("/api/notes/titles")
+ assert resp.status_code == 401