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

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:
2026-08-22 11:02:39 -04:00
parent bacedea8a3
commit 982d24c83b
13 changed files with 352 additions and 96 deletions
@@ -0,0 +1,67 @@
"""note_links.target_id — resolve [[links]] to a note, not to a string (M13 step 1)
Revision ID: 0023
Revises: 0022
Create Date: 2026-08-22
A wiki-link stored only as normalized TEXT means a note's name IS the edge: rename
the note and every inbound link stops matching. The old answer was to rewrite the
`[[Old Name]]` text inside every note that linked to it — workable while an explicit
title existed to hold still, untenable once a note's name is just its first body
line (M13).
`target_norm` stays: it is what an UNRESOLVED link carries, since linking to a note
that doesn't exist yet is a supported way to create one.
The backfill is safe to run bluntly because note_links is DERIVED data — every row
is recomputed from the source body on the next save regardless.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0023"
down_revision = "0022"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"note_links",
sa.Column("target_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_note_links_target",
"note_links",
"notes",
["target_id"],
["id"],
# A deleted target un-resolves its inbound links rather than deleting them:
# the link text is still in the source's body, and it should read as pointing
# at something that isn't there — which is also what lets it re-resolve if a
# note of that name appears again.
ondelete="SET NULL",
)
op.create_index("ix_note_links_target_id", "note_links", ["target_id"])
# Resolve what can be resolved right now, scoped to the source's owner so a link
# can never bind to another user's note.
op.execute(
"""
UPDATE note_links AS nl
SET target_id = t.id
FROM notes AS src, notes AS t
WHERE nl.source_id = src.id
AND t.owner_id = src.owner_id
AND t.deleted_at IS NULL
AND lower(btrim(t.display_title)) = nl.target_norm
AND t.id <> src.id
"""
)
def downgrade() -> None:
op.drop_index("ix_note_links_target_id", table_name="note_links")
op.drop_constraint("fk_note_links_target", "note_links", type_="foreignkey")
op.drop_column("note_links", "target_id")
+36 -13
View File
@@ -1,26 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useNotesStore } from "../stores/notes"; import { useNotesStore, type NoteLinkRef } from "../stores/notes";
import { useTitlesStore } from "../stores/titles"; import { useTitlesStore } from "../stores/titles";
import type { InlineToken } from "../notes/markdown"; import type { InlineToken } from "../notes/markdown";
defineProps<{ tokens: InlineToken[] }>(); const props = defineProps<{ tokens: InlineToken[]; links?: NoteLinkRef[] }>();
const router = useRouter(); const router = useRouter();
const notes = useNotesStore(); const notes = useNotesStore();
const titles = useTitlesStore(); 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. * Where a [[link]] token actually points, according to the server.
async function follow(title: string) { *
await titles.load(); * Keyed on the normalized written text, which is what survives in the body — the
let hit = titles.resolve(title); * server resolved it to an id when the link was saved, so this keeps working after
if (!hit) { * the target has been renamed and the written text has gone stale.
const created = await notes.createTitled(title); */
await titles.reload(); const bound = computed(() => {
hit = { id: created.id, title: created.title ?? title }; 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> </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" class="cursor-pointer font-medium text-brand-700 underline-offset-2 hover:underline dark:text-brand"
@click.stop="follow(t.value)" @click.stop="follow(t.value)"
@keydown.enter.stop.prevent="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 ><strong v-else-if="t.type === 'bold'" class="font-semibold">{{ t.value }}</strong
><em v-else-if="t.type === 'italic'">{{ t.value }}</em ><em v-else-if="t.type === 'italic'">{{ t.value }}</em
><code ><code
+11 -8
View File
@@ -2,35 +2,38 @@
import { computed } from "vue"; import { computed } from "vue";
import { parseMarkdown } from "../notes/markdown"; import { parseMarkdown } from "../notes/markdown";
import MarkdownInline from "./MarkdownInline.vue"; 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)); const blocks = computed(() => parseMarkdown(props.text));
</script> </script>
<template> <template>
<div class="space-y-1.5 break-words"> <div class="space-y-1.5 break-words">
<template v-for="(b, i) in blocks" :key="i"> <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> <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 ?? []" /></h4> <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 ?? []" /></h5> <h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></h5>
<blockquote <blockquote
v-else-if="b.type === 'quote'" 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" 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> </blockquote>
<ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5"> <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> </ul>
<ol v-else-if="b.type === 'ol'" class="list-decimal space-y-0.5 pl-5"> <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> </ol>
<pre <pre
v-else-if="b.type === '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" 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 >{{ 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> </template>
</div> </div>
</template> </template>
+1 -1
View File
@@ -237,7 +237,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
{{ note.title }} {{ note.title }}
</h3> </h3>
<div v-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300"> <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> </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
+8 -1
View File
@@ -266,7 +266,13 @@ onMounted(async () => {
}); });
// ---- outgoing links (edit mode) ---- // ---- 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 outgoingLinks = computed(() => {
const boundByNorm = new Map((props.note?.links ?? []).map((l) => [l.norm, l]));
const re = /\[\[([^[\]]+)\]\]/g; const re = /\[\[([^[\]]+)\]\]/g;
const seen = new Set<string>(); const seen = new Set<string>();
const out: { title: string; id: string | null }[] = []; const out: { title: string; id: string | null }[] = [];
@@ -276,7 +282,8 @@ const outgoingLinks = computed(() => {
const key = t.toLowerCase(); const key = t.toLowerCase();
if (t && !seen.has(key)) { if (t && !seen.has(key)) {
seen.add(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; return out;
+21
View File
@@ -63,6 +63,22 @@ export interface NoteRevision {
created_at: string | null; created_at: string | null;
} }
// One resolved [[wiki-link]] out of a note: the normalized text as WRITTEN, and the
// note it actually points at with that note's name as it stands NOW.
//
// The server sends this because the client can no longer work it out. Resolution used
// to be a name lookup in the titles index, which only held together because renaming
// a note rewrote the link text inside every note that linked to it. Links are bound
// by id now and bodies are left alone, so the written text can name something the
// target is no longer called — and only the server holds the binding.
export interface NoteLinkRef {
/** The link text as written, trimmed and lowercased — the key a token matches on. */
norm: string;
id: string;
/** The target's CURRENT name, which is what gets rendered. */
title: string;
}
export interface Note { export interface Note {
id: string; id: string;
title: string | null; title: string | null;
@@ -85,6 +101,11 @@ export interface Note {
items: ChecklistItem[]; items: ChecklistItem[];
attachments: Attachment[]; attachments: Attachment[];
previews: LinkPreview[]; previews: LinkPreview[];
// Absent offline: the desktop's local store derives links at query time and has no
// resolution to send. Rendering falls back to the titles index there, which is
// exactly right for a store where nothing else can have renamed the target behind
// this client's back.
links?: NoteLinkRef[];
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
} }
+14 -2
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify from quart import Blueprint, g, jsonify
from sqlalchemy import func, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from .auth import login_required from .auth import login_required
@@ -33,11 +33,23 @@ async def get_graph():
""" """
source = aliased(Note) source = aliased(Note)
target = aliased(Note) target = aliased(Note)
# A link joins on the note it was BOUND to, and only falls back to matching by
# name where it was never bound — a forward link written before its target
# existed. Name-matching alone is what used to make a rename break the graph.
edge_stmt = ( edge_stmt = (
select(source.id, target.id) select(source.id, target.id)
.select_from(NoteLink) .select_from(NoteLink)
.join(source, source.id == NoteLink.source_id) .join(source, source.id == NoteLink.source_id)
.join(target, func.lower(func.trim(target.display_title)) == NoteLink.target_norm) .join(
target,
or_(
target.id == NoteLink.target_id,
and_(
NoteLink.target_id.is_(None),
func.lower(func.trim(target.display_title)) == NoteLink.target_norm,
),
),
)
.where( .where(
source.owner_id == g.user_id, source.owner_id == g.user_id,
source.deleted_at.is_(None), source.deleted_at.is_(None),
+27 -3
View File
@@ -10,14 +10,38 @@ from . import Base
class NoteLink(Base): class NoteLink(Base):
"""A [[wiki-link]] from a source note to a target title (normalized). Resolved """A [[wiki-link]] from a source note to another note.
to a target note by matching target_norm against lower(trim(note.title))."""
Two target columns, and the pair is the point:
- ``target_id`` — the note this link actually points at, bound when the link was
written. This is what makes a link survive its target being RENAMED. A note's
name is derived from its first body line, so without an id the name is the
edge, and editing that line would silently break every inbound link (or, in the
older design, force a rewrite of every linking note's body).
- ``target_norm`` — the normalized link text, always stored. It is what an
UNRESOLVED link carries: `[[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 first and falls back to matching the norm against
``notes.display_title``, which is how a forward link connects the moment its
target appears. ``_claim_unresolved_links`` then binds the id, so the fallback is
a transitional state rather than a permanent one.
"""
__tablename__ = "note_links" __tablename__ = "note_links"
__table_args__ = (Index("ix_note_links_target", "target_norm"),) __table_args__ = (
Index("ix_note_links_target", "target_norm"),
Index("ix_note_links_target_id", "target_id"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
source_id: Mapped[uuid.UUID] = mapped_column( source_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
) )
# SET NULL rather than CASCADE: deleting the target un-resolves the link, it does
# not delete it. The link text is still sitting in the source's body.
target_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
target_norm: Mapped[str] = mapped_column(Text(), nullable=False) target_norm: Mapped[str] = mapped_column(Text(), nullable=False)
+22 -16
View File
@@ -19,7 +19,7 @@ import zipfile
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from quart import Response, g, jsonify, request, send_file from quart import Response, g, jsonify, request, send_file
from sqlalchemy import case, func, literal_column, select from sqlalchemy import and_, case, func, literal_column, or_, select
from ..acl import visible_to_user from ..acl import visible_to_user
from ..auth import login_required from ..auth import login_required
@@ -67,11 +67,10 @@ from .import_export import (
) )
from .links import ( from .links import (
_reconcile_tags, _reconcile_tags,
_rename_inbound_links, _claim_unresolved_links,
_rewrite_links, _rewrite_links,
parse_link_titles, parse_link_titles,
parse_tags, parse_tags,
rewrite_link_title,
) )
from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence
from .serialize import _items_for_notes, _labels_for_notes, _serialize_note, _serialize_notes from .serialize import _items_for_notes, _labels_for_notes, _serialize_note, _serialize_notes
@@ -83,12 +82,11 @@ __all__ = [
"parse_list_items", "parse_list_items",
"parse_tags", "parse_tags",
"parse_link_titles", "parse_link_titles",
"rewrite_link_title",
"normalize_color", "normalize_color",
"normalize_recurrence", "normalize_recurrence",
"next_occurrence", "next_occurrence",
"_reconcile_tags", "_reconcile_tags",
"_rename_inbound_links", "_claim_unresolved_links",
"_rewrite_links", "_rewrite_links",
"_serialize_notes", "_serialize_notes",
"_escape_like", "_escape_like",
@@ -435,15 +433,20 @@ async def note_backlinks(note_id: str):
) )
if note is None: if note is None:
return not_found() return not_found()
if not note.display_title: # Bound links (target_id) OR unbound ones still naming this note. The second
return jsonify({"backlinks": []}) # half is what catches a link written before this note existed and not yet
norm = note.display_title.strip().lower() # claimed; without it a forward link would go quiet until its source is next
# saved.
norm = (note.display_title or "").strip().lower()
matches = NoteLink.target_id == nid
if norm:
matches = or_(matches, and_(NoteLink.target_id.is_(None), NoteLink.target_norm == norm))
sources = ( sources = (
await db.scalars( await db.scalars(
select(Note) select(Note)
.join(NoteLink, NoteLink.source_id == Note.id) .join(NoteLink, NoteLink.source_id == Note.id)
.where( .where(
NoteLink.target_norm == norm, matches,
Note.owner_id == g.user_id, Note.owner_id == g.user_id,
Note.deleted_at.is_(None), Note.deleted_at.is_(None),
Note.id != nid, Note.id != nid,
@@ -524,6 +527,9 @@ async def create_note():
db.add(NoteItem(note_id=note.id, text=text, position=pos)) db.add(NoteItem(note_id=note.id, text=text, position=pos))
await _rewrite_links(db, note) await _rewrite_links(db, note)
await _reconcile_tags(db, note) await _reconcile_tags(db, note)
# A new note may be exactly what earlier `[[links]]` were pointing at — the
# create-by-linking flow writes the link first and the note second.
await _claim_unresolved_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
@@ -587,12 +593,12 @@ async def update_note(note_id: str):
if "body" in data: if "body" in data:
await _rewrite_links(db, note) await _rewrite_links(db, note)
await _reconcile_tags(db, note) await _reconcile_tags(db, note)
# The display NAME changing — via an explicit title OR the first body line — # A rename no longer touches anything else's TEXT. Inbound links already hold
# repoints inbound [[Old Name]] references so backlinks survive (skip pure # this note's id, so they follow it automatically; all that is left is to
# case/whitespace changes, which still resolve). # adopt any still-unresolved link that was waiting for this name.
new_display = note.display_title new_display = note.display_title
if old_display and new_display and old_display.strip().lower() != new_display.strip().lower(): if old_display != new_display:
await _rename_inbound_links(db, note, old_display, new_display) await _claim_unresolved_links(db, note)
# Version history: snapshot the PRE-edit title+body whenever either changed. # Version history: snapshot the PRE-edit title+body whenever either changed.
if note.title != old_title or note.body != old_body: if note.title != old_title or note.body != old_body:
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body)) db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
@@ -653,8 +659,8 @@ async def restore_revision(note_id: str, rev_id: str):
await _rewrite_links(db, note) await _rewrite_links(db, note)
await _reconcile_tags(db, note) await _reconcile_tags(db, note)
new_display = note.display_title new_display = note.display_title
if old_display and new_display and old_display.strip().lower() != new_display.strip().lower(): if old_display != new_display:
await _rename_inbound_links(db, note, old_display, new_display) await _claim_unresolved_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))
+58 -38
View File
@@ -5,7 +5,8 @@ from __future__ import annotations
import re import re
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select, update
from sqlalchemy.orm import aliased
from ..models.label import Label, NoteLabel from ..models.label import Label, NoteLabel
from ..models.note import Note from ..models.note import Note
@@ -49,11 +50,65 @@ def parse_link_titles(body: str | None) -> list[str]:
return out return out
async def _resolve_target(db, owner_id, norm: str, exclude_id=None):
"""The owner's note currently NAMED `norm`, or None.
Owner-scoped so a link can never bind to someone else's note, and self-excluded
so a note that opens with its own name doesn't link to itself.
"""
stmt = select(Note.id).where(
Note.owner_id == owner_id,
Note.deleted_at.is_(None),
func.lower(func.trim(Note.display_title)) == norm,
)
if exclude_id is not None:
stmt = stmt.where(Note.id != exclude_id)
return await db.scalar(stmt)
async def _rewrite_links(db, note: Note) -> None: async def _rewrite_links(db, note: Note) -> None:
"""Replace a note's outgoing wiki-links from its current body.""" """Replace a note's outgoing wiki-links from its current body.
Each link is bound to the target's ID where one exists under that name right now.
That binding is what survives the target being renamed later; the norm is kept
either way, so a link to a note that doesn't exist yet is still recorded and can
resolve when it does.
"""
await db.execute(delete(NoteLink).where(NoteLink.source_id == note.id)) await db.execute(delete(NoteLink).where(NoteLink.source_id == note.id))
for norm in parse_link_titles(note.body): for norm in parse_link_titles(note.body):
db.add(NoteLink(source_id=note.id, target_norm=norm)) target_id = await _resolve_target(db, note.owner_id, norm, exclude_id=note.id)
db.add(NoteLink(source_id=note.id, target_norm=norm, target_id=target_id))
async def _claim_unresolved_links(db, note: Note) -> None:
"""Bind still-unresolved links that name this note to it.
Called when a note's display name changes or a note is created. Two cases, one
mechanism: someone wrote `[[groceries]]` before any note was called that, or a
note has just been renamed INTO a name that other notes were already pointing at.
This is what replaced `_rename_inbound_links`, and the difference is the whole
point of the change: that function edited the BODIES of other people's notes to
keep their link text matching. This touches only link rows. A note's text is
never modified by something happening to a different note.
"""
norm = (note.display_title or "").strip().lower()
if not norm:
return
source = aliased(Note)
unresolved = (
select(NoteLink.id)
.join(source, source.id == NoteLink.source_id)
.where(
NoteLink.target_id.is_(None),
NoteLink.target_norm == norm,
source.owner_id == note.owner_id,
source.id != note.id,
)
)
await db.execute(
update(NoteLink).where(NoteLink.id.in_(unresolved.scalar_subquery())).values(target_id=note.id)
)
async def _find_or_create_label(db, owner_id, name: str): async def _find_or_create_label(db, owner_id, name: str):
@@ -89,38 +144,3 @@ async def _reconcile_tags(db, note: Note) -> None:
if lid not in attached_ids: if lid not in attached_ids:
db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=True)) db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=True))
attached_ids.add(lid) attached_ids.add(lid)
def rewrite_link_title(body: str | None, old_norm: str, new_title: str) -> str:
"""Repoint every [[token]] whose normalized form == old_norm to [[new_title]]."""
if not body:
return body or ""
def _sub(match: re.Match) -> str:
return f"[[{new_title}]]" if match.group(1).strip().lower() == old_norm else match.group(0)
return _LINK_RE.sub(_sub, body)
async def _rename_inbound_links(db, renamed: Note, old_title: str, new_title: str) -> None:
"""Rewrite [[old title]] references (and their link rows) in every note that
links to the renamed note, so its backlinks survive the title change."""
old_norm = old_title.strip().lower()
sources = (
await db.scalars(
select(Note)
.join(NoteLink, NoteLink.source_id == Note.id)
.where(
NoteLink.target_norm == old_norm,
Note.owner_id == renamed.owner_id,
Note.deleted_at.is_(None),
)
)
).all()
seen: set = set()
for source in sources:
if source.id in seen:
continue
seen.add(source.id)
source.body = rewrite_link_title(source.body, old_norm, new_title)
await _rewrite_links(db, source)
+67 -3
View File
@@ -1,14 +1,16 @@
"""Note serialization — turn a Note (+ its labels/items/attachments/previews) into """Note serialization — turn a Note (+ its labels/items/attachments/previews/links)
the JSON dict the API returns. The bulk loaders (`*_for_notes`) fetch each child into the JSON dict the API returns. The bulk loaders (`*_for_notes`) fetch each child
collection for a batch of notes in one query, so list endpoints avoid N+1s.""" collection for a batch of notes in one query, so list endpoints avoid N+1s."""
from __future__ import annotations from __future__ import annotations
from sqlalchemy import select from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import aliased
from ..models.label import Label, NoteLabel from ..models.label import Label, NoteLabel
from ..models.note import Note from ..models.note import 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
from ..models.note_link_preview import NoteLinkPreview from ..models.note_link_preview import NoteLinkPreview
@@ -104,6 +106,64 @@ async def _previews_for_notes(db, note_ids: list) -> dict:
return result return result
async def _links_for_notes(db, note_ids: list) -> dict:
"""Map note_id -> [{norm, id, title}] for each note's RESOLVED outgoing links.
The client cannot work this out for itself any more, and that is deliberate. It
used to resolve `[[text]]` by looking the text up in a client-side name index,
which only worked because a rename rewrote the text in every linking note. Now
that a link is bound to an id and the text is left alone, the stored text can name
something the target is no longer called — so the server, which holds the binding,
is the only place that knows where a link goes.
`title` is the target's name RIGHT NOW, so a renamed note reads correctly
everywhere it is linked from without a single body having been edited.
Unresolved links are simply absent: the client renders those as the
create-on-click affordance it already has.
"""
if not note_ids:
return {}
source = aliased(Note)
target = aliased(Note)
rows = (
await db.execute(
select(NoteLink.source_id, NoteLink.target_norm, target.id, target.display_title)
.select_from(NoteLink)
.join(source, source.id == NoteLink.source_id)
.join(
target,
or_(
target.id == NoteLink.target_id,
and_(
NoteLink.target_id.is_(None),
func.lower(func.trim(target.display_title)) == NoteLink.target_norm,
),
),
)
.where(
NoteLink.source_id.in_(note_ids),
target.deleted_at.is_(None),
# Owner-scoped, and NOT optional. A bound target_id was resolved
# owner-scoped when it was written, but the name fallback matches on
# display_title alone — without this, two users who both have a note
# called "Groceries" would leak each other's note id and name through
# an unresolved link. (Rule 47.)
target.owner_id == source.owner_id,
)
)
).all()
result: dict = {}
for source_id, norm, target_id, title in rows:
bucket = result.setdefault(source_id, [])
# The name-fallback join can produce more than one candidate for the same
# text; first one wins, deterministically enough for a display hint.
if any(link["norm"] == norm for link in bucket):
continue
bucket.append({"norm": norm, "id": str(target_id), "title": title})
return result
async def _serialize_note(db, note: Note) -> dict: async def _serialize_note(db, note: Note) -> dict:
data = note.serialize() data = note.serialize()
labels = await _labels_for_notes(db, [note.id]) labels = await _labels_for_notes(db, [note.id])
@@ -114,6 +174,8 @@ async def _serialize_note(db, note: Note) -> dict:
data["attachments"] = attachments.get(note.id, []) data["attachments"] = attachments.get(note.id, [])
previews = await _previews_for_notes(db, [note.id]) previews = await _previews_for_notes(db, [note.id])
data["previews"] = previews.get(note.id, []) data["previews"] = previews.get(note.id, [])
links = await _links_for_notes(db, [note.id])
data["links"] = links.get(note.id, [])
return data return data
@@ -123,6 +185,7 @@ async def _serialize_notes(db, notes: list) -> list:
items_map = await _items_for_notes(db, ids) items_map = await _items_for_notes(db, ids)
attach_map = await _attachments_for_notes(db, ids) attach_map = await _attachments_for_notes(db, ids)
preview_map = await _previews_for_notes(db, ids) preview_map = await _previews_for_notes(db, ids)
link_map = await _links_for_notes(db, ids)
out = [] out = []
for n in notes: for n in notes:
data = n.serialize() data = n.serialize()
@@ -130,5 +193,6 @@ async def _serialize_notes(db, notes: list) -> list:
data["items"] = items_map.get(n.id, []) data["items"] = items_map.get(n.id, [])
data["attachments"] = attach_map.get(n.id, []) data["attachments"] = attach_map.get(n.id, [])
data["previews"] = preview_map.get(n.id, []) data["previews"] = preview_map.get(n.id, [])
data["links"] = link_map.get(n.id, [])
out.append(data) out.append(data)
return out return out
+6 -3
View File
@@ -28,7 +28,7 @@ from .models.note_item import NoteItem
from .models.note_revision import NoteRevision from .models.note_revision import NoteRevision
from .notes import ( from .notes import (
_reconcile_tags, _reconcile_tags,
_rename_inbound_links, _claim_unresolved_links,
_rewrite_links, _rewrite_links,
_serialize_notes, _serialize_notes,
derive_display_title, derive_display_title,
@@ -295,9 +295,12 @@ async def _apply_note(db, ch: dict) -> dict:
await _rewrite_links(db, note) await _rewrite_links(db, note)
await _reconcile_tags(db, note) await _reconcile_tags(db, note)
await _apply_note_manual_labels(db, note, ch) await _apply_note_manual_labels(db, note, ch)
# Any change to the name — including a note arriving for the first time, where
# the old name was empty — may be what unresolved inbound links were waiting for.
# Nothing else's body is touched; see _claim_unresolved_links.
new_display = note.display_title new_display = note.display_title
if old_display and new_display and old_display.strip().lower() != new_display.strip().lower(): if old_display != new_display:
await _rename_inbound_links(db, note, old_display, new_display) await _claim_unresolved_links(db, note)
await db.flush() await db.flush()
await db.refresh(note, ["sync_revision"]) await db.refresh(note, ["sync_revision"])
return { return {
+14 -8
View File
@@ -22,7 +22,6 @@ from thoughtsync.notes import (
parse_link_titles, parse_link_titles,
parse_list_items, parse_list_items,
parse_tags, parse_tags,
rewrite_link_title,
) )
@@ -128,15 +127,22 @@ def test_parse_link_titles_empty():
assert parse_link_titles("no links here") == [] assert parse_link_titles("no links here") == []
def test_rewrite_link_title(): # `rewrite_link_title` and `_rename_inbound_links` are gone (M13 step 1). They kept
body = "see [[Alpha]] and [[ alpha ]] and [[Beta]]" # backlinks alive across a rename by editing the [[text]] inside every note that
assert rewrite_link_title(body, "alpha", "Gamma") == "see [[Gamma]] and [[Gamma]] and [[Beta]]" # linked to the renamed one — which meant one note's edit silently rewrote another's
# words. Links are bound to a note id now, so a rename needs no repair at all.
#
# What replaced them (`_resolve_target`, `_claim_unresolved_links`, and the id-first
# resolution in backlinks / the graph / serialization) is all SQL, and this suite runs
# without a database, so it is deliberately not asserted here. See the task log: that
# behaviour was checked by hand, and this repo has no integration lane to hold it.
def test_rewrite_link_title_noop(): def test_parse_link_titles_ignores_nesting():
assert rewrite_link_title("", "alpha", "Gamma") == "" # The link regex refuses [ and ] inside a token, so a malformed nest yields the
assert rewrite_link_title(None, "alpha", "Gamma") == "" # inner name rather than something spanning both — worth pinning, since this is
assert rewrite_link_title("no links here", "alpha", "Gamma") == "no links here" # the string that becomes a link's stored target.
assert parse_link_titles("[[outer [[inner]] ]]") == ["inner"]
def test_derive_display_title_explicit_wins(): def test_derive_display_title_explicit_wins():