Standalone Shonar Desktop: vendor portable sources + local engine; decouple from ~/Projects/Shonar
- shared/ = portable Android-origin sources vendored from deferred/desktop-server (app/build.gradle.kts srcDir repointed; PlaybackController.kt excluded as Android-only) - backend/ = bundled-lite engine (SQLite + inline queue); .venv symlinked from the old checkout, PYTHONPATH pins THIS backend's code over any editable install - repoRoot() resolves this project dir (env SHONAR_REPO still wins); desktop-dev.sh watches shared/ + backend/ - Verified: :app:compileKotlin + :app:test green (23 tests); engine boots on :8010, self-migrates, /healthz ok
This commit is contained in:
commit
76c867fca4
136 changed files with 21099 additions and 0 deletions
81
backend/shonar/api/v1/search_exports.py
Normal file
81
backend/shonar/api/v1/search_exports.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""Search + exports endpoints (M9).
|
||||
|
||||
Ownership is enforced everywhere: a search only ever sees the caller's own
|
||||
non-deleted recordings, and an export 404s on anything the caller doesn't
|
||||
own (no existence leak).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
|
||||
from shonar.api.deps import CurrentUser, SessionDep
|
||||
from shonar.api.schemas_search import SearchHitOut, SearchOut
|
||||
from shonar.db.models import Recording
|
||||
from shonar.services import exports as ex
|
||||
from shonar.services import search as sr
|
||||
|
||||
router = APIRouter(tags=["search", "exports"])
|
||||
|
||||
|
||||
# --- search -------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/search", response_model=SearchOut)
|
||||
async def search(
|
||||
user: CurrentUser,
|
||||
session: SessionDep,
|
||||
q: str = Query(..., min_length=1, max_length=200, description="Search text"),
|
||||
scope: str = Query(
|
||||
default="all",
|
||||
pattern="^(all|title|notes|transcript|summary|tag)$",
|
||||
description="Restrict the search to one field (default: all)",
|
||||
),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
):
|
||||
hits, total = await sr.search_recordings(
|
||||
session, user.id, q, scope=scope, limit=limit, offset=offset
|
||||
)
|
||||
items = [
|
||||
SearchHitOut(
|
||||
id=h.recording.id,
|
||||
title=h.recording.title,
|
||||
field=h.field,
|
||||
snippet=h.snippet,
|
||||
)
|
||||
for h in hits
|
||||
]
|
||||
return SearchOut(query=q, scope=scope, items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
# --- exports ------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/recordings/{recording_id}/export")
|
||||
async def export_recording(
|
||||
recording_id: uuid.UUID,
|
||||
user: CurrentUser,
|
||||
session: SessionDep,
|
||||
fmt: str = Query(default="zip", pattern="^(audio|txt|md|zip)$"),
|
||||
):
|
||||
"""Return one export artifact inline (audio / txt / md / zip bundle)."""
|
||||
rec = await session.get(Recording, recording_id)
|
||||
if rec is None or rec.user_id != user.id or rec.deleted_at is not None:
|
||||
raise HTTPException(404, "Recording not found")
|
||||
try:
|
||||
result = await ex.build_export(session, rec, fmt)
|
||||
except ex.ExportError as e:
|
||||
raise HTTPException(e.status_code, e.message) from None
|
||||
await ex.record_export(session, user.id, rec, fmt, result)
|
||||
return Response(
|
||||
content=result.data,
|
||||
media_type=result.mime_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{result.filename}"',
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue