Catch the thought
before it goes.
- Private voice notes, kept on your own server.
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 889e8e3..45087f4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,4 +1,4 @@ -# Voice KB project notes +# Faerro KB project notes - Keep audio, transcripts, and indexes on infrastructure controlled by the user. - Never add a cloud speech, LLM, or geocoding dependency. diff --git a/Dockerfile b/Dockerfile index 4c3881e..d7e9a80 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.12-slim -WORKDIR /srv/voice-kb +WORKDIR /srv/faerro-kb COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app ./app diff --git a/README.md b/README.md index 7c61224..8fa074b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Voice KB +# Faerro KB A self-hosted voice-note inbox for an iPhone-installed web app. Recordings are saved in the browser first, uploaded when connectivity is available, and stored with transcripts on your own server. diff --git a/app/main.py b/app/main.py index c187fae..da355f2 100644 --- a/app/main.py +++ b/app/main.py @@ -12,14 +12,14 @@ from fastapi.staticfiles import StaticFiles DATA_DIR = Path(os.environ.get("DATA_DIR", "./data")) AUDIO_DIR = DATA_DIR / "audio" -DB_PATH = DATA_DIR / "voice-kb.sqlite3" +DB_PATH = DATA_DIR / "faerro-kb.sqlite3" WHISPER_SERVER_URL = os.environ.get("WHISPER_SERVER_URL", "").strip() FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" DATA_DIR.mkdir(parents=True, exist_ok=True) AUDIO_DIR.mkdir(parents=True, exist_ok=True) -app = FastAPI(title="Voice KB") +app = FastAPI(title="Faerro KB") def connect_db() -> sqlite3.Connection: @@ -51,12 +51,12 @@ initialize_db() def update_transcript(note_id: str, transcript: str, status: str) -> None: with connect_db() as connection: - connection.execute( + result = connection.execute( "UPDATE notes SET transcript = ?, status = ? WHERE id = ?", (transcript, status, note_id), ) connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,)) - if transcript.strip(): + if result.rowcount and transcript.strip(): connection.execute( "INSERT INTO note_search (note_id, transcript) VALUES (?, ?)", (note_id, transcript.strip()), @@ -135,16 +135,31 @@ async def create_note( @app.get("/api/notes") -def list_notes(q: str = "") -> list[dict[str, str]]: +def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]: with connect_db() as connection: search_terms = re.findall(r"[\w]+", q, flags=re.UNICODE) if search_terms: query = " AND ".join(f'"{term.replace(chr(34), chr(34) * 2)}"' for term in search_terms) + if status: + rows = connection.execute( + """SELECT n.id, n.created_at, n.filename, n.transcript, n.status + FROM note_search s JOIN notes n ON n.id = s.note_id + WHERE note_search MATCH ? AND n.status = ? + ORDER BY n.created_at DESC LIMIT 100""", + (query, status), + ).fetchall() + else: + rows = connection.execute( + """SELECT n.id, n.created_at, n.filename, n.transcript, n.status + FROM note_search s JOIN notes n ON n.id = s.note_id + WHERE note_search MATCH ? ORDER BY n.created_at DESC LIMIT 100""", + (query,), + ).fetchall() + elif status: rows = connection.execute( - """SELECT n.id, n.created_at, n.filename, n.transcript, n.status - FROM note_search s JOIN notes n ON n.id = s.note_id - WHERE note_search MATCH ? ORDER BY n.created_at DESC LIMIT 100""", - (query,), + """SELECT id, created_at, filename, transcript, status + FROM notes WHERE status = ? ORDER BY created_at DESC LIMIT 100""", + (status,), ).fetchall() else: rows = connection.execute( @@ -154,6 +169,20 @@ def list_notes(q: str = "") -> list[dict[str, str]]: return [dict(row) for row in rows] +@app.delete("/api/notes/{note_id}") +def delete_note(note_id: str) -> dict[str, str]: + with connect_db() as connection: + note = connection.execute( + "SELECT audio_path FROM notes WHERE id = ?", (note_id,) + ).fetchone() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,)) + connection.execute("DELETE FROM notes WHERE id = ?", (note_id,)) + Path(note["audio_path"]).unlink(missing_ok=True) + return {"id": note_id, "status": "deleted"} + + @app.put("/api/notes/{note_id}/transcript") def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]: transcript = payload.get("transcript", "").strip() diff --git a/docker-compose.yml b/docker-compose.yml index 17f188d..f6e078a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ services: - voice-kb: + faerro-kb: image: gitea.faerro.it/andrea/faerro-kb:0.0.1 ports: - "${HTTP_PORT:-80}:80" diff --git a/frontend/app.js b/frontend/app.js index 09205ac..79a1935 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,9 +1,9 @@ -const DB_NAME = 'voice-kb-local'; +const DB_NAME = 'faerro-kb-local'; const STORE_NAME = 'recordings'; const MAX_RECORDING_MS = 30 * 60 * 1000; const elements = Object.fromEntries([ 'record-button', 'record-title', 'record-help', 'record-time', 'record-meter-fill', 'capture-message', - 'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty', 'search-input', + 'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty', 'network-state', 'date-label', 'storage-status', ].map((id) => [id, document.getElementById(id)])); @@ -13,7 +13,6 @@ let activeStream; let recordingChunks = []; let recordingStartedAt = 0; let recordingTimer; -let searchTimer; let syncing = false; function openDatabase() { @@ -138,6 +137,7 @@ async function syncQueue() { if (syncing || !navigator.onLine) return; syncing = true; elements['sync-button'].disabled = true; + elements['queue-list'].querySelectorAll('.delete-note').forEach((button) => { button.disabled = true; }); try { const recordings = await getQueued(); for (const recording of recordings.sort((a, b) => a.createdAtMs - b.createdAtMs)) { @@ -159,7 +159,7 @@ async function syncQueue() { syncing = false; elements['sync-button'].disabled = false; await renderQueue(); - await loadArchive(elements['search-input'].value.trim()); + await loadAwaitingNotes(); } } @@ -191,18 +191,41 @@ async function renderQueue() { const meta = document.createElement('div'); meta.className = 'item-meta'; meta.textContent = `${prettyDate(recording.createdAt)} · ${formatDuration(recording.durationMs)}`; + const remove = createDeleteButton(`Delete queued note ${recording.filename}`); + remove.disabled = syncing; + remove.addEventListener('click', async () => { + if (syncing) return; + remove.disabled = true; + try { + await deleteQueued(recording.id); + await renderQueue(); + } catch { + setMessage('Could not remove the queued recording.', true); + remove.disabled = false; + } + }); top.append(name, status); body.append(top, meta); - item.append(icon, body); + item.append(icon, body, remove); elements['queue-list'].append(item); } } +function createDeleteButton(label) { + const button = document.createElement('button'); + button.className = 'delete-note'; + button.type = 'button'; + button.textContent = '×'; + button.title = label; + button.setAttribute('aria-label', label); + return button; +} + function renderArchive(notes) { elements['archive-list'].replaceChildren(); elements['archive-empty'].hidden = notes.length > 0; - if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to browse or search stored notes.'; - else elements['archive-empty'].textContent = elements['search-input'].value.trim() ? 'No matching transcripts.' : 'Your stored notes will appear here.'; + if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to view notes awaiting transcription.'; + else elements['archive-empty'].textContent = 'No notes awaiting transcription.'; for (const note of notes) { const item = document.createElement('article'); item.className = 'archive-item'; @@ -230,8 +253,7 @@ function renderArchive(notes) { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript: transcript.value }), }); if (!response.ok) throw new Error('Save failed'); - save.textContent = 'SAVED'; - await loadArchive(elements['search-input'].value.trim()); + await loadAwaitingNotes(); } catch { save.textContent = 'RETRY SAVE'; } finally { @@ -240,22 +262,32 @@ function renderArchive(notes) { }); const status = document.createElement('div'); status.className = 'archive-status'; - status.textContent = note.status.replaceAll('_', ' ').toUpperCase(); + status.textContent = 'AWAITING TRANSCRIPTION'; + const remove = createDeleteButton(`Delete note ${note.filename}`); + remove.addEventListener('click', async () => { + remove.disabled = true; + try { + const response = await fetch(`/api/notes/${encodeURIComponent(note.id)}`, { method: 'DELETE' }); + if (!response.ok) throw new Error('Delete failed'); + await loadAwaitingNotes(); + } catch { + remove.disabled = false; + setMessage('Could not delete this note. Please try again.', true); + } + }); body.append(name, transcript, save, status); - item.append(date, body); + item.append(date, body, remove); elements['archive-list'].append(item); } } -async function loadArchive(query = '') { +async function loadAwaitingNotes() { if (!navigator.onLine) { renderArchive([]); return; } try { - const url = new URL('/api/notes', window.location.origin); - if (query) url.searchParams.set('q', query); - const response = await fetch(url); + const response = await fetch('/api/notes?status=awaiting_transcription'); if (!response.ok) throw new Error('Archive unavailable'); renderArchive(await response.json()); } catch { @@ -267,17 +299,13 @@ async function loadArchive(query = '') { elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording()); elements['sync-button'].addEventListener('click', syncQueue); -elements['search-input'].addEventListener('input', () => { - clearTimeout(searchTimer); - searchTimer = setTimeout(() => loadArchive(elements['search-input'].value.trim()), 250); -}); -window.addEventListener('online', () => { updateNetwork(); syncQueue(); loadArchive(elements['search-input'].value.trim()); }); +window.addEventListener('online', () => { updateNetwork(); syncQueue(); loadAwaitingNotes(); }); window.addEventListener('offline', updateNetwork); -window.addEventListener('focus', () => { updateNetwork(); syncQueue(); loadArchive(elements['search-input'].value.trim()); }); +window.addEventListener('focus', () => { updateNetwork(); syncQueue(); loadAwaitingNotes(); }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') syncQueue(); }); elements['date-label'].textContent = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' }).format(new Date()).toUpperCase(); updateNetwork(); renderQueue().then(syncQueue).catch(() => setMessage('Browser storage is unavailable. Check Safari storage settings.', true)); -loadArchive(); +loadAwaitingNotes(); if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {}); \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 272c5e4..bc573e6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,21 +6,19 @@ -
Private voice notes, kept on your own server.