Files
faerro-kb/frontend/app.js
T

367 lines
15 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 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',
'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 audioContext;
let audioLevelFrame;
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 = `<i></i>${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));
}
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' : '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(() => {});