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
+1
View File
@@ -19,6 +19,7 @@ Faerro KB is a self-hosted voice-note inbox designed for an iPhone-installed web
- Preserve the local-first capture and retry behavior when changing the PWA, API, or service worker. Prefer additive, recoverable data changes.
- Follow the existing small stack: FastAPI, SQLite/FTS5, and the vanilla JavaScript PWA. Avoid new dependencies unless they solve a real need and can run on the user's infrastructure.
- Keep changes scoped. Inspect the owning code path, add or update focused tests for behavior changes, and run the narrowest relevant checks available.
- If tooling required for a relevant test or check is missing, tell the user which tool is needed and prompt them to install it on the local machine; do not silently skip the check.
- Update README/UI documentation when behavior, deployment, security, backup, or platform limitations change. Do not claim a capability that iOS or browsers cannot guarantee.
- Preserve user data and existing local changes. Do not perform destructive migrations or remove stored recordings without explicit confirmation and a recovery path.
- At the end of each chat, check whether it introduced durable project requirements, decisions, workflows, or corrections missing from these instructions or a relevant skill. Update the narrowest relevant guidance file when the change is clearly reusable and within the user's authorized scope; otherwise note the gap and ask before expanding scope. Do not record transient task details or sensitive information.
@@ -26,5 +26,6 @@ Use this skill for feature work, bug fixes, and reviews in this repository. The
- Add or update focused tests for changed behavior, especially queue acknowledgement, retries, and data-preserving transcript workflows.
- Run the narrowest relevant test, compile, or lint check available, then document any important limitation or unverified check.
- If tooling required for a relevant test or check is missing, tell the user which tool is needed and prompt them to install it locally instead of silently skipping the check.
- Update `README.md` when setup, deployment, backups, privacy, or platform behavior changes.
- Never claim that iOS guarantees background execution or that browser storage is permanent.
+1
View File
@@ -29,6 +29,7 @@ In Safari on iPhone, open the HTTPS address and use **Share > Add to Home Screen
## Capture and offline behavior
- Recording data and its timestamp are committed to IndexedDB before upload is attempted.
- Written-note drafts and queued text notes are stored in IndexedDB on the device. A draft is restored when the app is reopened, and queued text is removed only after the server confirms storage.
- Microphone recordings use the audio format supported by the browser (commonly M4A/AAC on iPhone Safari or Opus/WebM elsewhere). You can also add an audio file from the device; both sources remain queued locally until the server confirms storage.
- The server keeps each uploaded original and creates a mono PCM WAV copy at 16-bit / 22.05 kHz for transcription. FFmpeg performs this conversion locally in the container. After a non-empty transcript is stored, both audio files are deleted; failed or empty transcriptions keep the audio for recovery.
- Uploads retry when the app opens, returns to the foreground, or the browser reports a connection. A recording is removed from the phone's queue only after the server confirms it was stored.
+36 -5
View File
@@ -199,6 +199,36 @@ async def create_note(
return {"id": note_id, "status": status}
@app.post("/api/text-notes", status_code=201)
def create_text_note(payload: dict[str, str]) -> dict[str, str]:
text = payload.get("text", "").strip()
if not text:
raise HTTPException(status_code=400, detail="Note text is required")
try:
note_id = str(uuid.UUID(payload.get("id", "")))
except ValueError as error:
raise HTTPException(status_code=400, detail="Invalid note ID") from error
try:
parsed_date = datetime.fromisoformat(payload.get("created_at", "").replace("Z", "+00:00"))
if parsed_date.tzinfo is None:
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
created_at = parsed_date.astimezone(timezone.utc).isoformat()
except ValueError:
created_at = datetime.now(timezone.utc).isoformat()
with connect_db() as connection:
existing = connection.execute("SELECT status FROM notes WHERE id = ?", (note_id,)).fetchone()
if existing:
return {"id": note_id, "status": existing["status"]}
connection.execute(
"""INSERT INTO notes (id, created_at, filename, audio_path, transcript, status)
VALUES (?, ?, ?, '', ?, 'written')""",
(note_id, created_at, "Written note", text),
)
connection.execute("INSERT INTO note_search (note_id, transcript) VALUES (?, ?)", (note_id, text))
return {"id": note_id, "status": "written"}
@app.get("/api/notes")
def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]:
with connect_db() as connection:
@@ -255,13 +285,14 @@ def delete_note(note_id: str) -> dict[str, str]:
def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]:
transcript = payload.get("transcript", "").strip()
with connect_db() as connection:
exists = connection.execute("SELECT 1 FROM notes WHERE id = ?", (note_id,)).fetchone()
if not exists:
note = connection.execute("SELECT status FROM notes WHERE id = ?", (note_id,)).fetchone()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
update_transcript(note_id, transcript, "transcribed" if transcript else "awaiting_transcription")
if transcript:
status = "written" if note["status"] == "written" else "transcribed" if transcript else "awaiting_transcription"
update_transcript(note_id, transcript, status)
if transcript and note["status"] != "written":
remove_transcribed_audio(note_id)
return {"id": note_id, "status": "transcribed" if transcript else "awaiting_transcription"}
return {"id": note_id, "status": status}
app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
+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(() => {});
+15 -2
View File
@@ -19,6 +19,11 @@
<main>
<section class="capture" aria-labelledby="capture-title">
<div class="capture-modes" role="tablist" aria-label="Note type">
<button id="voice-mode" class="capture-mode active" type="button" role="tab" aria-selected="true" aria-controls="voice-panel">Voice</button>
<button id="write-mode" class="capture-mode" type="button" role="tab" aria-selected="false" aria-controls="write-panel">Write</button>
</div>
<div id="voice-panel" role="tabpanel" aria-labelledby="voice-mode">
<div class="recorder-row">
<button id="record-button" class="record-button" type="button" aria-label="Start recording">
<span class="record-dot"></span>
@@ -35,10 +40,18 @@
<div class="audio-upload-row">
<label class="audio-upload-button" for="audio-file-input">↑ Upload audio</label>
<input id="audio-file-input" type="file" accept="audio/*,.mp3,.m4a,.aac,.ogg,.opus,.flac,.wav" multiple />
<span>Files stay on this device until the server confirms storage.</span>
</div>
<div class="record-meter"><span id="record-meter-fill"></span></div>
<div id="capture-message" class="capture-message" role="status"></div>
</div>
<div id="write-panel" class="write-panel" role="tabpanel" aria-labelledby="write-mode" hidden>
<label class="write-label" for="written-note">Write a note</label>
<textarea id="written-note" class="written-note" placeholder="What’s on your mind?" rows="7"></textarea>
<div class="write-actions">
<span id="draft-status" class="draft-status" role="status">Draft stays on this device until you save it.</span>
<button id="save-written-note" class="save-written-note" type="button" disabled>Save note</button>
</div>
</div>
</section>
<section class="queue-section" aria-labelledby="queue-title">
@@ -52,7 +65,7 @@
<section class="archive-section" aria-labelledby="archive-title">
<div class="section-heading archive-heading">
<div><span class="section-kicker">SERVER STORED</span><h2 id="archive-title">Awaiting transcription</h2></div>
<div><span class="section-kicker">SERVER STORED</span><h2 id="archive-title">Saved notes</h2></div>
</div>
<div id="archive-list" class="archive-list"></div>
<p id="archive-empty" class="empty-state">Notes awaiting transcription will appear here.</p>
+16 -2
View File
@@ -24,6 +24,17 @@ body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(
.network-state.online i { background: #57986b; }
main { max-width: 1040px; margin: auto; padding: 0 24px; }
.capture { padding: 54px 0 40px; border-bottom: 1px solid var(--line); background-image: radial-gradient(#c7c9bf 0.55px, transparent 0.55px); background-size: 9px 9px; background-position: right 22px top 28px; background-repeat: no-repeat; }
.capture-modes { display: flex; width: fit-content; margin: 0 auto 24px; border-bottom: 1px solid var(--line); }
.capture-mode { min-width: 108px; min-height: 40px; border: 0; border-bottom: 2px solid transparent; padding: 0 16px; color: var(--muted); background: transparent; font: 12px var(--mono); cursor: pointer; }
.capture-mode.active { border-bottom-color: var(--moss); color: var(--moss-dark); }
.write-panel { max-width: 590px; margin: auto; }
.write-label { display: block; margin: 0 0 10px; font: 500 20px var(--serif); }
.written-note { display: block; width: 100%; min-height: 190px; resize: vertical; border: 1px solid var(--line); padding: 14px; color: var(--ink); background: #fffefa; font: 14px/1.6 var(--sans); }
.written-note:focus { outline: 2px solid var(--moss); outline-offset: 2px; }
.write-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 12px; }
.draft-status { color: var(--muted); font-size: 11px; }
.save-written-note { min-height: 40px; flex: 0 0 auto; border: 1px solid var(--moss-dark); padding: 0 16px; color: white; background: var(--moss-dark); font: 12px var(--mono); cursor: pointer; }
.save-written-note:disabled { opacity: .45; cursor: default; }
.section-kicker { display: flex; justify-content: space-between; color: var(--muted); font: 10px var(--mono); letter-spacing: 0; }
h1 { margin: 24px 0 11px; font: 500 clamp(42px, 7vw, 72px)/0.99 var(--serif); letter-spacing: 0; }
h1 em { color: var(--moss); font-weight: 500; }
@@ -44,7 +55,7 @@ h1 em { color: var(--moss); font-weight: 500; }
.record-meter { height: 2px; max-width: 590px; margin-inline: auto; background: #e4e3dc; }
.record-meter span { display: block; height: 100%; width: 0; background: var(--coral); transition: width .25s linear; }
.capture-message { min-height: 20px; max-width: 590px; margin: 8px auto 0; padding-top: 6px; color: var(--muted); font-size: 11px; text-align: center; }
.capture-message.upload-confirmed { display: inline-flex; align-items: center; justify-content: center; gap: 8px; color: forestgreen; font-size: 16px; }
.capture-message.upload-confirmed { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; color: forestgreen; font-size: 16px; }
.capture-message.upload-confirmed::after { content: '✓'; color: forestgreen; font-size: 1.05em; line-height: 1; }
.audio-upload-row { max-width: 590px; margin: 14px auto 6px; display: flex; align-items: center; justify-content: center; gap: 12px; }
.audio-upload-button { display: inline-flex; min-height: 36px; align-items: center; gap: 7px; border: 1px solid var(--line); padding: 0 12px; color: var(--moss-dark); background: #fffefa; font-size: 12px; cursor: pointer; }
@@ -82,12 +93,15 @@ footer { max-width: 1040px; margin: auto; padding: 18px 24px calc(18px + env(saf
.topbar { height: 58px; padding: 0 18px; }
main { padding: 0 18px; }
.capture { padding: 38px 0 30px; background-position: right 8px top 25px; }
.capture-mode { min-width: 100px; }
.write-actions { align-items: flex-start; }
h1 { font-size: 48px; }
.recorder-row { gap: 12px; padding-right: 12px; }
.record-button { width: 46px; height: 46px; }
.record-copy strong { font-size: 12px; }
.record-copy span { font-size: 10px; }
.audio-upload-row { align-items: flex-start; flex-direction: column; }
.audio-upload-row { align-items: center; flex-direction: column; }
.audio-upload-row > span { text-align: center; }
h2 { font-size: 23px; }
.archive-date { width: 60px; font-size: 9px; }
footer { padding-left: 18px; padding-right: 18px; font-size: 8px; }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'faerro-kb-shell-v2';
const CACHE_NAME = 'faerro-kb-shell-v3';
const APP_SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/favicon.svg'];
self.addEventListener('install', (event) => {