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
+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))