Remove [[wiki-links]], backlinks and the graph
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 37s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 1m56s

Operator, 2026-08-22 (note 2897): ThoughtSync is an intermediary surface. You
write here because it's easy — a notebook in your pocket — and later you recall
the thing and go finish it somewhere else. Recall is the product; organization
is secondary. A linking system is organization, and it isn't what this is for.

So: `[[wiki-links]]`, backlinks, the `[[` autocomplete, the note_links table,
`/api/notes/link-search`, `/api/notes/<id>/backlinks`, the whole graph blueprint
and GraphView. Rust core loses `extract_links`, `backlinks`, `link_search` and
`create_titled`; the desktop loses the three Tauri commands that exposed them.

This subsumes 982d24c rather than reverting it. That commit bound links to a
note id so a rename would stop rewriting other notes' bodies — real infra, but
infra for a feature that is now gone, and nothing it added survives. Alembic
0023 stays in the chain anyway: it shipped in an image and may already be
applied, and deleting an applied revision strands a database's version pointer.
0024 drops the table and takes the column with it. The history stays honest
about the fact that it existed for a day.

Two things deliberately kept, because they were serving recall and only
incidentally serving links:

- `/api/notes/titles` and the titles store. The command palette lists them so
  you can jump to a note by name. `resolve()` — the name→note lookup that only
  linking needed — is gone.
- `display_title`. Every note still has a name for search results and export
  filenames. What that name is FOR changed; that it exists did not.

`notes/links.py` is now `notes/tags.py`, holding the #tag→label reconciliation
it always also owned. A file called links.py with no links in it would have been
exactly the drift this removal is meant to end.

Also swept out on the way: `_escape_like`, whose only caller was link-search,
and the `graph` icon. Nothing lost that a person typed — note_links was always
derived, and the `[[text]]` is still sitting in every body it was written in.
This commit is contained in:
2026-08-22 12:00:57 -04:00
parent 982d24c83b
commit bc22f8e249
38 changed files with 216 additions and 1485 deletions
-2
View File
@@ -15,7 +15,6 @@ from .auth import bp as auth_bp
from .client_dist import advertisement as client_advertisement, bp as client_bp
from .config import Config
from .db import session_scope
from .graph import bp as graph_bp
from .labels import bp as labels_bp
from .notes import bp as notes_bp
from .retention import run_sweeper
@@ -78,7 +77,6 @@ def create_app() -> Quart:
app.register_blueprint(auth_bp)
app.register_blueprint(notes_bp)
app.register_blueprint(labels_bp)
app.register_blueprint(graph_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(sync_bp)
app.register_blueprint(saved_filters_bp)
-117
View File
@@ -1,117 +0,0 @@
from __future__ import annotations
from quart import Blueprint, g, jsonify
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import aliased
from .auth import login_required
from .db import session_scope
from .models.label import Label, NoteLabel
from .models.note import Note
from .models.note_link import NoteLink
bp = Blueprint("graph", __name__, url_prefix="/api/graph")
@bp.get("")
@login_required
async def get_graph():
"""Spatial view of the owner's non-trashed notes.
Nodes are two kinds:
- notes (kind="note") — every non-trashed note; each carries its first
label's color for tinting.
- labels (kind="label", id "label:<uuid>") — every label actually attached
to a live note, acting as a clustering HUB so tagged notes gravitate
together even without wiki-links between them.
Edges are two kinds:
- wiki-links (kind="link") — resolved [[links]] (note_links.target_norm
matched to a note's normalized display_title).
- membership (kind="label") — each note → each of its label hubs.
The frontend toggles labels + unlinked notes; the graph is a light auxiliary
lens, not a focal surface.
"""
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,
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),
target.owner_id == g.user_id,
target.deleted_at.is_(None),
source.id != target.id,
)
)
async with session_scope() as db:
rows = (await db.execute(edge_stmt)).all()
edges = []
seen: set = set()
for src_id, tgt_id in rows:
key = (src_id, tgt_id)
if key in seen:
continue
seen.add(key)
edges.append({"source": str(src_id), "target": str(tgt_id), "kind": "link"})
# Note ↔ label membership: one row per (note, label) for the owner's
# non-trashed notes. Drives both the note-color tint (first label by name)
# and the label-hub nodes + membership edges.
label_rows = (
await db.execute(
select(NoteLabel.note_id, Label.id, Label.name, Label.color)
.join(Label, Label.id == NoteLabel.label_id)
.join(Note, Note.id == NoteLabel.note_id)
.where(
Label.owner_id == g.user_id,
Note.owner_id == g.user_id,
Note.deleted_at.is_(None),
)
.order_by(Label.name)
)
).all()
first_color: dict = {}
label_nodes: dict = {}
for note_id, label_id, label_name, label_color in label_rows:
first_color.setdefault(note_id, label_color)
hub_id = f"label:{label_id}"
if hub_id not in label_nodes:
label_nodes[hub_id] = {
"id": hub_id,
"title": f"#{label_name}",
"color": label_color or "default",
"kind": "label",
"label_id": str(label_id),
}
edges.append({"source": str(note_id), "target": hub_id, "kind": "label"})
note_rows = (
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
).all()
nodes = [
{
"id": str(n.id),
"title": n.display_title or "Untitled",
"color": first_color.get(n.id, "default"),
"kind": "note",
}
for n in note_rows
]
nodes.extend(label_nodes.values())
return jsonify({"nodes": nodes, "edges": edges})
-1
View File
@@ -10,7 +10,6 @@ from . import ( # noqa: F401
note,
note_attachment,
note_item,
note_link,
note_link_preview,
note_revision,
saved_filter,
+3 -3
View File
@@ -40,9 +40,9 @@ class Note(Base):
)
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
# The note's display NAME: explicit title if set, else the first non-empty body
# line (see notes.derive_display_title). Persisted + normalized-matched so every
# note — even a body-only one — is nameable, searchable, graphable, and
# [[wiki-link]]-able without forcing the user to type a title.
# line (see notes.derive_display_title). Persisted so every note — even a body-only
# one — has something to be called in search results and in an export filename,
# without forcing the user to type a title.
display_title: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
-47
View File
@@ -1,47 +0,0 @@
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, Index, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from . import Base
class NoteLink(Base):
"""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"),
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)
+10 -104
View File
@@ -1,7 +1,7 @@
"""Notes API (the `/api/notes` blueprint).
The bulk of the shared logic lives in cohesive sibling modules — serialization
(`serialize`), wiki-links/tags (`links`), recurring reminders (`recurrence`),
(`serialize`), #tags (`tags`), recurring reminders (`recurrence`),
small text/query helpers (`helpers`), and export/import (`import_export`). The
route handlers themselves stay here so blueprint registration is in one place, and
`bp` is defined in `_bp` so every module can import it without a cycle.
@@ -19,7 +19,7 @@ import zipfile
from datetime import datetime, timedelta, timezone
from quart import Response, g, jsonify, request, send_file
from sqlalchemy import and_, case, func, literal_column, or_, select
from sqlalchemy import func, literal_column, select
from ..acl import visible_to_user
from ..auth import login_required
@@ -32,7 +32,6 @@ 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
from ..models.note_revision import NoteRevision
from ..responses import json_error, not_found, parse_uuid
@@ -44,7 +43,6 @@ from .helpers import (
ALLOWED_IMAGE_MIMES,
VALID_FILTERS,
_attachment_ext,
_escape_like,
_get_owned,
_header_filename,
_safe_filename,
@@ -65,11 +63,8 @@ from .import_export import (
_read_import_specs,
_usec_to_dt,
)
from .links import (
from .tags import (
_reconcile_tags,
_claim_unresolved_links,
_rewrite_links,
parse_link_titles,
parse_tags,
)
from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence
@@ -81,15 +76,11 @@ __all__ = [
"is_empty_note",
"parse_list_items",
"parse_tags",
"parse_link_titles",
"normalize_color",
"normalize_recurrence",
"next_occurrence",
"_reconcile_tags",
"_claim_unresolved_links",
"_rewrite_links",
"_serialize_notes",
"_escape_like",
"_safe_filename",
"_attachment_ext",
"_header_filename",
@@ -378,9 +369,13 @@ async def import_notes():
@bp.get("/titles")
@login_required
async def list_titles():
# Owner's non-trashed notes, keyed by their display NAME (explicit title or
# first body line) — the index the frontend uses to resolve + autocomplete
# [[wiki-links]]. Every note has a name now, so body-only notes are linkable too.
"""Owner's non-trashed notes, keyed by their display NAME.
Survived the removal of [[wiki-links]] (note 2897) because it was serving two
different things, and only one of them was linking. This is what the command
palette lists so someone can jump to a note by name — which is recall, the thing
this app is actually for. The `[[` autocomplete that also read it is gone.
"""
async with session_scope() as db:
rows = (
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
@@ -390,78 +385,6 @@ async def list_titles():
)
@bp.get("/link-search")
@login_required
async def link_search():
# Autocomplete source for [[wiki-links]]: match the query against a note's display
# NAME *or* its BODY, so you can link by recalling any phrase — not just the name.
# Substring ILIKE (good for partial-word typing, deterministic, fine at personal
# scale; the FTS index still powers the heavier /search). Name matches rank above
# body-only matches, and a name prefix above a mid-name substring. Empty q → recent.
q = (request.args.get("q") or "").strip()
async with session_scope() as db:
base = select(Note).where(
Note.owner_id == g.user_id, Note.deleted_at.is_(None), Note.display_title != ""
)
if not q:
stmt = base.order_by(Note.updated_at.desc()).limit(10)
else:
esc = _escape_like(q)
name_hit = Note.display_title.ilike(f"%{esc}%", escape="\\")
stmt = (
base.where(name_hit | Note.body.ilike(f"%{esc}%", escape="\\"))
.order_by(
case((name_hit, 0), else_=1),
case((Note.display_title.ilike(f"{esc}%", escape="\\"), 0), else_=1),
Note.updated_at.desc(),
)
.limit(10)
)
rows = (await db.scalars(stmt)).all()
return jsonify({"results": [{"id": str(n.id), "title": n.display_title} for n in rows]})
@bp.get("/<note_id>/backlinks")
@login_required
async def note_backlinks(note_id: str):
nid = parse_uuid(note_id)
if nid is None:
return not_found()
async with session_scope() as db:
note = await db.scalar(
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
)
if note is None:
return not_found()
# 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(
matches,
Note.owner_id == g.user_id,
Note.deleted_at.is_(None),
Note.id != nid,
)
)
).all()
seen: set = set()
out = []
for n in sources:
if n.id not in seen:
seen.add(n.id)
out.append({"id": str(n.id), "title": n.display_title})
return jsonify({"backlinks": out})
@bp.post("/reorder")
@login_required
async def reorder_notes():
@@ -525,11 +448,7 @@ async def create_note():
await db.flush() # assign note.id before writing items/links
for pos, text in enumerate(item_texts):
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
@@ -558,7 +477,6 @@ async def update_note(note_id: str):
note = await _get_owned(db, note_id)
if note is None:
return not_found()
old_display = note.display_title
old_title = note.title
old_body = note.body
if "title" in data:
@@ -591,14 +509,7 @@ async def update_note(note_id: str):
if "title" in data or "body" in data:
note.display_title = derive_display_title(note.title, note.body)
if "body" in data:
await _rewrite_links(db, note)
await _reconcile_tags(db, note)
# 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 != 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))
@@ -651,16 +562,11 @@ async def restore_revision(note_id: str, rev_id: str):
return jsonify(await _serialize_note(db, note)) # already at this version — no-op
# Snapshot the CURRENT state first, so restoring is itself undoable, then apply
# the revision — with the same title/body ripple as a normal edit.
old_display = note.display_title
db.add(NoteRevision(note_id=note.id, title=note.title, body=note.body))
note.title = rev.title
note.body = rev.body
note.display_title = derive_display_title(note.title, note.body)
await _rewrite_links(db, note)
await _reconcile_tags(db, note)
new_display = note.display_title
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
View File
@@ -74,11 +74,6 @@ async def _get_owned(db, note_id: str) -> Note | None:
)
def _escape_like(s: str) -> str:
"""Escape LIKE wildcards so user input matches literally (escape char = \\)."""
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _slugify(text: str) -> str:
"""A filesystem-safe slug from a note's display name (for the .md filename)."""
s = re.sub(r"[^\w\s-]", "", (text or "").strip().lower())
+1 -2
View File
@@ -28,7 +28,7 @@ from .helpers import (
derive_display_title,
is_empty_note,
)
from .links import _find_or_create_label, _reconcile_tags, _rewrite_links
from .tags import _find_or_create_label, _reconcile_tags
from .recurrence import normalize_recurrence
@@ -333,6 +333,5 @@ async def _create_imported_note(
if isinstance(att, dict):
_import_attachment(db, note, zf, att, budget)
await _rewrite_links(db, note)
await _reconcile_tags(db, note)
return True
-146
View File
@@ -1,146 +0,0 @@
"""[[wiki-links]] and #tags — parsing note bodies and keeping the derived
note_links / tag-sourced note_labels rows in sync with the text. Manual (picker)
labels are NOT touched here (see the labeling module)."""
from __future__ import annotations
import re
from sqlalchemy import delete, func, select, update
from sqlalchemy.orm import aliased
from ..models.label import Label, NoteLabel
from ..models.note import Note
from ..models.note_link import NoteLink
_LINK_RE = re.compile(r"\[\[([^\[\]]+)\]\]")
# A #tag: `#` at the start of the body or after whitespace, then a word char and
# word chars/hyphens. A URL fragment (foo#bar) or mid-word `#` is not preceded by
# whitespace, so it won't match.
_TAG_RE = re.compile(r"(?:^|(?<=\s))#(\w[\w-]*)")
def parse_tags(body: str | None) -> list[str]:
"""Distinct #hashtags from a note body, in order, deduped case-insensitively.
A tag must contain a letter, so #2024 or #_ are ignored (avoids numeric noise)."""
if not body:
return []
out: list[str] = []
seen: set[str] = set()
for match in _TAG_RE.finditer(body):
tag = match.group(1)
if not any(c.isalpha() for c in tag):
continue
norm = tag.lower()
if norm not in seen:
seen.add(norm)
out.append(tag)
return out
def parse_link_titles(body: str | None) -> list[str]:
"""Extract distinct normalized [[wiki-link]] titles from a note body."""
if not body:
return []
out: list[str] = []
for match in _LINK_RE.finditer(body):
norm = match.group(1).strip().lower()
if norm and norm not in out:
out.append(norm)
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.
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):
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):
"""Owner's label id for `name` (case-insensitive match), creating it if absent."""
existing = await db.scalar(
select(Label.id).where(Label.owner_id == owner_id, func.lower(Label.name) == name.lower())
)
if existing is not None:
return existing
label = Label(owner_id=owner_id, name=name)
db.add(label)
await db.flush()
return label.id
async def _reconcile_tags(db, note: Note) -> None:
"""Sync tag-sourced labels (via_tag=True) with the #hashtags in the note body:
attach labels for current tags, detach tag-labels whose #tag was removed. Manual
picker labels (via_tag=False) are never touched."""
tag_label_ids: set = set()
for name in parse_tags(note.body):
tag_label_ids.add(await _find_or_create_label(db, note.owner_id, name))
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
attached_ids = {r.label_id for r in rows}
# Detach tag-labels no longer backed by a #tag in the body.
for r in rows:
if r.via_tag and r.label_id not in tag_label_ids:
await db.delete(r)
attached_ids.discard(r.label_id)
# Attach new tags — skip labels already attached (in any form) to respect the PK
# and leave a manually-added label of the same name as-is.
for lid in tag_label_ids:
if lid not in attached_ids:
db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=True))
attached_ids.add(lid)
+3 -67
View File
@@ -1,16 +1,14 @@
"""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
"""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
collection for a batch of notes in one query, so list endpoints avoid N+1s."""
from __future__ import annotations
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import aliased
from sqlalchemy import select
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
@@ -106,64 +104,6 @@ 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])
@@ -174,8 +114,6 @@ 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
@@ -185,7 +123,6 @@ 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()
@@ -193,6 +130,5 @@ 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
+75
View File
@@ -0,0 +1,75 @@
"""#tags — parsing note bodies and keeping the derived tag-sourced note_labels rows
in sync with the text. Manual (picker) labels are NOT touched here (see the labeling
module).
Was `links.py`, and also owned `[[wiki-links]]` until they were removed (note 2897):
this app is an intermediary surface for capture and recall, and a linking system is
organization, which is not what it is for. A file called links.py holding no links
would have been exactly the kind of drift that removal was meant to end.
"""
from __future__ import annotations
import re
from sqlalchemy import func, select
from ..models.label import Label, NoteLabel
from ..models.note import Note
# A #tag: `#` at the start of the body or after whitespace, then a word char and
# word chars/hyphens. A URL fragment (foo#bar) or mid-word `#` is not preceded by
# whitespace, so it won't match.
_TAG_RE = re.compile(r"(?:^|(?<=\s))#(\w[\w-]*)")
def parse_tags(body: str | None) -> list[str]:
"""Distinct #hashtags from a note body, in order, deduped case-insensitively.
A tag must contain a letter, so #2024 or #_ are ignored (avoids numeric noise)."""
if not body:
return []
out: list[str] = []
seen: set[str] = set()
for match in _TAG_RE.finditer(body):
tag = match.group(1)
if not any(c.isalpha() for c in tag):
continue
norm = tag.lower()
if norm not in seen:
seen.add(norm)
out.append(tag)
return out
async def _find_or_create_label(db, owner_id, name: str):
"""Owner's label id for `name` (case-insensitive match), creating it if absent."""
existing = await db.scalar(
select(Label.id).where(Label.owner_id == owner_id, func.lower(Label.name) == name.lower())
)
if existing is not None:
return existing
label = Label(owner_id=owner_id, name=name)
db.add(label)
await db.flush()
return label.id
async def _reconcile_tags(db, note: Note) -> None:
"""Sync tag-sourced labels (via_tag=True) with the #hashtags in the note body:
attach labels for current tags, detach tag-labels whose #tag was removed. Manual
picker labels (via_tag=False) are never touched."""
tag_label_ids: set = set()
for name in parse_tags(note.body):
tag_label_ids.add(await _find_or_create_label(db, note.owner_id, name))
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
attached_ids = {r.label_id for r in rows}
# Detach tag-labels no longer backed by a #tag in the body.
for r in rows:
if r.via_tag and r.label_id not in tag_label_ids:
await db.delete(r)
attached_ids.discard(r.label_id)
# Attach new tags — skip labels already attached (in any form) to respect the PK
# and leave a manually-added label of the same name as-is.
for lid in tag_label_ids:
if lid not in attached_ids:
db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=True))
attached_ids.add(lid)
-2
View File
@@ -33,7 +33,6 @@ from .models.label import 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
from .models.note_revision import NoteRevision
from .settings import get_setting
@@ -88,7 +87,6 @@ async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None:
await db.execute(sa_delete(NoteAttachment).where(NoteAttachment.note_id == note.id))
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id))
await db.execute(sa_delete(NoteLink).where(NoteLink.source_id == note.id))
await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))
await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id))
note.title = None
+1 -10
View File
@@ -28,8 +28,6 @@ from .models.note_item import NoteItem
from .models.note_revision import NoteRevision
from .notes import (
_reconcile_tags,
_claim_unresolved_links,
_rewrite_links,
_serialize_notes,
derive_display_title,
normalize_color,
@@ -282,7 +280,7 @@ async def _apply_note(db, ch: dict) -> dict:
elif note.purged_at is not None:
note.purged_at = None # client re-created/edited → clear the tombstone
old_title, old_body, old_display = note.title, note.body, note.display_title
old_title, old_body = note.title, note.body
_assign_note_fields(note, ch)
note.display_title = derive_display_title(note.title, note.body)
if edited_at is not None:
@@ -292,15 +290,8 @@ async def _apply_note(db, ch: dict) -> dict:
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
await db.flush() # assign note.id before items/labels/links
await _apply_note_items(db, note, ch)
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 != new_display:
await _claim_unresolved_links(db, note)
await db.flush()
await db.refresh(note, ["sync_revision"])
return {