diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e4ff300..d6b6e2f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -19,6 +19,7 @@ Faerro KB is a self-hosted voice-note inbox designed for an iPhone-installed web - Preserve the local-first capture and retry behavior when changing the PWA, API, or service worker. Prefer additive, recoverable data changes. - Follow the existing small stack: FastAPI, SQLite/FTS5, and the vanilla JavaScript PWA. Avoid new dependencies unless they solve a real need and can run on the user's infrastructure. - Keep changes scoped. Inspect the owning code path, add or update focused tests for behavior changes, and run the narrowest relevant checks available. +- If tooling required for a relevant test or check is missing, tell the user which tool is needed and prompt them to install it on the local machine; do not silently skip the check. - Update README/UI documentation when behavior, deployment, security, backup, or platform limitations change. Do not claim a capability that iOS or browsers cannot guarantee. - Preserve user data and existing local changes. Do not perform destructive migrations or remove stored recordings without explicit confirmation and a recovery path. - At the end of each chat, check whether it introduced durable project requirements, decisions, workflows, or corrections missing from these instructions or a relevant skill. Update the narrowest relevant guidance file when the change is clearly reusable and within the user's authorized scope; otherwise note the gap and ask before expanding scope. Do not record transient task details or sensitive information. diff --git a/.github/skills/faerro-kb-development/SKILL.md b/.github/skills/faerro-kb-development/SKILL.md index 2659650..34243d8 100644 --- a/.github/skills/faerro-kb-development/SKILL.md +++ b/.github/skills/faerro-kb-development/SKILL.md @@ -26,5 +26,6 @@ Use this skill for feature work, bug fixes, and reviews in this repository. The - Add or update focused tests for changed behavior, especially queue acknowledgement, retries, and data-preserving transcript workflows. - Run the narrowest relevant test, compile, or lint check available, then document any important limitation or unverified check. +- If tooling required for a relevant test or check is missing, tell the user which tool is needed and prompt them to install it locally instead of silently skipping the check. - Update `README.md` when setup, deployment, backups, privacy, or platform behavior changes. - Never claim that iOS guarantees background execution or that browser storage is permanent. \ No newline at end of file diff --git a/README.md b/README.md index be31a63..e56e942 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ In Safari on iPhone, open the HTTPS address and use **Share > Add to Home Screen ## Capture and offline behavior - Recording data and its timestamp are committed to IndexedDB before upload is attempted. +- Written-note drafts and queued text notes are stored in IndexedDB on the device. A draft is restored when the app is reopened, and queued text is removed only after the server confirms storage. - Microphone recordings use the audio format supported by the browser (commonly M4A/AAC on iPhone Safari or Opus/WebM elsewhere). You can also add an audio file from the device; both sources remain queued locally until the server confirms storage. - The server keeps each uploaded original and creates a mono PCM WAV copy at 16-bit / 22.05 kHz for transcription. FFmpeg performs this conversion locally in the container. After a non-empty transcript is stored, both audio files are deleted; failed or empty transcriptions keep the audio for recovery. - Uploads retry when the app opens, returns to the foreground, or the browser reports a connection. A recording is removed from the phone's queue only after the server confirms it was stored. diff --git a/app/main.py b/app/main.py index 689ea67..696c16c 100644 --- a/app/main.py +++ b/app/main.py @@ -199,6 +199,36 @@ async def create_note( return {"id": note_id, "status": status} +@app.post("/api/text-notes", status_code=201) +def create_text_note(payload: dict[str, str]) -> dict[str, str]: + text = payload.get("text", "").strip() + if not text: + raise HTTPException(status_code=400, detail="Note text is required") + try: + note_id = str(uuid.UUID(payload.get("id", ""))) + except ValueError as error: + raise HTTPException(status_code=400, detail="Invalid note ID") from error + try: + parsed_date = datetime.fromisoformat(payload.get("created_at", "").replace("Z", "+00:00")) + if parsed_date.tzinfo is None: + parsed_date = parsed_date.replace(tzinfo=timezone.utc) + created_at = parsed_date.astimezone(timezone.utc).isoformat() + except ValueError: + created_at = datetime.now(timezone.utc).isoformat() + + with connect_db() as connection: + existing = connection.execute("SELECT status FROM notes WHERE id = ?", (note_id,)).fetchone() + if existing: + return {"id": note_id, "status": existing["status"]} + connection.execute( + """INSERT INTO notes (id, created_at, filename, audio_path, transcript, status) + VALUES (?, ?, ?, '', ?, 'written')""", + (note_id, created_at, "Written note", text), + ) + connection.execute("INSERT INTO note_search (note_id, transcript) VALUES (?, ?)", (note_id, text)) + return {"id": note_id, "status": "written"} + + @app.get("/api/notes") def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]: with connect_db() as connection: @@ -255,13 +285,14 @@ def delete_note(note_id: str) -> dict[str, str]: def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]: transcript = payload.get("transcript", "").strip() with connect_db() as connection: - exists = connection.execute("SELECT 1 FROM notes WHERE id = ?", (note_id,)).fetchone() - if not exists: + note = connection.execute("SELECT status FROM notes WHERE id = ?", (note_id,)).fetchone() + if not note: raise HTTPException(status_code=404, detail="Note not found") - update_transcript(note_id, transcript, "transcribed" if transcript else "awaiting_transcription") - if transcript: + status = "written" if note["status"] == "written" else "transcribed" if transcript else "awaiting_transcription" + update_transcript(note_id, transcript, status) + if transcript and note["status"] != "written": remove_transcribed_audio(note_id) - return {"id": note_id, "status": "transcribed" if transcript else "awaiting_transcription"} + return {"id": note_id, "status": status} app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend") \ No newline at end of file diff --git a/frontend/app.js b/frontend/app.js index 4ceb551..d63a38a 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,10 +1,12 @@ const DB_NAME = 'faerro-kb-local'; const STORE_NAME = 'recordings'; +const TEXT_STORE_NAME = 'text-notes'; +const DRAFT_ID = 'current-draft'; const MAX_RECORDING_MS = 30 * 60 * 1000; const elements = Object.fromEntries([ 'record-button', 'record-title', 'record-help', 'record-time', 'record-meter-fill', 'audio-level', 'audio-level-fill', 'capture-message', 'audio-file-input', 'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty', - 'network-state', 'storage-status', + 'network-state', 'storage-status', 'voice-mode', 'write-mode', 'voice-panel', 'write-panel', 'written-note', 'draft-status', 'save-written-note', ].map((id) => [id, document.getElementById(id)])); let databasePromise; @@ -16,12 +18,17 @@ let recordingChunks = []; let recordingStartedAt = 0; let recordingTimer; let syncing = false; +let draftSaveTimer; function openDatabase() { if (databasePromise) return databasePromise; databasePromise = new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, 1); - request.onupgradeneeded = () => request.result.createObjectStore(STORE_NAME, { keyPath: 'id' }); + const request = indexedDB.open(DB_NAME, 2); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME, { keyPath: 'id' }); + if (!db.objectStoreNames.contains(TEXT_STORE_NAME)) db.createObjectStore(TEXT_STORE_NAME, { keyPath: 'id' }); + }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); @@ -44,6 +51,92 @@ const getQueued = () => queueTransaction('readonly', (store) => store.getAll()); const putQueued = (recording) => queueTransaction('readwrite', (store) => store.put(recording)); const deleteQueued = (id) => queueTransaction('readwrite', (store) => store.delete(id)); +async function textTransaction(mode, operation) { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(TEXT_STORE_NAME, mode); + const request = operation(transaction.objectStore(TEXT_STORE_NAME)); + transaction.oncomplete = () => resolve(request?.result); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); +} + +const getTextRecords = () => textTransaction('readonly', (store) => store.getAll()); +const putTextRecord = (record) => textTransaction('readwrite', (store) => store.put(record)); +const deleteTextRecord = (id) => textTransaction('readwrite', (store) => store.delete(id)); + +function setCaptureMode(mode) { + const writing = mode === 'write'; + elements['voice-mode'].classList.toggle('active', !writing); + elements['write-mode'].classList.toggle('active', writing); + elements['voice-mode'].setAttribute('aria-selected', String(!writing)); + elements['write-mode'].setAttribute('aria-selected', String(writing)); + elements['voice-panel'].hidden = writing; + elements['write-panel'].hidden = !writing; +} + +async function saveDraft() { + const text = elements['written-note'].value; + try { + await putTextRecord({ id: DRAFT_ID, text, updatedAt: Date.now() }); + elements['draft-status'].textContent = 'Draft saved on this device.'; + elements['save-written-note'].disabled = !text.trim(); + } catch { + elements['draft-status'].textContent = 'Could not save draft in browser storage. Keep this page open.'; + elements['save-written-note'].disabled = !text.trim(); + } +} + +function scheduleDraftSave() { + elements['save-written-note'].disabled = !elements['written-note'].value.trim(); + elements['draft-status'].textContent = 'Saving draft…'; + clearTimeout(draftSaveTimer); + draftSaveTimer = window.setTimeout(saveDraft, 250); +} + +async function queueWrittenNote() { + const text = elements['written-note'].value.trim(); + if (!text || elements['save-written-note'].disabled) return; + clearTimeout(draftSaveTimer); + const createdAt = new Date().toISOString(); + const note = { id: crypto.randomUUID(), text, createdAt, createdAtMs: Date.now(), filename: 'Written note' }; + try { + const db = await openDatabase(); + await new Promise((resolve, reject) => { + const transaction = db.transaction(TEXT_STORE_NAME, 'readwrite'); + const store = transaction.objectStore(TEXT_STORE_NAME); + store.put(note); + store.delete(DRAFT_ID); + transaction.oncomplete = resolve; + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); + elements['written-note'].value = ''; + elements['draft-status'].textContent = navigator.onLine ? 'Saved on this device. Sending to the server…' : 'Saved on this device. It will send when you reconnect.'; + elements['save-written-note'].disabled = true; + await renderQueue(); + await syncQueue(); + } catch { + elements['draft-status'].textContent = 'Could not queue this note. Your draft is still here; try again.'; + } +} + +async function restoreDraft() { + try { + const records = await getTextRecords(); + const draft = records.find((record) => record.id === DRAFT_ID); + if (draft?.text) { + elements['written-note'].value = draft.text; + elements['draft-status'].textContent = 'Draft restored from this device.'; + elements['save-written-note'].disabled = false; + setCaptureMode('write'); + } + } catch { + elements['draft-status'].textContent = 'Browser storage is unavailable. This draft may not survive leaving the page.'; + } +} + function setMessage(message, isError = false, isSuccess = false) { elements['capture-message'].textContent = message; elements['capture-message'].style.color = isError ? 'var(--coral)' : ''; @@ -237,6 +330,22 @@ async function syncQueue() { break; } } + const textNotes = (await getTextRecords()).filter((record) => record.id !== DRAFT_ID).sort((a, b) => a.createdAtMs - b.createdAtMs); + for (const note of textNotes) { + try { + const response = await fetch('/api/text-notes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: note.id, text: note.text, created_at: note.createdAt }), + }); + if (!response.ok) throw new Error(`Save failed: ${response.status}`); + await deleteTextRecord(note.id); + elements['draft-status'].textContent = 'Note saved on the server.'; + } catch { + elements['draft-status'].textContent = 'Upload paused. Your note is still saved on this device.'; + break; + } + } } finally { syncing = false; elements['sync-button'].disabled = false; @@ -250,7 +359,9 @@ function prettyDate(value) { } async function renderQueue() { - const recordings = (await getQueued()).sort((a, b) => b.createdAtMs - a.createdAtMs); + const audioRecords = await getQueued(); + const textRecords = (await getTextRecords()).filter((record) => record.id !== DRAFT_ID); + const recordings = [...audioRecords, ...textRecords].sort((a, b) => b.createdAtMs - a.createdAtMs); elements['queue-list'].replaceChildren(); elements['queue-count'].textContent = recordings.length; elements['queue-empty'].hidden = recordings.length > 0; @@ -259,7 +370,8 @@ async function renderQueue() { item.className = 'queue-item'; const icon = document.createElement('span'); icon.className = 'queue-icon'; - icon.textContent = '◖'; + const isTextNote = 'text' in recording; + icon.textContent = isTextNote ? 'T' : '◖'; const body = document.createElement('div'); body.className = 'item-body'; const top = document.createElement('div'); @@ -279,7 +391,8 @@ async function renderQueue() { if (syncing) return; remove.disabled = true; try { - await deleteQueued(recording.id); + if (isTextNote) await deleteTextRecord(recording.id); + else await deleteQueued(recording.id); await renderQueue(); } catch { setMessage('Could not remove the queued recording.', true); @@ -306,8 +419,8 @@ function createDeleteButton(label) { function renderArchive(notes) { elements['archive-list'].replaceChildren(); elements['archive-empty'].hidden = notes.length > 0; - if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to view notes awaiting transcription.'; - else elements['archive-empty'].textContent = 'No notes awaiting transcription.'; + if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to view saved notes.'; + else elements['archive-empty'].textContent = 'No saved notes yet.'; for (const note of notes) { const item = document.createElement('article'); item.className = 'archive-item'; @@ -319,15 +432,16 @@ function renderArchive(notes) { const name = document.createElement('div'); name.className = 'item-name'; name.textContent = note.filename; + const isWrittenNote = note.status === 'written'; const transcript = document.createElement('textarea'); transcript.className = 'archive-transcript'; - transcript.placeholder = note.status === 'transcribing' ? 'Transcription in progress…' : 'Transcript not available yet. Add or correct it here.'; + transcript.placeholder = isWrittenNote ? 'Write or edit your note…' : note.status === 'transcribing' ? 'Transcription in progress…' : 'Transcript not available yet. Add or correct it here.'; transcript.value = note.transcript; - transcript.setAttribute('aria-label', `Transcript for ${note.filename}`); + transcript.setAttribute('aria-label', `${isWrittenNote ? 'Note' : 'Transcript'} for ${note.filename}`); const save = document.createElement('button'); save.className = 'save-transcript'; save.type = 'button'; - save.textContent = 'SAVE TRANSCRIPT'; + save.textContent = isWrittenNote ? 'SAVE NOTE' : 'SAVE TRANSCRIPT'; save.addEventListener('click', async () => { save.disabled = true; try { @@ -344,7 +458,7 @@ function renderArchive(notes) { }); const status = document.createElement('div'); status.className = 'archive-status'; - status.textContent = 'AWAITING TRANSCRIPTION'; + status.textContent = isWrittenNote ? 'WRITTEN NOTE' : note.status === 'transcribing' ? 'TRANSCRIBING' : note.status === 'transcribed' ? 'TRANSCRIPT SAVED' : 'AWAITING TRANSCRIPTION'; const remove = createDeleteButton(`Delete note ${note.filename}`); remove.addEventListener('click', async () => { remove.disabled = true; @@ -369,9 +483,14 @@ async function loadAwaitingNotes() { return; } try { - const response = await fetch('/api/notes?status=awaiting_transcription'); - if (!response.ok) throw new Error('Archive unavailable'); - renderArchive(await response.json()); + const responses = await Promise.all([ + fetch('/api/notes?status=awaiting_transcription'), + fetch('/api/notes?status=written'), + ]); + if (responses.some((response) => !response.ok)) throw new Error('Archive unavailable'); + const notes = (await Promise.all(responses.map((response) => response.json()))).flat(); + notes.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + renderArchive(notes); } catch { elements['archive-list'].replaceChildren(); elements['archive-empty'].hidden = false; @@ -380,6 +499,14 @@ async function loadAwaitingNotes() { } elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording()); +elements['voice-mode'].addEventListener('click', () => setCaptureMode('voice')); +elements['write-mode'].addEventListener('click', () => setCaptureMode('write')); +elements['written-note'].addEventListener('input', scheduleDraftSave); +elements['save-written-note'].addEventListener('click', queueWrittenNote); +window.addEventListener('pagehide', () => { + clearTimeout(draftSaveTimer); + saveDraft(); +}); elements['audio-file-input'].addEventListener('change', async (event) => { for (const file of event.target.files) await queueAudioFile(file); event.target.value = ''; @@ -393,5 +520,6 @@ document.addEventListener('visibilitychange', () => { if (document.visibilitySta updateNetwork(); renderQueue().then(syncQueue).catch(() => setMessage('Browser storage is unavailable. Check Safari storage settings.', true)); +restoreDraft(); 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 3c31707..11ec3b1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,6 +19,11 @@
+
+ + +
+
+
@@ -52,7 +65,7 @@
-
SERVER STORED

Awaiting transcription

+
SERVER STORED

Saved notes

Notes awaiting transcription will appear here.

diff --git a/frontend/styles.css b/frontend/styles.css index 3098d4f..c88ab33 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -24,6 +24,17 @@ body { margin: 0; background: var(--paper); color: var(--ink); font-family: var( .network-state.online i { background: #57986b; } main { max-width: 1040px; margin: auto; padding: 0 24px; } .capture { padding: 54px 0 40px; border-bottom: 1px solid var(--line); background-image: radial-gradient(#c7c9bf 0.55px, transparent 0.55px); background-size: 9px 9px; background-position: right 22px top 28px; background-repeat: no-repeat; } +.capture-modes { display: flex; width: fit-content; margin: 0 auto 24px; border-bottom: 1px solid var(--line); } +.capture-mode { min-width: 108px; min-height: 40px; border: 0; border-bottom: 2px solid transparent; padding: 0 16px; color: var(--muted); background: transparent; font: 12px var(--mono); cursor: pointer; } +.capture-mode.active { border-bottom-color: var(--moss); color: var(--moss-dark); } +.write-panel { max-width: 590px; margin: auto; } +.write-label { display: block; margin: 0 0 10px; font: 500 20px var(--serif); } +.written-note { display: block; width: 100%; min-height: 190px; resize: vertical; border: 1px solid var(--line); padding: 14px; color: var(--ink); background: #fffefa; font: 14px/1.6 var(--sans); } +.written-note:focus { outline: 2px solid var(--moss); outline-offset: 2px; } +.write-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 12px; } +.draft-status { color: var(--muted); font-size: 11px; } +.save-written-note { min-height: 40px; flex: 0 0 auto; border: 1px solid var(--moss-dark); padding: 0 16px; color: white; background: var(--moss-dark); font: 12px var(--mono); cursor: pointer; } +.save-written-note:disabled { opacity: .45; cursor: default; } .section-kicker { display: flex; justify-content: space-between; color: var(--muted); font: 10px var(--mono); letter-spacing: 0; } h1 { margin: 24px 0 11px; font: 500 clamp(42px, 7vw, 72px)/0.99 var(--serif); letter-spacing: 0; } h1 em { color: var(--moss); font-weight: 500; } @@ -44,7 +55,7 @@ h1 em { color: var(--moss); font-weight: 500; } .record-meter { height: 2px; max-width: 590px; margin-inline: auto; background: #e4e3dc; } .record-meter span { display: block; height: 100%; width: 0; background: var(--coral); transition: width .25s linear; } .capture-message { min-height: 20px; max-width: 590px; margin: 8px auto 0; padding-top: 6px; color: var(--muted); font-size: 11px; text-align: center; } -.capture-message.upload-confirmed { display: inline-flex; align-items: center; justify-content: center; gap: 8px; color: forestgreen; font-size: 16px; } +.capture-message.upload-confirmed { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; color: forestgreen; font-size: 16px; } .capture-message.upload-confirmed::after { content: '✓'; color: forestgreen; font-size: 1.05em; line-height: 1; } .audio-upload-row { max-width: 590px; margin: 14px auto 6px; display: flex; align-items: center; justify-content: center; gap: 12px; } .audio-upload-button { display: inline-flex; min-height: 36px; align-items: center; gap: 7px; border: 1px solid var(--line); padding: 0 12px; color: var(--moss-dark); background: #fffefa; font-size: 12px; cursor: pointer; } @@ -82,12 +93,15 @@ footer { max-width: 1040px; margin: auto; padding: 18px 24px calc(18px + env(saf .topbar { height: 58px; padding: 0 18px; } main { padding: 0 18px; } .capture { padding: 38px 0 30px; background-position: right 8px top 25px; } + .capture-mode { min-width: 100px; } + .write-actions { align-items: flex-start; } h1 { font-size: 48px; } .recorder-row { gap: 12px; padding-right: 12px; } .record-button { width: 46px; height: 46px; } .record-copy strong { font-size: 12px; } .record-copy span { font-size: 10px; } - .audio-upload-row { align-items: flex-start; flex-direction: column; } + .audio-upload-row { align-items: center; flex-direction: column; } + .audio-upload-row > span { text-align: center; } h2 { font-size: 23px; } .archive-date { width: 60px; font-size: 9px; } footer { padding-left: 18px; padding-right: 18px; font-size: 8px; } diff --git a/frontend/sw.js b/frontend/sw.js index cbe4709..1b5dd41 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'faerro-kb-shell-v2'; +const CACHE_NAME = 'faerro-kb-shell-v3'; const APP_SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/favicon.svg']; self.addEventListener('install', (event) => {