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
+90 -1
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { api } from "../api/client";
import { useNotesStore } from "../stores/notes";
import { useTitlesStore } from "../stores/titles";
import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue";
@@ -9,8 +11,9 @@ import type { Note, NoteLabel } from "../stores/notes";
import type { NoteColor } from "../notes/colors";
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 titles = useTitlesStore();
// 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.
@@ -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 () => {
void titles.load();
void loadBacklinks();
await nextTick();
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[]) {
labelList.value = next;
await notes.setLabels(
@@ -176,6 +225,46 @@ async function act(fn: () => Promise<void>) {
</button>
</span>
</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 class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">