"""REST routes for snippets — reusable functions/components recorded for recall. A snippet is a note with note_type='snippet' (see services/snippets.py). These routes feed the web management UI; the MCP tools (mcp/tools/snippets.py) are the agent-facing surface. Both go through services/snippets.py, so the serialize/parse contract and embedding-on-create live in one place (DRY). ACL (rule #78): reads/writes of a single snippet resolve through the share-aware `get_note_for_user` + `can_write_note`, and writes are performed as the OWNER so a shared editor isn't rejected by the owner-scoped service — mirroring routes/notes.py. The list is owner-scoped, matching the note-browse surface. """ import logging from quart import Blueprint, jsonify, request from scribe.auth import get_current_user_id, login_required from scribe.routes.utils import not_found, parse_pagination from scribe.services import dedup as dedup_svc from scribe.services import snippets as snippets_svc from scribe.services import systems as systems_svc from scribe.services.access import ( can_write_note, describe_provenance, label_shared_items, ) from scribe.services.notes import get_note_for_user logger = logging.getLogger(__name__) snippets_bp = Blueprint("snippets", __name__, url_prefix="/api/snippets") # Fields the create/update payload may carry, mapped straight to the service. _STR_FIELDS = ("name", "code", "language", "signature", "when_to_use", "repo", "path", "symbol") async def _load_snippet(uid: int, snippet_id: int): """Share-aware resolve of a snippet by id → (note, permission) or None if it isn't accessible or isn't a snippet.""" result = await get_note_for_user(uid, snippet_id) if result is None: return None note, permission = result if note.note_type != snippets_svc.SNIPPET_NOTE_TYPE: return None return note, permission @snippets_bp.route("", methods=["GET"]) @login_required async def list_snippets_route(): uid = get_current_user_id() q = request.args.get("q") or None tag = request.args.get("tag", "") try: project_id = int(request.args.get("project_id", 0) or 0) or None except (TypeError, ValueError): project_id = None limit, offset = parse_pagination() items, total = await snippets_svc.list_snippets( uid, q=q, tag=tag, limit=limit, offset=offset, project_id=project_id, ) # Mark rows owned by someone else so the UI can show whose they are — an # unmarked row in your own list reads as one you recorded and vetted. items = await label_shared_items(uid, items) return jsonify({"snippets": items, "total": total}) @snippets_bp.route("", methods=["POST"]) @login_required async def create_snippet_route(): uid = get_current_user_id() data = await request.get_json() or {} name = (data.get("name") or "").strip() code = (data.get("code") or "").strip() if not name or not code: return jsonify({"error": "name and code are required"}), 400 project_id = data.get("project_id") or None # Same near-duplicate gate the MCP create path applies: recording the same # reusable thing twice is what merge then has to undo, so catch it here too. # `force` is the deliberate override once the operator has seen the warning. if not data.get("force"): dup = await dedup_svc.find_duplicate_note( uid, snippets_svc.compose_title(name, data.get("when_to_use", "")), snippets_svc.compose_body( code=data.get("code", ""), language=data.get("language", ""), signature=data.get("signature", ""), when_to_use=data.get("when_to_use", ""), repo=data.get("repo", ""), path=data.get("path", ""), symbol=data.get("symbol", ""), locations=data.get("locations"), ), project_id=project_id, is_task=False, note_type=snippets_svc.SNIPPET_NOTE_TYPE, ) if dup is not None: return jsonify(dedup_svc.duplicate_response(dup, "snippet")), 409 note = await snippets_svc.create_snippet( uid, name=name, code=data.get("code", ""), language=data.get("language", ""), signature=data.get("signature", ""), when_to_use=data.get("when_to_use", ""), repo=data.get("repo", ""), path=data.get("path", ""), symbol=data.get("symbol", ""), locations=data.get("locations"), tags=data.get("tags"), project_id=project_id, ) if data.get("system_ids") is not None: await systems_svc.set_record_systems(uid, note.id, data["system_ids"]) out = snippets_svc.snippet_to_dict(note) out["systems"] = [ s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id) ] return jsonify(out), 201 @snippets_bp.route("/", methods=["GET"]) @login_required async def get_snippet_route(snippet_id: int): uid = get_current_user_id() loaded = await _load_snippet(uid, snippet_id) if loaded is None: return not_found("Snippet") note, permission = loaded data = snippets_svc.snippet_to_dict(note) data["permission"] = permission # Read the association as the OWNER: a shared reader isn't scoped to the # owner's project, so their own id would come back empty (mirrors the # write-as-owner pattern this module already uses). data["systems"] = [ s.to_dict() for s in await systems_svc.list_record_systems(note.user_id, snippet_id) ] data.update(await describe_provenance(uid, note)) return jsonify(data) @snippets_bp.route("/", methods=["PATCH"]) @login_required async def update_snippet_route(snippet_id: int): uid = get_current_user_id() loaded = await _load_snippet(uid, snippet_id) if loaded is None: return not_found("Snippet") note, _ = loaded if not await can_write_note(uid, snippet_id): return jsonify({"error": "Permission denied"}), 403 owner_uid = note.user_id data = await request.get_json() or {} # Partial update: only keys present in the payload change (the service # treats None as "leave unchanged"). Empty strings ARE applied — the form # sends the full field set, so a cleared field is an intentional clear. kwargs = {k: data[k] for k in _STR_FIELDS if k in data} if "locations" in data: kwargs["locations"] = data["locations"] if "tags" in data: kwargs["tags"] = data["tags"] if "project_id" in data: # A present-but-empty project_id is a deliberate detach, not "unchanged" # — the service distinguishes the two via its UNSET sentinel. kwargs["project_id"] = data["project_id"] or None updated = await snippets_svc.update_snippet(owner_uid, snippet_id, **kwargs) if updated is None: return not_found("Snippet") if data.get("system_ids") is not None: await systems_svc.set_record_systems(owner_uid, snippet_id, data["system_ids"]) out = snippets_svc.snippet_to_dict(updated) out["systems"] = [ s.to_dict() for s in await systems_svc.list_record_systems(owner_uid, snippet_id) ] return jsonify(out) @snippets_bp.route("//merge", methods=["POST"]) @login_required async def merge_snippet_route(snippet_id: int): """Unify source snippets into this one (the canonical target). Body: {"source_ids": [int, ...]}. Requires write on the target and every source, and all must share the target's owner (cross-owner merge is out of scope).""" uid = get_current_user_id() loaded = await _load_snippet(uid, snippet_id) if loaded is None: return not_found("Snippet") target, _ = loaded if not await can_write_note(uid, snippet_id): return jsonify({"error": "Permission denied"}), 403 data = await request.get_json() or {} raw = data.get("source_ids") or [] source_ids = [s for s in raw if isinstance(s, int) and s != snippet_id] if not source_ids: return jsonify({"error": "source_ids (non-empty list of other snippet ids) is required"}), 400 owner_uid = target.user_id for sid in source_ids: sloaded = await _load_snippet(uid, sid) if sloaded is None: return not_found(f"Snippet {sid}") snote, _ = sloaded if snote.user_id != owner_uid: return jsonify({"error": "can only merge snippets with the same owner"}), 400 if not await can_write_note(uid, sid): return jsonify({"error": f"Permission denied for snippet {sid}"}), 403 result = await snippets_svc.merge_snippets(owner_uid, snippet_id, source_ids) if result is None: return not_found("Snippet") note, merged_ids = result out = snippets_svc.snippet_to_dict(note) out["merged_ids"] = merged_ids return jsonify(out) @snippets_bp.route("/", methods=["DELETE"]) @login_required async def delete_snippet_route(snippet_id: int): uid = get_current_user_id() loaded = await _load_snippet(uid, snippet_id) if loaded is None: return not_found("Snippet") note, _ = loaded if not await can_write_note(uid, snippet_id): return jsonify({"error": "Permission denied"}), 403 if not await snippets_svc.delete_snippet(note.user_id, snippet_id): return not_found("Snippet") return "", 204