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', 'network-state', 'storage-status', ].map((id) => [id, document.getElementById(id)])); let databasePromise; let mediaRecorder; let activeStream; let recordingChunks = []; let recordingStartedAt = 0; let recordingTimer; let syncing = false; 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' }); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); return databasePromise; } async function queueTransaction(mode, operation) { const db = await openDatabase(); return new Promise((resolve, reject) => { const transaction = db.transaction(STORE_NAME, mode); const store = transaction.objectStore(STORE_NAME); const request = operation(store); transaction.oncomplete = () => resolve(request?.result); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error); }); } 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)); function setMessage(message, isError = false) { elements['capture-message'].textContent = message; elements['capture-message'].style.color = isError ? 'var(--coral)' : ''; } function updateNetwork() { const online = navigator.onLine; elements['network-state'].classList.toggle('online', online); elements['network-state'].innerHTML = `${online ? 'Connected' : 'Offline'}`; elements['storage-status'].textContent = online ? 'Pending audio stays here until server confirmation.' : 'Offline: new audio is saved on this device.'; } function formatDuration(milliseconds) { const seconds = Math.floor(milliseconds / 1000); return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`; } function preferredMimeType() { return ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus'] .find((type) => window.MediaRecorder?.isTypeSupported(type)); } async function startRecording() { if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) { setMessage('This browser cannot record audio. Open the app in current iOS Safari.', true); return; } try { activeStream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mimeType = preferredMimeType(); mediaRecorder = new MediaRecorder(activeStream, mimeType ? { mimeType } : undefined); recordingChunks = []; mediaRecorder.ondataavailable = (event) => { if (event.data.size) recordingChunks.push(event.data); }; mediaRecorder.onstop = saveRecording; mediaRecorder.start(1000); recordingStartedAt = Date.now(); elements['record-button'].classList.add('recording'); elements['record-button'].setAttribute('aria-label', 'Stop recording'); elements['record-title'].textContent = 'Recording in progress'; elements['record-help'].textContent = 'Tap the square to finish and save'; setMessage('Audio is being recorded on this device.'); recordingTimer = window.setInterval(() => { const elapsed = Date.now() - recordingStartedAt; elements['record-time'].textContent = formatDuration(elapsed); elements['record-meter-fill'].style.width = `${Math.min(100, elapsed / MAX_RECORDING_MS * 100)}%`; if (elapsed >= MAX_RECORDING_MS) stopRecording(); }, 250); } catch (error) { setMessage(error.name === 'NotAllowedError' ? 'Microphone access was denied.' : 'Could not start microphone recording.', true); } } function stopRecording() { if (mediaRecorder?.state === 'recording') mediaRecorder.stop(); activeStream?.getTracks().forEach((track) => track.stop()); activeStream = undefined; clearInterval(recordingTimer); elements['record-button'].classList.remove('recording'); elements['record-button'].setAttribute('aria-label', 'Start recording'); elements['record-title'].textContent = 'Recording saved'; elements['record-help'].textContent = 'Adding it to the upload queue'; elements['record-time'].textContent = '00:00'; elements['record-meter-fill'].style.width = '0'; } async function saveRecording() { const blob = new Blob(recordingChunks, { type: mediaRecorder.mimeType || 'audio/mp4' }); if (!blob.size) { setMessage('No audio was captured. Please try again.', true); return; } const extension = blob.type.includes('webm') ? 'webm' : blob.type.includes('ogg') ? 'ogg' : 'm4a'; const createdAt = new Date(recordingStartedAt).toISOString(); const recording = { id: crypto.randomUUID(), blob, createdAt, filename: `voice-note-${createdAt.replaceAll(':', '-')}.${extension}`, durationMs: Date.now() - recordingStartedAt, createdAtMs: recordingStartedAt, }; try { await putQueued(recording); setMessage(navigator.onLine ? 'Saved on device. Uploading now…' : 'Saved on device. It will upload when you reconnect.'); await renderQueue(); await syncQueue(); } catch { setMessage('Could not save this recording in browser storage. Keep this page open and try again.', true); } } 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)) { const form = new FormData(); form.append('recording_id', recording.id); form.append('audio', recording.blob, recording.filename); form.append('created_at', recording.createdAt); try { const response = await fetch('/api/notes', { method: 'POST', body: form }); if (!response.ok) throw new Error(`Upload failed: ${response.status}`); await deleteQueued(recording.id); setMessage('Server confirmed the recording is stored.'); } catch { setMessage('Upload paused. Your recording is still saved on this device.'); break; } } } finally { syncing = false; elements['sync-button'].disabled = false; await renderQueue(); await loadAwaitingNotes(); } } function prettyDate(value) { return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)); } async function renderQueue() { const recordings = (await getQueued()).sort((a, b) => b.createdAtMs - a.createdAtMs); elements['queue-list'].replaceChildren(); elements['queue-count'].textContent = recordings.length; elements['queue-empty'].hidden = recordings.length > 0; for (const recording of recordings) { const item = document.createElement('li'); item.className = 'queue-item'; const icon = document.createElement('span'); icon.className = 'queue-icon'; icon.textContent = '◖'; const body = document.createElement('div'); body.className = 'item-body'; const top = document.createElement('div'); top.className = 'item-top'; const name = document.createElement('span'); name.className = 'item-name'; name.textContent = recording.filename; const status = document.createElement('span'); status.className = 'item-status'; status.textContent = navigator.onLine ? 'WAITING TO SYNC' : 'SAVED OFFLINE'; 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, 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 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'; const date = document.createElement('time'); date.className = 'archive-date'; date.textContent = prettyDate(note.created_at); const body = document.createElement('div'); body.className = 'item-body'; const name = document.createElement('div'); name.className = 'item-name'; name.textContent = note.filename; 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.value = note.transcript; transcript.setAttribute('aria-label', `Transcript for ${note.filename}`); const save = document.createElement('button'); save.className = 'save-transcript'; save.type = 'button'; save.textContent = 'SAVE TRANSCRIPT'; save.addEventListener('click', async () => { save.disabled = true; try { const response = await fetch(`/api/notes/${encodeURIComponent(note.id)}/transcript`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript: transcript.value }), }); if (!response.ok) throw new Error('Save failed'); await loadAwaitingNotes(); } catch { save.textContent = 'RETRY SAVE'; } finally { save.disabled = false; } }); const status = document.createElement('div'); status.className = 'archive-status'; 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, remove); elements['archive-list'].append(item); } } async function loadAwaitingNotes() { if (!navigator.onLine) { renderArchive([]); return; } try { const response = await fetch('/api/notes?status=awaiting_transcription'); if (!response.ok) throw new Error('Archive unavailable'); renderArchive(await response.json()); } catch { elements['archive-list'].replaceChildren(); elements['archive-empty'].hidden = false; elements['archive-empty'].textContent = 'Could not reach the archive. Pending recordings remain on this device.'; } } elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording()); elements['sync-button'].addEventListener('click', syncQueue); window.addEventListener('online', () => { updateNetwork(); syncQueue(); loadAwaitingNotes(); }); window.addEventListener('offline', updateNetwork); window.addEventListener('focus', () => { updateNetwork(); syncQueue(); loadAwaitingNotes(); }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') syncQueue(); }); updateNetwork(); renderQueue().then(syncQueue).catch(() => setMessage('Browser storage is unavailable. Check Safari storage settings.', true)); loadAwaitingNotes(); if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});