Enhance text note functionality: add API for creating and managing written notes, implement draft saving, and improve UI for note capture modes

This commit is contained in:
2026-09-28 12:36:44 +02:00
parent f4100af8ec
commit 8d4f46a7d1
8 changed files with 214 additions and 25 deletions
+143 -15
View File
@@ -1,10 +1,12 @@
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',
'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;
@@ -16,12 +18,17 @@ 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, 1);
request.onupgradeneeded = () => request.result.createObjectStore(STORE_NAME, { keyPath: 'id' });
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);
});
@@ -44,6 +51,92 @@ 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)' : '';
@@ -237,6 +330,22 @@ async function syncQueue() {
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;
@@ -250,7 +359,9 @@ function prettyDate(value) {
}
async function renderQueue() {
const recordings = (await getQueued()).sort((a, b) => b.createdAtMs - a.createdAtMs);
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;
@@ -259,7 +370,8 @@ async function renderQueue() {
item.className = 'queue-item';
const icon = document.createElement('span');
icon.className = 'queue-icon';
icon.textContent = '◖';
const isTextNote = 'text' in recording;
icon.textContent = isTextNote ? 'T' : '◖';
const body = document.createElement('div');
body.className = 'item-body';
const top = document.createElement('div');
@@ -279,7 +391,8 @@ async function renderQueue() {
if (syncing) return;
remove.disabled = true;
try {
await deleteQueued(recording.id);
if (isTextNote) await deleteTextRecord(recording.id);
else await deleteQueued(recording.id);
await renderQueue();
} catch {
setMessage('Could not remove the queued recording.', true);
@@ -306,8 +419,8 @@ function createDeleteButton(label) {
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.';
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';
@@ -319,15 +432,16 @@ function renderArchive(notes) {
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 = note.status === 'transcribing' ? 'Transcription in progress…' : 'Transcript not available yet. Add or correct it here.';
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', `Transcript for ${note.filename}`);
transcript.setAttribute('aria-label', `${isWrittenNote ? 'Note' : 'Transcript'} for ${note.filename}`);
const save = document.createElement('button');
save.className = 'save-transcript';
save.type = 'button';
save.textContent = 'SAVE TRANSCRIPT';
save.textContent = isWrittenNote ? 'SAVE NOTE' : 'SAVE TRANSCRIPT';
save.addEventListener('click', async () => {
save.disabled = true;
try {
@@ -344,7 +458,7 @@ function renderArchive(notes) {
});
const status = document.createElement('div');
status.className = 'archive-status';
status.textContent = 'AWAITING TRANSCRIPTION';
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;
@@ -369,9 +483,14 @@ async function loadAwaitingNotes() {
return;
}
try {
const response = await fetch('/api/notes?status=awaiting_transcription');
if (!response.ok) throw new Error('Archive unavailable');
renderArchive(await response.json());
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;
@@ -380,6 +499,14 @@ async function loadAwaitingNotes() {
}
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 = '';
@@ -393,5 +520,6 @@ document.addEventListener('visibilitychange', () => { if (document.visibilitySta
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(() => {});