Person spricht ~15-20s frei (Headset). Danach wird ihr Stimmprofil gespeichert und in Aufnahmen automatisch erkannt.
-
-
-
-
00:00
-
-
-
-
-
Personen
-
Eine Person darf mehrere Namen haben („Sascha“ und „Sascha Wolf“ = ein Mensch) und
- mehrere Stimmproben aus verschiedenen Aufnahmen. Erkannt wird über die beste Probe.
Eigennamen & Fachbegriffe, die die Spracherkennung verhört (z.B. „Aime" wird als „Amy" gehört). Die KI-Korrektur ersetzt lautähnliche Wörter dann durch die richtige Schreibweise.
-
-
-
-
-
-
-
-
Glossar
Lade…
`;
- const listEl = v.querySelector('#glist');
- let editId = null; // id of the entry currently loaded into the form
- const formTitle = v.querySelector('#gformtitle'), saveBtn = v.querySelector('#gadd'), cancelBtn = v.querySelector('#gcancel');
- function setEdit(g) {
- editId = g ? g.id : null;
- formTitle.textContent = g ? `„${g.term}" bearbeiten` : 'Begriff hinzufügen';
- saveBtn.textContent = g ? 'Speichern' : 'Hinzufügen';
- cancelBtn.style.display = g ? '' : 'none';
- v.querySelector('#gterm').value = g ? g.term : '';
- v.querySelector('#gsound').value = g ? (g.soundalikes || '') : '';
- if (g) v.querySelector('#gterm').focus();
- }
- async function load() {
- try {
- const gs = await api.get('/api/glossary');
- listEl.innerHTML = gs.length ? gs.map(g => `
-
'; }
- }
- await load();
- cancelBtn.onclick = () => setEdit(null);
- saveBtn.onclick = async () => {
- const term = v.querySelector('#gterm').value.trim();
- if (!term) { toast('Bitte Begriff eingeben', true); return; }
- const soundalikes = v.querySelector('#gsound').value.trim();
- try {
- if (editId != null) await api.put('/api/glossary/' + editId, { term, soundalikes });
- else await api.post('/api/glossary', { term, soundalikes });
- toast(`„${term}" gespeichert ✓`);
- setEdit(null);
- load();
- } catch (e) { toast(e.message, true); }
- };
-}
-
-// ---------- Search ----------
-// Two-stage on purpose (see search.js): the hit list is SQL and lands in
-// milliseconds, the written answer is a ~30-90 s LLM call that streams in
-// afterwards. Never make the user wait on the slow half to see the fast half.
-const SEARCH_EXAMPLES = [
- 'Welches Projekt hat Johannes Jeckel letzte Woche vorgestellt?',
- 'Was wurde über die Preiserhöhung besprochen?',
- 'Welche Aufgaben hat Markus im Juli übernommen?',
-];
-
-function viewSearch() {
- const v = document.querySelector('#view');
- const hasResults = !!state.searchRes;
- v.innerHTML = `
-
';
- state.searchBusy = false;
- return;
- }
- state.searchBusy = false;
- renderSearchResults();
- // Only bother the NPU when there is something to summarise.
- if (state.searchRes.hits.length) askSearchAnswer(q);
-}
-
-// The written answer. Fired after the hits are already on screen; the box shows
-// a live elapsed counter because on this hardware it genuinely takes a while.
-async function askSearchAnswer(q) {
- const box = document.querySelector('#answerbox');
- if (!box) return;
- const t0 = Date.now();
- const tick = setInterval(() => {
- const el = box.querySelector('.answer-elapsed');
- if (el) el.textContent = Math.round((Date.now() - t0) / 1000) + ' s';
- }, 1000);
- try {
- const a = await api.post('/api/search/answer', { q });
- // A late answer to a query the user has already replaced must not overwrite
- // the newer result.
- if (state.searchQ !== q) return;
- state.searchAnswer = a;
- renderAnswer();
- } catch (e) {
- if (state.searchQ !== q) return;
- box.innerHTML = `
Antwort
${escapeHtml(e.message)}
`;
- } finally { clearInterval(tick); }
-}
-
-// Highlight the query's content words inside a snippet, the way a search engine
-// bolds the matched terms. Escaped first, so the highlight cannot inject markup.
-function highlight(text, terms) {
- let html = escapeHtml(text);
- for (const t of (terms || [])) {
- if (t.length < 3) continue;
- const safe = t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- html = html.replace(new RegExp(`(${safe})`, 'gi'), '$1');
- }
- return html;
-}
-
-function renderSearchResults() {
- const out = document.querySelector('#searchout');
- if (!out || !state.searchRes) return;
- const { hits, terms, range, semantic, total } = state.searchRes;
- if (!hits.length) {
- out.innerHTML = `
- Keine Treffer für „${escapeHtml(state.searchQ)}".
-
${range ? `Eingeschränkt auf ${escapeHtml(range.label)} — vielleicht liegt es daran. ` : ''}
- Andere Formulierung oder weniger Wörter versuchen.
`, {
- okText: '📌 Anheften',
- onReady: (root) => {
- const pp = root.querySelector('#pp'), pt = root.querySelector('#pt');
- // Unterreiter hängen am gewählten Ordner → bei jedem Wechsel nachladen.
- const loadTabs = async () => {
- if (pp.value === 'new') { pt.innerHTML = ''; return; }
- const p = await api.get('/api/projects/' + pp.value);
- pt.innerHTML = `` +
- p.tabs.map(t => ``).join('');
- };
- pp.onchange = async () => {
- if (pp.value === 'new') {
- const name = prompt('Name des neuen Projektordners:', '');
- if (name && name.trim()) {
- const np = await api.post('/api/projects', { name: name.trim() });
- pp.insertAdjacentHTML('afterbegin', ``);
- pp.value = String(np.id);
- } else { pp.value = projects.length ? String(projects[0].id) : 'new'; }
- }
- loadTabs();
- };
- bindUnpin(root);
- if (projects.length) loadTabs();
- },
- onOk: async (root) => {
- const pid = root.querySelector('#pp').value;
- if (pid === 'new' || !pid) { toast('Bitte einen Ordner wählen oder anlegen', true); return false; }
- await api.post(`/api/projects/${pid}/pins`, { recording_id: rec.id, tab_id: +root.querySelector('#pt').value });
- toast('Angeheftet ✓');
- if (done) done();
- }
- });
- function bindUnpin(root) {
- root.querySelectorAll('[data-unpin]').forEach(b => b.onclick = async () => {
- await api.del(`/api/projects/${b.dataset.proj}/pins/${b.dataset.unpin}`);
- pins = pins.filter(p => p.pin_id != b.dataset.unpin);
- root.querySelector('#pcur').innerHTML = pinsHtml();
- bindUnpin(root);
- toast('Anheftung gelöst');
- if (done) done();
- });
- }
- return m;
-}
-
-// Aus der Projektansicht in die Aufnahme springen (gleiche Mechanik wie aus der
-// Suche, nur ohne Zeitsprung).
-function jumpToRecording(id) {
- state.tab = 'recordings';
- state.selectedId = id;
- state.trashOpen = false;
- render();
-}
-
-// ---------- Recordings ----------
-// Analysis-duration counter. Shows the total wall time of a processing run
-// (transcribe+diar+LLM): a live 1-Hz counter while it runs (from analysis_started_at),
-// frozen to analysis_ms once done. Returns the inner only; callers add the ⏱.
-function anaTimeSpan(r) {
- if (r.analysis_ms != null) return `${fmt(r.analysis_ms)}`;
- if (r.analysis_started_at && (r.analysis_status === 'pending' || r.status === 'transcribing'))
- return `${fmt(Date.now() - r.analysis_started_at)}`;
- return '';
-}
-function ensureAnaTicker() {
- if (state.anaTicker) return;
- state.anaTicker = setInterval(() => {
- document.querySelectorAll('.ana-time.live').forEach(el => {
- const t0 = +el.dataset.anaStart;
- if (t0) el.textContent = fmt(Date.now() - t0);
- });
- }, 1000);
-}
-
-// Split layout: LEFT = date-grouped, collapsible recording list; RIGHT = the
-// selected recording's detail (with its own audio player). Crucially, the
-// while-analysing auto-refresh only rebuilds the LEFT list — it never touches
-// the RIGHT detail pane, so you can keep listening to an old recording while a
-// new one is being transcribed/analysed (the old bug: the whole view — detail +
-// audio element — was re-rendered every 5s, stopping playback).
-function viewRecordings() {
- const v = document.querySelector('#view');
- // Entering the tab: show only the newest day expanded, the rest collapsed
- // (applied in refreshRecList once the day groups are known).
- state.resetCollapse = true;
- v.innerHTML = `
-
-
-
-
← Aufnahme wählen
-
`;
- v.querySelector('#trashtoggle').onclick = () => {
- state.trashOpen = !state.trashOpen;
- state.lastListSig = null;
- v.querySelector('#trashtoggle').classList.toggle('active', state.trashOpen);
- refreshRecList();
- };
- // Draggable splitter: list width between LIST_MIN (the old fixed width) and
- // twice that; persisted for all sessions.
- const LIST_MIN = 340, LIST_MAX = 680;
- const listPane = v.querySelector('.rec-list-pane');
- const applyW = (w) => { listPane.style.flex = `0 0 ${Math.min(LIST_MAX, Math.max(LIST_MIN, w))}px`; };
- applyW(+localStorage.getItem('aime-list-width') || LIST_MIN);
- v.querySelector('.rec-splitter').onmousedown = (e) => {
- e.preventDefault();
- const startX = e.clientX, startW = listPane.getBoundingClientRect().width;
- document.body.classList.add('resizing');
- const move = (ev) => applyW(startW + (ev.clientX - startX));
- const up = () => {
- document.removeEventListener('mousemove', move);
- document.removeEventListener('mouseup', up);
- document.body.classList.remove('resizing');
- localStorage.setItem('aime-list-width', Math.round(listPane.getBoundingClientRect().width));
- };
- document.addEventListener('mousemove', move);
- document.addEventListener('mouseup', up);
- };
- refreshRecList();
- if (state.selectedId != null) openRecording(state.selectedId);
-}
-
-// (Re)build ONLY the left list pane. Called on entry and by the auto-refresh
-// timer; safe to run while the right pane holds a playing recording.
-async function refreshRecList() {
- const listEl = document.querySelector('#reclist');
- if (!listEl) return; // left the tab
- if (state.trashOpen) return refreshTrashList(listEl);
- let recs, pinCounts = {};
- try { recs = await api.get('/api/recordings'); }
- catch (e) { listEl.innerHTML = '
' + e.message + '
'; return; }
- // Wie oft steckt jede Aufnahme in einem Projekt? Ein Request für die ganze
- // Liste; scheitert er, bleibt der 📌-Button einfach unmarkiert.
- try { pinCounts = await api.get('/api/pins/counts'); } catch { pinCounts = {}; }
- // Reschedule the poll ONLY while something is still processing.
- const stillRunning = recs.some(r => r.analysis_status === 'pending' || r.status === 'transcribing');
- const scheduleNext = () => { if (stillRunning) setTimeout(() => { if (state.tab === 'recordings') refreshRecList(); }, 5000); };
- try {
- if (!recs.length && !state.recording) { listEl.innerHTML = '
Noch keine Aufnahmen.
'; state.lastListSig = 'empty'; scheduleNext(); return; }
- // Signature of everything the list RENDERS except the live ⏱ seconds (those
- // tick via ensureAnaTicker without a rebuild). If nothing meaningful changed,
- // DON'T touch the DOM at all — no flicker, no scroll jump, selection kept.
- // This is the real fix for "I get pulled away from my recording every 5s":
- // the 5s poll now no-ops unless a status/title/todo actually changed.
- const sig = recs.map(r => `${r.id}|${r.status}|${r.analysis_status}|${r.title || ''}|${(r.todos || []).length}|${r.summary ? 1 : 0}|${r.duration_ms || 0}|${pinCounts[r.id] || 0}`).join('~');
- if (sig === state.lastListSig && listEl.querySelector('.day-group')) { ensureAnaTicker(); scheduleNext(); return; }
- state.lastListSig = sig;
- // Preserve the left pane's scroll position across the rebuild.
- const scroller = listEl.closest('.rec-list-pane') || listEl;
- const savedTop = scroller.scrollTop;
- // Group by calendar day (recs come newest-first). Each day is a collapsible
- // section; the day header shows the recording count.
- const groups = [];
- const byDay = {};
- for (const r of recs) {
- const day = new Date(r.created_at).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' });
- if (!byDay[day]) { byDay[day] = []; groups.push(day); }
- byDay[day].push(r);
- }
- // Fresh tab entry: only the newest day (first group, recs are newest-first)
- // stays open; manual toggles afterwards are respected until the next entry.
- if (state.resetCollapse) { state.collapsedDates = new Set(groups.slice(1)); state.resetCollapse = false; }
- // Live placeholder while a recording runs: its own card at the very top.
- const liveHtml = state.recording ? `
-
`;
- }).join('');
- // Click a row (not one of its buttons) → open in the right pane.
- listEl.querySelectorAll('.item.rec').forEach(it => it.onclick = (e) => {
- if (e.target.closest('[data-del]') || e.target.closest('[data-ren]') || e.target.closest('[data-pin]')) return;
- openRecording(+it.dataset.open);
- });
- listEl.querySelectorAll('[data-pin]').forEach(b => b.onclick = (e) => {
- e.stopPropagation();
- const rec = recs.find(r => r.id == b.dataset.pin);
- pinToProjectDialog(rec || { id: +b.dataset.pin }, () => { state.lastListSig = null; refreshRecList(); });
- });
- listEl.querySelectorAll('[data-ren]').forEach(b => b.onclick = async (e) => {
- e.stopPropagation();
- const rec = recs.find(r => r.id == b.dataset.ren);
- await renameRecording(+b.dataset.ren, (rec && rec.title) || '');
- });
- listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async (e) => {
- e.stopPropagation();
- const rec = recs.find(r => r.id == b.dataset.del);
- const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.del;
- if (!confirm(`${name} in den Papierkorb verschieben?\nWiederherstellbar für 30 Tage, danach wird sie automatisch endgültig gelöscht.`)) return;
- await api.del('/api/recordings/' + b.dataset.del);
- if (state.selectedId == b.dataset.del) { state.selectedId = null; const d = document.querySelector('#detail'); if (d) d.innerHTML = '
← Aufnahme wählen
'; }
- toast('In den Papierkorb verschoben');
- state.lastListSig = null; refreshRecList();
- });
- bindMarkerChips();
- listEl.querySelectorAll('.day-head').forEach(h => h.onclick = () => {
- const day = h.dataset.day;
- if (state.collapsedDates.has(day)) state.collapsedDates.delete(day); else state.collapsedDates.add(day);
- h.closest('.day-group').classList.toggle('collapsed');
- });
- scroller.scrollTop = savedTop; // keep the list where the user had it
- ensureAnaTicker();
- scheduleNext();
- } catch (e) { listEl.innerHTML = '
' + e.message + '
'; }
-}
-
-// Rename a recording's title. An empty answer clears the title and hands
-// naming back to the LLM on the next analysis (see db.renameRecording).
-async function renameRecording(id, current) {
- const t = prompt('Titel der Aufnahme:', current || '');
- if (t === null) return false; // cancelled
- try {
- const r = await api.patch(`/api/recordings/${id}/title`, { title: t });
- state.lastListSig = null;
- refreshRecList();
- // Keep the open detail pane in sync without rebuilding it (audio keeps playing).
- const h = document.querySelector('#detail h2');
- if (h && state.selectedId == id) {
- const badge = h.querySelector('.badge');
- h.textContent = r.title || 'Aufnahme #' + id;
- if (badge) h.appendChild(badge);
- }
- toast(r.title ? 'Titel geändert' : 'Titel zurückgesetzt');
- return true;
- } catch (e) { toast('Umbenennen fehlgeschlagen: ' + e.message, true); return false; }
-}
-
-// Trash pane: same list slot, different contents. Deliberately flat (no day
-// grouping) — the interesting order here is "when did I delete it", and the
-// remaining days matter more than the recording date.
-async function refreshTrashList(listEl) {
- let data;
- try { data = await api.get('/api/trash'); }
- catch (e) { listEl.innerHTML = '
` : '';
- // Live-typed name markers: which cluster each one was resolved to, plus the
- // option to add one while listening back (same resolution path) and to re-run
- // the assignment after a re-diarization.
- const CONF_LBL = { high: 'sicher', mid: 'wahrscheinlich', low: 'unsicher' };
- const markersHtml = `
-
Namensmarker (während der Aufnahme getippt)
- ${(r.markers || []).length ? `
${r.markers.map(m => {
- const i = spkOrder.indexOf(m.speaker);
- const to = m.speaker ? spkLabel(m.speaker, i >= 0 ? i : 0) : '– kein Sprecher gefunden';
- return `
Person spricht ~15-20s frei (Headset). Danach wird ihr Stimmprofil gespeichert und in Aufnahmen automatisch erkannt.
+
+
+
+
00:00
+
+
+
+
+
Personen
+
Eine Person darf mehrere Namen haben („Sascha“ und „Sascha Wolf“ = ein Mensch) und
+ mehrere Stimmproben aus verschiedenen Aufnahmen. Erkannt wird über die beste Probe.
Eigennamen & Fachbegriffe, die die Spracherkennung verhört (z.B. „Aime" wird als „Amy" gehört). Die KI-Korrektur ersetzt lautähnliche Wörter dann durch die richtige Schreibweise.
+
+
+
+
+
+
+
+
Glossar
Lade…
`;
+ const listEl = v.querySelector('#glist');
+ let editId = null; // id of the entry currently loaded into the form
+ const formTitle = v.querySelector('#gformtitle'), saveBtn = v.querySelector('#gadd'), cancelBtn = v.querySelector('#gcancel');
+ function setEdit(g) {
+ editId = g ? g.id : null;
+ formTitle.textContent = g ? `„${g.term}" bearbeiten` : 'Begriff hinzufügen';
+ saveBtn.textContent = g ? 'Speichern' : 'Hinzufügen';
+ cancelBtn.style.display = g ? '' : 'none';
+ v.querySelector('#gterm').value = g ? g.term : '';
+ v.querySelector('#gsound').value = g ? (g.soundalikes || '') : '';
+ if (g) v.querySelector('#gterm').focus();
+ }
+ async function load() {
+ try {
+ const gs = await api.get('/api/glossary');
+ listEl.innerHTML = gs.length ? gs.map(g => `
+
'; }
+ }
+ await load();
+ cancelBtn.onclick = () => setEdit(null);
+ saveBtn.onclick = async () => {
+ const term = v.querySelector('#gterm').value.trim();
+ if (!term) { toast('Bitte Begriff eingeben', true); return; }
+ const soundalikes = v.querySelector('#gsound').value.trim();
+ try {
+ if (editId != null) await api.put('/api/glossary/' + editId, { term, soundalikes });
+ else await api.post('/api/glossary', { term, soundalikes });
+ toast(`„${term}" gespeichert ✓`);
+ setEdit(null);
+ load();
+ } catch (e) { toast(e.message, true); }
+ };
+}
+
+// ---------- Search ----------
+// Two-stage on purpose (see search.js): the hit list is SQL and lands in
+// milliseconds, the written answer is a ~30-90 s LLM call that streams in
+// afterwards. Never make the user wait on the slow half to see the fast half.
+const SEARCH_EXAMPLES = [
+ 'Welches Projekt hat Johannes Jeckel letzte Woche vorgestellt?',
+ 'Was wurde über die Preiserhöhung besprochen?',
+ 'Welche Aufgaben hat Markus im Juli übernommen?',
+];
+
+function viewSearch() {
+ const v = document.querySelector('#view');
+ const hasResults = !!state.searchRes;
+ v.innerHTML = `
+
';
+ state.searchBusy = false;
+ return;
+ }
+ state.searchBusy = false;
+ renderSearchResults();
+ // Only bother the NPU when there is something to summarise.
+ if (state.searchRes.hits.length) askSearchAnswer(q);
+}
+
+// The written answer. Fired after the hits are already on screen; the box shows
+// a live elapsed counter because on this hardware it genuinely takes a while.
+async function askSearchAnswer(q) {
+ const box = document.querySelector('#answerbox');
+ if (!box) return;
+ const t0 = Date.now();
+ const tick = setInterval(() => {
+ const el = box.querySelector('.answer-elapsed');
+ if (el) el.textContent = Math.round((Date.now() - t0) / 1000) + ' s';
+ }, 1000);
+ try {
+ const a = await api.post('/api/search/answer', { q });
+ // A late answer to a query the user has already replaced must not overwrite
+ // the newer result.
+ if (state.searchQ !== q) return;
+ state.searchAnswer = a;
+ renderAnswer();
+ } catch (e) {
+ if (state.searchQ !== q) return;
+ box.innerHTML = `
Antwort
${escapeHtml(e.message)}
`;
+ } finally { clearInterval(tick); }
+}
+
+// Highlight the query's content words inside a snippet, the way a search engine
+// bolds the matched terms. Escaped first, so the highlight cannot inject markup.
+function highlight(text, terms) {
+ let html = escapeHtml(text);
+ for (const t of (terms || [])) {
+ if (t.length < 3) continue;
+ const safe = t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ html = html.replace(new RegExp(`(${safe})`, 'gi'), '$1');
+ }
+ return html;
+}
+
+function renderSearchResults() {
+ const out = document.querySelector('#searchout');
+ if (!out || !state.searchRes) return;
+ const { hits, terms, range, semantic, total } = state.searchRes;
+ if (!hits.length) {
+ out.innerHTML = `
+ Keine Treffer für „${escapeHtml(state.searchQ)}".
+
${range ? `Eingeschränkt auf ${escapeHtml(range.label)} — vielleicht liegt es daran. ` : ''}
+ Andere Formulierung oder weniger Wörter versuchen.
`, {
+ okText: '📌 Anheften',
+ onReady: (root) => {
+ const pp = root.querySelector('#pp'), pt = root.querySelector('#pt');
+ // Unterreiter hängen am gewählten Ordner → bei jedem Wechsel nachladen.
+ const loadTabs = async () => {
+ if (pp.value === 'new') { pt.innerHTML = ''; return; }
+ const p = await api.get('/api/projects/' + pp.value);
+ pt.innerHTML = `` +
+ p.tabs.map(t => ``).join('');
+ };
+ pp.onchange = async () => {
+ if (pp.value === 'new') {
+ const name = prompt('Name des neuen Projektordners:', '');
+ if (name && name.trim()) {
+ const np = await api.post('/api/projects', { name: name.trim() });
+ pp.insertAdjacentHTML('afterbegin', ``);
+ pp.value = String(np.id);
+ } else { pp.value = projects.length ? String(projects[0].id) : 'new'; }
+ }
+ loadTabs();
+ };
+ bindUnpin(root);
+ if (projects.length) loadTabs();
+ },
+ onOk: async (root) => {
+ const pid = root.querySelector('#pp').value;
+ if (pid === 'new' || !pid) { toast('Bitte einen Ordner wählen oder anlegen', true); return false; }
+ await api.post(`/api/projects/${pid}/pins`, { recording_id: rec.id, tab_id: +root.querySelector('#pt').value });
+ toast('Angeheftet ✓');
+ if (done) done();
+ }
+ });
+ function bindUnpin(root) {
+ root.querySelectorAll('[data-unpin]').forEach(b => b.onclick = async () => {
+ await api.del(`/api/projects/${b.dataset.proj}/pins/${b.dataset.unpin}`);
+ pins = pins.filter(p => p.pin_id != b.dataset.unpin);
+ root.querySelector('#pcur').innerHTML = pinsHtml();
+ bindUnpin(root);
+ toast('Anheftung gelöst');
+ if (done) done();
+ });
+ }
+ return m;
+}
+
+// Aus der Projektansicht in die Aufnahme springen (gleiche Mechanik wie aus der
+// Suche, nur ohne Zeitsprung).
+function jumpToRecording(id) {
+ state.tab = 'recordings';
+ state.selectedId = id;
+ state.trashOpen = false;
+ render();
+}
+
+// ---------- Recordings ----------
+// Analysis-duration counter. Shows the total wall time of a processing run
+// (transcribe+diar+LLM): a live 1-Hz counter while it runs (from analysis_started_at),
+// frozen to analysis_ms once done. Returns the inner only; callers add the ⏱.
+function anaTimeSpan(r) {
+ if (r.analysis_ms != null) return `${fmt(r.analysis_ms)}`;
+ if (r.analysis_started_at && (r.analysis_status === 'pending' || r.status === 'transcribing'))
+ return `${fmt(Date.now() - r.analysis_started_at)}`;
+ return '';
+}
+function ensureAnaTicker() {
+ if (state.anaTicker) return;
+ state.anaTicker = setInterval(() => {
+ document.querySelectorAll('.ana-time.live').forEach(el => {
+ const t0 = +el.dataset.anaStart;
+ if (t0) el.textContent = fmt(Date.now() - t0);
+ });
+ }, 1000);
+}
+
+// Split layout: LEFT = date-grouped, collapsible recording list; RIGHT = the
+// selected recording's detail (with its own audio player). Crucially, the
+// while-analysing auto-refresh only rebuilds the LEFT list — it never touches
+// the RIGHT detail pane, so you can keep listening to an old recording while a
+// new one is being transcribed/analysed (the old bug: the whole view — detail +
+// audio element — was re-rendered every 5s, stopping playback).
+function viewRecordings() {
+ const v = document.querySelector('#view');
+ // Entering the tab: show only the newest day expanded, the rest collapsed
+ // (applied in refreshRecList once the day groups are known).
+ state.resetCollapse = true;
+ v.innerHTML = `
+
+
+
+
← Aufnahme wählen
+
`;
+ v.querySelector('#trashtoggle').onclick = () => {
+ state.trashOpen = !state.trashOpen;
+ state.lastListSig = null;
+ v.querySelector('#trashtoggle').classList.toggle('active', state.trashOpen);
+ refreshRecList();
+ };
+ // Draggable splitter: list width between LIST_MIN (the old fixed width) and
+ // twice that; persisted for all sessions.
+ const LIST_MIN = 340, LIST_MAX = 680;
+ const listPane = v.querySelector('.rec-list-pane');
+ const applyW = (w) => { listPane.style.flex = `0 0 ${Math.min(LIST_MAX, Math.max(LIST_MIN, w))}px`; };
+ applyW(+localStorage.getItem('aime-list-width') || LIST_MIN);
+ v.querySelector('.rec-splitter').onmousedown = (e) => {
+ e.preventDefault();
+ const startX = e.clientX, startW = listPane.getBoundingClientRect().width;
+ document.body.classList.add('resizing');
+ const move = (ev) => applyW(startW + (ev.clientX - startX));
+ const up = () => {
+ document.removeEventListener('mousemove', move);
+ document.removeEventListener('mouseup', up);
+ document.body.classList.remove('resizing');
+ localStorage.setItem('aime-list-width', Math.round(listPane.getBoundingClientRect().width));
+ };
+ document.addEventListener('mousemove', move);
+ document.addEventListener('mouseup', up);
+ };
+ refreshRecList();
+ if (state.selectedId != null) openRecording(state.selectedId);
+}
+
+// (Re)build ONLY the left list pane. Called on entry and by the auto-refresh
+// timer; safe to run while the right pane holds a playing recording.
+async function refreshRecList() {
+ const listEl = document.querySelector('#reclist');
+ if (!listEl) return; // left the tab
+ if (state.trashOpen) return refreshTrashList(listEl);
+ let recs, pinCounts = {};
+ try { recs = await api.get('/api/recordings'); }
+ catch (e) { listEl.innerHTML = '
' + e.message + '
'; return; }
+ // Wie oft steckt jede Aufnahme in einem Projekt? Ein Request für die ganze
+ // Liste; scheitert er, bleibt der 📌-Button einfach unmarkiert.
+ try { pinCounts = await api.get('/api/pins/counts'); } catch { pinCounts = {}; }
+ // Reschedule the poll ONLY while something is still processing.
+ const stillRunning = recs.some(r => r.analysis_status === 'pending' || r.status === 'transcribing');
+ const scheduleNext = () => { if (stillRunning) setTimeout(() => { if (state.tab === 'recordings') refreshRecList(); }, 5000); };
+ try {
+ if (!recs.length && !state.recording) { listEl.innerHTML = '
Noch keine Aufnahmen.
'; state.lastListSig = 'empty'; scheduleNext(); return; }
+ // Signature of everything the list RENDERS except the live ⏱ seconds (those
+ // tick via ensureAnaTicker without a rebuild). If nothing meaningful changed,
+ // DON'T touch the DOM at all — no flicker, no scroll jump, selection kept.
+ // This is the real fix for "I get pulled away from my recording every 5s":
+ // the 5s poll now no-ops unless a status/title/todo actually changed.
+ const sig = recs.map(r => `${r.id}|${r.status}|${r.analysis_status}|${r.title || ''}|${(r.todos || []).length}|${r.summary ? 1 : 0}|${r.duration_ms || 0}|${pinCounts[r.id] || 0}`).join('~');
+ if (sig === state.lastListSig && listEl.querySelector('.day-group')) { ensureAnaTicker(); scheduleNext(); return; }
+ state.lastListSig = sig;
+ // Preserve the left pane's scroll position across the rebuild.
+ const scroller = listEl.closest('.rec-list-pane') || listEl;
+ const savedTop = scroller.scrollTop;
+ // Group by calendar day (recs come newest-first). Each day is a collapsible
+ // section; the day header shows the recording count.
+ const groups = [];
+ const byDay = {};
+ for (const r of recs) {
+ const day = new Date(r.created_at).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' });
+ if (!byDay[day]) { byDay[day] = []; groups.push(day); }
+ byDay[day].push(r);
+ }
+ // Fresh tab entry: only the newest day (first group, recs are newest-first)
+ // stays open; manual toggles afterwards are respected until the next entry.
+ if (state.resetCollapse) { state.collapsedDates = new Set(groups.slice(1)); state.resetCollapse = false; }
+ // Live placeholder while a recording runs: its own card at the very top.
+ const liveHtml = state.recording ? `
+
`;
+ }).join('');
+ // Click a row (not one of its buttons) → open in the right pane.
+ listEl.querySelectorAll('.item.rec').forEach(it => it.onclick = (e) => {
+ if (e.target.closest('[data-del]') || e.target.closest('[data-ren]') || e.target.closest('[data-pin]')) return;
+ openRecording(+it.dataset.open);
+ });
+ listEl.querySelectorAll('[data-pin]').forEach(b => b.onclick = (e) => {
+ e.stopPropagation();
+ const rec = recs.find(r => r.id == b.dataset.pin);
+ pinToProjectDialog(rec || { id: +b.dataset.pin }, () => { state.lastListSig = null; refreshRecList(); });
+ });
+ listEl.querySelectorAll('[data-ren]').forEach(b => b.onclick = async (e) => {
+ e.stopPropagation();
+ const rec = recs.find(r => r.id == b.dataset.ren);
+ await renameRecording(+b.dataset.ren, (rec && rec.title) || '');
+ });
+ listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async (e) => {
+ e.stopPropagation();
+ const rec = recs.find(r => r.id == b.dataset.del);
+ const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.del;
+ if (!confirm(`${name} in den Papierkorb verschieben?\nWiederherstellbar für 30 Tage, danach wird sie automatisch endgültig gelöscht.`)) return;
+ await api.del('/api/recordings/' + b.dataset.del);
+ if (state.selectedId == b.dataset.del) { state.selectedId = null; const d = document.querySelector('#detail'); if (d) d.innerHTML = '
← Aufnahme wählen
'; }
+ toast('In den Papierkorb verschoben');
+ state.lastListSig = null; refreshRecList();
+ });
+ bindMarkerChips();
+ listEl.querySelectorAll('.day-head').forEach(h => h.onclick = () => {
+ const day = h.dataset.day;
+ if (state.collapsedDates.has(day)) state.collapsedDates.delete(day); else state.collapsedDates.add(day);
+ h.closest('.day-group').classList.toggle('collapsed');
+ });
+ scroller.scrollTop = savedTop; // keep the list where the user had it
+ ensureAnaTicker();
+ scheduleNext();
+ } catch (e) { listEl.innerHTML = '
' + e.message + '
'; }
+}
+
+// Rename a recording's title. An empty answer clears the title and hands
+// naming back to the LLM on the next analysis (see db.renameRecording).
+async function renameRecording(id, current) {
+ const t = prompt('Titel der Aufnahme:', current || '');
+ if (t === null) return false; // cancelled
+ try {
+ const r = await api.patch(`/api/recordings/${id}/title`, { title: t });
+ state.lastListSig = null;
+ refreshRecList();
+ // Keep the open detail pane in sync without rebuilding it (audio keeps playing).
+ const h = document.querySelector('#detail h2');
+ if (h && state.selectedId == id) {
+ const badge = h.querySelector('.badge');
+ h.textContent = r.title || 'Aufnahme #' + id;
+ if (badge) h.appendChild(badge);
+ }
+ toast(r.title ? 'Titel geändert' : 'Titel zurückgesetzt');
+ return true;
+ } catch (e) { toast('Umbenennen fehlgeschlagen: ' + e.message, true); return false; }
+}
+
+// Trash pane: same list slot, different contents. Deliberately flat (no day
+// grouping) — the interesting order here is "when did I delete it", and the
+// remaining days matter more than the recording date.
+async function refreshTrashList(listEl) {
+ let data;
+ try { data = await api.get('/api/trash'); }
+ catch (e) { listEl.innerHTML = '
` : '';
+ // Live-typed name markers: which cluster each one was resolved to, plus the
+ // option to add one while listening back (same resolution path) and to re-run
+ // the assignment after a re-diarization.
+ const CONF_LBL = { high: 'sicher', mid: 'wahrscheinlich', low: 'unsicher' };
+ const markersHtml = `
+
Namensmarker (während der Aufnahme getippt)
+ ${(r.markers || []).length ? `
${r.markers.map(m => {
+ const i = spkOrder.indexOf(m.speaker);
+ const to = m.speaker ? spkLabel(m.speaker, i >= 0 ? i : 0) : '– kein Sprecher gefunden';
+ return `
`;
+ }).join('');
+}
+function analysisBadge(st) {
+ if (st === 'pending') return '🧠 Analyse…';
+ if (st === 'error') return 'KI-Fehler';
+ if (st === 'done') return 'KI ✓';
+ return '';
+}
+
+// Der Aufnahmezustand lebt im Server (recorder.js `current`), nicht im Browser.
+// Ohne diesen Abgleich zeigt der Recorder nach jedem Neuladen 00:00, obwohl
+// parecord weiterläuft — und der Stop-Knopf ist unerreichbar, weil das Kopfband
+// dann den Start-Knopf malt.
+async function rehydrateRecording() {
+ try {
+ const h = await api.get('/api/health');
+ if (!h || !h.recording) return;
+ state.recording = true;
+ state.recStart = h.startedAt || Date.now();
+ state.markers = h.markers || [];
+ state.mkDraft = '';
+ render();
+ if (state.tab === 'recordings') { state.lastListSig = null; refreshRecList(); }
+ } catch (err) { /* Server nicht erreichbar: normale Ansicht, kein Toast */ }
+}
+
+render();
+rehydrateRecording();
diff --git a/app/web/src/style.css b/app/web/src/style.css
index 86f1e86..3cf595d 100644
--- a/app/web/src/style.css
+++ b/app/web/src/style.css
@@ -527,12 +527,20 @@ body.resizing { cursor: col-resize; user-select: none; }
.ptab.active { color: var(--accent); background: var(--accent-soft); border-color: var(--accent); font-weight: 600; }
.ptab.add { color: var(--muted); border-style: dashed; border-color: var(--line); }
.ptab.add:hover { color: var(--accent); border-color: var(--accent); }
-.ptab-n { font-size: 11px; opacity: .7; margin-left: 4px; }
+/* pointer-events aus: sonst feuert beim Ueberfahren des Zaehlers ein
+ dragleave am Reiter und die Ablagemarkierung flackert. */
+.ptab-n { font-size: 11px; opacity: .7; margin-left: 4px; pointer-events: none; }
+/* Reiter als Ablageziel, waehrend ein Pin darueber gezogen wird. */
+.ptab.drop {
+ color: var(--accent); background: var(--accent-soft);
+ border-color: var(--accent); border-style: dashed;
+}
.proj-level { margin-top: 14px; }
.proj-level-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
.proj-level-head .grow { flex: 1; }
.proj-level .item.rec { cursor: pointer; }
+.proj-level .item.rec.dragging { opacity: .45; border-style: dashed; }
/* Stecknadel in der Aufnahmenliste: blass, solange die Aufnahme nirgends
hängt — angeheftet wird sie gruen (dieselbe Farbe wie „KI ✓“, also
diff --git a/docs/08-changelog.md b/docs/08-changelog.md
index 3c2bb68..149877d 100644
--- a/docs/08-changelog.md
+++ b/docs/08-changelog.md
@@ -3,6 +3,39 @@
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
Was, Warum, betroffene Dateien/Backups).
+## 2026-08-14 — Angeheftete Aufnahmen per Drag & Drop zwischen Unterreitern verschieben
+
+**Wunsch:** eine im Projekt abgelegte Aufnahme sollte sich innerhalb des Ordners umhängen lassen, z. B. von
+`DFG/Projektleiter Runden` nach `DFG/INF-Projektboard`. Bisher ging das nur über Lösen + neu Anheften,
+also zwei Dialoge für eine Bewegung.
+
+**Umsetzung:** die Pin-Zeilen in der Ordneransicht sind `draggable`, die Reiter der Reiterleiste sind
+Ablageziele (`bindPinDrag` in `main.js`). Ein Drop schickt `PATCH /api/projects/:id/pins/:pinId {tab_id}`;
+`db.movePin` schreibt `project_pins.tab_id` um. Verschoben wird ausschließlich die Referenz — an der
+Aufnahme, ihrer Analyse oder ihren übrigen Anheftungen ändert sich nichts.
+
+Drei Punkte, die beim Bauen aufgefallen sind:
+
+- Hängt die Aufnahme auf der Zielebene bereits, würde das `UPDATE` an `UNIQUE uniq_pin` scheitern.
+ `movePin` löscht dann den Quell-Pin (**Zusammenführen statt Fehler**) und meldet `{merged:true}`.
+- Der Zielreiter wird serverseitig gegen den Ordner geprüft (`project_tabs.project_id`), sonst könnte ein
+ veralteter Browser-Tab einen Pin in ein fremdes Projekt schieben → sonst 404.
+- `.ptab-n` (Zähler im Reiter) braucht `pointer-events: none`, sonst feuert beim Überfahren des Zählers
+ ein `dragleave` und die Ablagemarkierung flackert.
+
+**Verifiziert** direkt gegen die laufende App (Ordner „DFG", Pin #12): Verschieben Reiter 2 → 3 → 2 jeweils
+`{"moved":true}` und `tab_id` in der DB entsprechend; fremder Reiter (999) und fremdes Projekt (2) je
+`404 {"error":"Anheftung oder Zielreiter nicht gefunden"}`; gleicher Reiter `{"moved":false}` ohne
+Schreibzugriff; Zusammenführen getestet mit Aufnahme #109 (zwei Pins → einer). Testdaten danach auf den
+Ausgangsstand zurückgesetzt. `vite build` sauber, `systemctl restart callrec` erst nach Prüfung auf
+`analysis_status='pending'` (keine laufende Analyse, keine laufende Aufnahme).
+
+Dateien: `app/db.js` (`movePin`), `app/server.js` (`PATCH …/pins/:pinId`), `app/web/src/main.js`
+(`bindPinDrag`, `draggable` an der Pin-Zeile, Hinweistext in der Ebenen-Kopfzeile),
+`app/web/src/style.css` (`.ptab.drop`, `.item.rec.dragging`, `.ptab-n`).
+Board-Backups: `.bak-movepin` (db.js, server.js, web/src/main.js, web/src/style.css, web/dist).
+Kapitel 10 (neuer Abschnitt „Verschieben per Drag & Drop" + Sequenzdiagramm + API-Tabelle).
+
## 2026-08-10 — Recorder überlebt das Neuladen (GUI holt den Zustand vom Server)
**Symptom:** während einer laufenden Aufnahme klickte der Nutzer in eine andere Aufnahme; danach stand der
diff --git a/docs/10-projektansicht.md b/docs/10-projektansicht.md
index 6fbfeac..357829a 100644
--- a/docs/10-projektansicht.md
+++ b/docs/10-projektansicht.md
@@ -96,6 +96,49 @@ Ein Klick auf eine angeheftete Aufnahme springt in den Reiter „Aufnahmen" und
Detailbereich (`jumpToRecording`, dieselbe Mechanik wie der Sprung aus einem Suchtreffer, nur ohne
Zeitmarke).
+## Verschieben per Drag & Drop (2026-08-14)
+
+Eine angeheftete Aufnahme lässt sich in der Ordneransicht auf einen **anderen Reiter der Reiterleiste
+ziehen** und wechselt damit die Ebene — z. B. von „Projektleiter Runden" nach „INF-Projektboard".
+Verschoben wird nur der Pin (`project_pins.tab_id`); die Aufnahme selbst wird nicht angefasst.
+
+```mermaid
+sequenceDiagram
+ participant U as Nutzer
+ participant G as GUI (main.js)
+ participant S as server.js
+ participant D as MariaDB
+ U->>G: dragstart auf 📌-Zeile
+ Note over G: merkt pin_id, setzt dataTransfer (Firefox braucht das)
+ U->>G: dragover Zielreiter
+ G-->>U: Reiter gestrichelt markiert (.ptab.drop) eigener Reiter = kein Ziel
+ U->>G: drop
+ G->>S: PATCH /api/projects/:id/pins/:pinId {tab_id}
+ S->>D: db.movePin()
+ alt Zielreiter gehört nicht zum Ordner / Pin unbekannt
+ D-->>S: null
+ S-->>G: 404
+ else Aufnahme hängt dort schon
+ D->>D: DELETE Quell-Pin (Zusammenführen)
+ S-->>G: {moved:true, merged:true}
+ else Normalfall
+ D->>D: UPDATE project_pins SET tab_id
+ S-->>G: {moved:true}
+ end
+ G->>G: renderProjectDetail() neu zeichnen
+```
+
+Drei Details, die nicht offensichtlich sind:
+
+- **Zusammenführen statt Duplikat:** hängt dieselbe Aufnahme auf der Zielebene bereits, würde das `UPDATE`
+ an `UNIQUE uniq_pin` scheitern. `movePin` löscht in diesem Fall den Quell-Pin, statt einen Fehler zu
+ werfen — im Ergebnis ist die Aufnahme genau einmal auf der Zielebene, was der Nutzer erwartet hat.
+- **Ziel wird serverseitig geprüft:** `tab_id` muss ein Reiter *desselben* Ordners sein (oder 0 für die
+ Ordner-Ebene), sonst 404. Ein veralteter Reiter im offenen Browser-Tab kann so keinen Pin in ein
+ fremdes Projekt schieben.
+- **`.ptab-n` (der Zähler im Reiter) bekommt `pointer-events: none`.** Sonst feuert beim Überfahren des
+ Zählers ein `dragleave` am Reiter und die Ablagemarkierung flackert.
+
## API
| Route | Zweck |
@@ -109,13 +152,15 @@ Zeitmarke).
| `PATCH /api/projects/:id/tabs/:tid` | Unterreiter umbenennen; `:tid = 0` benennt die Ordner-Ebene um (leerer Name = zurück auf „Ordner") |
| `DELETE /api/projects/:id/tabs/:tid` | Unterreiter löschen |
| `POST /api/projects/:id/pins` | anheften `{recording_id, tab_id}` (`tab_id` fehlt/0 = Ordner-Ebene) |
+| `PATCH /api/projects/:id/pins/:pinId` | Anheftung auf eine andere Ebene desselben Ordners verschieben `{tab_id}` (Drag & Drop); 404 bei fremdem Reiter/Ordner, `{merged:true}` wenn dort schon vorhanden |
| `DELETE /api/projects/:id/pins/:pinId` | Anheftung lösen |
| `GET /api/recordings/:id/pins` | wo hängt diese Aufnahme überall |
| `GET /api/pins/counts` | `{recording_id: anzahl}` für die ganze Liste |
## Dateien
-`app/db.js` (Tabellen + CRUD), `app/server.js` (Routen), `app/web/src/main.js`
-(`viewProjects`/`renderProjectList`/`renderProjectDetail`/`pinPickRecordingDialog`/`pinToProjectDialog`/
-`openModal`), `app/web/src/style.css` (Ordnergitter, Reiterleiste, Dialog).
-Board-Backups: `.bak-projects`.
+`app/db.js` (Tabellen + CRUD, `movePin`), `app/server.js` (Routen), `app/web/src/main.js`
+(`viewProjects`/`renderProjectList`/`renderProjectDetail`/`bindPinDrag`/`pinPickRecordingDialog`/
+`pinToProjectDialog`/`openModal`), `app/web/src/style.css` (Ordnergitter, Reiterleiste, `.ptab.drop`,
+`.item.rec.dragging`, Dialog).
+Board-Backups: `.bak-projects` (Grundfunktion), `.bak-movepin` (Drag & Drop).