0.2.0 — a notebook in your pocket, ready to be hosted #3
@@ -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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user