Enhance note management: add corrected transcript field, update UI for reading mode, and improve archive display

This commit is contained in:
2026-09-28 17:17:58 +02:00
parent bc60c16079
commit 422a3548fd
4 changed files with 74 additions and 31 deletions
+23 -10
View File
@@ -55,6 +55,7 @@ def initialize_db() -> None:
filename TEXT NOT NULL, filename TEXT NOT NULL,
audio_path TEXT NOT NULL, audio_path TEXT NOT NULL,
transcript TEXT NOT NULL DEFAULT '', transcript TEXT NOT NULL DEFAULT '',
corrected_transcript TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL status TEXT NOT NULL
)""" )"""
) )
@@ -74,6 +75,8 @@ def initialize_db() -> None:
columns = {row[1] for row in connection.execute("PRAGMA table_info(notes)")} columns = {row[1] for row in connection.execute("PRAGMA table_info(notes)")}
if "original_audio_path" not in columns: if "original_audio_path" not in columns:
connection.execute("ALTER TABLE notes ADD COLUMN original_audio_path TEXT") connection.execute("ALTER TABLE notes ADD COLUMN original_audio_path TEXT")
if "corrected_transcript" not in columns:
connection.execute("ALTER TABLE notes ADD COLUMN corrected_transcript TEXT NOT NULL DEFAULT ''")
initialize_db() initialize_db()
@@ -636,7 +639,7 @@ def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]:
query = " AND ".join(f'"{term.replace(chr(34), chr(34) * 2)}"' for term in search_terms) query = " AND ".join(f'"{term.replace(chr(34), chr(34) * 2)}"' for term in search_terms)
if status: if status:
rows = connection.execute( rows = connection.execute(
"""SELECT n.id, n.created_at, n.filename, n.transcript, n.status """SELECT n.id, n.created_at, n.filename, n.transcript, n.corrected_transcript, n.status
FROM note_search s JOIN notes n ON n.id = s.note_id FROM note_search s JOIN notes n ON n.id = s.note_id
WHERE note_search MATCH ? AND n.status = ? WHERE note_search MATCH ? AND n.status = ?
ORDER BY n.created_at DESC LIMIT 100""", ORDER BY n.created_at DESC LIMIT 100""",
@@ -644,20 +647,20 @@ def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]:
).fetchall() ).fetchall()
else: else:
rows = connection.execute( rows = connection.execute(
"""SELECT n.id, n.created_at, n.filename, n.transcript, n.status """SELECT n.id, n.created_at, n.filename, n.transcript, n.corrected_transcript, n.status
FROM note_search s JOIN notes n ON n.id = s.note_id FROM note_search s JOIN notes n ON n.id = s.note_id
WHERE note_search MATCH ? ORDER BY n.created_at DESC LIMIT 100""", WHERE note_search MATCH ? ORDER BY n.created_at DESC LIMIT 100""",
(query,), (query,),
).fetchall() ).fetchall()
elif status: elif status:
rows = connection.execute( rows = connection.execute(
"""SELECT id, created_at, filename, transcript, status """SELECT id, created_at, filename, transcript, corrected_transcript, status
FROM notes WHERE status = ? ORDER BY created_at DESC LIMIT 100""", FROM notes WHERE status = ? ORDER BY created_at DESC LIMIT 100""",
(status,), (status,),
).fetchall() ).fetchall()
else: else:
rows = connection.execute( rows = connection.execute(
"""SELECT id, created_at, filename, transcript, status """SELECT id, created_at, filename, transcript, corrected_transcript, status
FROM notes ORDER BY created_at DESC LIMIT 100""" FROM notes ORDER BY created_at DESC LIMIT 100"""
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [dict(row) for row in rows]
@@ -684,14 +687,24 @@ def delete_note(note_id: str) -> dict[str, str]:
async def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]: async def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]:
transcript = payload.get("transcript", "").strip() transcript = payload.get("transcript", "").strip()
with connect_db() as connection: with connect_db() as connection:
note = connection.execute("SELECT status FROM notes WHERE id = ?", (note_id,)).fetchone() note = connection.execute("SELECT transcript FROM notes WHERE id = ?", (note_id,)).fetchone()
if not note: if not note:
raise HTTPException(status_code=404, detail="Note not found") raise HTTPException(status_code=404, detail="Note not found")
status = "written" if note["status"] == "written" else "transcribed" if transcript else "awaiting_transcription" with connect_db() as connection:
update_transcript(note_id, transcript, status) connection.execute(
if transcript and note["status"] != "written": "UPDATE notes SET corrected_transcript = ? WHERE id = ?",
remove_transcribed_audio(note_id) (transcript, note_id),
return {"id": note_id, "status": status} )
connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,))
searchable_text = transcript or note["transcript"]
if searchable_text.strip():
connection.execute(
"INSERT INTO note_search (note_id, transcript) VALUES (?, ?)",
(note_id, searchable_text.strip()),
)
connection.execute("UPDATE backup_state SET revision = revision + 1 WHERE id = 1")
schedule_backup()
return {"id": note_id, "status": "saved"}
app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend") app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
+38 -18
View File
@@ -6,7 +6,8 @@ const MAX_RECORDING_MS = 30 * 60 * 1000;
const elements = Object.fromEntries([ 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', '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', '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', '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', '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', 'clear-backup-password', 'close-settings', 'test-backup', 'backup-status', 'retry-backup',
].map((id) => [id, document.getElementById(id)])); ].map((id) => [id, document.getElementById(id)]));
@@ -71,12 +72,18 @@ const deleteTextRecord = (id) => textTransaction('readwrite', (store) => store.d
function setCaptureMode(mode) { function setCaptureMode(mode) {
const writing = mode === 'write'; const writing = mode === 'write';
elements['voice-mode'].classList.toggle('active', !writing); const reading = mode === 'read';
elements['voice-mode'].classList.toggle('active', !writing && !reading);
elements['write-mode'].classList.toggle('active', writing); elements['write-mode'].classList.toggle('active', writing);
elements['voice-mode'].setAttribute('aria-selected', String(!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['write-mode'].setAttribute('aria-selected', String(writing));
elements['voice-panel'].hidden = writing; elements['read-mode'].setAttribute('aria-selected', String(reading));
elements['write-panel'].hidden = !writing; 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() { async function saveDraft() {
@@ -537,12 +544,19 @@ function renderArchive(notes) {
const transcript = document.createElement('textarea'); const transcript = document.createElement('textarea');
transcript.className = 'archive-transcript'; 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.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.value = note.corrected_transcript || note.transcript;
transcript.setAttribute('aria-label', `${isWrittenNote ? 'Note' : 'Transcript'} for ${note.filename}`); 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'); const save = document.createElement('button');
save.className = 'save-transcript'; save.className = 'save-transcript';
save.type = 'button'; save.type = 'button';
save.textContent = isWrittenNote ? 'SAVE NOTE' : 'SAVE TRANSCRIPT'; save.textContent = isWrittenNote ? 'SAVE NOTE' : 'SAVE EDIT';
save.addEventListener('click', async () => { save.addEventListener('click', async () => {
save.disabled = true; save.disabled = true;
try { try {
@@ -550,7 +564,10 @@ function renderArchive(notes) {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript: transcript.value }), method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript: transcript.value }),
}); });
if (!response.ok) throw new Error('Save failed'); if (!response.ok) throw new Error('Save failed');
await loadAwaitingNotes(); save.textContent = 'SAVED';
window.setTimeout(() => {
if (save.isConnected) save.textContent = isWrittenNote ? 'SAVE NOTE' : 'SAVE EDIT';
}, 1500);
} catch { } catch {
save.textContent = 'RETRY SAVE'; save.textContent = 'RETRY SAVE';
} finally { } finally {
@@ -569,9 +586,9 @@ function renderArchive(notes) {
progress.setAttribute('aria-valuemax', '100'); progress.setAttribute('aria-valuemax', '100');
progress.setAttribute('aria-valuetext', 'In progress'); progress.setAttribute('aria-valuetext', 'In progress');
progress.append(document.createElement('span')); progress.append(document.createElement('span'));
body.append(name, transcript, save, status, progress); body.append(name, original, transcript, save, status, progress);
} else { } else {
body.append(name, transcript, save, status); body.append(name, original, transcript, save, status);
} }
const remove = createDeleteButton(`Delete note ${note.filename}`); const remove = createDeleteButton(`Delete note ${note.filename}`);
remove.addEventListener('click', async () => { remove.addEventListener('click', async () => {
@@ -596,13 +613,15 @@ async function loadAwaitingNotes() {
return; return;
} }
try { try {
const responses = await Promise.all([ const responses = elements['main'].classList.contains('reading')
fetch('/api/notes?status=awaiting_transcription'), ? [await fetch('/api/notes')]
fetch('/api/notes?status=written'), : await Promise.all([
fetch('/api/notes?status=transcribing'), fetch('/api/notes?status=awaiting_transcription'),
fetch('/api/notes?status=transcription_failed'), fetch('/api/notes?status=written'),
fetch('/api/notes?status=empty_transcript'), 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'); if (responses.some((response) => !response.ok)) throw new Error('Archive unavailable');
const notes = (await Promise.all(responses.map((response) => response.json()))).flat(); 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)); notes.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
@@ -617,6 +636,7 @@ async function loadAwaitingNotes() {
elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording()); elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording());
elements['voice-mode'].addEventListener('click', () => setCaptureMode('voice')); elements['voice-mode'].addEventListener('click', () => setCaptureMode('voice'));
elements['write-mode'].addEventListener('click', () => setCaptureMode('write')); elements['write-mode'].addEventListener('click', () => setCaptureMode('write'));
elements['read-mode'].addEventListener('click', () => setCaptureMode('read'));
elements['written-note'].addEventListener('input', scheduleDraftSave); elements['written-note'].addEventListener('input', scheduleDraftSave);
elements['save-written-note'].addEventListener('click', queueWrittenNote); elements['save-written-note'].addEventListener('click', queueWrittenNote);
window.addEventListener('pagehide', () => { window.addEventListener('pagehide', () => {
+3 -2
View File
@@ -50,6 +50,7 @@
<div class="capture-modes" role="tablist" aria-label="Note type"> <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="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> <button id="write-mode" class="capture-mode" type="button" role="tab" aria-selected="false" aria-controls="write-panel">Write</button>
<button id="read-mode" class="capture-mode" type="button" role="tab" aria-selected="false" aria-controls="archive-section">Read</button>
</div> </div>
<div id="voice-panel" role="tabpanel" aria-labelledby="voice-mode"> <div id="voice-panel" role="tabpanel" aria-labelledby="voice-mode">
<div class="recorder-row"> <div class="recorder-row">
@@ -82,7 +83,7 @@
</div> </div>
</section> </section>
<section class="queue-section" aria-labelledby="queue-title"> <section id="queue-section" class="queue-section" aria-labelledby="queue-title">
<div class="section-heading"> <div class="section-heading">
<div><span class="section-kicker">ON THIS DEVICE</span><h2 id="queue-title">Upload queue <span id="queue-count">0</span></h2></div> <div><span class="section-kicker">ON THIS DEVICE</span><h2 id="queue-title">Upload queue <span id="queue-count">0</span></h2></div>
<button id="sync-button" class="icon-button" type="button" title="Sync queued recordings" aria-label="Sync queued recordings">↻</button> <button id="sync-button" class="icon-button" type="button" title="Sync queued recordings" aria-label="Sync queued recordings">↻</button>
@@ -91,7 +92,7 @@
<p id="queue-empty" class="empty-state">New recordings wait here until the server confirms they’re stored.</p> <p id="queue-empty" class="empty-state">New recordings wait here until the server confirms they’re stored.</p>
</section> </section>
<section class="archive-section" aria-labelledby="archive-title"> <section id="archive-section" class="archive-section" aria-labelledby="archive-title">
<div class="section-heading archive-heading"> <div class="section-heading archive-heading">
<div><span class="section-kicker">SERVER STORED</span><h2 id="archive-title">Saved notes</h2></div> <div><span class="section-kicker">SERVER STORED</span><h2 id="archive-title">Saved notes</h2></div>
</div> </div>
+10 -1
View File
@@ -28,6 +28,15 @@ main { max-width: 1040px; margin: auto; padding: 0 24px; }
.capture-modes { display: flex; width: fit-content; margin: 0 auto 24px; border-bottom: 1px solid var(--line); } .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 { 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); } .capture-mode.active { border-bottom-color: var(--moss); color: var(--moss-dark); }
.reading .capture { padding-bottom: 0; border-bottom: 0; background-image: none; }
.reading #queue-section { display: none; }
.reading #archive-section { min-height: calc(100vh - 190px); padding-top: 24px; border-top: 1px solid var(--line); }
.reading .archive-list { margin-top: 24px; }
.reading .archive-item { padding: 22px 0; }
.reading .archive-transcript { min-height: 180px; resize: vertical; border: 1px solid var(--line); padding: 14px; background: #fffefa; }
.original-transcript { margin: 0 0 12px; color: var(--muted); font-size: 11px; }
.original-transcript summary { width: fit-content; font: 10px var(--mono); cursor: pointer; }
.original-transcript p { margin: 9px 0 0; color: var(--ink); font: 13px/1.55 var(--sans); white-space: pre-wrap; overflow-wrap: anywhere; }
.write-panel { max-width: 590px; margin: auto; } .write-panel { max-width: 590px; margin: auto; }
.write-label { display: block; margin: 0 0 10px; font: 500 20px var(--serif); } .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 { 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); }
@@ -115,7 +124,7 @@ footer { max-width: 1040px; margin: auto; padding: 18px 24px calc(18px + env(saf
.topbar-actions { gap: 10px; } .topbar-actions { gap: 10px; }
main { padding: 0 18px; } main { padding: 0 18px; }
.capture { padding: 38px 0 30px; background-position: right 8px top 25px; } .capture { padding: 38px 0 30px; background-position: right 8px top 25px; }
.capture-mode { min-width: 100px; } .capture-mode { min-width: 86px; padding-inline: 12px; }
.write-actions { align-items: flex-start; } .write-actions { align-items: flex-start; }
h1 { font-size: 48px; } h1 { font-size: 48px; }
.recorder-row { gap: 12px; padding-right: 12px; } .recorder-row { gap: 12px; padding-right: 12px; }