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 @@ + + + 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 @@ 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