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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user