662 lines
28 KiB
JavaScript
662 lines
28 KiB
JavaScript
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(() => {}); |