CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m44s
CI & Build / Build & push image (push) Successful in 29s
A System tag is how `list_system_records` gathers an area's pile, so a
record that cannot be tagged is reachable by search and by nothing else.
`update_lesson` did not take `system_ids` while `create_lesson` did, which
made tagging available exactly once — at the moment of least information.
A lesson is usually written at the end of a piece of work, which is
precisely when that argument gets dropped, and after that the record could
never be filed at all.
Filling in the rest of the table found three more gaps, and the issue's own
generalisation was wrong. It read as "the REST door can do something the
MCP door cannot", on three instances in a row. In fact:
* `create_preference` is the exact MIRROR of the lesson bug — update
takes `system_ids`, create does not. The same capability missing from
the opposite end of the same lifecycle.
* `routes/notes.py` handled `system_ids` NOWHERE, while the MCP door
handled both ends. That runs the opposite way round from the premise.
* `create_process` / `update_process` took it at neither door, though a
process is a note and has always been taggable in the data model.
The real pattern is that whichever door nobody exercised for a kind is the
one that never grew the parameter — which is a better statement of #4248
than the one recorded there, and is not something a reviewer reliably
notices, because each door is only ever read on its own.
Milestones are NOT a fifth gap. `RecordSystem.note_id` is a ForeignKey to
`notes.id` and milestones are their own table, so they cannot be tagged at
any door by construction. Pinned in the test so the next pass does not
re-open it.
So the guard is the point, not the four parameters. `update_lesson` alone
would have left the shape that produced it intact. The new test asserts the
TABLE — every kind taggable anywhere is taggable everywhere it is written —
and keys the registry-coverage check on the SIGNATURE rather than on a
grep, so a module that only names the argument in prose is not swept in and
no hand-kept skip list can go stale. A fifth kind fails there rather than
shipping half-wired, the same reasoning test_derived_mirror_generic_door.py
records for derived mirrors (#3734).
One inconsistency found and deliberately not changed here: for the same
operation `routes/tasks.py` scopes `set_record_systems` by the caller while
`routes/lessons.py` and `routes/snippets.py` scope it by the owner. The new
notes code follows tasks.py and says why in a comment (#47 — an editor-share
holder should tag from what they can see rather than inherit the owner's
reach). Recorded in #4249 rather than fixed as a drive-by.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
581 lines
22 KiB
Python
581 lines
22 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,
|
|
mark_note_verified,
|
|
notes_due_for_verification,
|
|
update_note,
|
|
verification_row,
|
|
)
|
|
from scribe.services.note_drafts import upsert_draft, get_draft, delete_draft
|
|
from scribe.services import dedup as dedup_svc
|
|
from scribe.services import supersession as supersession_svc
|
|
from scribe.services import systems as systems_svc
|
|
from scribe.services.note_usage import record_pulled
|
|
|
|
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,
|
|
verify_with=data.get("verify_with"),
|
|
expires_when=data.get("expires_when"),
|
|
)
|
|
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
|
|
# #4249: this door could not file a note to a System at all, while the
|
|
# MCP door could — the reverse of the asymmetry the same issue records for
|
|
# lessons. The pattern is not that one door is richer; it is that the door
|
|
# nobody exercised for a kind is the one that never grew the parameter.
|
|
if data.get("system_ids") is not None:
|
|
await systems_svc.set_record_systems(uid, note.id, data["system_ids"])
|
|
out = note.to_dict()
|
|
await supersession_svc.attach_relations(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("/duplicates", methods=["GET"])
|
|
@login_required
|
|
async def find_duplicate_records_route():
|
|
"""Near-duplicate notes or tasks, grouped, with the per-kind suggestion.
|
|
|
|
?kind=note|task, ?threshold= (0 = configured setting). Registered above the
|
|
`/<int:note_id>` routes on purpose — the literal path first, same reasoning
|
|
as /api/snippets/duplicates. Mirrors the MCP tool (#33): the web UI has no
|
|
dedup gate by design, so this report is the UI's only window onto what that
|
|
decision admits.
|
|
"""
|
|
uid = get_current_user_id()
|
|
kind = request.args.get("kind", "note")
|
|
if kind not in ("note", "task"):
|
|
return jsonify({"error": 'kind must be "note" or "task"'}), 400
|
|
try:
|
|
threshold = float(request.args.get("threshold", 0) or 0)
|
|
except (TypeError, ValueError):
|
|
threshold = 0.0
|
|
return jsonify(await dedup_svc.find_duplicate_records(
|
|
uid, kind=kind, threshold=threshold if threshold > 0 else None,
|
|
))
|
|
|
|
|
|
@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 supersession_svc.attach_relations(uid, note_id, data)
|
|
return jsonify(data)
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>", methods=["PUT", "PATCH"])
|
|
@login_required
|
|
async def update_note_route(note_id: int):
|
|
"""Partial update — only the keys present in the payload change. PUT and
|
|
PATCH are the same handler on purpose: the form sends the field set it
|
|
edited, and the two verbs used to be two near-identical copies of this
|
|
function that drifted (one carried the supersedes contract, one did not)."""
|
|
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",
|
|
# A cleared form input arrives as "" and the service reads that as
|
|
# NULL (NULLABLE_NOTE_TEXT), so this door expresses "remove the check"
|
|
# with its own idiom and needs no `clear` list (milestone 317).
|
|
"verify_with", "expires_when",
|
|
):
|
|
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
|
|
# Set-semantics like the above: present-and-empty clears, absent leaves
|
|
# alone. Scoped by the CALLER rather than owner_uid, for the same reason
|
|
# the supersedes call is (#47) — `set_record_systems` links only Systems
|
|
# the acting user can read, and an editor-share holder should tag from
|
|
# what they can see rather than inherit the owner's reach. This matches
|
|
# routes/tasks.py; routes/lessons.py and routes/snippets.py pass the owner
|
|
# for the same operation, which is a real inconsistency, recorded in #4249
|
|
# rather than changed here.
|
|
if "system_ids" in data:
|
|
await systems_svc.set_record_systems(uid, note_id, data["system_ids"] or [])
|
|
out = note.to_dict()
|
|
await supersession_svc.attach_relations(uid, note_id, out)
|
|
return jsonify(out)
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
|
# ── The staleness sweep (milestone 317) ──────────────────────────────────────
|
|
# The web half of notes_due_for_verification / mark_note_verified. Same
|
|
# contract as the MCP door and the rules routes beside it — the service holds
|
|
# the behaviour, these two just parse and serialise.
|
|
|
|
@notes_bp.route("/due-for-verification", methods=["GET"])
|
|
@login_required
|
|
async def notes_due_route():
|
|
"""Notes that carry a check, oldest verification first, never-checked top.
|
|
|
|
Query params: older_than_days, project_id, never_only. A note with no
|
|
`verify_with` never appears — it is a decision, not a fact.
|
|
"""
|
|
uid = get_current_user_id()
|
|
args = request.args
|
|
try:
|
|
older = int(args.get("older_than_days", 0) or 0)
|
|
project = int(args.get("project_id", 0) or 0)
|
|
except ValueError:
|
|
return jsonify({"error": "older_than_days and project_id must be integers"}), 400
|
|
try:
|
|
notes = await notes_due_for_verification(
|
|
uid,
|
|
older_than_days=older,
|
|
project_id=project or None,
|
|
never_only=args.get("never_only", "").lower() in ("1", "true", "yes"),
|
|
)
|
|
except ValueError as exc:
|
|
# A 400, not a silently narrowed result: a filter that quietly answers
|
|
# a different question is the failure this whole surface exists to
|
|
# catch.
|
|
return jsonify({"error": str(exc)}), 400
|
|
return jsonify({
|
|
"notes": [verification_row(n) for n in notes],
|
|
"total": len(notes),
|
|
})
|
|
|
|
|
|
@notes_bp.route("/<int:note_id>/verify", methods=["POST"])
|
|
@login_required
|
|
async def mark_note_verified_route(note_id: int):
|
|
"""Record that the note's check was run. Body: {"still_true": bool}.
|
|
|
|
`still_true: false` writes nothing — a note whose check failed is wrong,
|
|
not in a recordable state — so it keeps its place at the top of the sweep.
|
|
"""
|
|
data = await request.get_json() or {}
|
|
uid = get_current_user_id()
|
|
still_true = bool(data.get("still_true", True))
|
|
note = await mark_note_verified(note_id, uid, still_true)
|
|
if note is None:
|
|
return jsonify({
|
|
"error": "note not found, not writable by you, or carries no verify_with"
|
|
}), 404
|
|
payload = verification_row(note)
|
|
payload["verified"] = still_true
|
|
return jsonify(payload)
|