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', 'main', 'voice-mode', 'write-mode', 'read-mode', 'voice-panel', 'write-panel', 'written-note', 'draft-status', 'save-written-note', 'queue-section', 'archive-section', 'archive-title', 'settings-button', 'settings-dialog', 'backup-form', 'backup-fields', 'backup-url', 'backup-username', 'backup-password', 'clear-backup-password', 'close-settings', 'test-backup', 'backup-status', 'retry-backup', ].map((id) => [id, document.getElementById(id)])); let databasePromise; let mediaRecorder; let activeStream; let audioContext; let audioLevelFrame; let recordingChunks = []; let recordingStartedAt = 0; let recordingTimer; let syncing = false; let draftSaveTimer; let transcriptionPoll; function openDatabase() { if (databasePromise) return databasePromise; databasePromise = new Promise((resolve, reject) => { 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); }); 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)); 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'; const reading = mode === 'read'; elements['voice-mode'].classList.toggle('active', !writing && !reading); elements['write-mode'].classList.toggle('active', writing); elements['read-mode'].classList.toggle('active', reading); elements['voice-mode'].setAttribute('aria-selected', String(!writing && !reading)); elements['write-mode'].setAttribute('aria-selected', String(writing)); elements['read-mode'].setAttribute('aria-selected', String(reading)); elements['voice-panel'].hidden = writing || reading; elements['write-panel'].hidden = !writing || reading; elements['main'].classList.toggle('reading', reading); elements['archive-title'].textContent = reading ? 'Stored transcriptions' : 'Saved notes'; loadAwaitingNotes(); } 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)' : ''; elements['capture-message'].classList.toggle('upload-confirmed', isSuccess); elements['capture-message'].setAttribute('aria-label', isSuccess ? 'Server confirmed the recording is stored.' : message); } function updateNetwork() { const online = navigator.onLine; elements['network-state'].classList.toggle('online', online); elements['network-state'].innerHTML = `${online ? 'Connected' : 'Offline'}`; if (elements['storage-status']) { elements['storage-status'].textContent = online ? 'Pending audio stays here until server confirmation.' : 'Offline: new audio is saved on this device.'; } } async function refreshBackupStatus() { try { const response = await fetch('/api/backup'); if (!response.ok) throw new Error('Backup status unavailable'); const backup = await response.json(); elements['backup-fields'].disabled = backup.managed_by_environment; const managedNotice = backup.managed_by_environment ? 'Settings are managed by environment variables. ' : ''; if (backup.last_error) { elements['backup-status'].textContent = `${managedNotice}Backup issue: ${backup.last_error}`; } else if (backup.pending && backup.enabled) { elements['backup-status'].textContent = `${managedNotice}A database snapshot is waiting to upload.`; } else if (backup.pending) { elements['backup-status'].textContent = 'Backup is waiting; configure a WebDAV folder to upload it.'; } else if (backup.managed_by_environment) { elements['backup-status'].textContent = `${managedNotice}Latest database snapshot is current.`; } else if (backup.enabled) { elements['backup-status'].textContent = 'WebDAV backup is enabled. Latest database snapshot is current.'; } else { elements['backup-status'].textContent = 'WebDAV backup is not configured.'; } elements['retry-backup'].hidden = !backup.enabled || !backup.pending; if (!backup.managed_by_environment) { elements['backup-url'].value = backup.url; elements['backup-username'].value = backup.username; } return backup; } catch { elements['backup-status'].textContent = 'Could not load backup status.'; return null; } } async function saveBackupConfiguration() { const response = await fetch('/api/backup/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: elements['backup-url'].value.trim(), username: elements['backup-username'].value.trim(), password: elements['backup-password'].value, clear_password: String(elements['clear-backup-password'].checked), }), }); const result = await response.json(); if (!response.ok) throw new Error(result.detail || 'Could not save backup settings.'); elements['backup-password'].value = ''; elements['clear-backup-password'].checked = false; await refreshBackupStatus(); } elements['settings-button'].addEventListener('click', () => { elements['settings-dialog'].showModal(); refreshBackupStatus(); }); elements['close-settings'].addEventListener('click', () => elements['settings-dialog'].close()); elements['backup-form'].addEventListener('submit', async (event) => { event.preventDefault(); elements['backup-status'].textContent = 'Saving settings…'; try { await saveBackupConfiguration(); } catch (error) { elements['backup-status'].textContent = error.message; } }); elements['test-backup'].addEventListener('click', async () => { elements['backup-status'].textContent = 'Testing WebDAV…'; try { await saveBackupConfiguration(); const response = await fetch('/api/backup/test', { method: 'POST' }); const result = await response.json(); if (!response.ok) throw new Error(result.detail || 'WebDAV test failed.'); elements['backup-status'].textContent = result.message; } catch (error) { elements['backup-status'].textContent = error.message; } }); elements['retry-backup'].addEventListener('click', async () => { elements['backup-status'].textContent = 'Queuing backup retry…'; try { const response = await fetch('/api/backup/retry', { method: 'POST' }); const result = await response.json(); if (!response.ok) throw new Error(result.detail || 'Could not retry backup.'); await refreshBackupStatus(); } catch (error) { elements['backup-status'].textContent = error.message; } }); 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)); } function prepareAudioLevelMeter() { const AudioContextClass = window.AudioContext || window.webkitAudioContext; if (!AudioContextClass) return; try { audioContext = new AudioContextClass(); audioContext.resume().catch(() => {}); } catch { audioContext = undefined; } } function startAudioLevelMeter(stream) { if (!audioContext) return; try { const analyser = audioContext.createAnalyser(); analyser.fftSize = 512; audioContext.createMediaStreamSource(stream).connect(analyser); const samples = new Uint8Array(analyser.fftSize); elements['audio-level'].hidden = false; const updateLevel = () => { analyser.getByteTimeDomainData(samples); const sumSquares = samples.reduce((sum, sample) => sum + (sample - 128) ** 2, 0); const level = Math.min(100, Math.round(Math.sqrt(sumSquares / samples.length) / 32 * 100)); elements['audio-level-fill'].style.width = `${level}%`; elements['audio-level'].setAttribute('aria-valuenow', level); audioLevelFrame = requestAnimationFrame(updateLevel); }; audioLevelFrame = requestAnimationFrame(updateLevel); } catch { audioContext?.close().catch(() => {}); audioContext = undefined; } } function stopAudioLevelMeter() { cancelAnimationFrame(audioLevelFrame); audioLevelFrame = undefined; if (audioContext) { audioContext.close().catch(() => {}); audioContext = undefined; } elements['audio-level'].hidden = true; elements['audio-level-fill'].style.width = '0'; elements['audio-level'].setAttribute('aria-valuenow', '0'); } 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 { prepareAudioLevelMeter(); 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); startAudioLevelMeter(activeStream); 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) { stopAudioLevelMeter(); activeStream?.getTracks().forEach((track) => track.stop()); activeStream = undefined; setMessage(error.name === 'NotAllowedError' ? 'Microphone access was denied.' : 'Could not start microphone recording.', true); } } function stopRecording() { if (mediaRecorder?.state === 'recording') mediaRecorder.stop(); stopAudioLevelMeter(); 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' : blob.type.includes('mp4') ? 'm4a' : 'audio'; 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 queueAudioFile(file) { const createdAtMs = Date.now(); const createdAt = new Date(createdAtMs).toISOString(); const recording = { id: crypto.randomUUID(), blob: file, createdAt, filename: file.name, durationMs: 0, createdAtMs, }; try { await putQueued(recording); setMessage(navigator.onLine ? 'Audio saved on device. Uploading now…' : 'Audio saved on device. It will upload when you reconnect.'); await renderQueue(); await syncQueue(); } catch { setMessage('Could not save this file in browser storage. Check available device storage 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('Upload complete', false, true); } catch { setMessage('Upload paused. Your recording is still saved on this device.'); 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; await renderQueue(); await loadAwaitingNotes(); } } function prettyDate(value) { return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)); } async function renderQueue() { 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; for (const recording of recordings) { const item = document.createElement('li'); item.className = 'queue-item'; const icon = document.createElement('span'); icon.className = 'queue-icon'; const isTextNote = 'text' in recording; icon.textContent = isTextNote ? 'T' : '◖'; 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)}${recording.durationMs ? ` · ${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 { if (isTextNote) await deleteTextRecord(recording.id); else 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 saved notes.'; else if (elements['main'].classList.contains('reading')) elements['archive-empty'].textContent = 'No stored transcriptions yet.'; else elements['archive-empty'].textContent = 'No saved notes yet.'; if (notes.some((note) => note.status === 'transcribing')) { if (!transcriptionPoll) { transcriptionPoll = window.setInterval(() => { if (navigator.onLine && document.visibilityState === 'visible') loadAwaitingNotes(); }, 5000); } } else { clearInterval(transcriptionPoll); transcriptionPoll = undefined; } 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 isWrittenNote = note.status === 'written'; const transcript = document.createElement('textarea'); transcript.className = 'archive-transcript'; 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.corrected_transcript || note.transcript; transcript.setAttribute('aria-label', `${isWrittenNote ? 'Note' : 'Edited transcript'} for ${note.filename}`); const original = document.createElement('details'); original.className = 'original-transcript'; const originalLabel = document.createElement('summary'); originalLabel.textContent = 'Original transcript'; const originalText = document.createElement('p'); originalText.textContent = note.transcript || 'No original transcript is available.'; original.append(originalLabel, originalText); const save = document.createElement('button'); save.className = 'save-transcript'; save.type = 'button'; save.textContent = isWrittenNote ? 'SAVE NOTE' : 'SAVE EDIT'; 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'); save.textContent = 'SAVED'; window.setTimeout(() => { if (save.isConnected) save.textContent = isWrittenNote ? 'SAVE NOTE' : 'SAVE EDIT'; }, 1500); } catch { save.textContent = 'RETRY SAVE'; } finally { save.disabled = false; } }); const status = document.createElement('div'); status.className = 'archive-status'; status.textContent = isWrittenNote ? 'WRITTEN NOTE' : note.status === 'transcribing' ? 'TRANSCRIBING' : note.status === 'transcribed' ? 'TRANSCRIPT SAVED' : note.status === 'transcription_failed' ? 'TRANSCRIPTION FAILED; AUDIO RETAINED' : note.status === 'empty_transcript' ? 'NO SPEECH DETECTED; AUDIO RETAINED' : 'AWAITING TRANSCRIPTION'; if (note.status === 'transcribing') { const progress = document.createElement('div'); progress.className = 'transcription-progress'; progress.setAttribute('role', 'progressbar'); progress.setAttribute('aria-label', 'Transcription in progress'); progress.setAttribute('aria-valuemin', '0'); progress.setAttribute('aria-valuemax', '100'); progress.setAttribute('aria-valuetext', 'In progress'); progress.append(document.createElement('span')); body.append(name, original, transcript, save, status, progress); } else { body.append(name, original, transcript, save, status); } 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); } }); item.append(date, body, remove); elements['archive-list'].append(item); } } async function loadAwaitingNotes() { if (!navigator.onLine) { renderArchive([]); return; } try { const responses = elements['main'].classList.contains('reading') ? [await fetch('/api/notes')] : await Promise.all([ fetch('/api/notes?status=awaiting_transcription'), fetch('/api/notes?status=written'), fetch('/api/notes?status=transcribing'), fetch('/api/notes?status=transcription_failed'), fetch('/api/notes?status=empty_transcript'), ]); 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; 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['voice-mode'].addEventListener('click', () => setCaptureMode('voice')); elements['write-mode'].addEventListener('click', () => setCaptureMode('write')); elements['read-mode'].addEventListener('click', () => setCaptureMode('read')); 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 = ''; }); elements['sync-button'].addEventListener('click', syncQueue); 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)); restoreDraft(); loadAwaitingNotes(); if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});