Files
faerro-kb/frontend/app.js
T

662 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `<i></i>${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(() => {});