M3 wiki-links: [[links]] + backlinks
- 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:
@@ -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")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{ text: string }>();
|
||||||
|
|
||||||
|
interface Part {
|
||||||
|
text: string;
|
||||||
|
link: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the body into plain segments and [[wiki-link]] segments (styled, non-
|
||||||
|
// interactive here — navigation happens from the editor's Links / Linked-from
|
||||||
|
// lists so we don't nest interactive controls inside the card's open target).
|
||||||
|
const parts = computed<Part[]>(() => {
|
||||||
|
const result: Part[] = [];
|
||||||
|
const re = /\[\[([^[\]]+)\]\]/g;
|
||||||
|
let last = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = re.exec(props.text)) !== null) {
|
||||||
|
if (match.index > last) result.push({ text: props.text.slice(last, match.index), link: false });
|
||||||
|
result.push({ text: match[1].trim(), link: true });
|
||||||
|
last = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
if (last < props.text.length) result.push({ text: props.text.slice(last), link: false });
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="whitespace-pre-wrap break-words"
|
||||||
|
><template v-for="(part, i) in parts" :key="i"
|
||||||
|
><span v-if="part.link" class="font-medium text-brand-700 dark:text-brand">{{ part.text }}</span
|
||||||
|
><template v-else>{{ part.text }}</template></template
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
@@ -3,6 +3,7 @@ import { useNotesStore } from "../stores/notes";
|
|||||||
import { NOTE_CARD_CLASSES, type NoteColor } from "../notes/colors";
|
import { NOTE_CARD_CLASSES, type NoteColor } from "../notes/colors";
|
||||||
import type { Note } from "../stores/notes";
|
import type { Note } from "../stores/notes";
|
||||||
import Icon from "./Icon.vue";
|
import Icon from "./Icon.vue";
|
||||||
|
import LinkedText from "./LinkedText.vue";
|
||||||
import NoteChecklist from "./NoteChecklist.vue";
|
import NoteChecklist from "./NoteChecklist.vue";
|
||||||
|
|
||||||
defineProps<{ note: Note; reorderable?: boolean }>();
|
defineProps<{ note: Note; reorderable?: boolean }>();
|
||||||
@@ -61,9 +62,9 @@ function cardClass(color: NoteColor): string {
|
|||||||
<h3 v-if="note.title" class="mb-1 break-words text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
<h3 v-if="note.title" class="mb-1 break-words text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
{{ note.title }}
|
{{ note.title }}
|
||||||
</h3>
|
</h3>
|
||||||
<p v-if="note.body" class="whitespace-pre-wrap break-words text-sm text-neutral-700 dark:text-neutral-300">
|
<div v-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
{{ note.body }}
|
<LinkedText :text="note.body" />
|
||||||
</p>
|
</div>
|
||||||
<p v-if="!note.title && !note.body && !note.attachments.length" class="text-sm italic text-neutral-400">
|
<p v-if="!note.title && !note.body && !note.attachments.length" class="text-sm italic text-neutral-400">
|
||||||
Empty note
|
Empty note
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||||
|
import { api } from "../api/client";
|
||||||
import { useNotesStore } from "../stores/notes";
|
import { useNotesStore } from "../stores/notes";
|
||||||
|
import { useTitlesStore } from "../stores/titles";
|
||||||
import ColorPicker from "./ColorPicker.vue";
|
import ColorPicker from "./ColorPicker.vue";
|
||||||
import Icon from "./Icon.vue";
|
import Icon from "./Icon.vue";
|
||||||
import LabelPicker from "./LabelPicker.vue";
|
import LabelPicker from "./LabelPicker.vue";
|
||||||
@@ -9,8 +11,9 @@ import type { Note, NoteLabel } from "../stores/notes";
|
|||||||
import type { NoteColor } from "../notes/colors";
|
import type { NoteColor } from "../notes/colors";
|
||||||
|
|
||||||
const props = defineProps<{ note: Note }>();
|
const props = defineProps<{ note: Note }>();
|
||||||
const emit = defineEmits<{ (e: "close"): void }>();
|
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
|
||||||
const notes = useNotesStore();
|
const notes = useNotesStore();
|
||||||
|
const titles = useTitlesStore();
|
||||||
|
|
||||||
// Read the note reactively from the store so checklist item add/toggle/delete
|
// Read the note reactively from the store so checklist item add/toggle/delete
|
||||||
// (which reconcile a fresh note object) reflect live while the editor is open.
|
// (which reconcile a fresh note object) reflect live while the editor is open.
|
||||||
@@ -32,11 +35,57 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const backlinks = ref<{ id: string; title: string }[]>([]);
|
||||||
|
|
||||||
|
async function loadBacklinks() {
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ backlinks: { id: string; title: string }[] }>(
|
||||||
|
`/api/notes/${props.note.id}/backlinks`,
|
||||||
|
);
|
||||||
|
backlinks.value = res.backlinks;
|
||||||
|
} catch {
|
||||||
|
backlinks.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
void titles.load();
|
||||||
|
void loadBacklinks();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
bodyInput.value?.focus();
|
bodyInput.value?.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.note.id,
|
||||||
|
() => void loadBacklinks(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const outgoingLinks = computed(() => {
|
||||||
|
const re = /\[\[([^[\]]+)\]\]/g;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: { title: string; id: string | null }[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = re.exec(body.value)) !== null) {
|
||||||
|
const title = match[1].trim();
|
||||||
|
const key = title.toLowerCase();
|
||||||
|
if (title && !seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
out.push({ title, id: titles.resolve(title)?.id ?? null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openLink(link: { title: string; id: string | null }) {
|
||||||
|
if (link.id) {
|
||||||
|
emit("navigate", link.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created = await notes.createTitled(link.title);
|
||||||
|
await titles.reload();
|
||||||
|
emit("navigate", created.id);
|
||||||
|
}
|
||||||
|
|
||||||
async function onLabelsChange(next: NoteLabel[]) {
|
async function onLabelsChange(next: NoteLabel[]) {
|
||||||
labelList.value = next;
|
labelList.value = next;
|
||||||
await notes.setLabels(
|
await notes.setLabels(
|
||||||
@@ -176,6 +225,46 @@ async function act(fn: () => Promise<void>) {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="outgoingLinks.length || backlinks.length"
|
||||||
|
class="flex flex-col gap-2 border-t border-neutral-100 pt-2 dark:border-neutral-800"
|
||||||
|
>
|
||||||
|
<div v-if="outgoingLinks.length">
|
||||||
|
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-400">Links</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="link in outgoingLinks"
|
||||||
|
:key="link.title"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
|
||||||
|
:class="
|
||||||
|
link.id
|
||||||
|
? 'bg-brand/15 text-brand-700 dark:text-brand'
|
||||||
|
: 'bg-black/5 text-neutral-500 dark:bg-white/10 dark:text-neutral-400'
|
||||||
|
"
|
||||||
|
:title="link.id ? `Open ${link.title}` : `Create ${link.title}`"
|
||||||
|
@click="openLink(link)"
|
||||||
|
>
|
||||||
|
{{ link.title }}<span v-if="!link.id" class="opacity-60">+</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="backlinks.length">
|
||||||
|
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-400">Linked from</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="b in backlinks"
|
||||||
|
:key="b.id"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-full bg-black/5 px-2 py-0.5 text-xs text-neutral-600 hover:bg-black/10 dark:bg-white/10 dark:text-neutral-300"
|
||||||
|
@click="emit('navigate', b.id)"
|
||||||
|
>
|
||||||
|
{{ b.title }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
|
<div class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
|
||||||
|
|||||||
@@ -141,6 +141,20 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
reconcile(await api.del<Note>(`/api/notes/${id}/attachments/${attId}`));
|
reconcile(await api.del<Note>(`/api/notes/${id}/attachments/${attId}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchOne(id: string): Promise<Note | null> {
|
||||||
|
try {
|
||||||
|
return await api.get<Note>(`/api/notes/${id}`);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTitled(title: string): Promise<Note> {
|
||||||
|
const created = await api.post<Note>("/api/notes", { title, body: "" });
|
||||||
|
reconcile(created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
async function reorder(orderedIds: string[]): Promise<void> {
|
async function reorder(orderedIds: string[]): Promise<void> {
|
||||||
// Optimistically assign positions matching the backend (total - index), sort,
|
// Optimistically assign positions matching the backend (total - index), sort,
|
||||||
// then persist.
|
// then persist.
|
||||||
@@ -185,6 +199,8 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
deleteItem,
|
deleteItem,
|
||||||
uploadAttachment,
|
uploadAttachment,
|
||||||
deleteAttachment,
|
deleteAttachment,
|
||||||
|
fetchOne,
|
||||||
|
createTitled,
|
||||||
reorder,
|
reorder,
|
||||||
trash,
|
trash,
|
||||||
restore,
|
restore,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
|
||||||
|
export interface TitleEntry {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner's {id,title} index, used to resolve [[wiki-links]] client-side.
|
||||||
|
export const useTitlesStore = defineStore("titles", () => {
|
||||||
|
const items = ref<TitleEntry[]>([]);
|
||||||
|
const loaded = ref(false);
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
if (loaded.value) return;
|
||||||
|
const res = await api.get<{ titles: TitleEntry[] }>("/api/notes/titles");
|
||||||
|
items.value = res.titles;
|
||||||
|
loaded.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload(): Promise<void> {
|
||||||
|
loaded.value = false;
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve(title: string): TitleEntry | null {
|
||||||
|
const norm = title.trim().toLowerCase();
|
||||||
|
return items.value.find((t) => t.title.trim().toLowerCase() === norm) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, loaded, load, reload, resolve };
|
||||||
|
});
|
||||||
@@ -47,6 +47,16 @@ function closeEditor() {
|
|||||||
editing.value = null;
|
editing.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onNavigate(id: string) {
|
||||||
|
const found = notes.items.find((n) => n.id === id);
|
||||||
|
if (found) {
|
||||||
|
editing.value = found;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fetched = await notes.fetchOne(id);
|
||||||
|
if (fetched) editing.value = fetched;
|
||||||
|
}
|
||||||
|
|
||||||
const draggingId = ref<string | null>(null);
|
const draggingId = ref<string | null>(null);
|
||||||
function onDragStart(note: Note) {
|
function onDragStart(note: Note) {
|
||||||
draggingId.value = note.id;
|
draggingId.value = note.id;
|
||||||
@@ -125,6 +135,6 @@ async function onDrop(target: Note) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="editing">
|
<template v-if="editing">
|
||||||
<NoteEditor :note="editing" @close="closeEditor" />
|
<NoteEditor :note="editing" @close="closeEditor" @navigate="onNavigate" />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { Note } from "../stores/notes";
|
import { useNotesStore, type Note } from "../stores/notes";
|
||||||
import NoteCard from "../components/NoteCard.vue";
|
import NoteCard from "../components/NoteCard.vue";
|
||||||
import NoteEditor from "../components/NoteEditor.vue";
|
import NoteEditor from "../components/NoteEditor.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const notes = useNotesStore();
|
||||||
|
|
||||||
const results = ref<Note[]>([]);
|
const results = ref<Note[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -38,6 +39,15 @@ async function closeEditor() {
|
|||||||
editing.value = null;
|
editing.value = null;
|
||||||
await run(); // reflect any edits made from a result
|
await run(); // reflect any edits made from a result
|
||||||
}
|
}
|
||||||
|
async function onNavigate(id: string) {
|
||||||
|
const found = results.value.find((n) => n.id === id);
|
||||||
|
if (found) {
|
||||||
|
editing.value = found;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fetched = await notes.fetchOne(id);
|
||||||
|
if (fetched) editing.value = fetched;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -62,6 +72,6 @@ async function closeEditor() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="editing">
|
<template v-if="editing">
|
||||||
<NoteEditor :note="editing" @close="closeEditor" />
|
<NoteEditor :note="editing" @close="closeEditor" @navigate="onNavigate" />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
Imported for side effects only (model registration on Base.metadata).
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
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 import NOTE_COLORS, Note
|
||||||
from .models.note_attachment import NoteAttachment
|
from .models.note_attachment import NoteAttachment
|
||||||
from .models.note_item import NoteItem
|
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"}
|
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")
|
bp = Blueprint("notes", __name__, url_prefix="/api/notes")
|
||||||
|
|
||||||
VALID_FILTERS = {"active", "archived", "trash"}
|
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))
|
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("")
|
@bp.get("")
|
||||||
@login_required
|
@login_required
|
||||||
async def list_notes():
|
async def list_notes():
|
||||||
@@ -175,6 +198,57 @@ async def search_notes():
|
|||||||
return jsonify({"notes": await _serialize_notes(db, 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")
|
@bp.post("/reorder")
|
||||||
@login_required
|
@login_required
|
||||||
async def reorder_notes():
|
async def reorder_notes():
|
||||||
@@ -225,6 +299,8 @@ async def create_note():
|
|||||||
position=int(max_pos) + 1,
|
position=int(max_pos) + 1,
|
||||||
)
|
)
|
||||||
db.add(note)
|
db.add(note)
|
||||||
|
await db.flush() # assign note.id before writing links
|
||||||
|
await _rewrite_links(db, note)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(note)
|
await db.refresh(note)
|
||||||
return jsonify(await _serialize_note(db, note)), 201
|
return jsonify(await _serialize_note(db, note)), 201
|
||||||
@@ -267,6 +343,8 @@ async def update_note(note_id: str):
|
|||||||
note.pinned = bool(data["pinned"])
|
note.pinned = bool(data["pinned"])
|
||||||
if "archived" in data:
|
if "archived" in data:
|
||||||
note.archived = bool(data["archived"])
|
note.archived = bool(data["archived"])
|
||||||
|
if "body" in data:
|
||||||
|
await _rewrite_links(db, note)
|
||||||
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))
|
||||||
|
|||||||
+17
-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
|
from thoughtsync.notes import is_empty_note, normalize_color, parse_link_titles
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -74,3 +74,19 @@ async def test_reorder_requires_auth(app):
|
|||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
resp = await client.post("/api/notes/reorder", json={"ids": []})
|
resp = await client.post("/api/notes/reorder", json={"ids": []})
|
||||||
assert resp.status_code == 401
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user