import asyncio import logging import re from scribe.services.embeddings import upsert_note_embedding 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.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") entity_meta = data.get("metadata") or None 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, entity_meta=entity_meta, ) except ValueError as e: return jsonify({"error": str(e)}), 400 text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "") if text: asyncio.create_task(upsert_note_embedding(note.id, uid, text)) return jsonify(note.to_dict()), 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("//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("/", 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 return jsonify(data) @notes_bp.route("/", 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 "metadata" in data: fields["entity_meta"] = data["metadata"] or None 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") text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "") if text: asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text)) return jsonify(note.to_dict()) @notes_bp.route("/", 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 "metadata" in data: fields["entity_meta"] = data["metadata"] or None 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") text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "") if text: asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text)) return jsonify(note.to_dict()) @notes_bp.route("/", 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("//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("//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("//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("//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("//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("//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("//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("//versions/", 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("//versions//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( "//versions//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)