CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 25s
CI failed on 8d9e96c — eight tests in test_mcp_tool_notes.py, all
"Connect call failed (127.0.0.1, 5432)".
The proximate cause is that `_attach_supersession` runs on every note
read/write and those are unit tests of the tool layer with no database. But the
test failure exposed a worse decision underneath it.
I had written the two directions as two service calls, so every `get_note`
made TWO extra round trips plus TWO ACL checks — on the hottest path in the
product — to save a two-line partition in Python. That is the wrong trade
whether or not a test noticed.
`get_relations` replaces both: one OR query, one ACL check, partitioned by
which column holds the note's id. Its test asserts `execute.await_count == 1`,
so the collapse can't quietly come apart later.
The tests then get an autouse stub rather than the code getting a swallow. The
tool genuinely has a new dependency; hiding that behind a try/except to keep
unit tests green would be arranging for the code to lie about what it does.
This file already records the same hazard for note 2109, so the stub sits next
to that precedent.
Added the test that matters, which the first pass missed: a superseded record
still surfaces, so an agent WILL read stale material — and it must arrive with
a plain-language warning, not just a numeric field to notice. Also pinned that
both keys are ABSENT rather than present-and-empty when there are no relations.
Refs #278
514 lines
18 KiB
Python
514 lines
18 KiB
Python
import logging
|
|
import re
|
|
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
|
|
from scribe.auth import login_required, get_current_user_id
|
|
from scribe.routes.utils import not_found, parse_iso_date, parse_pagination
|
|
from scribe.services.access import can_write_note
|
|
from scribe.services.notes import (
|
|
build_note_graph,
|
|
convert_note_to_task,
|
|
convert_task_to_note,
|
|
create_note,
|
|
get_all_tags,
|
|
get_backlinks,
|
|
get_note,
|
|
get_note_by_title,
|
|
get_note_for_user,
|
|
get_or_create_note_by_title,
|
|
list_notes,
|
|
update_note,
|
|
)
|
|
from scribe.services.note_drafts import upsert_draft, get_draft, delete_draft
|
|
from scribe.services import supersession as supersession_svc
|
|
from scribe.services.note_usage import record_pulled
|
|
|
|
|
|
async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
|
|
"""Both directions of the supersession relation on a note payload.
|
|
|
|
Mirrors the MCP helper of the same name — the two surfaces must agree about
|
|
what a note's payload says, or the web UI and the agent would disagree about
|
|
whether a record is current.
|
|
|
|
Omitted when empty: a field that always says nothing trains readers to skip
|
|
fields, which is what `consolidated_at` cost (#2483).
|
|
"""
|
|
rel = await supersession_svc.get_relations(uid, note_id)
|
|
if rel["supersedes"]:
|
|
data["supersedes"] = rel["supersedes"]
|
|
if rel["superseded_by"]:
|
|
data["superseded_by"] = rel["superseded_by"]
|
|
from scribe.services.note_versions import list_versions, get_version
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
notes_bp = Blueprint("notes", __name__, url_prefix="/api/notes")
|
|
|
|
|
|
@notes_bp.route("", methods=["GET"])
|
|
@login_required
|
|
async def list_notes_route():
|
|
uid = get_current_user_id()
|
|
q = request.args.get("q")
|
|
tag = request.args.getlist("tag")
|
|
sort = request.args.get("sort", "updated_at")
|
|
order = request.args.get("order", "desc")
|
|
limit, offset = parse_pagination()
|
|
|
|
# Default to non-task notes only; ?is_task=true for tasks, ?all=true for everything
|
|
is_task: bool | None = False
|
|
if request.args.get("all", "").lower() == "true":
|
|
is_task = None
|
|
elif request.args.get("is_task", "").lower() == "true":
|
|
is_task = True
|
|
|
|
project_id = request.args.get("project_id", type=int)
|
|
milestone_id = request.args.get("milestone_id", type=int)
|
|
parent_id = request.args.get("parent_id", type=int)
|
|
no_project = request.args.get("no_project", "").lower() == "true"
|
|
|
|
# type= shorthand used by web frontend (?type=task or ?type=note)
|
|
type_param = request.args.get("type")
|
|
if type_param == "task":
|
|
is_task = True
|
|
elif type_param == "note":
|
|
is_task = False
|
|
|
|
status = request.args.getlist("status") or None
|
|
priority = request.args.getlist("priority") or None
|
|
|
|
notes, total = await list_notes(
|
|
uid, q=q, tags=tag or None, is_task=is_task, sort=sort, order=order,
|
|
limit=limit, offset=offset,
|
|
project_id=project_id, milestone_id=milestone_id, parent_id=parent_id,
|
|
no_project=no_project, status=status, priority=priority,
|
|
)
|
|
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
|
|
|
|
|
|
@notes_bp.route("", methods=["POST"])
|
|
@login_required
|
|
async def create_note_route():
|
|
uid = get_current_user_id()
|
|
data = await request.get_json()
|
|
body = data.get("body", "")
|
|
tags = data.get("tags", [])
|
|
|
|
# Optional task fields
|
|
status = data.get("status")
|
|
priority = data.get("priority")
|
|
due_date = parse_iso_date(data.get("due_date"), "due_date")
|
|
if isinstance(due_date, tuple):
|
|
return due_date
|
|
|
|
project_id = data.get("project_id")
|
|
if project_id is None and data.get("project"):
|
|
from scribe.services.projects import get_project_by_title as _gpbt
|
|
proj = await _gpbt(uid, data["project"])
|
|
if proj:
|
|
project_id = proj.id
|
|
|
|
note_type = data.get("note_type", "note")
|
|
|
|
try:
|
|
note = await create_note(
|
|
uid,
|
|
title=data.get("title", ""),
|
|
body=body,
|
|
description=data.get("description"),
|
|
tags=tags,
|
|
parent_id=data.get("parent_id"),
|
|
project_id=project_id,
|
|
milestone_id=data.get("milestone_id"),
|
|
status=status,
|
|
priority=priority,
|
|
due_date=due_date,
|
|
note_type=note_type,
|
|
)
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 400
|
|
|
|
# Same capability as the MCP create path (#33). Without it the web UI would
|
|
# be the surface on which a supersession claim silently cannot be made.
|
|
if data.get("supersedes"):
|
|
try:
|
|
await supersession_svc.set_supersedes(uid, note.id, data["supersedes"])
|
|
except PermissionError as exc:
|
|
# 403, not 400: the request is well-formed and the caller simply
|
|
# may not write the target. The note itself was created.
|
|
return jsonify({"error": str(exc), "note": note.to_dict()}), 403
|
|
out = note.to_dict()
|
|
await _attach_supersession(uid, note.id, out)
|
|
return jsonify(out), 201
|
|
|
|
|
|
@notes_bp.route("/tags", methods=["GET"])
|
|
@login_required
|
|
async def list_tags_route():
|
|
uid = get_current_user_id()
|
|
q = request.args.get("q")
|
|
tags = await get_all_tags(uid, q=q)
|
|
return jsonify({"tags": tags})
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>/append-tag", methods=["POST"])
|
|
@login_required
|
|
async def append_tag_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
data = await request.get_json()
|
|
tag = data.get("tag", "").strip().replace(" ", "-")
|
|
if not tag:
|
|
return jsonify({"error": "tag is required"}), 400
|
|
|
|
note = await get_note(uid, note_id)
|
|
if note is None:
|
|
return not_found("Note")
|
|
|
|
existing = list(note.tags or [])
|
|
if tag not in existing:
|
|
existing.append(tag)
|
|
updated = await update_note(uid, note_id, tags=existing)
|
|
return jsonify(updated.to_dict())
|
|
|
|
|
|
@notes_bp.route("/by-title", methods=["GET"])
|
|
@login_required
|
|
async def get_note_by_title_route():
|
|
uid = get_current_user_id()
|
|
title = request.args.get("title", "")
|
|
if not title:
|
|
return jsonify({"error": "title parameter is required"}), 400
|
|
note = await get_note_by_title(uid, title)
|
|
if note is None:
|
|
return not_found("Note")
|
|
return jsonify(note.to_dict())
|
|
|
|
|
|
@notes_bp.route("/resolve-title", methods=["POST"])
|
|
@login_required
|
|
async def resolve_title_route():
|
|
uid = get_current_user_id()
|
|
data = await request.get_json()
|
|
title = data.get("title", "").strip()
|
|
if not title:
|
|
return jsonify({"error": "title is required"}), 400
|
|
note = await get_or_create_note_by_title(uid, title)
|
|
return jsonify(note.to_dict())
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>", methods=["GET"])
|
|
@login_required
|
|
async def get_note_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
result = await get_note_for_user(uid, note_id)
|
|
if result is None:
|
|
return not_found("Note")
|
|
note, permission = result
|
|
data = note.to_dict()
|
|
data["permission"] = permission
|
|
# Opening the detail view IS a pull — the operator chose to look. Tagged by
|
|
# SURFACE, not by the record's kind, matching rest_snippet: the kind is a
|
|
# join away, but which surface asked is not recoverable after the fact.
|
|
# Keeping rest_* apart from mcp_* is load-bearing, not tidiness — "was that
|
|
# injected line useful?" is answered by agent pulls alone, and a human
|
|
# clicking a link would inflate exactly the number #1038 and #2085 gate on.
|
|
record_pulled(user_id=uid, note_id=note_id, source="rest_note")
|
|
await _attach_supersession(uid, note_id, data)
|
|
return jsonify(data)
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>", methods=["PUT"])
|
|
@login_required
|
|
async def update_note_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
# Share-aware: resolve through the ACL and write as the OWNER, so a shared
|
|
# editor's save isn't rejected by the owner-scoped update service.
|
|
result = await get_note_for_user(uid, note_id)
|
|
if result is None:
|
|
return not_found("Note")
|
|
note_obj, _ = result
|
|
if not await can_write_note(uid, note_id):
|
|
return jsonify({"error": "Permission denied"}), 403
|
|
owner_uid = note_obj.user_id
|
|
data = await request.get_json()
|
|
fields = {}
|
|
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
|
if key in data:
|
|
fields[key] = data[key]
|
|
if "due_date" in data:
|
|
if data["due_date"]:
|
|
result = parse_iso_date(data["due_date"], "due_date")
|
|
if isinstance(result, tuple):
|
|
return result
|
|
fields["due_date"] = result
|
|
else:
|
|
fields["due_date"] = None
|
|
|
|
if "tags" in data:
|
|
fields["tags"] = data["tags"]
|
|
try:
|
|
note = await update_note(owner_uid, note_id, **fields)
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 400
|
|
if note is None:
|
|
return not_found("Note")
|
|
# Set-semantics, matching MCP and the PATCH route: present-and-empty
|
|
# clears, absent leaves alone. Scoped by the CALLER, not owner_uid — an
|
|
# editor-share holder may edit this note and must not thereby inherit the
|
|
# owner's write access to whatever they name as superseded (#47).
|
|
if "supersedes" in data:
|
|
try:
|
|
await supersession_svc.set_supersedes(uid, note_id, data["supersedes"] or [])
|
|
except PermissionError as exc:
|
|
return jsonify({"error": str(exc)}), 403
|
|
out = note.to_dict()
|
|
await _attach_supersession(uid, note_id, out)
|
|
return jsonify(out)
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>", methods=["PATCH"])
|
|
@login_required
|
|
async def patch_note_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
result = await get_note_for_user(uid, note_id)
|
|
if result is None:
|
|
return not_found("Note")
|
|
note_obj, _ = result
|
|
if not await can_write_note(uid, note_id):
|
|
return jsonify({"error": "Permission denied"}), 403
|
|
owner_uid = note_obj.user_id
|
|
data = await request.get_json()
|
|
fields = {}
|
|
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
|
if key in data:
|
|
fields[key] = data[key]
|
|
if "due_date" in data:
|
|
if data["due_date"]:
|
|
result = parse_iso_date(data["due_date"], "due_date")
|
|
if isinstance(result, tuple):
|
|
return result
|
|
fields["due_date"] = result
|
|
else:
|
|
fields["due_date"] = None
|
|
if "tags" in data:
|
|
fields["tags"] = data["tags"]
|
|
try:
|
|
note = await update_note(owner_uid, note_id, **fields)
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 400
|
|
if note is None:
|
|
return not_found("Note")
|
|
return jsonify(note.to_dict())
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>", methods=["DELETE"])
|
|
@login_required
|
|
async def delete_note_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
result = await get_note_for_user(uid, note_id)
|
|
if result is None:
|
|
return not_found("Note")
|
|
note_obj, _ = result
|
|
if not await can_write_note(uid, note_id):
|
|
return jsonify({"error": "Permission denied"}), 403
|
|
from scribe.services.trash import delete as trash_delete
|
|
batch = await trash_delete(note_obj.user_id, "note", note_id)
|
|
if batch is None:
|
|
return not_found("Note")
|
|
return "", 204
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>/convert-to-task", methods=["POST"])
|
|
@login_required
|
|
async def convert_note_to_task_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
try:
|
|
note = await convert_note_to_task(uid, note_id)
|
|
return jsonify(note.to_dict()), 200
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 404
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>/convert-to-note", methods=["POST"])
|
|
@login_required
|
|
async def convert_task_to_note_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
try:
|
|
note = await convert_task_to_note(uid, note_id)
|
|
return jsonify(note.to_dict()), 200
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 404
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>/backlinks", methods=["GET"])
|
|
@login_required
|
|
async def get_backlinks_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
links = await get_backlinks(uid, note_id)
|
|
return jsonify({"backlinks": links})
|
|
|
|
|
|
# ── Link suggestions ─────────────────────────────────────────────────────────
|
|
|
|
_WIKILINK_RE = re.compile(r'\[\[[^\]]+\]\]')
|
|
|
|
|
|
def _find_unlinked_terms(body: str, note_titles: list[tuple[int, str]]) -> list[dict]:
|
|
"""Return project note titles that appear in body as plain text (not inside [[...]])."""
|
|
linked_ranges = [(m.start(), m.end()) for m in _WIKILINK_RE.finditer(body)]
|
|
|
|
def in_wikilink(start: int, end: int) -> bool:
|
|
return any(ls <= start and end <= le for ls, le in linked_ranges)
|
|
|
|
suggestions = []
|
|
for note_id, title in note_titles:
|
|
title = title.strip()
|
|
if len(title) < 3:
|
|
continue
|
|
pattern = re.compile(r'\b' + re.escape(title) + r'\b', re.IGNORECASE)
|
|
count = sum(1 for m in pattern.finditer(body) if not in_wikilink(m.start(), m.end()))
|
|
if count > 0:
|
|
suggestions.append({"note_id": note_id, "title": title, "count": count})
|
|
|
|
suggestions.sort(key=lambda x: x["count"], reverse=True)
|
|
return suggestions
|
|
|
|
|
|
@notes_bp.route("/link-suggestions", methods=["POST"])
|
|
@login_required
|
|
async def link_suggestions_route():
|
|
"""Find project note titles that appear unlinked in the given body text."""
|
|
uid = get_current_user_id()
|
|
data = await request.get_json()
|
|
|
|
body_text = data.get("body", "")
|
|
project_id = data.get("project_id")
|
|
exclude_note_id = data.get("exclude_note_id")
|
|
|
|
if not project_id or not body_text:
|
|
return jsonify({"suggestions": []})
|
|
|
|
try:
|
|
project_notes, _ = await list_notes(
|
|
uid, project_id=int(project_id), sort="title", order="asc", limit=200
|
|
)
|
|
titles = [
|
|
(n.id, n.title)
|
|
for n in project_notes
|
|
if n.id != exclude_note_id and n.title
|
|
]
|
|
suggestions = _find_unlinked_terms(body_text, titles)
|
|
except Exception:
|
|
logger.warning("Failed to compute link suggestions", exc_info=True)
|
|
suggestions = []
|
|
|
|
return jsonify({"suggestions": suggestions})
|
|
|
|
|
|
# ── Draft routes ─────────────────────────────────────────────────────────────
|
|
|
|
@notes_bp.route("/<int:note_id>/draft", methods=["GET"])
|
|
@login_required
|
|
async def get_draft_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
draft = await get_draft(uid, note_id)
|
|
if draft is None:
|
|
return jsonify({"error": "No draft found"}), 404
|
|
return jsonify(draft.to_dict())
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>/draft", methods=["PUT"])
|
|
@login_required
|
|
async def upsert_draft_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
# Verify note ownership
|
|
note = await get_note(uid, note_id)
|
|
if note is None:
|
|
return not_found("Note")
|
|
data = await request.get_json()
|
|
draft = await upsert_draft(
|
|
user_id=uid,
|
|
note_id=note_id,
|
|
proposed_body=data.get("proposed_body", ""),
|
|
original_body=data.get("original_body", ""),
|
|
instruction=data.get("instruction", ""),
|
|
scope=data.get("scope", "document"),
|
|
)
|
|
return jsonify(draft.to_dict()), 200
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>/draft", methods=["DELETE"])
|
|
@login_required
|
|
async def delete_draft_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
await delete_draft(uid, note_id)
|
|
return "", 204
|
|
|
|
|
|
# ── Version routes ────────────────────────────────────────────────────────────
|
|
|
|
@notes_bp.route("/<int:note_id>/versions", methods=["GET"])
|
|
@login_required
|
|
async def list_versions_route(note_id: int):
|
|
uid = get_current_user_id()
|
|
versions = await list_versions(uid, note_id)
|
|
return jsonify({"versions": [v.to_dict(include_body=False) for v in versions]})
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>/versions/<int:version_id>", methods=["GET"])
|
|
@login_required
|
|
async def get_version_route(note_id: int, version_id: int):
|
|
uid = get_current_user_id()
|
|
version = await get_version(uid, note_id, version_id)
|
|
if version is None:
|
|
return not_found("Version")
|
|
return jsonify(version.to_dict(include_body=True))
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>/versions/<int:version_id>/pin", methods=["POST"])
|
|
@login_required
|
|
async def pin_version_route(note_id: int, version_id: int):
|
|
"""Mark a version as manually pinned. Body: {"label": str | null}."""
|
|
uid = get_current_user_id()
|
|
data = await request.get_json() or {}
|
|
label = data.get("label")
|
|
if label is not None and not isinstance(label, str):
|
|
return jsonify({"error": "label must be a string or null"}), 400
|
|
from scribe.services.version_pinning import pin_version
|
|
try:
|
|
version = await pin_version(uid, note_id, version_id, label=label)
|
|
except ValueError as e:
|
|
return jsonify({"error": str(e)}), 400
|
|
if version is None:
|
|
return not_found("Version")
|
|
return jsonify(version.to_dict(include_body=False))
|
|
|
|
|
|
@notes_bp.route(
|
|
"/<int:note_id>/versions/<int:version_id>/pin", methods=["DELETE"],
|
|
)
|
|
@login_required
|
|
async def unpin_version_route(note_id: int, version_id: int):
|
|
"""Downgrade a manually-pinned version back to rolling."""
|
|
uid = get_current_user_id()
|
|
from scribe.services.version_pinning import unpin_version
|
|
version = await unpin_version(uid, note_id, version_id)
|
|
if version is None:
|
|
return not_found("Version")
|
|
return jsonify(version.to_dict(include_body=False))
|
|
|
|
|
|
# ── Graph route ────────────────────────────────────────────────────────────────
|
|
|
|
@notes_bp.route("/graph", methods=["GET"])
|
|
@login_required
|
|
async def graph_route():
|
|
uid = get_current_user_id()
|
|
project_id = request.args.get("project_id", type=int)
|
|
shared_tags = request.args.get("shared_tags", "false").lower() == "true"
|
|
graph = await build_note_graph(uid, project_id=project_id, include_shared_tags=shared_tags)
|
|
return jsonify(graph)
|