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
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:
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
@@ -10,7 +10,6 @@ from . import ( # noqa: F401
|
||||
note,
|
||||
note_attachment,
|
||||
note_item,
|
||||
note_link,
|
||||
note_link_preview,
|
||||
note_revision,
|
||||
saved_filter,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user