refactor: rename package fabledassistant -> scribe (code-only)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 1m14s

Renames src/fabledassistant -> src/scribe and all imports, plus the
default DB name and DB user/password (fabled -> scribe) in config +
compose. 952 refs / 154 files. Reverses the old 'internal name stays
fabledassistant' convention.

Code-only: live databases are still physically named 'fabledassistant'.
Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the
DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git
host (fabledsword), MCP (fabled-git) and the image name (fabledscribe)
are intentionally unchanged.

ruff check src/ clean locally; CI (typecheck + pytest) is the gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 15:48:35 -04:00
parent 1d4c206563
commit b255a0f90e
167 changed files with 1183 additions and 2368 deletions
View File
+264
View File
@@ -0,0 +1,264 @@
import asyncio
import json
from quart import Blueprint, Response, g, jsonify, request
from scribe.auth import admin_required, login_required, get_current_user_id
from scribe.services.auth import (
create_invitation,
delete_user,
is_registration_open,
list_pending_invitations,
list_users,
revoke_invitation,
set_registration_open,
)
from scribe.services.backup import (
export_full_backup,
export_user_backup,
restore_full_backup,
)
from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
from scribe.services.logging import get_logs, get_log_stats, log_audit
from scribe.services.notifications import send_invitation_email
from scribe.services.settings import set_setting, set_settings_batch
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
@admin_bp.route("/backup", methods=["GET"])
@login_required
async def backup():
uid = get_current_user_id()
scope = request.args.get("scope", "user")
if scope == "full":
# Full backup requires admin
if g.user.role != "admin":
return jsonify({"error": "Admin access required for full backup"}), 403
data = await export_full_backup()
else:
data = await export_user_backup(uid)
await log_audit("backup", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"scope": scope})
return Response(
json.dumps(data, indent=2, default=str),
content_type="application/json",
headers={
"Content-Disposition": f'attachment; filename="fabled-backup-{scope}.json"',
},
)
@admin_bp.route("/restore", methods=["POST"])
@admin_required
async def restore():
data = await request.get_json()
if not data:
return jsonify({"error": "No backup data provided"}), 400
stats = await restore_full_backup(data)
uid = get_current_user_id()
await log_audit("restore", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"stats": stats})
return jsonify({"status": "ok", "stats": stats})
@admin_bp.route("/users", methods=["GET"])
@admin_required
async def get_users():
users = await list_users()
return jsonify({"users": [u.to_dict() for u in users]})
@admin_bp.route("/users/<int:user_id>", methods=["DELETE"])
@admin_required
async def remove_user(user_id: int):
current_uid = get_current_user_id()
if user_id == current_uid:
return jsonify({"error": "Cannot delete your own account"}), 400
deleted = await delete_user(user_id)
if not deleted:
return jsonify({"error": "User not found"}), 404
await log_audit("user_delete", user_id=current_uid, username=g.user.username, ip_address=request.remote_addr, details={"deleted_user_id": user_id})
return jsonify({"status": "ok"})
@admin_bp.route("/registration", methods=["GET"])
@admin_required
async def get_registration():
open_status = await is_registration_open()
return jsonify({"open": open_status})
@admin_bp.route("/registration", methods=["PUT"])
@admin_required
async def toggle_registration():
data = await request.get_json()
open_val = data.get("open")
if open_val is None:
return jsonify({"error": "Missing 'open' field"}), 400
uid = get_current_user_id()
await set_registration_open(uid, bool(open_val))
await log_audit("registration_toggle", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"open": bool(open_val)})
return jsonify({"status": "ok", "open": bool(open_val)})
@admin_bp.route("/smtp", methods=["GET"])
@admin_required
async def get_smtp():
config = await get_smtp_config()
# Mask password
if config.get("smtp_password"):
config["smtp_password"] = "********"
return jsonify(config)
@admin_bp.route("/smtp", methods=["PUT"])
@admin_required
async def update_smtp():
data = await request.get_json()
uid = get_current_user_id()
settings_to_save = {}
for key in SMTP_SETTING_KEYS:
if key in data:
# Skip password if it's the mask placeholder
if key == "smtp_password" and data[key] == "********":
continue
settings_to_save[key] = str(data[key])
if settings_to_save:
await set_settings_batch(uid, settings_to_save)
await log_audit("smtp_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr)
return jsonify({"status": "ok"})
@admin_bp.route("/smtp/test", methods=["POST"])
@admin_required
async def test_smtp():
data = await request.get_json()
recipient = (data.get("recipient") or "").strip()
if not recipient:
return jsonify({"error": "Recipient email is required"}), 400
uid = get_current_user_id()
try:
await send_test_email(recipient)
await log_audit("smtp_test", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"recipient": recipient})
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@admin_bp.route("/logs", methods=["GET"])
@admin_required
async def list_logs():
category = request.args.get("category")
user_id = request.args.get("user_id", type=int)
search = request.args.get("search")
date_from = request.args.get("date_from")
date_to = request.args.get("date_to")
limit = request.args.get("limit", 50, type=int)
offset = request.args.get("offset", 0, type=int)
logs, total = await get_logs(
category=category,
user_id=user_id,
search=search,
date_from=date_from,
date_to=date_to,
limit=min(limit, 200),
offset=offset,
)
return jsonify({"logs": logs, "total": total})
@admin_bp.route("/logs/stats", methods=["GET"])
@admin_required
async def log_stats():
stats = await get_log_stats()
return jsonify(stats)
@admin_bp.route("/base-url", methods=["GET"])
@admin_required
async def get_base_url_setting():
base_url = await get_base_url()
return jsonify({"base_url": base_url})
@admin_bp.route("/base-url", methods=["PUT"])
@admin_required
async def update_base_url():
data = await request.get_json()
url = (data.get("base_url") or "").strip().rstrip("/")
if url:
scheme = url.split("://")[0].lower() if "://" in url else ""
if scheme not in ("http", "https"):
return jsonify({"error": "Base URL must use http or https"}), 400
uid = get_current_user_id()
await set_setting(uid, "base_url", url)
await log_audit("base_url_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"base_url": url})
return jsonify({"status": "ok"})
@admin_bp.route("/invitations", methods=["POST"])
@admin_required
async def create_invite():
data = await request.get_json()
email = (data.get("email") or "").strip().lower()
if not email:
return jsonify({"error": "Email is required"}), 400
if not await is_smtp_configured():
return jsonify({"error": "SMTP is not configured. Configure email settings first."}), 400
uid = get_current_user_id()
raw_token = await create_invitation(email, uid)
base_url = await get_base_url()
invite_url = f"{base_url}/register-invite?token={raw_token}"
asyncio.create_task(send_invitation_email(email, invite_url, g.user.username))
await log_audit(
"invitation_created",
user_id=uid,
username=g.user.username,
ip_address=request.remote_addr,
details={"invited_email": email},
)
return jsonify({"status": "ok"}), 201
@admin_bp.route("/invitations", methods=["GET"])
@admin_required
async def get_invitations():
invitations = await list_pending_invitations()
return jsonify({
"invitations": [
{
"id": inv.id,
"email": inv.email,
"created_at": inv.created_at.isoformat(),
"expires_at": inv.expires_at.isoformat(),
}
for inv in invitations
]
})
@admin_bp.route("/invitations/<int:invitation_id>", methods=["DELETE"])
@admin_required
async def delete_invitation(invitation_id: int):
uid = get_current_user_id()
revoked = await revoke_invitation(invitation_id)
if not revoked:
return jsonify({"error": "Invitation not found or already used"}), 404
await log_audit(
"invitation_revoked",
user_id=uid,
username=g.user.username,
ip_address=request.remote_addr,
details={"invitation_id": invitation_id},
)
return jsonify({"status": "ok"})
+15
View File
@@ -0,0 +1,15 @@
import os
from quart import Blueprint, jsonify
api = Blueprint("api", __name__, url_prefix="/api")
@api.route("/health")
async def health():
return jsonify({"status": "ok"})
@api.route("/version")
async def version():
return jsonify({"version": os.environ.get("APP_VERSION", "dev")})
+42
View File
@@ -0,0 +1,42 @@
from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id
from scribe.services.api_keys import create_api_key, list_api_keys, revoke_api_key
api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys")
@api_keys_bp.route("", methods=["GET"])
@login_required
async def list_keys_route():
uid = get_current_user_id()
keys = await list_api_keys(uid)
return jsonify({"api_keys": keys})
@api_keys_bp.route("", methods=["POST"])
@login_required
async def create_key_route():
uid = get_current_user_id()
data = await request.get_json(force=True, silent=True) or {}
name = (data.get("name") or "").strip()
scope = (data.get("scope") or "").strip()
if not name:
return jsonify({"error": "name is required"}), 400
if scope not in ("read", "write"):
return jsonify({"error": "scope must be 'read' or 'write'"}), 400
full_key, key_dict = await create_api_key(uid, name=name, scope=scope)
# Return the full key ONCE — it is never retrievable again
return jsonify({"key": full_key, "api_key": key_dict}), 201
@api_keys_bp.route("/<int:key_id>", methods=["DELETE"])
@login_required
async def revoke_key_route(key_id: int):
uid = get_current_user_id()
deleted = await revoke_api_key(uid, key_id)
if not deleted:
return jsonify({"error": "API key not found"}), 404
return "", 204
+375
View File
@@ -0,0 +1,375 @@
import asyncio
import base64
import hashlib
import os
import secrets
from quart import Blueprint, g, jsonify, redirect, request, session
from scribe.auth import login_required, get_current_user_id
from scribe.config import Config
from scribe.rate_limit import is_rate_limited
from scribe.services.auth import (
authenticate,
change_password,
create_password_reset_token,
create_user,
get_user_by_email,
get_user_by_id,
get_user_by_username,
get_user_count,
invalidate_other_sessions,
is_registration_open,
register_with_invitation,
reset_password_with_token,
update_user_email,
validate_invitation_token,
verify_password,
)
from scribe.services.logging import log_audit
from scribe.services.notifications import (
notify_security_event,
send_password_reset_email,
send_password_reset_success_email,
)
from scribe.services.email import get_base_url, is_smtp_configured
from scribe.services.oauth import (
build_auth_url,
exchange_code,
get_userinfo,
find_or_create_oauth_user,
)
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
def _client_ip() -> str:
if Config.TRUST_PROXY_HEADERS:
for header in ("X-Forwarded-For", "X-Real-IP"):
val = request.headers.get(header, "").split(",")[0].strip()
if val:
return val
return request.remote_addr or ""
@auth_bp.route("/register", methods=["POST"])
async def register():
if not Config.LOCAL_AUTH_ENABLED:
return jsonify({"error": "Local authentication is disabled. Please use SSO."}), 403
if await is_rate_limited(f"register:{_client_ip()}", max_requests=5, window_seconds=300):
return jsonify({"error": "Too many registration attempts. Try again later."}), 429
if not await is_registration_open():
return jsonify({"error": "Registration is closed"}), 403
data = await request.get_json()
username = (data.get("username") or "").strip()
password = data.get("password") or ""
email = (data.get("email") or "").strip() or None
if not username:
return jsonify({"error": "Username is required"}), 400
if len(password) < 8:
return jsonify({"error": "Password must be at least 8 characters"}), 400
try:
user = await create_user(username, password, email)
except Exception:
return jsonify({"error": "Username already taken"}), 409
session["user_id"] = user.id
session["session_version"] = user.session_version
await log_audit("register", user_id=user.id, username=user.username, ip_address=_client_ip())
return jsonify(user.to_dict()), 201
@auth_bp.route("/login", methods=["POST"])
async def login():
if not Config.LOCAL_AUTH_ENABLED:
return jsonify({"error": "Local authentication is disabled. Please use SSO."}), 403
if await is_rate_limited(f"login:{_client_ip()}", max_requests=10, window_seconds=60):
return jsonify({"error": "Too many login attempts. Try again later."}), 429
data = await request.get_json()
username = (data.get("username") or "").strip()
password = data.get("password") or ""
if not username or not password:
return jsonify({"error": "Username and password are required"}), 400
user = await authenticate(username, password)
if not user:
await log_audit("login_failed", username=username, ip_address=_client_ip())
# Try to notify the actual user about the failed attempt
target_user = await get_user_by_username(username)
if target_user:
asyncio.create_task(notify_security_event(
target_user.id, "login_failed",
{"ip_address": _client_ip(), "username": username},
))
return jsonify({"error": "Invalid username or password"}), 401
session["user_id"] = user.id
session["session_version"] = user.session_version
await log_audit("login", user_id=user.id, username=user.username, ip_address=_client_ip())
asyncio.create_task(notify_security_event(
user.id, "login",
{"ip_address": _client_ip()},
))
return jsonify(user.to_dict())
@auth_bp.route("/logout", methods=["POST"])
async def logout():
uid = session.get("user_id")
if uid:
user = await get_user_by_id(uid)
await log_audit("logout", user_id=uid, username=user.username if user else None, ip_address=_client_ip())
asyncio.create_task(notify_security_event(
uid, "logout",
{"ip_address": _client_ip()},
))
session.clear()
return jsonify({"status": "ok"})
@auth_bp.route("/me", methods=["GET"])
@login_required
async def me():
user = await get_user_by_id(get_current_user_id())
if not user:
return jsonify({"error": "User not found"}), 404
return jsonify(user.to_dict())
@auth_bp.route("/password", methods=["PUT"])
@login_required
async def update_password():
data = await request.get_json()
current = data.get("current_password") or ""
new_pw = data.get("new_password") or ""
if not current:
return jsonify({"error": "Current password is required"}), 400
if len(new_pw) < 8:
return jsonify({"error": "New password must be at least 8 characters"}), 400
uid = get_current_user_id()
new_version = await change_password(uid, current, new_pw)
if new_version is None:
return jsonify({"error": "Current password is incorrect"}), 403
# Keep the current session alive with the new version (other sessions are now invalidated)
session["session_version"] = new_version
await log_audit("password_change", user_id=uid, username=g.user.username, ip_address=_client_ip())
asyncio.create_task(notify_security_event(
uid, "password_change",
{"ip_address": _client_ip()},
))
return jsonify({"status": "ok"})
@auth_bp.route("/invalidate-sessions", methods=["POST"])
@login_required
async def invalidate_sessions_route():
"""Invalidate all other active sessions by bumping the session version.
The current session is kept alive. Useful after an SSO/OAuth password change
where the app has no visibility into credential rotation.
"""
uid = get_current_user_id()
new_version = await invalidate_other_sessions(uid)
session["session_version"] = new_version
await log_audit("sessions_invalidated", user_id=uid, username=g.user.username, ip_address=_client_ip())
return jsonify({"status": "ok"})
@auth_bp.route("/email", methods=["PUT"])
@login_required
async def update_email():
data = await request.get_json()
new_email = (data.get("email") or "").strip().lower() or None
password = data.get("password") or ""
uid = get_current_user_id()
user = await get_user_by_id(uid)
if not user:
return jsonify({"error": "User not found"}), 404
# Require password confirmation only for local-auth users
if user.password_hash is not None:
if not password:
return jsonify({"error": "Password is required to change your email"}), 400
if not verify_password(password, user.password_hash):
return jsonify({"error": "Incorrect password"}), 403
# Check the new email isn't taken by a different account
if new_email:
existing = await get_user_by_email(new_email)
if existing and existing.id != uid:
return jsonify({"error": "Email already in use by another account"}), 409
await update_user_email(uid, new_email)
await log_audit("email_change", user_id=uid, username=user.username, ip_address=_client_ip())
updated = await get_user_by_id(uid)
return jsonify(updated.to_dict())
@auth_bp.route("/forgot-password", methods=["POST"])
async def forgot_password():
if await is_rate_limited(f"forgot:{_client_ip()}", max_requests=5, window_seconds=300):
return jsonify({"status": "ok"})
data = await request.get_json()
email = (data.get("email") or "").strip().lower()
if not email:
return jsonify({"error": "Email is required"}), 400
# Always return success to prevent email enumeration
user = await get_user_by_email(email)
if user and await is_smtp_configured():
raw_token = await create_password_reset_token(user.id)
base_url = await get_base_url()
reset_url = f"{base_url}/reset-password?token={raw_token}"
asyncio.create_task(send_password_reset_email(user.email, reset_url))
await log_audit(
"password_reset_requested",
user_id=user.id,
username=user.username,
ip_address=_client_ip(),
)
return jsonify({"status": "ok"})
@auth_bp.route("/reset-password", methods=["POST"])
async def reset_password():
if await is_rate_limited(f"reset:{_client_ip()}", max_requests=10, window_seconds=60):
return jsonify({"error": "Too many attempts. Try again later."}), 429
data = await request.get_json()
token = (data.get("token") or "").strip()
new_password = data.get("new_password") or ""
if not token:
return jsonify({"error": "Reset token is required"}), 400
if len(new_password) < 8:
return jsonify({"error": "Password must be at least 8 characters"}), 400
user_id = await reset_password_with_token(token, new_password)
if user_id is None:
return jsonify({"error": "Invalid or expired reset link"}), 400
user = await get_user_by_id(user_id)
if user:
await log_audit(
"password_reset_completed",
user_id=user.id, username=user.username, ip_address=_client_ip(),
)
if user.email:
asyncio.create_task(send_password_reset_success_email(user.email))
return jsonify({"status": "ok"})
@auth_bp.route("/invitation/<token>", methods=["GET"])
async def check_invitation(token: str):
invitation = await validate_invitation_token(token)
if not invitation:
return jsonify({"valid": False})
return jsonify({"valid": True, "email": invitation.email})
@auth_bp.route("/register-with-invite", methods=["POST"])
async def register_with_invite():
data = await request.get_json()
raw_token = (data.get("token") or "").strip()
username = (data.get("username") or "").strip()
password = data.get("password") or ""
if not raw_token:
return jsonify({"error": "Invitation token is required"}), 400
if not username:
return jsonify({"error": "Username is required"}), 400
if len(password) < 8:
return jsonify({"error": "Password must be at least 8 characters"}), 400
try:
user = await register_with_invitation(raw_token, username, password)
except Exception:
return jsonify({"error": "Username already taken"}), 409
if not user:
return jsonify({"error": "Invalid or expired invitation"}), 400
session["user_id"] = user.id
session["session_version"] = user.session_version
await log_audit(
"register_with_invite",
user_id=user.id,
username=user.username,
ip_address=_client_ip(),
)
return jsonify(user.to_dict()), 201
@auth_bp.route("/status", methods=["GET"])
async def status():
count = await get_user_count()
reg_open = await is_registration_open()
return jsonify({
"has_users": count > 0,
"registration_open": reg_open,
"oauth_enabled": Config.oidc_enabled(),
"local_auth_enabled": Config.LOCAL_AUTH_ENABLED,
})
@auth_bp.route("/oauth/login", methods=["GET"])
async def oauth_login():
if not Config.oidc_enabled():
return jsonify({"error": "SSO is not configured"}), 404
state = secrets.token_hex(32)
code_verifier = secrets.token_urlsafe(64)
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
session["oauth_state"] = state
session["oauth_code_verifier"] = code_verifier
url = await build_auth_url(state, code_challenge)
return redirect(url)
@auth_bp.route("/oauth/callback", methods=["GET"])
async def oauth_callback():
error = request.args.get("error")
code = request.args.get("code")
state = request.args.get("state")
stored_state = session.pop("oauth_state", None)
code_verifier = session.pop("oauth_code_verifier", None)
if error or not code or state != stored_state:
return redirect("/login?error=oauth")
redirect_uri = Config.BASE_URL.rstrip("/") + "/api/auth/oauth/callback"
try:
token_response = await exchange_code(code, redirect_uri, code_verifier)
claims = await get_userinfo(token_response["access_token"])
except Exception:
return redirect("/login?error=oauth")
sub = claims.get("sub", "")
# Only trust the email claim for account linking if the provider has verified it.
# An unverified email could be used to hijack an existing local account.
email_verified = claims.get("email_verified", False)
email = claims.get("email", "") if email_verified else ""
preferred_username = claims.get("preferred_username", "")
if not sub:
return redirect("/login?error=oauth")
user = await find_or_create_oauth_user(sub, email, preferred_username)
session["user_id"] = user.id
session["session_version"] = user.session_version
await log_audit("oauth_login", user_id=user.id, username=user.username, ip_address=_client_ip())
return redirect("/")
+13
View File
@@ -0,0 +1,13 @@
"""Dashboard REST endpoint — the aggregated landing payload."""
from quart import Blueprint, g, jsonify
from scribe.auth import login_required
from scribe.services.dashboard import build_dashboard
dashboard_bp = Blueprint("dashboard", __name__, url_prefix="/api/dashboard")
@dashboard_bp.get("")
@login_required
async def get_dashboard():
return jsonify(await build_dashboard(g.user.id))
+142
View File
@@ -0,0 +1,142 @@
"""Calendar events REST API."""
from __future__ import annotations
from datetime import datetime, timezone
from quart import Blueprint, g, jsonify, request
from scribe.auth import login_required
import scribe.services.events as events_svc
events_bp = Blueprint("events", __name__, url_prefix="/api/events")
def _parse_dt(value: str) -> datetime:
"""Parse ISO 8601 datetime string, ensuring UTC-awareness."""
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def _get_current_user_id() -> int:
return g.user.id
@events_bp.get("")
@login_required
async def list_events():
date_from_str = request.args.get("from")
date_to_str = request.args.get("to")
if not date_from_str or not date_to_str:
return jsonify({"error": "from and to query params are required"}), 400
try:
date_from = _parse_dt(date_from_str)
date_to = _parse_dt(date_to_str)
except ValueError:
return jsonify({"error": "Invalid datetime format"}), 400
events = await events_svc.list_events(
user_id=_get_current_user_id(),
date_from=date_from,
date_to=date_to,
)
return jsonify(events)
@events_bp.post("")
@login_required
async def create_event():
data = await request.get_json() or {}
if not data.get("title") or not data.get("start_dt"):
return jsonify({"error": "title and start_dt are required"}), 400
try:
start_dt = _parse_dt(data["start_dt"])
end_dt = _parse_dt(data["end_dt"]) if data.get("end_dt") else None
except ValueError:
return jsonify({"error": "Invalid datetime format"}), 400
try:
event = await events_svc.create_event(
user_id=_get_current_user_id(),
title=data["title"],
start_dt=start_dt,
end_dt=end_dt,
duration_minutes=data.get("duration_minutes"),
all_day=data.get("all_day", False),
description=data.get("description", ""),
location=data.get("location", ""),
color=data.get("color", ""),
recurrence=data.get("recurrence"),
project_id=data.get("project_id"),
reminder_minutes=data.get("reminder_minutes"),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(event.to_dict()), 201
@events_bp.get("/<int:event_id>")
@login_required
async def get_event(event_id: int):
event = await events_svc.get_event(
user_id=_get_current_user_id(),
event_id=event_id,
)
if event is None:
return jsonify({"error": "Event not found"}), 404
return jsonify(event.to_dict())
@events_bp.patch("/<int:event_id>")
@login_required
async def update_event(event_id: int):
data = await request.get_json() or {}
fields: dict = {}
for str_field in ("title", "description", "location", "color", "recurrence"):
if str_field in data:
fields[str_field] = data[str_field]
for bool_field in ("all_day",):
if bool_field in data:
fields[bool_field] = data[bool_field]
for int_field in ("project_id", "reminder_minutes", "duration_minutes"):
if int_field in data:
fields[int_field] = data[int_field]
for dt_field in ("start_dt", "end_dt"):
if dt_field in data:
if data[dt_field] is None:
# Explicit null clears the field (e.g. removing end_dt)
fields[dt_field] = None
elif data[dt_field]:
try:
fields[dt_field] = _parse_dt(data[dt_field])
except ValueError:
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
try:
event = await events_svc.update_event(
user_id=_get_current_user_id(),
event_id=event_id,
**fields,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if event is None:
return jsonify({"error": "Event not found"}), 404
return jsonify(event.to_dict())
@events_bp.delete("/<int:event_id>")
@login_required
async def delete_event(event_id: int):
from scribe.services.trash import delete as trash_delete
batch = await trash_delete(_get_current_user_id(), "event", event_id)
if batch is None:
return jsonify({"error": "Event not found"}), 404
return "", 204
@events_bp.post("/sync")
@login_required
async def sync_caldav():
"""Trigger a CalDAV pull sync for the current user."""
from scribe.services.caldav_sync import sync_user_events
result = await sync_user_events(user_id=_get_current_user_id())
return jsonify(result)
+117
View File
@@ -0,0 +1,117 @@
import io
import json
import re
import zipfile
from datetime import datetime, timezone
from quart import Blueprint, Response, request
from scribe.auth import login_required, get_current_user_id
from scribe.models import async_session
from scribe.models.note import Note
from sqlalchemy import select
export_bp = Blueprint("export", __name__)
def _safe_filename(title: str) -> str:
name = re.sub(r"[^\w\s-]", "", title or "untitled").strip()
name = re.sub(r"[\s]+", "_", name) or "untitled"
return name[:80]
def _note_frontmatter(note: Note) -> str:
lines = ["---"]
lines.append(f"title: {json.dumps(note.title or '')}")
lines.append(f"type: {'task' if note.is_task else 'note'}")
if note.tags:
lines.append(f"tags: [{', '.join(note.tags)}]")
if note.is_task:
lines.append(f"status: {note.status or 'todo'}")
lines.append(f"priority: {note.priority or 'none'}")
if note.due_date:
lines.append(f"due_date: {note.due_date}")
if note.project_id:
lines.append(f"project_id: {note.project_id}")
lines.append(f"created_at: {note.created_at.isoformat() if note.created_at else ''}")
lines.append(f"updated_at: {note.updated_at.isoformat() if note.updated_at else ''}")
lines.append("---")
return "\n".join(lines) + "\n\n"
async def _fetch_notes(uid: int):
async with async_session() as db:
result = await db.execute(
select(Note)
.where(Note.user_id == uid)
.order_by(Note.updated_at.desc())
)
return result.scalars().all()
@export_bp.get("/api/export")
@login_required
async def export_data():
uid = get_current_user_id()
fmt = request.args.get("format", "markdown")
notes = await _fetch_notes(uid)
if fmt == "json":
payload = [
{
"id": n.id,
"title": n.title,
"body": n.body,
"is_task": n.is_task,
"status": n.status,
"priority": n.priority,
"tags": n.tags or [],
"due_date": str(n.due_date) if n.due_date else None,
"project_id": n.project_id,
"milestone_id": n.milestone_id,
"parent_id": n.parent_id,
"created_at": n.created_at.isoformat() if n.created_at else None,
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
}
for n in notes
]
data = json.dumps(payload, indent=2, ensure_ascii=False).encode("utf-8")
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
return Response(
data,
status=200,
headers={
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": f'attachment; filename="scribe-{stamp}.json"',
},
)
# Markdown ZIP
buf = io.BytesIO()
used_names: dict[str, int] = {}
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
index_rows = []
for note in notes:
base = _safe_filename(note.title)
count = used_names.get(base, 0)
used_names[base] = count + 1
fname = f"{base}_{count}.md" if count else f"{base}.md"
content = _note_frontmatter(note) + (note.body or "")
zf.writestr(fname, content.encode("utf-8"))
index_rows.append({"file": fname, "title": note.title, "id": note.id})
zf.writestr("index.json", json.dumps(index_rows, indent=2, ensure_ascii=False))
buf.seek(0)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
return Response(
buf.read(),
status=200,
headers={
"Content-Type": "application/zip",
"Content-Disposition": f'attachment; filename="scribe-{stamp}.zip"',
},
)
+112
View File
@@ -0,0 +1,112 @@
import asyncio
from quart import Blueprint, g, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.services import groups as group_svc
from scribe.services.notifications import notify_group_added
groups_bp = Blueprint("groups", __name__, url_prefix="/api/groups")
@groups_bp.route("", methods=["GET"])
@login_required
async def list_groups():
uid = get_current_user_id()
return jsonify({"groups": await group_svc.list_groups(uid)})
@groups_bp.route("", methods=["POST"])
@login_required
async def create_group():
uid = get_current_user_id()
data = await request.get_json()
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": "Name is required"}), 400
try:
group = await group_svc.create_group(uid, name, data.get("description"))
except Exception:
return jsonify({"error": "Group name already taken"}), 409
return jsonify(group.to_dict()), 201
@groups_bp.route("/<int:group_id>", methods=["GET"])
@login_required
async def get_group(group_id: int):
group = await group_svc.get_group(group_id)
if not group:
return jsonify({"error": "Not found"}), 404
members = await group_svc.list_members(group_id)
return jsonify({**group.to_dict(), "members": members})
@groups_bp.route("/<int:group_id>", methods=["PATCH"])
@login_required
async def update_group(group_id: int):
uid = get_current_user_id()
data = await request.get_json()
updates = {k: v for k, v in data.items() if k in ("name", "description")}
group = await group_svc.update_group(uid, group_id, g.user.role == "admin", **updates)
if not group:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify(group.to_dict())
@groups_bp.route("/<int:group_id>", methods=["DELETE"])
@login_required
async def delete_group(group_id: int):
uid = get_current_user_id()
deleted = await group_svc.delete_group(uid, group_id, g.user.role == "admin")
if not deleted:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify({"status": "ok"})
@groups_bp.route("/<int:group_id>/members", methods=["GET"])
@login_required
async def list_members(group_id: int):
members = await group_svc.list_members(group_id)
return jsonify({"members": members})
@groups_bp.route("/<int:group_id>/members", methods=["POST"])
@login_required
async def add_member(group_id: int):
uid = get_current_user_id()
data = await request.get_json()
target_uid = data.get("user_id")
role = data.get("role", "member")
if not target_uid:
return jsonify({"error": "user_id required"}), 400
if role not in ("owner", "member"):
return jsonify({"error": "role must be owner or member"}), 400
membership = await group_svc.add_member(uid, group_id, target_uid, role, g.user.role == "admin")
if not membership:
return jsonify({"error": "Forbidden, group not found, or user already a member"}), 403
asyncio.create_task(notify_group_added(group_id, role, uid, target_uid))
return jsonify(membership.to_dict()), 201
@groups_bp.route("/<int:group_id>/members/<int:target_uid>", methods=["PATCH"])
@login_required
async def update_member(group_id: int, target_uid: int):
uid = get_current_user_id()
data = await request.get_json()
role = data.get("role")
if role not in ("owner", "member"):
return jsonify({"error": "role must be owner or member"}), 400
m = await group_svc.update_member_role(uid, group_id, target_uid, role, g.user.role == "admin")
if not m:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify(m.to_dict())
@groups_bp.route("/<int:group_id>/members/<int:target_uid>", methods=["DELETE"])
@login_required
async def remove_member(group_id: int, target_uid: int):
uid = get_current_user_id()
removed = await group_svc.remove_member(uid, group_id, target_uid, g.user.role == "admin")
if not removed:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify({"status": "ok"})
+44
View File
@@ -0,0 +1,44 @@
from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.services.notifications import (
list_in_app_notifications,
mark_all_notifications_read,
mark_notification_read,
unread_notification_count,
)
notifications_bp = Blueprint("notifications", __name__, url_prefix="/api/notifications")
@notifications_bp.route("", methods=["GET"])
@login_required
async def list_notifications():
uid = get_current_user_id()
all_flag = request.args.get("all", "false").lower() == "true"
items = await list_in_app_notifications(uid, unread_only=not all_flag)
return jsonify({"notifications": items})
@notifications_bp.route("/count", methods=["GET"])
@login_required
async def get_count():
uid = get_current_user_id()
return jsonify({"count": await unread_notification_count(uid)})
@notifications_bp.route("/<int:notif_id>/read", methods=["POST"])
@login_required
async def mark_read(notif_id: int):
uid = get_current_user_id()
if not await mark_notification_read(uid, notif_id):
return jsonify({"error": "Not found"}), 404
return jsonify({"status": "ok"})
@notifications_bp.route("/read-all", methods=["POST"])
@login_required
async def mark_all_read():
uid = get_current_user_id()
count = await mark_all_notifications_read(uid)
return jsonify({"status": "ok", "marked": count})
+144
View File
@@ -0,0 +1,144 @@
"""Unified Knowledge endpoint — notes, people, places, lists in one queryable feed."""
import logging
from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.routes.utils import parse_pagination
logger = logging.getLogger(__name__)
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
_VALID_TYPES = {"note", "person", "place", "list", "task", "plan", "process"}
_VALID_SORTS = {"modified", "created", "alpha", "type"}
@knowledge_bp.route("", methods=["GET"])
@login_required
async def list_knowledge():
"""Return paginated knowledge objects with optional filtering.
Query params:
type — one of note|person|place|list (omit for all, excludes tasks)
tags — comma-separated tag filter (AND logic)
sort — modified|created|alpha|type (default: modified)
q — search query (semantic when provided, keyword fallback)
page — 1-based page number (default 1)
per_page — items per page (default 24, max 100)
"""
uid = get_current_user_id()
note_type = request.args.get("type", "").strip().lower() or None
tags_raw = request.args.get("tags", "").strip()
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else []
sort = request.args.get("sort", "modified").strip().lower()
q = request.args.get("q", "").strip() or None
if note_type and note_type not in _VALID_TYPES:
return jsonify({"error": f"Invalid type. Must be one of: {', '.join(sorted(_VALID_TYPES))}"}), 400
if sort not in _VALID_SORTS:
sort = "modified"
limit, offset = parse_pagination(default_limit=24, max_limit=100)
page = max(1, int(request.args.get("page", 1)))
from scribe.services.knowledge import query_knowledge
items, total = await query_knowledge(
user_id=uid,
note_type=note_type,
tags=tags,
sort=sort,
q=q,
limit=limit,
offset=offset,
)
return jsonify({
"items": items,
"total": total,
"page": page,
"per_page": limit,
"pages": max(1, (total + limit - 1) // limit),
})
@knowledge_bp.route("/ids", methods=["GET"])
@login_required
async def list_knowledge_ids():
"""Return note IDs only (cheap) for the two-tier pagination feed.
Same filter params as GET /api/knowledge.
Additional params: limit (default 100, max 200), offset (default 0).
Returns {ids, total, has_more}.
"""
uid = get_current_user_id()
note_type = request.args.get("type", "").strip().lower() or None
tags_raw = request.args.get("tags", "").strip()
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else []
sort = request.args.get("sort", "modified").strip().lower()
q = request.args.get("q", "").strip() or None
if sort not in _VALID_SORTS:
sort = "modified"
try:
limit = min(int(request.args.get("limit", 100)), 200)
offset = max(0, int(request.args.get("offset", 0)))
except ValueError:
return jsonify({"error": "Invalid limit or offset"}), 400
if note_type and note_type not in _VALID_TYPES:
return jsonify({"error": "Invalid type"}), 400
from scribe.services.knowledge import query_knowledge_ids
ids, total = await query_knowledge_ids(
user_id=uid, note_type=note_type, tags=tags,
sort=sort, q=q, limit=limit, offset=offset,
)
return jsonify({"ids": ids, "total": total, "has_more": (offset + len(ids)) < total})
@knowledge_bp.route("/batch", methods=["GET"])
@login_required
async def get_knowledge_batch():
"""Fetch full items for a comma-separated list of IDs (max 100).
Returns {items: [...]} in the order of the requested IDs.
"""
uid = get_current_user_id()
ids_raw = request.args.get("ids", "").strip()
if not ids_raw:
return jsonify({"items": []})
try:
ids = [int(x) for x in ids_raw.split(",") if x.strip()]
except ValueError:
return jsonify({"error": "Invalid IDs"}), 400
if len(ids) > 100:
return jsonify({"error": "Too many IDs (max 100)"}), 400
from scribe.services.knowledge import get_knowledge_by_ids
items = await get_knowledge_by_ids(uid, ids)
return jsonify({"items": items})
@knowledge_bp.route("/tags", methods=["GET"])
@login_required
async def list_knowledge_tags():
"""Return all tags used across knowledge objects (excludes tasks)."""
uid = get_current_user_id()
note_type = request.args.get("type", "").strip().lower() or None
from scribe.services.knowledge import get_knowledge_tags
tags = await get_knowledge_tags(uid, note_type=note_type)
return jsonify({"tags": tags})
@knowledge_bp.route("/counts", methods=["GET"])
@login_required
async def get_knowledge_counts():
"""Return per-type counts — used by the sidebar to show item counts."""
uid = get_current_user_id()
tags_raw = request.args.get("tags", "").strip()
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else None
from scribe.services.knowledge import get_knowledge_counts as _counts
counts = await _counts(uid, tags=tags)
return jsonify(counts)
+142
View File
@@ -0,0 +1,142 @@
"""Milestone routes nested under /api/projects/<project_id>/milestones."""
import logging
from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id
from scribe.routes.utils import not_found, parse_pagination
from scribe.services.access import can_write_project
from scribe.services.milestones import (
create_milestone,
delete_milestone,
get_milestone_in_project,
get_milestone_progress,
list_milestones,
update_milestone,
)
from scribe.services.notes import list_notes
from scribe.services.projects import get_project_for_user
logger = logging.getLogger(__name__)
milestones_bp = Blueprint("milestones", __name__, url_prefix="/api/projects")
async def _milestone_dict(m) -> dict:
d = m.to_dict()
d.update(await get_milestone_progress(m.id))
return d
@milestones_bp.route("/<int:project_id>/milestones", methods=["GET"])
@login_required
async def list_milestones_route(project_id: int):
uid = get_current_user_id()
result = await get_project_for_user(uid, project_id)
if result is None:
return not_found("Project")
project, _ = result
# List milestones using the project owner's uid so ownership filter matches
owner_uid = project.user_id or uid
status = request.args.get("status")
milestones = await list_milestones(owner_uid, project_id, status=status)
return jsonify({"milestones": [await _milestone_dict(m) for m in milestones]})
@milestones_bp.route("/<int:project_id>/milestones", methods=["POST"])
@login_required
async def create_milestone_route(project_id: int):
uid = get_current_user_id()
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
if not await can_write_project(uid, project_id):
return jsonify({"error": "Permission denied"}), 403
data = await request.get_json()
if not data.get("title"):
return jsonify({"error": "title is required"}), 400
status = data.get("status", "active")
if status not in ("active", "done"):
return jsonify({"error": "status must be 'active' or 'done'"}), 400
milestone = await create_milestone(
uid,
project_id,
title=data["title"],
description=data.get("description"),
order_index=data.get("order_index", 0),
status=status,
)
return jsonify(await _milestone_dict(milestone)), 201
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["GET"])
@login_required
async def get_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
milestone = await get_milestone_in_project(project_id, milestone_id)
if milestone is None:
return not_found("Milestone")
return jsonify(await _milestone_dict(milestone))
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["PATCH"])
@login_required
async def update_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
if not await can_write_project(uid, project_id):
return jsonify({"error": "Permission denied"}), 403
milestone = await get_milestone_in_project(project_id, milestone_id)
if milestone is None:
return not_found("Milestone")
data = await request.get_json()
allowed = {"title", "description", "status", "order_index"}
fields = {k: v for k, v in data.items() if k in allowed}
if "status" in fields and fields["status"] not in ("active", "done"):
return jsonify({"error": "status must be 'active' or 'done'"}), 400
updated = await update_milestone(milestone.user_id, milestone_id, **fields)
if updated is None:
return not_found("Milestone")
return jsonify(await _milestone_dict(updated))
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["DELETE"])
@login_required
async def delete_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
if not await can_write_project(uid, project_id):
return jsonify({"error": "Permission denied"}), 403
milestone = await get_milestone_in_project(project_id, milestone_id)
if milestone is None:
return not_found("Milestone")
from scribe.services.trash import delete as trash_delete
batch = await trash_delete(milestone.user_id, "milestone", milestone_id)
if batch is None:
return not_found("Milestone")
return "", 204
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>/tasks", methods=["GET"])
@login_required
async def get_milestone_tasks_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
milestone = await get_milestone_in_project(project_id, milestone_id)
if milestone is None:
return not_found("Milestone")
status_filter = request.args.get("status")
limit, offset = parse_pagination(default_limit=100)
notes, total = await list_notes(
milestone.user_id,
is_task=True,
status=status_filter,
milestone_id=milestone_id,
limit=limit,
offset=offset,
)
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
+481
View File
@@ -0,0 +1,481 @@
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("/<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
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 "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("/<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 "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("/<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)
+43
View File
@@ -0,0 +1,43 @@
from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.services.user_profile import (
VALID_EXPERTISE,
VALID_STYLES,
VALID_TONES,
get_profile,
update_profile,
)
profile_bp = Blueprint("profile", __name__, url_prefix="/api/profile")
@profile_bp.route("", methods=["GET"])
@login_required
async def get_profile_route():
uid = get_current_user_id()
profile = await get_profile(uid)
return jsonify(profile.to_dict())
@profile_bp.route("", methods=["PUT"])
@login_required
async def update_profile_route():
uid = get_current_user_id()
data = await request.get_json()
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
if "expertise_level" in data and data["expertise_level"] not in VALID_EXPERTISE:
return jsonify({"error": f"expertise_level must be one of {sorted(VALID_EXPERTISE)}"}), 400
if "response_style" in data and data["response_style"] not in VALID_STYLES:
return jsonify({"error": f"response_style must be one of {sorted(VALID_STYLES)}"}), 400
if "tone" in data and data["tone"] not in VALID_TONES:
return jsonify({"error": f"tone must be one of {sorted(VALID_TONES)}"}), 400
if "interests" in data and not isinstance(data["interests"], list):
return jsonify({"error": "interests must be an array"}), 400
if "work_schedule" in data and not isinstance(data["work_schedule"], dict):
return jsonify({"error": "work_schedule must be an object"}), 400
profile = await update_profile(uid, data)
return jsonify(profile.to_dict())
+148
View File
@@ -0,0 +1,148 @@
"""Project management routes."""
import asyncio
import logging
from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id
from scribe.routes.utils import not_found, parse_pagination
from scribe.services.milestones import list_milestones
from scribe.services.notes import list_notes
from scribe.services.projects import (
create_project,
delete_project,
get_project,
get_project_for_user,
get_project_summary,
list_projects_for_user,
update_project,
)
logger = logging.getLogger(__name__)
projects_bp = Blueprint("projects", __name__, url_prefix="/api/projects")
@projects_bp.route("", methods=["GET"])
@login_required
async def list_projects_route():
uid = get_current_user_id()
status = request.args.get("status")
include_summary = request.args.get("include_summary", "").lower() in ("1", "true")
projects = await list_projects_for_user(uid, status=status)
if include_summary:
# Fetch all summaries in parallel — one backend pass instead of N+1 frontend calls
async def _attach(project_dict: dict) -> dict:
try:
owner_uid = project_dict.get("user_id") or uid # user_id now in to_dict()
summary = await get_project_summary(owner_uid, project_dict["id"])
project_dict["summary"] = summary
except Exception:
pass
return project_dict
projects = list(await asyncio.gather(*[_attach(p) for p in projects]))
return jsonify({"projects": projects})
@projects_bp.route("", methods=["POST"])
@login_required
async def create_project_route():
uid = get_current_user_id()
data = await request.get_json()
if not data.get("title"):
return jsonify({"error": "title is required"}), 400
status = data.get("status", "active")
if status not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
project = await create_project(
uid,
title=data["title"],
description=data.get("description", ""),
goal=data.get("goal", ""),
color=data.get("color"),
status=status,
)
return jsonify(project.to_dict()), 201
@projects_bp.route("/<int:project_id>", methods=["GET"])
@login_required
async def get_project_route(project_id: int):
uid = get_current_user_id()
result = await get_project_for_user(uid, project_id)
if result is None:
return not_found("Project")
project, permission = result
# Summary uses the project owner's uid for stats when viewer is not the owner
owner_uid = project.user_id or uid
summary = await get_project_summary(owner_uid, project_id)
data = project.to_dict()
data["summary"] = summary
data["permission"] = permission
return jsonify(data)
@projects_bp.route("/<int:project_id>", methods=["PATCH"])
@login_required
async def update_project_route(project_id: int):
uid = get_current_user_id()
data = await request.get_json()
allowed = {"title", "description", "goal", "status", "color"}
fields = {k: (v if v is not None else "") for k, v in data.items() if k in allowed}
if "status" in fields and fields["status"] not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
project = await update_project(uid, project_id, **fields)
if project is None:
return not_found("Project")
return jsonify(project.to_dict())
@projects_bp.route("/<int:project_id>", methods=["DELETE"])
@login_required
async def delete_project_route(project_id: int):
uid = get_current_user_id()
from scribe.services.trash import delete as trash_delete
batch = await trash_delete(uid, "project", project_id)
if batch is None:
return not_found("Project")
return "", 204
@projects_bp.route("/<int:project_id>/notes", methods=["GET"])
@login_required
async def get_project_notes_route(project_id: int):
uid = get_current_user_id()
result = await get_project_for_user(uid, project_id)
if result is None:
return not_found("Project")
project, _ = result
# Use the project owner's uid so the ownership filter on notes/milestones
# matches for shared collaborators (who'd otherwise see an empty panel).
owner_uid = project.user_id or uid
# type filter: "note", "task", or None (both)
type_filter = request.args.get("type")
status_filter = request.args.get("status")
limit, offset = parse_pagination(default_limit=100)
is_task: bool | None = None
if type_filter == "task":
is_task = True
elif type_filter == "note":
is_task = False
ms_list = await list_milestones(owner_uid, project_id)
milestone_ids = [m.id for m in ms_list]
notes, total = await list_notes(
owner_uid,
is_task=is_task,
status=status_filter,
project_id=project_id,
milestone_ids=milestone_ids,
limit=limit,
offset=offset,
sort="updated_at",
order="desc",
)
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
+315
View File
@@ -0,0 +1,315 @@
"""Rulebook / topic REST endpoints.
Wraps services/rulebooks.py. Standard Scribe auth: g.user.id is the
authenticated owner; the service enforces ownership scoping.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from scribe.auth import login_required
import scribe.services.rulebooks as rulebooks_svc
from scribe.services.trash import delete as trash_delete
rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api")
def _uid() -> int:
return g.user.id
# ── Rulebooks ───────────────────────────────────────────────────────────
@rulebooks_bp.get("/rulebooks")
@login_required
async def list_rulebooks():
rows = await rulebooks_svc.list_rulebooks(_uid())
return jsonify({"rulebooks": [rb.to_dict() for rb in rows]})
@rulebooks_bp.post("/rulebooks")
@login_required
async def create_rulebook():
data = await request.get_json() or {}
title = (data.get("title") or "").strip()
if not title:
return jsonify({"error": "title is required"}), 400
rb = await rulebooks_svc.create_rulebook(
user_id=_uid(),
title=title,
description=data.get("description", ""),
)
return jsonify(rb.to_dict()), 201
@rulebooks_bp.get("/rulebooks/<int:rulebook_id>")
@login_required
async def get_rulebook(rulebook_id: int):
rb = await rulebooks_svc.get_rulebook(rulebook_id, _uid())
if rb is None:
return jsonify({"error": "rulebook not found"}), 404
return jsonify(rb.to_dict())
@rulebooks_bp.patch("/rulebooks/<int:rulebook_id>")
@login_required
async def update_rulebook(rulebook_id: int):
data = await request.get_json() or {}
fields = {k: v for k, v in data.items() if k in ("title", "description", "always_on")}
rb = await rulebooks_svc.update_rulebook(rulebook_id, _uid(), **fields)
if rb is None:
return jsonify({"error": "rulebook not found"}), 404
return jsonify(rb.to_dict())
@rulebooks_bp.delete("/rulebooks/<int:rulebook_id>")
@login_required
async def delete_rulebook(rulebook_id: int):
await trash_delete(_uid(), "rulebook", rulebook_id)
return "", 204
# ── Topics ──────────────────────────────────────────────────────────────
@rulebooks_bp.get("/rulebooks/<int:rulebook_id>/topics")
@login_required
async def list_topics(rulebook_id: int):
try:
rows = await rulebooks_svc.list_topics(rulebook_id, _uid())
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify({"topics": [t.to_dict() for t in rows]})
@rulebooks_bp.post("/rulebooks/<int:rulebook_id>/topics")
@login_required
async def create_topic(rulebook_id: int):
data = await request.get_json() or {}
title = (data.get("title") or "").strip()
if not title:
return jsonify({"error": "title is required"}), 400
try:
topic = await rulebooks_svc.create_topic(
rulebook_id=rulebook_id,
user_id=_uid(),
title=title,
description=data.get("description", ""),
order_index=data.get("order_index", 0),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify(topic.to_dict()), 201
@rulebooks_bp.patch("/rulebook-topics/<int:topic_id>")
@login_required
async def update_topic(topic_id: int):
data = await request.get_json() or {}
fields = {
k: v for k, v in data.items()
if k in ("title", "description", "order_index")
}
topic = await rulebooks_svc.update_topic(topic_id, _uid(), **fields)
if topic is None:
return jsonify({"error": "topic not found"}), 404
return jsonify(topic.to_dict())
@rulebooks_bp.delete("/rulebook-topics/<int:topic_id>")
@login_required
async def delete_topic(topic_id: int):
if await trash_delete(_uid(), "topic", topic_id) is None:
return jsonify({"error": "topic not found"}), 404
return "", 204
# ── Rules ───────────────────────────────────────────────────────────────
@rulebooks_bp.get("/rules")
@login_required
async def list_rules():
def _opt_int(name):
raw = request.args.get(name)
return int(raw) if raw else None
try:
rulebook_id = _opt_int("rulebook_id")
topic_id = _opt_int("topic_id")
project_id = _opt_int("project_id")
except ValueError:
return jsonify({"error": "rulebook_id, topic_id, project_id must be integers"}), 400
rows = await rulebooks_svc.list_rules(
user_id=_uid(),
rulebook_id=rulebook_id,
topic_id=topic_id,
project_id=project_id,
)
return jsonify({"rules": [r.to_dict() for r in rows]})
@rulebooks_bp.post("/rulebook-topics/<int:topic_id>/rules")
@login_required
async def create_rule(topic_id: int):
data = await request.get_json() or {}
title = (data.get("title") or "").strip()
statement = (data.get("statement") or "").strip()
if not title or not statement:
return jsonify({"error": "title and statement are required"}), 400
try:
rule = await rulebooks_svc.create_rule(
topic_id=topic_id,
user_id=_uid(),
title=title,
statement=statement,
why=data.get("why", ""),
how_to_apply=data.get("how_to_apply", ""),
order_index=data.get("order_index", 0),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify(rule.to_dict()), 201
@rulebooks_bp.get("/rules/<int:rule_id>")
@login_required
async def get_rule(rule_id: int):
rule = await rulebooks_svc.get_rule(rule_id, _uid())
if rule is None:
return jsonify({"error": "rule not found"}), 404
return jsonify(rule.to_dict())
@rulebooks_bp.patch("/rules/<int:rule_id>")
@login_required
async def update_rule(rule_id: int):
data = await request.get_json() or {}
fields = {
k: v for k, v in data.items()
if k in ("title", "statement", "why", "how_to_apply", "order_index")
}
rule = await rulebooks_svc.update_rule(rule_id, _uid(), **fields)
if rule is None:
return jsonify({"error": "rule not found"}), 404
return jsonify(rule.to_dict())
@rulebooks_bp.delete("/rules/<int:rule_id>")
@login_required
async def delete_rule(rule_id: int):
if await trash_delete(_uid(), "rule", rule_id) is None:
return jsonify({"error": "rule not found"}), 404
return "", 204
# ── Subscriptions ──────────────────────────────────────────────────────
@rulebooks_bp.post("/projects/<int:project_id>/rulebook-subscriptions")
@login_required
async def subscribe_project(project_id: int):
data = await request.get_json() or {}
rulebook_id = data.get("rulebook_id")
if not rulebook_id:
return jsonify({"error": "rulebook_id is required"}), 400
try:
await rulebooks_svc.subscribe_project(
project_id=project_id, rulebook_id=int(rulebook_id), user_id=_uid(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.delete(
"/projects/<int:project_id>/rulebook-subscriptions/<int:rulebook_id>"
)
@login_required
async def unsubscribe_project(project_id: int, rulebook_id: int):
try:
await rulebooks_svc.unsubscribe_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=_uid(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.get("/projects/<int:project_id>/rules")
@login_required
async def get_project_rules(project_id: int):
result = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=_uid(),
)
return jsonify(result)
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
@login_required
async def suppress_project_rule(project_id: int, rule_id: int):
try:
await rulebooks_svc.suppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=_uid(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
@login_required
async def unsuppress_project_rule(project_id: int, rule_id: int):
try:
await rulebooks_svc.unsuppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=_uid(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
@login_required
async def suppress_project_topic(project_id: int, topic_id: int):
try:
await rulebooks_svc.suppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=_uid(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
@login_required
async def unsuppress_project_topic(project_id: int, topic_id: int):
try:
await rulebooks_svc.unsuppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=_uid(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.post("/projects/<int:project_id>/rules")
@login_required
async def create_project_rule(project_id: int):
"""Create a rule scoped to a single project. Frontend fast path."""
data = await request.get_json() or {}
statement = (data.get("statement") or "").strip()
if not statement:
return jsonify({"error": "statement is required"}), 400
title = (data.get("title") or "").strip() or statement.split(".")[0][:50]
try:
rule = await rulebooks_svc.create_project_rule(
project_id=project_id,
user_id=_uid(),
title=title,
statement=statement,
why=data.get("why", ""),
how_to_apply=data.get("how_to_apply", ""),
order_index=data.get("order_index", 0),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify(rule.to_dict()), 201
+46
View File
@@ -0,0 +1,46 @@
from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id
from scribe.services.embeddings import semantic_search_notes
search_bp = Blueprint("search", __name__, url_prefix="/api/search")
def _content_type_to_is_task(content_type: str) -> bool | None:
"""Map content_type query param to semantic_search_notes is_task arg."""
if content_type == "note":
return False
if content_type == "task":
return True
return None # "all" or unknown → no filter
@search_bp.route("", methods=["GET"])
@login_required
async def search_route():
uid = get_current_user_id()
q = (request.args.get("q") or "").strip()
if not q:
return jsonify({"error": "q is required"}), 400
content_type = request.args.get("content_type", "all")
limit = min(request.args.get("limit", 10, type=int), 50)
is_task = _content_type_to_is_task(content_type)
results = await semantic_search_notes(
uid, q, limit=limit, is_task=is_task, threshold=0.3
)
return jsonify({
"results": [
{
"id": note.id,
"title": note.title,
"body": note.body or "",
"is_task": note.is_task,
"tags": note.tags or [],
"similarity": score,
}
for score, note in results # semantic_search_notes returns list[tuple[float, Note]]
],
"total": len(results),
})
+131
View File
@@ -0,0 +1,131 @@
"""User settings + integrations (CalDAV, SearXNG status).
Chat-model picker endpoints (/models), KV-cache priming, and journal-schedule
hooks were removed in Phase 8 alongside the chat/journal subsystems.
"""
import ipaddress
import logging
import socket
from urllib.parse import urlparse
from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id
from scribe.config import Config
from scribe.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection
from scribe.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch
logger = logging.getLogger(__name__)
def _is_private_url(url: str) -> bool:
"""SSRF-blocking helper: returns True for URLs that resolve to private,
loopback, or link-local addresses. Inlined here after services/llm.py
(the original home) was removed in Phase 8."""
try:
host = urlparse(url).hostname
if not host:
return True
# Resolve to all addresses; reject if any is private/loopback/link-local.
infos = socket.getaddrinfo(host, None)
for family, *_rest, sockaddr in infos:
ip_str = sockaddr[0]
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
continue
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return True
except Exception:
# Conservative: if we can't resolve, treat as private (reject).
return True
return False
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
@settings_bp.route("", methods=["GET"])
@login_required
async def get_settings_route():
uid = get_current_user_id()
settings = await get_all_settings(uid)
return jsonify(settings)
@settings_bp.route("", methods=["PUT"])
@login_required
async def update_settings_route():
uid = get_current_user_id()
data = await request.get_json()
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
to_save = {}
for k, v in data.items():
str_v = str(v)
if not str_v:
await delete_setting(uid, k)
else:
to_save[k] = str_v
if to_save:
await set_settings_batch(uid, to_save)
settings = await get_all_settings(uid)
return jsonify(settings)
@settings_bp.route("/caldav", methods=["GET"])
@login_required
async def get_caldav():
uid = get_current_user_id()
config = await get_caldav_config(uid)
if config.get("caldav_password"):
config["caldav_password"] = "********"
return jsonify(config)
@settings_bp.route("/caldav", methods=["PUT"])
@login_required
async def update_caldav():
uid = get_current_user_id()
data = await request.get_json()
# Validate CalDAV URL before saving — block internal/private addresses
if "caldav_url" in data:
url = str(data.get("caldav_url") or "").strip()
if url:
parsed_scheme = url.split("://")[0].lower() if "://" in url else ""
if parsed_scheme not in ("http", "https"):
return jsonify({"error": "CalDAV URL must use http or https"}), 400
if _is_private_url(url):
return jsonify({"error": "CalDAV URL must not point to an internal or private address"}), 400
settings_to_save = {}
for key in CALDAV_SETTING_KEYS:
if key in data:
if key == "caldav_password" and data[key] == "********":
continue
settings_to_save[key] = str(data[key])
if settings_to_save:
await set_settings_batch(uid, settings_to_save)
return jsonify({"status": "ok"})
@settings_bp.route("/caldav/test", methods=["POST"])
@login_required
async def test_caldav():
uid = get_current_user_id()
result = await test_connection(uid)
return jsonify(result)
@settings_bp.route("/search", methods=["GET"])
@login_required
async def test_search():
"""Report SearXNG configuration status (used by the Integrations tab)."""
if not Config.searxng_enabled():
return jsonify({"configured": False, "results": [], "searxng_url": ""})
return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL})
+128
View File
@@ -0,0 +1,128 @@
import asyncio
from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.services import sharing as share_svc
from scribe.services.access import can_admin_project, can_write_note
from scribe.services.notifications import notify_note_shared, notify_project_shared
from scribe.services.sharing import list_shared_with_me
shares_bp = Blueprint("shares", __name__)
# ---------------------------------------------------------------------------
# Project shares
# ---------------------------------------------------------------------------
@shares_bp.route("/api/projects/<int:project_id>/shares", methods=["GET"])
@login_required
async def list_project_shares(project_id: int):
uid = get_current_user_id()
if not await can_admin_project(uid, project_id):
return jsonify({"error": "Forbidden"}), 403
return jsonify({"shares": await share_svc.list_project_shares(project_id)})
@shares_bp.route("/api/projects/<int:project_id>/shares", methods=["POST"])
@login_required
async def create_project_share(project_id: int):
uid = get_current_user_id()
data = await request.get_json()
share = await share_svc.share_project(
uid, project_id,
target_user_id=data.get("user_id"),
target_group_id=data.get("group_id"),
permission=data.get("permission", "viewer"),
)
if not share:
return jsonify({"error": "Forbidden or invalid permission"}), 403
asyncio.create_task(notify_project_shared(
project_id, share.permission, uid,
data.get("user_id"), data.get("group_id"),
))
return jsonify(share.to_dict()), 201
@shares_bp.route("/api/projects/<int:project_id>/shares/<int:share_id>", methods=["PATCH"])
@login_required
async def update_project_share(project_id: int, share_id: int):
uid = get_current_user_id()
data = await request.get_json()
share = await share_svc.update_project_share(uid, share_id, data.get("permission", ""))
if not share:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify(share.to_dict())
@shares_bp.route("/api/projects/<int:project_id>/shares/<int:share_id>", methods=["DELETE"])
@login_required
async def remove_project_share(project_id: int, share_id: int):
uid = get_current_user_id()
if not await share_svc.remove_project_share(uid, share_id):
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify({"status": "ok"})
# ---------------------------------------------------------------------------
# Note / task shares
# ---------------------------------------------------------------------------
@shares_bp.route("/api/notes/<int:note_id>/shares", methods=["GET"])
@login_required
async def list_note_shares(note_id: int):
uid = get_current_user_id()
if not await can_write_note(uid, note_id):
return jsonify({"error": "Forbidden"}), 403
return jsonify({"shares": await share_svc.list_note_shares(note_id)})
@shares_bp.route("/api/notes/<int:note_id>/shares", methods=["POST"])
@login_required
async def create_note_share(note_id: int):
uid = get_current_user_id()
data = await request.get_json()
share = await share_svc.share_note(
uid, note_id,
target_user_id=data.get("user_id"),
target_group_id=data.get("group_id"),
permission=data.get("permission", "viewer"),
)
if not share:
return jsonify({"error": "Forbidden or invalid permission"}), 403
asyncio.create_task(notify_note_shared(
note_id, share.permission, uid,
data.get("user_id"), data.get("group_id"),
))
return jsonify(share.to_dict()), 201
@shares_bp.route("/api/notes/<int:note_id>/shares/<int:share_id>", methods=["PATCH"])
@login_required
async def update_note_share(note_id: int, share_id: int):
uid = get_current_user_id()
data = await request.get_json()
share = await share_svc.update_note_share(uid, share_id, data.get("permission", ""))
if not share:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify(share.to_dict())
@shares_bp.route("/api/notes/<int:note_id>/shares/<int:share_id>", methods=["DELETE"])
@login_required
async def remove_note_share(note_id: int, share_id: int):
uid = get_current_user_id()
if not await share_svc.remove_note_share(uid, share_id):
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify({"status": "ok"})
# ---------------------------------------------------------------------------
# Shared-with-me
# ---------------------------------------------------------------------------
@shares_bp.route("/api/shared-with-me", methods=["GET"])
@login_required
async def shared_with_me():
uid = get_current_user_id()
return jsonify(await list_shared_with_me(uid))
+68
View File
@@ -0,0 +1,68 @@
"""Task work log routes — /api/tasks/<task_id>/logs."""
import logging
from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.services.task_logs import create_log, delete_log, list_logs, update_log
logger = logging.getLogger(__name__)
task_logs_bp = Blueprint("task_logs", __name__, url_prefix="/api/tasks")
@task_logs_bp.route("/<int:task_id>/logs", methods=["GET"])
@login_required
async def list_logs_route(task_id: int):
uid = get_current_user_id()
logs = await list_logs(uid, task_id)
return jsonify({"logs": [log.to_dict() for log in logs]})
@task_logs_bp.route("/<int:task_id>/logs", methods=["POST"])
@login_required
async def create_log_route(task_id: int):
uid = get_current_user_id()
data = await request.get_json()
content = (data or {}).get("content", "").strip()
if not content:
return jsonify({"error": "content is required"}), 400
duration_minutes = (data or {}).get("duration_minutes")
if duration_minutes is not None:
try:
duration_minutes = int(duration_minutes)
except (TypeError, ValueError):
return jsonify({"error": "duration_minutes must be an integer"}), 400
try:
log = await create_log(uid, task_id, content, duration_minutes)
except ValueError as e:
return jsonify({"error": str(e)}), 404
return jsonify(log.to_dict()), 201
@task_logs_bp.route("/<int:task_id>/logs/<int:log_id>", methods=["PATCH"])
@login_required
async def update_log_route(task_id: int, log_id: int):
uid = get_current_user_id()
data = await request.get_json() or {}
content = data.get("content")
if content is not None:
content = content.strip() or None
from scribe.services.task_logs import _UNSET
duration_minutes = data.get("duration_minutes", _UNSET)
log = await update_log(uid, log_id, content=content, duration_minutes=duration_minutes)
if log is None:
return jsonify({"error": "Log entry not found"}), 404
return jsonify(log.to_dict())
@task_logs_bp.route("/<int:task_id>/logs/<int:log_id>", methods=["DELETE"])
@login_required
async def delete_log_route(task_id: int, log_id: int):
uid = get_current_user_id()
deleted = await delete_log(uid, log_id)
if not deleted:
return jsonify({"error": "Log entry not found"}), 404
return "", 204
+308
View File
@@ -0,0 +1,308 @@
import asyncio
from datetime import date
from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id
from scribe.models.note import TaskPriority, TaskStatus
from scribe.routes.utils import not_found, parse_iso_date, parse_pagination
from scribe.services.access import can_write_note
from scribe.services.embeddings import upsert_note_embedding
from scribe.services.notes import (
create_note,
get_note,
get_note_for_user,
list_notes,
update_note,
)
from scribe.services.planning import start_planning as svc_start_planning
from scribe.services.recurrence import calculate_next_due, validate_recurrence_rule
tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
_UNSET = object()
def _parse_recurrence_rule(data: dict) -> tuple[object, tuple | None]:
"""Extract and validate recurrence_rule from request data.
Returns (rule_or_UNSET, error_response_or_None):
_UNSET → key not present, do nothing
None → key present and null, clear the rule
dict → key present and valid, set the rule
"""
if "recurrence_rule" not in data:
return _UNSET, None
rule = data["recurrence_rule"]
if rule is None:
return None, None
try:
validate_recurrence_rule(rule)
except ValueError as exc:
return _UNSET, (jsonify({"error": str(exc)}), 400)
return rule, None
@tasks_bp.route("", methods=["GET"])
@login_required
async def list_tasks_route():
uid = get_current_user_id()
q = request.args.get("q")
tag = request.args.getlist("tag")
status = request.args.getlist("status") or None
priority = request.args.getlist("priority") or None
sort = request.args.get("sort", "updated_at")
order = request.args.get("order", "desc")
limit, offset = parse_pagination()
project_id = request.args.get("project_id", type=int)
no_project = request.args.get("no_project", "").lower() == "true"
kind = request.args.get("kind") or None
due_before = parse_iso_date(request.args.get("due_before"), "due_before")
if isinstance(due_before, tuple):
return due_before
due_after = parse_iso_date(request.args.get("due_after"), "due_after")
if isinstance(due_after, tuple):
return due_after
tasks, total = await list_notes(
uid,
q=q,
tags=tag or None,
is_task=True,
status=status,
priority=priority,
due_before=due_before,
due_after=due_after,
project_id=project_id,
task_kind=kind,
no_project=no_project,
sort=sort,
order=order,
limit=limit,
offset=offset,
)
return jsonify({"tasks": [t.to_dict() for t in tasks], "total": total})
@tasks_bp.route("", methods=["POST"])
@login_required
async def create_task_route():
uid = get_current_user_id()
data = await request.get_json()
# Description (user-stated goal) and body (machine-maintained summary) are
# separate fields under the task-as-durable-record design. Don't fold one
# into the other.
body = data.get("body", "")
description = data.get("description")
tags = data.get("tags", [])
due_date = parse_iso_date(data.get("due_date"), "due_date")
if isinstance(due_date, tuple):
return due_date
try:
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
except ValueError:
return jsonify({"error": f"Invalid status: {data['status']}"}), 400
try:
priority = TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
except ValueError:
return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400
recurrence_rule, recurrence_err = _parse_recurrence_rule(data)
if recurrence_err:
return recurrence_err
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
task = await create_note(
uid,
title=data.get("title", ""),
body=body,
description=description,
status=status,
priority=priority,
due_date=due_date,
tags=tags,
project_id=project_id,
milestone_id=data.get("milestone_id"),
parent_id=data.get("parent_id"),
recurrence_rule=recurrence_rule if recurrence_rule is not _UNSET else None,
task_kind=data.get("kind", "work"),
)
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
return jsonify(task.to_dict()), 201
@tasks_bp.route("/planning", methods=["POST"])
@login_required
async def start_planning_route():
uid = get_current_user_id()
data = await request.get_json() or {}
project_id = data.get("project_id")
title = (data.get("title") or "").strip()
if not project_id or not title:
return jsonify({"error": "project_id and title are required"}), 400
try:
result = await svc_start_planning(
user_id=uid, project_id=int(project_id), title=title,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify(result), 201
@tasks_bp.route("/<int:task_id>", methods=["GET"])
@login_required
async def get_task_route(task_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task, permission = result
data = task.to_dict()
data["permission"] = permission
if task.parent_id:
parent = await get_note(uid, task.parent_id)
data["parent_title"] = parent.title if parent else None
return jsonify(data)
@tasks_bp.route("/<int:task_id>", methods=["PUT", "PATCH"])
@login_required
async def update_task_route(task_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task_note, _ = result
if not await can_write_note(uid, task_id):
return jsonify({"error": "Permission denied"}), 403
data = await request.get_json()
fields = {}
for key in ("title",):
if key in data:
fields[key] = data[key]
if "status" in data:
try:
fields["status"] = TaskStatus(data["status"]).value
except ValueError:
return jsonify({"error": f"Invalid status: {data['status']}"}), 400
if "priority" in data:
try:
fields["priority"] = TaskPriority(data["priority"]).value
except ValueError:
return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400
# Body and description are distinct fields under the task-as-durable-record
# design. Don't alias one to the other.
if "body" in data:
fields["body"] = data["body"]
if "description" in data:
fields["description"] = data["description"]
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"]
for key in ("project_id", "milestone_id", "parent_id"):
if key in data:
fields[key] = data[key]
recurrence_rule, recurrence_err = _parse_recurrence_rule(data)
if recurrence_err:
return recurrence_err
if recurrence_rule is not _UNSET:
fields["recurrence_rule"] = recurrence_rule
task = await update_note(task_note.user_id, task_id, **fields)
if task is None:
return not_found("Task")
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
asyncio.create_task(upsert_note_embedding(task.id, task_note.user_id, text))
return jsonify(task.to_dict())
@tasks_bp.route("/<int:task_id>/status", methods=["PATCH"])
@login_required
async def patch_task_status(task_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task_note, _ = result
if not await can_write_note(uid, task_id):
return jsonify({"error": "Permission denied"}), 403
data = await request.get_json()
status_val = data.get("status")
if not status_val:
return jsonify({"error": "status is required"}), 400
try:
TaskStatus(status_val)
except ValueError:
return jsonify({"error": f"Invalid status: {status_val}"}), 400
task = await update_note(task_note.user_id, task_id, status=status_val)
if task is None:
return not_found("Task")
return jsonify(task.to_dict())
@tasks_bp.route("/<int:task_id>/recurrence-preview", methods=["GET"])
@login_required
async def recurrence_preview_route(task_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task, _ = result
if not task.recurrence_rule:
return jsonify({"error": "Task has no recurrence rule"}), 400
count = min(request.args.get("count", 5, type=int), 10)
base = task.due_date or date.today()
dates = []
current = base
for _ in range(count):
current = calculate_next_due(task.recurrence_rule, current)
dates.append(current.isoformat())
return jsonify({"dates": dates})
@tasks_bp.route("/<int:task_id>", methods=["DELETE"])
@login_required
async def delete_task_route(task_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task_note, _ = result
if not await can_write_note(uid, task_id):
return jsonify({"error": "Permission denied"}), 403
from scribe.services.trash import delete as trash_delete
batch = await trash_delete(task_note.user_id, "task", task_id)
if batch is None:
return not_found("Task")
return "", 204
+33
View File
@@ -0,0 +1,33 @@
"""Trash REST API — list / restore / purge soft-deleted content by batch."""
from __future__ import annotations
from quart import Blueprint, g, jsonify
from scribe.auth import login_required
import scribe.services.trash as trash_svc
trash_bp = Blueprint("trash", __name__, url_prefix="/api/trash")
def _uid() -> int:
return g.user.id
@trash_bp.get("")
@login_required
async def list_trash():
return jsonify({"batches": await trash_svc.list_trash(_uid())})
@trash_bp.post("/<batch_id>/restore")
@login_required
async def restore_batch(batch_id: str):
n = await trash_svc.restore(_uid(), batch_id)
return jsonify({"restored": n})
@trash_bp.delete("/<batch_id>")
@login_required
async def purge_batch(batch_id: str):
n = await trash_svc.purge(_uid(), batch_id)
return jsonify({"purged": n})
+29
View File
@@ -0,0 +1,29 @@
from quart import Blueprint, jsonify, request
from sqlalchemy import or_, select
from scribe.auth import get_current_user_id, login_required
from scribe.models import async_session
from scribe.models.user import User
users_bp = Blueprint("users", __name__, url_prefix="/api/users")
@users_bp.route("/search", methods=["GET"])
@login_required
async def search_users():
uid = get_current_user_id()
q = (request.args.get("q") or "").strip()
if len(q) < 2:
return jsonify({"users": []})
like = f"{q}%"
async with async_session() as session:
users = (await session.execute(
select(User).where(
User.id != uid,
or_(User.username.ilike(like), User.email.ilike(like)),
).limit(10)
)).scalars().all()
return jsonify({"users": [
{"id": u.id, "username": u.username}
for u in users
]})
+24
View File
@@ -0,0 +1,24 @@
from datetime import date
from quart import jsonify, request
def not_found(resource: str = "Item"):
return jsonify({"error": f"{resource} not found"}), 404
def parse_iso_date(value: str | None, field: str = "date"):
"""Parse an ISO date string. Returns a date, None, or a (response, 400) tuple."""
if not value:
return None
try:
return date.fromisoformat(value)
except ValueError:
return jsonify({"error": f"Invalid {field} format. Use YYYY-MM-DD."}), 400
def parse_pagination(default_limit: int = 50, max_limit: int = 500) -> tuple[int, int]:
"""Extract and clamp ``limit`` / ``offset`` from the current request's query string."""
limit = min(request.args.get("limit", default_limit, type=int), max_limit)
offset = request.args.get("offset", 0, type=int)
return limit, offset