links: bind a [[link]] to a note, not to a string
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 34s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 5s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 34s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 5s
A wiki-link was stored only as normalized TEXT, so a note's NAME was the edge. Renaming it broke every inbound link — and the fix that shipped for that (task 1848, option b) was `_rename_inbound_links`: rewrite the `[[Old Name]]` text inside the body of every note that linked to the renamed one. That works while an explicit title exists to hold still. It stops being defensible the moment a note's name is just its first body line, which is where M13 is going: fixing a typo in your opening sentence would silently edit other notes' words, with nothing to opt out to. So this lands first, before the title comes out, and that window never ships. `note_links` gains `target_id`, bound when the link is written. `target_norm` stays and is what an UNRESOLVED link carries — linking to a note that doesn't exist yet is a supported way to create one, so a link has to be able to name a target that isn't there. Resolution reads the id, falling back to the name only where nothing was bound, which is what lets a forward link connect the moment its target appears. `_claim_unresolved_links` then binds it, so the fallback is a transitional state rather than a permanent one. `_rename_inbound_links` and `rewrite_link_title` are gone. What replaced them touches link rows only: a note's text is never modified by something happening to a different note. The client can no longer resolve links for itself, and that is the point. It used to look `[[text]]` up in a client-side name index, which only held together BECAUSE renaming rewrote the text everywhere. Now the written text can name something the target is no longer called, and only the server holds the binding — so each note serializes its resolved links (`norm`, `id`, and the target's name as it stands NOW). A renamed note reads correctly everywhere it is linked from, without a single body having been edited. Unresolved links are simply absent and fall through to the create-on-click affordance that already existed; so does the offline desktop store, which derives links at query time and has no binding to send. The name-fallback join is owner-scoped everywhere it appears. Bound ids were resolved owner-scoped when written, but matching on display_title alone would have let two users who each have a note called "Groceries" see the other's id and name through an unresolved link (rule 47). The new behaviour is all SQL and this suite runs without a database, so the dead helpers' tests are removed rather than replaced. This repo has no integration lane to hold that ground — noted, not papered over.
This commit is contained in:
@@ -1,26 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import { useNotesStore, type NoteLinkRef } from "../stores/notes";
|
||||
import { useTitlesStore } from "../stores/titles";
|
||||
import type { InlineToken } from "../notes/markdown";
|
||||
|
||||
defineProps<{ tokens: InlineToken[] }>();
|
||||
const props = defineProps<{ tokens: InlineToken[]; links?: NoteLinkRef[] }>();
|
||||
|
||||
const router = useRouter();
|
||||
const notes = useNotesStore();
|
||||
const titles = useTitlesStore();
|
||||
|
||||
// A [[wiki-link]] on the card: resolve the title and open the target note (creating
|
||||
// it first if it doesn't exist), via the board's ?open=<id> mechanism.
|
||||
async function follow(title: string) {
|
||||
await titles.load();
|
||||
let hit = titles.resolve(title);
|
||||
if (!hit) {
|
||||
const created = await notes.createTitled(title);
|
||||
await titles.reload();
|
||||
hit = { id: created.id, title: created.title ?? title };
|
||||
/**
|
||||
* Where a [[link]] token actually points, according to the server.
|
||||
*
|
||||
* Keyed on the normalized written text, which is what survives in the body — the
|
||||
* server resolved it to an id when the link was saved, so this keeps working after
|
||||
* the target has been renamed and the written text has gone stale.
|
||||
*/
|
||||
const bound = computed(() => {
|
||||
const map = new Map<string, NoteLinkRef>();
|
||||
for (const l of props.links ?? []) map.set(l.norm, l);
|
||||
return map;
|
||||
});
|
||||
|
||||
/** What to SHOW for a link: the target's current name, else the text as written. */
|
||||
function label(token: string): string {
|
||||
return bound.value.get(token.trim().toLowerCase())?.title ?? token;
|
||||
}
|
||||
|
||||
// Open the target via the board's ?open=<id> mechanism, creating the note first if
|
||||
// the link names one that doesn't exist — which is a supported way to make a note.
|
||||
async function follow(token: string) {
|
||||
const hit = bound.value.get(token.trim().toLowerCase());
|
||||
if (hit) {
|
||||
void router.push({ path: "/", query: { open: hit.id } });
|
||||
return;
|
||||
}
|
||||
void router.push({ path: "/", query: { open: hit.id } });
|
||||
// No server binding: either this is running offline against the local store, or
|
||||
// the link genuinely resolves to nothing. The name index answers the first case.
|
||||
await titles.load();
|
||||
const byName = titles.resolve(token);
|
||||
const id = byName ? byName.id : (await notes.createTitled(token)).id;
|
||||
if (!byName) await titles.reload();
|
||||
void router.push({ path: "/", query: { open: id } });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -35,7 +58,7 @@ async function follow(title: string) {
|
||||
class="cursor-pointer font-medium text-brand-700 underline-offset-2 hover:underline dark:text-brand"
|
||||
@click.stop="follow(t.value)"
|
||||
@keydown.enter.stop.prevent="follow(t.value)"
|
||||
>{{ t.value }}</span
|
||||
>{{ label(t.value) }}</span
|
||||
><strong v-else-if="t.type === 'bold'" class="font-semibold">{{ t.value }}</strong
|
||||
><em v-else-if="t.type === 'italic'">{{ t.value }}</em
|
||||
><code
|
||||
|
||||
@@ -2,35 +2,38 @@
|
||||
import { computed } from "vue";
|
||||
import { parseMarkdown } from "../notes/markdown";
|
||||
import MarkdownInline from "./MarkdownInline.vue";
|
||||
import type { NoteLinkRef } from "../stores/notes";
|
||||
|
||||
const props = defineProps<{ text: string }>();
|
||||
// `links` is the owning note's resolved [[links]], passed straight through to every
|
||||
// inline run — only MarkdownInline uses it, but only this component knows the note.
|
||||
const props = defineProps<{ text: string; links?: NoteLinkRef[] }>();
|
||||
const blocks = computed(() => parseMarkdown(props.text));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1.5 break-words">
|
||||
<template v-for="(b, i) in blocks" :key="i">
|
||||
<h3 v-if="b.type === 'h1'" class="text-base font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h3>
|
||||
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h4>
|
||||
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" /></h5>
|
||||
<h3 v-if="b.type === 'h1'" class="text-base font-bold"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></h3>
|
||||
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></h4>
|
||||
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></h5>
|
||||
<blockquote
|
||||
v-else-if="b.type === 'quote'"
|
||||
class="whitespace-pre-wrap border-l-2 border-neutral-300 pl-2 text-neutral-600 dark:border-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
<MarkdownInline :tokens="b.inline ?? []" />
|
||||
<MarkdownInline :tokens="b.inline ?? []" :links="links" />
|
||||
</blockquote>
|
||||
<ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5">
|
||||
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
|
||||
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :links="links" /></li>
|
||||
</ul>
|
||||
<ol v-else-if="b.type === 'ol'" class="list-decimal space-y-0.5 pl-5">
|
||||
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
|
||||
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :links="links" /></li>
|
||||
</ol>
|
||||
<pre
|
||||
v-else-if="b.type === 'pre'"
|
||||
class="overflow-x-auto whitespace-pre-wrap rounded-md bg-black/5 p-2 font-mono text-xs dark:bg-white/10"
|
||||
>{{ b.value ?? "" }}</pre
|
||||
>
|
||||
<p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" /></p>
|
||||
<p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -237,7 +237,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
{{ note.title }}
|
||||
</h3>
|
||||
<div v-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
<MarkdownText :text="note.body" />
|
||||
<MarkdownText :text="note.body" :links="note.links" />
|
||||
</div>
|
||||
<p v-if="!note.title && !note.body && !note.attachments.length" class="text-sm italic text-neutral-400">
|
||||
Empty note
|
||||
|
||||
@@ -266,7 +266,13 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
// ---- outgoing links (edit mode) ----
|
||||
//
|
||||
// Two sources, in order. The note's SAVED links carry the server's binding, so a
|
||||
// target that has since been renamed still resolves and is listed under its current
|
||||
// name. A link just typed into the textarea has no saved row yet, and the name index
|
||||
// is the best that can be said about it until the note is saved.
|
||||
const outgoingLinks = computed(() => {
|
||||
const boundByNorm = new Map((props.note?.links ?? []).map((l) => [l.norm, l]));
|
||||
const re = /\[\[([^[\]]+)\]\]/g;
|
||||
const seen = new Set<string>();
|
||||
const out: { title: string; id: string | null }[] = [];
|
||||
@@ -276,7 +282,8 @@ const outgoingLinks = computed(() => {
|
||||
const key = t.toLowerCase();
|
||||
if (t && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
out.push({ title: t, id: titles.resolve(t)?.id ?? null });
|
||||
const hit = boundByNorm.get(key);
|
||||
out.push(hit ? { title: hit.title, id: hit.id } : { title: t, id: titles.resolve(t)?.id ?? null });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
Reference in New Issue
Block a user