Files

525 lines
22 KiB
JavaScript
Raw Permalink 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', 'voice-mode', 'write-mode', 'voice-panel', 'write-panel', 'written-note', 'draft-status', 'save-written-note',
].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;
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';
elements['voice-mode'].classList.toggle('active', !writing);
elements['write-mode'].classList.toggle('active', writing);
elements['voice-mode'].setAttribute('aria-selected', String(!writing));
elements['write-mode'].setAttribute('aria-selected', String(writing));
elements['voice-panel'].hidden = writing;
elements['write-panel'].hidden = !writing;
}
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.';
}
}
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 elements['archive-empty'].textContent = 'No saved notes yet.';
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.transcript;
transcript.setAttribute('aria-label', `${isWrittenNote ? 'Note' : 'Transcript'} for ${note.filename}`);
const save = document.createElement('button');
save.className = 'save-transcript';
save.type = 'button';
save.textContent = isWrittenNote ? 'SAVE NOTE' : '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 = isWrittenNote ? 'WRITTEN NOTE' : note.status === 'transcribing' ? 'TRANSCRIBING' : note.status === 'transcribed' ? 'TRANSCRIPT SAVED' : '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 responses = await Promise.all([
fetch('/api/notes?status=awaiting_transcription'),
fetch('/api/notes?status=written'),
]);
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['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(() => {});