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">
|
||||
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;
|
||||
|
||||
@@ -63,6 +63,22 @@ export interface NoteRevision {
|
||||
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 {
|
||||
id: string;
|
||||
title: string | null;
|
||||
@@ -85,6 +101,11 @@ export interface Note {
|
||||
items: ChecklistItem[];
|
||||
attachments: Attachment[];
|
||||
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;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from .auth import login_required
|
||||
@@ -33,11 +33,23 @@ async def get_graph():
|
||||
"""
|
||||
source = 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 = (
|
||||
select(source.id, target.id)
|
||||
.select_from(NoteLink)
|
||||
.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(
|
||||
source.owner_id == g.user_id,
|
||||
source.deleted_at.is_(None),
|
||||
|
||||
@@ -10,14 +10,38 @@ from . import Base
|
||||
|
||||
|
||||
class NoteLink(Base):
|
||||
"""A [[wiki-link]] from a source note to a target title (normalized). Resolved
|
||||
to a target note by matching target_norm against lower(trim(note.title))."""
|
||||
"""A [[wiki-link]] from a source note to another note.
|
||||
|
||||
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"
|
||||
__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)
|
||||
source_id: Mapped[uuid.UUID] = mapped_column(
|
||||
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)
|
||||
|
||||
@@ -19,7 +19,7 @@ import zipfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
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 ..auth import login_required
|
||||
@@ -67,11 +67,10 @@ from .import_export import (
|
||||
)
|
||||
from .links import (
|
||||
_reconcile_tags,
|
||||
_rename_inbound_links,
|
||||
_claim_unresolved_links,
|
||||
_rewrite_links,
|
||||
parse_link_titles,
|
||||
parse_tags,
|
||||
rewrite_link_title,
|
||||
)
|
||||
from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence
|
||||
from .serialize import _items_for_notes, _labels_for_notes, _serialize_note, _serialize_notes
|
||||
@@ -83,12 +82,11 @@ __all__ = [
|
||||
"parse_list_items",
|
||||
"parse_tags",
|
||||
"parse_link_titles",
|
||||
"rewrite_link_title",
|
||||
"normalize_color",
|
||||
"normalize_recurrence",
|
||||
"next_occurrence",
|
||||
"_reconcile_tags",
|
||||
"_rename_inbound_links",
|
||||
"_claim_unresolved_links",
|
||||
"_rewrite_links",
|
||||
"_serialize_notes",
|
||||
"_escape_like",
|
||||
@@ -435,15 +433,20 @@ async def note_backlinks(note_id: str):
|
||||
)
|
||||
if note is None:
|
||||
return not_found()
|
||||
if not note.display_title:
|
||||
return jsonify({"backlinks": []})
|
||||
norm = note.display_title.strip().lower()
|
||||
# Bound links (target_id) OR unbound ones still naming this note. The second
|
||||
# half is what catches a link written before this note existed and not yet
|
||||
# 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 = (
|
||||
await db.scalars(
|
||||
select(Note)
|
||||
.join(NoteLink, NoteLink.source_id == Note.id)
|
||||
.where(
|
||||
NoteLink.target_norm == norm,
|
||||
matches,
|
||||
Note.owner_id == g.user_id,
|
||||
Note.deleted_at.is_(None),
|
||||
Note.id != nid,
|
||||
@@ -524,6 +527,9 @@ async def create_note():
|
||||
db.add(NoteItem(note_id=note.id, text=text, position=pos))
|
||||
await _rewrite_links(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.refresh(note)
|
||||
return jsonify(await _serialize_note(db, note)), 201
|
||||
@@ -587,12 +593,12 @@ async def update_note(note_id: str):
|
||||
if "body" in data:
|
||||
await _rewrite_links(db, note)
|
||||
await _reconcile_tags(db, note)
|
||||
# The display NAME changing — via an explicit title OR the first body line —
|
||||
# repoints inbound [[Old Name]] references so backlinks survive (skip pure
|
||||
# case/whitespace changes, which still resolve).
|
||||
# A rename no longer touches anything else's TEXT. Inbound links already hold
|
||||
# this note's id, so they follow it automatically; all that is left is to
|
||||
# adopt any still-unresolved link that was waiting for this name.
|
||||
new_display = note.display_title
|
||||
if old_display and new_display and old_display.strip().lower() != new_display.strip().lower():
|
||||
await _rename_inbound_links(db, note, old_display, new_display)
|
||||
if old_display != new_display:
|
||||
await _claim_unresolved_links(db, note)
|
||||
# Version history: snapshot the PRE-edit title+body whenever either changed.
|
||||
if note.title != old_title or note.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 _reconcile_tags(db, note)
|
||||
new_display = note.display_title
|
||||
if old_display and new_display and old_display.strip().lower() != new_display.strip().lower():
|
||||
await _rename_inbound_links(db, note, old_display, new_display)
|
||||
if old_display != new_display:
|
||||
await _claim_unresolved_links(db, note)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
return jsonify(await _serialize_note(db, note))
|
||||
|
||||
@@ -5,7 +5,8 @@ from __future__ import annotations
|
||||
|
||||
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.note import Note
|
||||
@@ -49,11 +50,65 @@ def parse_link_titles(body: str | None) -> list[str]:
|
||||
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:
|
||||
"""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))
|
||||
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):
|
||||
@@ -89,38 +144,3 @@ async def _reconcile_tags(db, note: Note) -> None:
|
||||
if lid not in attached_ids:
|
||||
db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=True))
|
||||
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
|
||||
the JSON dict the API returns. The bulk loaders (`*_for_notes`) fetch each child
|
||||
"""Note serialization — turn a Note (+ its labels/items/attachments/previews/links)
|
||||
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."""
|
||||
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.note import Note
|
||||
from ..models.note_attachment import NoteAttachment
|
||||
from ..models.note_item import NoteItem
|
||||
from ..models.note_link import NoteLink
|
||||
from ..models.note_link_preview import NoteLinkPreview
|
||||
|
||||
|
||||
@@ -104,6 +106,64 @@ async def _previews_for_notes(db, note_ids: list) -> dict:
|
||||
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:
|
||||
data = note.serialize()
|
||||
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, [])
|
||||
previews = await _previews_for_notes(db, [note.id])
|
||||
data["previews"] = previews.get(note.id, [])
|
||||
links = await _links_for_notes(db, [note.id])
|
||||
data["links"] = links.get(note.id, [])
|
||||
return data
|
||||
|
||||
|
||||
@@ -123,6 +185,7 @@ async def _serialize_notes(db, notes: list) -> list:
|
||||
items_map = await _items_for_notes(db, ids)
|
||||
attach_map = await _attachments_for_notes(db, ids)
|
||||
preview_map = await _previews_for_notes(db, ids)
|
||||
link_map = await _links_for_notes(db, ids)
|
||||
out = []
|
||||
for n in notes:
|
||||
data = n.serialize()
|
||||
@@ -130,5 +193,6 @@ async def _serialize_notes(db, notes: list) -> list:
|
||||
data["items"] = items_map.get(n.id, [])
|
||||
data["attachments"] = attach_map.get(n.id, [])
|
||||
data["previews"] = preview_map.get(n.id, [])
|
||||
data["links"] = link_map.get(n.id, [])
|
||||
out.append(data)
|
||||
return out
|
||||
|
||||
@@ -28,7 +28,7 @@ from .models.note_item import NoteItem
|
||||
from .models.note_revision import NoteRevision
|
||||
from .notes import (
|
||||
_reconcile_tags,
|
||||
_rename_inbound_links,
|
||||
_claim_unresolved_links,
|
||||
_rewrite_links,
|
||||
_serialize_notes,
|
||||
derive_display_title,
|
||||
@@ -295,9 +295,12 @@ async def _apply_note(db, ch: dict) -> dict:
|
||||
await _rewrite_links(db, note)
|
||||
await _reconcile_tags(db, note)
|
||||
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
|
||||
if old_display and new_display and old_display.strip().lower() != new_display.strip().lower():
|
||||
await _rename_inbound_links(db, note, old_display, new_display)
|
||||
if old_display != new_display:
|
||||
await _claim_unresolved_links(db, note)
|
||||
await db.flush()
|
||||
await db.refresh(note, ["sync_revision"])
|
||||
return {
|
||||
|
||||
+14
-8
@@ -22,7 +22,6 @@ from thoughtsync.notes import (
|
||||
parse_link_titles,
|
||||
parse_list_items,
|
||||
parse_tags,
|
||||
rewrite_link_title,
|
||||
)
|
||||
|
||||
|
||||
@@ -128,15 +127,22 @@ def test_parse_link_titles_empty():
|
||||
assert parse_link_titles("no links here") == []
|
||||
|
||||
|
||||
def test_rewrite_link_title():
|
||||
body = "see [[Alpha]] and [[ alpha ]] and [[Beta]]"
|
||||
assert rewrite_link_title(body, "alpha", "Gamma") == "see [[Gamma]] and [[Gamma]] and [[Beta]]"
|
||||
# `rewrite_link_title` and `_rename_inbound_links` are gone (M13 step 1). They kept
|
||||
# backlinks alive across a rename by editing the [[text]] inside every note that
|
||||
# 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():
|
||||
assert rewrite_link_title("", "alpha", "Gamma") == ""
|
||||
assert rewrite_link_title(None, "alpha", "Gamma") == ""
|
||||
assert rewrite_link_title("no links here", "alpha", "Gamma") == "no links here"
|
||||
def test_parse_link_titles_ignores_nesting():
|
||||
# The link regex refuses [ and ] inside a token, so a malformed nest yields the
|
||||
# inner name rather than something spanning both — worth pinning, since this is
|
||||
# the string that becomes a link's stored target.
|
||||
assert parse_link_titles("[[outer [[inner]] ]]") == ["inner"]
|
||||
|
||||
|
||||
def test_derive_display_title_explicit_wins():
|
||||
|
||||
Reference in New Issue
Block a user