/* FlowDeck — AI Meeting Notes, bloc façon Notion (v7.1.1). * * Refondte alignée sur docs/architecture-meeting-notion-flowdeck.md (§3A/3B) : * - un seul bloc à états (idle/recording/paused/processing/done/failed, §6.5), * jamais d'écrans multiples ; * - révélation progressive des onglets : [Notes] → [Notes, Transcript] → * [Summary, Notes, Transcript], Summary actif par défaut après l'arrêt ; * - coquille fixe : en-tête (calendrier + titre généré), rangée * onglets + contrôles d'état, corps selon (état × onglet), pied * (instructions + attestation de consentement à l'état initial, * bandeau d'info ensuite) ; * - capture Web 2 modes : A micro (getUserMedia), B onglet + micro * (getDisplayMedia + micro, canaux séparés non mixés, §6.2) ; * - forme d'onde en direct (AnalyserNode, ~10 img/s), chronomètre de session, * alerte « je n'entends que vous » si le canal distant reste muet ; * - tampon local IndexedDB : les segments non envoyés survivent au * rechargement ; un rechargement restaure l'état exact (recording → * paused avec reprise proposée, processing → reprise du suivi) ; * - le titre généré par l'IA ne touche JAMAIS le titre de la page hôte. * * Surcharge window.FDMeeting (défini par page_editor_scripts.js) : le * placeholder .block-meeting et l'appel window.FDMeeting.mount() existants * sont réutilisés tels quels. Les anciens blocs (v1, champs started/lines/ * lastTab) sont migrés à la volée par _ensure(). */ (function () { 'use strict'; /* ── utilitaires ─────────────────────────────────────────────── */ function esc(v) { return String(v == null ? '' : v) .replace(/&/g, '&').replace(//g, '>'); } function attr(v) { return esc(v).replace(/"/g, '"'); } function findBlock(bid) { var E = window.E; if (E && E.blocks) for (var i = 0; i < E.blocks.length; i++) if (E.blocks[i].id === bid) return E.blocks[i]; return null; } function save() { var E = window.E; if (E) { E.dirty = true; if (E.autoSave) E.autoSave(); } } function csrf() { try { if (typeof getCsrf === 'function') return getCsrf(); } catch (e) {} var m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/); return m ? decodeURIComponent(m[1]) : ''; } function api(path, opts) { opts = opts || {}; var headers = { 'X-CSRF-Token': csrf() }; if (opts.body !== undefined) headers['Content-Type'] = 'application/json'; return fetch(path, { method: opts.method || 'GET', credentials: 'same-origin', headers: headers, body: opts.body === undefined ? undefined : JSON.stringify(opts.body) }).then(function (r) { if (!r.ok) return r.text().then(function (t) { var err = new Error('HTTP ' + r.status + ' ' + (t || '').slice(0, 200)); err.status = r.status; throw err; }); return r.json(); }); } function toast(msg) { try { if (window.E && window.E.showToast) { window.E.showToast(msg); return; } } catch (e) {} try { console.log('[meeting]', msg); } catch (e2) {} } function fmtClock(ms) { var s = Math.max(0, Math.floor(ms / 1000)); var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60; function p(n) { return (n < 10 ? '0' : '') + n; } return (h ? h + ':' + p(m) : p(m)) + ':' + p(ss); } function fmtTs(ms) { var s = Math.floor(Math.max(0, ms || 0) / 1000); return Math.floor(s / 60) + ':' + (s % 60 < 10 ? '0' : '') + (s % 60); } var CONSENT_TEXT = 'This meeting is being transcribed by FlowDeck AI Meeting Notes. ' + 'By staying, you agree to the transcription.'; var INSTRUCTIONS = [ ['auto', 'Auto'], ['standup', 'Standup'], ['team', 'Team meeting'], ['sales', 'Sales call'], ['one_to_one', '1:1'], ['interview', 'Interview'] ]; var THINKING_STEPS = [ ['reading_transcript', 'Reading transcript…'], ['analyzing_transcript', 'Analyzing the transcript'], ['resolving_references', 'Thinking about how to refer to people'], ['understanding_content', 'Understanding the content'], ['classifying_completeness', 'Checking completeness'], ['generating_summary', 'Writing the summary'], ['generating_title', 'Choosing a title'], ['validating_citations', 'Checking citations'] ]; /* ── état du bloc ────────────────────────────────────────────── */ function defState() { return { v: 2, title: 'Meeting', date: new Date().toISOString().slice(0, 10), state: 'idle', mode: 'mic_only', instruction: 'auto', lang: 'fr', consent: null, consentAudio: false, tab: 'notes', channels: [], segments: [], segSeq: 0, notes: '', manual: '', interim: '', startedAt: 0, elapsedBase: 0, pausedMs: 0, steps: [], summaryMd: '', completeness: '', genTitle: '', titleEdited: false, shareHidden: false, bannerHidden: false, transcriptId: null, error: '', remoteSilentWarned: false, menuOpen: false, settingsOpen: false }; } function ensure(bid) { var b = findBlock(bid); if (!b) return null; if (!b.meeting || !b.meeting.v) { var old = b.meeting || {}, m = defState(); if (old.title) m.title = old.title; if (old.date) m.date = old.date; if (old.notes) m.notes = old.notes; if (old.manualTranscript) m.manual = old.manualTranscript; if (old.summary) m.summaryMd = old.summary; if (old.summaryError) m.error = old.summaryError; var st = old.status || 'idle'; if (st === 'running') st = 'recording'; else if (st === 'manual') st = 'idle'; m.state = (st === 'recording' || st === 'paused' || st === 'done') ? st : 'idle'; m.tab = old.lastTab === 'summary' ? 'summary' : old.lastTab === 'notes' ? 'notes' : (m.state === 'idle' ? 'notes' : 'transcript'); if (m.state === 'done' && !m.summaryMd) m.state = 'idle'; m.segments = (old.lines || []).map(function (l, i) { return { seq: i + 1, channel: 'self', source: '', speaker: l.who || '', startMs: 0, endMs: 0, text: l.text || '', mid: false }; }); m.segSeq = m.segments.length; b.meeting = m; } var m2 = b.meeting; ['segments', 'channels', 'steps'].forEach(function (k) { if (!Array.isArray(m2[k])) m2[k] = []; }); return b; } function displayTitle(m) { if (m.genTitle && !m.titleEdited) return m.genTitle; return m.title || 'Meeting'; } /* ── tampon local IndexedDB (segments non envoyés) ───────────── */ var idb = null; function idbOpen() { if (idb) return Promise.resolve(idb); return new Promise(function (resolve) { try { var req = indexedDB.open('fd-meet', 1); req.onupgradeneeded = function () { req.result.createObjectStore('outbox'); }; req.onsuccess = function () { idb = req.result; resolve(idb); }; req.onerror = function () { resolve(null); }; } catch (e) { resolve(null); } }); } function idbPut(bid, segs) { idbOpen().then(function (db) { if (!db) return; try { var tx = db.transaction('outbox', 'readwrite'); if (segs && segs.length) tx.objectStore('outbox').put(segs, 'out-' + bid); else tx.objectStore('outbox').delete('out-' + bid); } catch (e) {} }); } function idbGet(bid) { return idbOpen().then(function (db) { if (!db) return []; return new Promise(function (resolve) { try { var tx = db.transaction('outbox', 'readonly'); var rq = tx.objectStore('outbox').get('out-' + bid); rq.onsuccess = function () { resolve(rq.result || []); }; rq.onerror = function () { resolve([]); }; } catch (e) { resolve([]); } }); }); } /* ── rendu : coquille fixe (en-tête / rangée / corps / pied) ─── */ function tabDefs(state) { if (state === 'idle') return [['notes', '✎', 'Notes']]; if (state === 'recording' || state === 'paused') return [['notes', '✎', 'Notes'], ['transcript', 'mic', 'Transcript']]; return [['summary', '☰', 'Summary'], ['notes', '✎', 'Notes'], ['transcript', 'mic', 'Transcript']]; } function defaultTab(state) { return (state === 'processing' || state === 'done' || state === 'failed') ? 'summary' : state === 'idle' ? 'notes' : 'transcript'; } function renderHead(m) { return '
' + '▤' + '▾' + '' + '
'; } function renderRow(bid, m) { var tabs = tabDefs(m.state).map(function (t) { var active = (m.tab === t[0]) || (!m.tab && defaultTab(m.state) === t[0]); return ''; }).join(''); var ctrl = ''; if (m.state === 'idle') { ctrl = '
' + '' + '' + '
' + (m.menuOpen ? renderStartMenu(m) : ''); } else if (m.state === 'recording') { ctrl = '' + '' + esc(fmtClock(elapsed(m))) + '' + '' + ''; } else if (m.state === 'paused') { ctrl = 'Paused' + '' + esc(fmtClock(elapsed(m))) + '' + '' + ''; } else if (m.state === 'processing') { ctrl = 'Thinking'; } return '
' + tabs + '
' + '
' + '' + '' + ctrl + '
' + (m.settingsOpen ? renderSettings(m) : ''); } function renderStartMenu(m) { var stt = hasSTT(); var tabCap = hasDisplayMedia(); return ''; } function renderSettings(m) { var inner; if (m.state === 'idle') { inner = '
Audio source
' + ' ' + '
' + '
Language
' + ' ' + '
' + '
Summary instructions' + '
'; } else { inner = '
Mode' + esc(m.mode === 'tab_plus_mic' ? 'Tab + microphone' : 'Microphone') + ' · ' + esc(m.lang.toUpperCase()) + ' · instructions: ' + esc(m.instruction) + '
' + ((m.state === 'done' || m.state === 'failed') ? '
' : ''); } return '
' + inner + '
'; } function renderBody(bid, m) { var tab = m.tab || defaultTab(m.state); if (m.state === 'idle') return renderIdleBody(m); if (m.state === 'processing') return tab === 'summary' ? renderSteps(m) : renderBodyTab(bid, m, tab); if (m.state === 'done' || m.state === 'failed') return tab === 'summary' ? renderSummary(bid, m) : renderBodyTab(bid, m, tab); return renderBodyTab(bid, m, tab); // recording / paused } function renderIdleBody(m) { var stt = hasSTT(), tab = hasDisplayMedia(); return '
' + 'FlowDeck AI will summarize the notes and transcript
' + '
' + '○ Microphone…' + '' + (stt ? '✓ Live transcription' : '⚠ Live transcription unavailable — manual fallback') + '' + '' + (tab ? '✓ Tab + mic' : '○ Tab + mic unsupported here') + '' + '' + '
' + '' + '
Press \'space\' for AI or \'/\' for commands
'; } // État de la permission micro (async, sans repaint : pas de perte de focus). function refreshMicPerm(bid) { var rt = FDMeeting._rt[bid]; if (!rt || !rt.root) return; if (!rt.root.querySelector('[data-fm-micperm]')) return; function set(txt) { var r2 = FDMeeting._rt[bid]; if (!r2 || !r2.root) return; var el = r2.root.querySelector('[data-fm-micperm]'); if (el) el.textContent = txt; } try { if (!navigator.permissions || !navigator.permissions.query) { set('○ Microphone — permission unknown'); return; } navigator.permissions.query({ name: 'microphone' }).then(function (st) { var label = st.state === 'granted' ? '✓ Microphone allowed' : st.state === 'denied' ? '✕ Microphone blocked — allow it in the address bar, or paste a transcript' : '○ Microphone — requested at Start (or Test now)'; set(label); try { st.onchange = function () { refreshMicPerm(bid); }; } catch (e) {} }).catch(function () { set('○ Microphone — permission unknown'); }); } catch (e) { set('○ Microphone — permission unknown'); } } function renderBodyTab(bid, m, tab) { if (tab === 'notes') { return '
' + '
Press \'space\' for AI or \'/\' for commands
'; } return renderTranscript(bid, m); } function groupSegments(m) { var groups = [], byKey = {}; (m.segments || []).forEach(function (s) { var key = s.source || (s.channel === 'remote' ? 'Tab audio' : 'Microphone'); if (!byKey[key]) { byKey[key] = []; groups.push({ source: key, items: byKey[key] }); } byKey[key].push(s); }); return groups; } function renderTranscript(bid, m) { var groups = groupSegments(m); var live = (m.state === 'recording' || m.state === 'paused'); var h = '
'; if (!groups.length && !m.interim && !m.manual) { h += '
The transcription will appear here live. ' + 'Record the meeting to generate a transcript.
'; } groups.forEach(function (g) { h += '
◎ ' + esc(g.source) + '
'; g.items.forEach(function (s) { h += '
' + '' + esc(fmtTs(s.startMs)) + '' + '' + '' + '' + esc(s.text) + '
'; }); }); if (m.interim) h += '
·' + '' + esc(m.interim) + '…
'; h += '
'; var stt = hasSTT(); h += '
' + 'Manual transcript (paste, or import audio below)' + '' + '
' + 'MP3 · WAV · M4A · OGG · FLAC · AAC (100 MB max)
' + '
'; h += '
'; return h; } function renderSteps(m) { var steps = m.steps && m.steps.length ? m.steps : THINKING_STEPS.map(function (s) { return { key: s[0], label: s[1], state: 'pending' }; }); var h = '
'; steps.forEach(function (s) { var icon = s.state === 'done' ? '✓' : s.state === 'running' ? '⊙' : s.state === 'failed' ? '✕' : '○'; h += '
' + icon + '' + esc(s.label) + '
'; }); h += '
'; return h; } function renderSummaryMd(md) { // Sections ## + puces • + marqueurs ¹ [seg N] → HTML sûr. var h = esc(md || ''); h = h.replace(/^## (.+)$/gm, '

$1

'); h = '
' + h + '
'; h = h.replace(/^•\s?(.*)$/gm, '
•$1
'); h = h.replace(/ ⁠¹ \[seg (\d+)\]/g, ' '); h = h.replace(/ ⁠¹/g, ' ¹'); h = h.replace(/^_([^_]+)_$/gm, '
$1
'); h = h.replace(/\n/g, '
'); return '
' + h + '
'; } function renderSummary(bid, m) { var h = '
'; if (m.state === 'failed') { h += '
' + esc(m.error || 'Processing failed.') + ' The transcript is kept — retry when ready.
' + '
'; if (m.segments.length) h += renderTranscript(bid, m).replace( '
', '
'); return h + '
'; } if (!m.shareHidden && m.summaryMd) { h += '
Share this summary
'; } if (m.completeness && m.completeness !== 'complete') h += '
Summary class: ' + esc(m.completeness) + ' — partial recording, verify against the transcript.
'; h += renderSummaryMd(m.summaryMd); h += '
'; return h + '
'; } function renderFoot(bid, m) { if (m.state === 'idle') { return '
Instructions:' + ' ' + '│' + 'By starting, you confirm everyone being transcribed has given consent.' + '' + '
'; } if (m.bannerHidden) return ''; return '
✨ AI Meeting Notes — live transcription' + ' in your browser · audio is ephemeral, transcript is kept.' + '
'; } function html(bid) { var b = ensure(bid); if (!b) return ''; var m = b.meeting; var warn = ''; if ((m.state === 'recording') && m.remoteSilentWarn && !m.remoteSilentWarned) warn = '
🔇 I can only hear you. If the call is in another' + ' tab, check its audio is shared (Tab + microphone mode).
'; else if ((m.state === 'recording') && m.micLowWarn) warn = '
🎤 Microphone level is very low — move closer or check input.
'; return '
' + renderHead(m) + renderRow(bid, m) + warn + renderBody(bid, m) + renderFoot(bid, m) + '
'; } /* ── elapsed ─────────────────────────────────────────────────── */ function elapsed(m) { if (m.state === 'recording') return (m.elapsedBase || 0) + (Date.now() - (m.startedAt || Date.now())); if (m.state === 'paused' || m.state === 'processing' || m.state === 'done' || m.state === 'failed') return m.elapsedBase || 0; return 0; } /* ── moteur de capture (modes A/B, §6.2) ─────────────────────── */ function hasSTT() { return !!(window.SpeechRecognition || window.webkitSpeechRecognition); } function hasDisplayMedia() { return !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia); } function stopTracks(rt) { ['micStream', 'tabStream'].forEach(function (k) { var s = rt[k]; if (s) try { s.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {} rt[k] = null; }); if (rt.audioCtx) try { rt.audioCtx.close(); } catch (e) {} rt.audioCtx = null; rt.analyserSelf = null; rt.analyserRemote = null; if (rt.waveRaf) try { cancelAnimationFrame(rt.waveRaf); } catch (e) {} rt.waveRaf = null; } function stopRecog(rt) { if (rt.recog) { rt.recogStopping = true; try { rt.recog.stop(); } catch (e) {} } rt.recog = null; } function teardownCapture(bid) { var rt = FDMeeting._rt[bid] || {}; stopRecog(rt); stopTracks(rt); if (rt.timer) { try { clearInterval(rt.timer); } catch (e) {} rt.timer = null; } if (rt.poller) { try { clearInterval(rt.poller); } catch (e) {} rt.poller = null; } } function rms(analyser, buf) { if (!analyser) return 0; analyser.getByteTimeDomainData(buf); var sum = 0; for (var i = 0; i < buf.length; i++) { var v = (buf[i] - 128) / 128; sum += v * v; } return Math.sqrt(sum / buf.length); } function attachAnalyser(rt, stream) { var Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return null; if (!rt.audioCtx) { try { rt.audioCtx = new Ctx(); } catch (e) { return null; } } try { var src = rt.audioCtx.createMediaStreamSource(stream); var an = rt.audioCtx.createAnalyser(); an.fftSize = 512; src.connect(an); return an; } catch (e) { return null; } } function startWave(bid, rt) { var bars = rt.waveBars || (rt.waveBars = new Array(28).fill(0.05)); var bufS = new Uint8Array(512), bufR = new Uint8Array(512); var last = 0, remoteQuietSince = 0; function frame(t) { var b = findBlock(bid); if (!b || b.meeting.state !== 'recording') return; if (t - last > 100) { last = t; var lvS = rms(rt.analyserSelf, bufS); var lvR = rt.analyserRemote ? rms(rt.analyserRemote, bufR) : 0; var lv = Math.max(lvS, lvR); bars.push(Math.min(1, lv * 6 + 0.04)); bars.shift(); if (rt.analyserRemote) { if (lvR < 0.015 && lvS > 0.02) { if (!remoteQuietSince) remoteQuietSince = Date.now(); else if (Date.now() - remoteQuietSince > 20000 && !b.meeting.remoteSilentWarn) { b.meeting.remoteSilentWarn = true; save(); FDMeeting.paint(bid); } } else { remoteQuietSince = 0; } } if (lvS < 0.008) { rt.lowSince = rt.lowSince || Date.now(); if (Date.now() - rt.lowSince > 20000 && !b.meeting.micLowWarn) { b.meeting.micLowWarn = true; save(); FDMeeting.paint(bid); } } else { rt.lowSince = 0; } drawWave(bid, bars); } rt.waveRaf = requestAnimationFrame(frame); } rt.waveRaf = requestAnimationFrame(frame); } function drawWave(bid, bars) { var rt = FDMeeting._rt[bid]; if (!rt || !rt.root) return; var cv = rt.root.querySelector('[data-fm-wave]'); if (!cv) return; var ctx = cv.getContext('2d'); if (!ctx) return; ctx.clearRect(0, 0, cv.width, cv.height); var n = bars.length, w = cv.width / n; ctx.fillStyle = '#2383E2'; for (var i = 0; i < n; i++) { var h = Math.max(2, bars[i] * cv.height); var x = i * w + w * 0.25; ctx.beginPath(); if (bars[i] < 0.12) { ctx.arc(x + w * 0.25, cv.height / 2, 1.4, 0, 7); ctx.fill(); } else { ctx.fillRect(x, (cv.height - h) / 2, w * 0.5, h); } } } function startTimer(bid) { var rt = FDMeeting._rt[bid] || {}; if (rt.timer) try { clearInterval(rt.timer); } catch (e) {} rt.timer = setInterval(function () { var b = findBlock(bid); if (!b || (b.meeting.state !== 'recording' && b.meeting.state !== 'paused')) { try { clearInterval(rt.timer); } catch (e2) {} return; } var el = rt.root && rt.root.querySelector('[data-fm-timer]'); if (el) el.textContent = fmtClock(elapsed(b.meeting)); }, 1000); } function pushSegment(bid, seg) { var b = ensure(bid); if (!b) return; var m = b.meeting; m.segSeq = (m.segSeq || 0) + 1; var item = { seq: m.segSeq, channel: seg.channel || 'self', source: seg.source || '', speaker: seg.speaker || '', startMs: seg.startMs != null ? seg.startMs : elapsed(m), endMs: seg.endMs != null ? seg.endMs : elapsed(m), text: (seg.mid ? '--' : '') + (seg.text || ''), mid: !!seg.mid }; m.segments.push(item); save(); // tampon local + envoi serveur (reprise sur rechargement). idbGet(bid).then(function (pending) { pending.push({ channel: item.channel, source_label: item.source, speaker: item.speaker, text: (seg.text || ''), start_ms: item.startMs, end_ms: item.endMs, starts_mid: item.mid }); idbPut(bid, pending); flushSegments(bid); }); // rafraîchir seulement la zone transcript (pas de repaint complet). var rt = FDMeeting._rt[bid]; if (rt && rt.root && (m.tab === 'transcript' || !m.tab)) { var body = rt.root.querySelector('[data-fm-transcript]'); if (body) { var tmp = document.createElement('div'); tmp.innerHTML = renderTranscript(bid, m); var fresh = tmp.querySelector('[data-fm-transcript]'); if (fresh) body.innerHTML = fresh.innerHTML; } } } var flushBusy = {}; function flushSegments(bid) { var b = findBlock(bid); if (!b || !b.meeting.transcriptId || flushBusy[bid]) return; flushBusy[bid] = true; idbGet(bid).then(function (pending) { if (!pending.length) { flushBusy[bid] = false; return; } var batch = pending.slice(0, 50); api('/api/v2/meetings/transcripts/' + b.meeting.transcriptId + '/segments', { method: 'POST', body: { segments: batch } }).then(function () { idbPut(bid, pending.slice(batch.length)); flushBusy[bid] = false; if (pending.length > batch.length) flushSegments(bid); }).catch(function () { flushBusy[bid] = false; }); }); } // Vide le tampon local vers le serveur avant toute analyse : le manuel // collé après un échec (cas du 400 en boucle) part lui aussi — c'était // le chaînon manquant. Résout true si le serveur a tout, false sinon. function drainOutbox(bid, timeoutMs) { var b = findBlock(bid); if (b && (b.meeting.manual || '').trim() && b.meeting.transcriptId) { var pasted = b.meeting.manual.trim(); b.meeting.manual = ''; save(); FDMeeting._pushSegment(bid, { channel: 'self', source: 'Pasted transcript', speaker: '', text: pasted, startMs: 0, endMs: elapsed(b.meeting), mid: false }); } else { flushSegments(bid); } var bb = findBlock(bid); if (!bb || !bb.meeting.transcriptId) return Promise.resolve(true); var waited = 0, step = 400, max = timeoutMs || 5000; return new Promise(function (resolve) { var iv = setInterval(function () { idbGet(bid).then(function (pending) { if (!pending.length || waited >= max) { clearInterval(iv); resolve(!pending.length); } waited += step; }); }, step); }); } // Réponse d'analyse (/stop ou /retry) → état done, dans tous les cas. function applyDone(bid, out) { var bb = findBlock(bid); if (!bb) return; var rtx = FDMeeting._rt[bid]; if (rtx) rtx.allowEmpty = false; bb.meeting.state = 'done'; bb.meeting.summaryMd = (out && out.summary) || ''; bb.meeting.completeness = (out && out.completeness) || ''; bb.meeting.genTitle = (out && out.generated_title) || ''; bb.meeting.error = ''; save(); FDMeeting.paint(bid); idbPut(bid, []); } function applyFailed(bid, err, defaultMsg) { var bb = findBlock(bid); if (!bb) return; var rtx = FDMeeting._rt[bid]; if (rtx) rtx.allowEmpty = false; bb.meeting.state = 'failed'; bb.meeting.error = err ? friendlyMeetingError(err) : (defaultMsg || 'Processing failed — the transcript is kept, Retry when ready.'); save(); FDMeeting.paint(bid); } function startRecognition(bid, rt, m) { var SR = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SR) return false; stopRecog(rt); var rec; try { rec = new SR(); } catch (e) { return false; } rt.recog = rec; rt.recogStopping = false; rec.lang = m.lang === 'en' ? 'en-US' : 'fr-FR'; rec.continuous = true; rec.interimResults = true; rec.maxAlternatives = 1; var base = 0; rec.onresult = function (ev) { var b = findBlock(bid); if (!b) return; var finalText = '', interim = ''; for (var i = base; i < ev.results.length; i++) { var r = ev.results[i]; var t = (r[0] && r[0].transcript) || ''; if (r.isFinal) finalText += t; else interim += t; } base = ev.results.length; if (finalText.trim()) { var firstEver = b.meeting.segments.length === 0; FDMeeting._pushSegment(bid, { channel: 'self', source: m.mode === 'tab_plus_mic' ? 'My microphone' : "My audio", speaker: '', text: finalText.trim(), // Heuristique §3A.7 : si le premier segment arrive alors que la // session tourne depuis un moment, on a pris en pleine phrase. mid: firstEver && elapsed(b.meeting) > 5000 }); b.meeting.interim = ''; } else { b.meeting.interim = interim.trim(); var el = rt.root && rt.root.querySelector('.fm-interim .fm-txt'); if (el) el.textContent = (b.meeting.interim || '') + '…'; } }; rec.onend = function () { var b2 = findBlock(bid); if (!rt.recogStopping && b2 && b2.meeting.state === 'recording') { try { rec.start(); } catch (e) {} } }; rec.onerror = function (ev) { if (ev && ev.error === 'not-allowed') { var b3 = findBlock(bid); if (b3) { toast('Microphone refused — allow the mic or use the manual transcript.'); paintBody(bid); } } }; try { rec.start(); return true; } catch (e) { return false; } } function paintBody(bid) { FDMeeting.paint(bid); } function elapsedOf(m) { return elapsed(m); } /* ── objet public (surcharge) ────────────────────────────────── */ var FDMeeting = { _rt: {}, mount: function (bid, root) { var rt = this._rt[bid] || (this._rt[bid] = {}); rt.root = root; if (!rt.wired) { rt.wired = true; var self = this; root.addEventListener('click', function (ev) { var t = ev.target && ev.target.closest ? ev.target.closest('[data-act]') : null; if (t) self.act(bid, t.getAttribute('data-act'), t, ev); }); root.addEventListener('input', function (ev) { self.onInput(bid, ev.target, ev); }); root.addEventListener('change', function (ev) { self.onInput(bid, ev.target, ev); }); } var b = ensure(bid); if (b) { var m = b.meeting; // Rechargement en pleine session : restaurer l'état exact (§6.5). if (m.state === 'recording' || m.state === 'paused') { if (m.startedAt && m.state === 'recording') { m.elapsedBase = elapsed(m); m.state = 'paused'; toast('Capture interrupted by reload — press Resume to continue.'); save(); } } else if (m.state === 'processing') { this.followProcessing(bid); } // Rejouer le tampon local vers le serveur. if (m.transcriptId) flushSegments(bid); if (m.state === 'recording') startTimer(bid); } this.paint(bid); }, focus: function () {}, paint: function (bid) { var rt = this._rt[bid], b = ensure(bid); if (!b || !rt || !rt.root) return; rt.root.innerHTML = html(bid); if (b.meeting.state === 'idle') refreshMicPerm(bid); if (b.meeting.state === 'recording') { startTimer(bid); if (!rt.waveRaf && (rt.analyserSelf || !hasSTT())) startWave(bid, rt); } }, onInput: function (bid, el) { var b = ensure(bid); if (!b || !el) return; var m = b.meeting, k = el.getAttribute && el.getAttribute('data-fm'); if (k === 'title') { var v = el.value || ''; if (m.genTitle && !m.titleEdited && v !== m.genTitle && v !== m.title) { m.titleEdited = true; m.title = v; if (m.transcriptId) api('/api/v2/meetings/transcripts/' + m.transcriptId + '/generated-title', { method: 'PATCH', body: { title: v } }).catch(function () {}); } else if (!m.genTitle || m.titleEdited) { m.title = v; } else if (v !== m.genTitle) { m.titleEdited = true; m.title = v; } save(); return; // pas de repaint (garder le focus) } if (k === 'notes') { m.notes = el.value; save(); if (m.transcriptId) { clearTimeout(this._notesT); this._notesT = setTimeout(function () { api('/api/v2/meetings/transcripts/' + m.transcriptId + '/notes', { method: 'PATCH', body: { notes: m.notes } }).catch(function () {}); }, 1200); } return; } if (k === 'manual') { m.manual = el.value; save(); return; } if (k === 'instruction') { m.instruction = el.value; save(); this.paint(bid); return; } if (k === 'consentAudio') { m.consentAudio = !!el.checked; save(); return; } if (el.getAttribute && el.getAttribute('data-fm') === 'importAudio' && el.files) { this.importAudio(bid, el.files[0]); return; } // input file import audio (attribut data-fm="importAudio") if (el.type === 'file' && el.files) { this.importAudio(bid, el.files[0]); } }, act: function (bid, act, el, ev) { var b = ensure(bid); if (!b) return; var m = b.meeting; if (ev && (act === 'mode' || act === 'lang')) ev.stopPropagation(); switch (act) { case 'tab': m.tab = (el && el.getAttribute('data-tab')) || defaultTab(m.state); save(); this.paint(bid); break; case 'menu': m.menuOpen = !m.menuOpen; save(); this.paint(bid); break; case 'settings': m.settingsOpen = !m.settingsOpen; save(); this.paint(bid); break; case 'testmic': this.testMic(bid); break; case 'mode': m.mode = el.getAttribute('data-v'); m.menuOpen = false; save(); this.paint(bid); break; case 'lang': m.lang = el.getAttribute('data-v'); m.menuOpen = false; save(); this.paint(bid); break; case 'suggest': this.suggest(bid); break; case 'consentAudio': this.playConsent(); break; case 'consentCopy': copyText(CONSENT_TEXT); toast('Consent message copied — paste it in the call chat.'); break; case 'bannerhide': m.bannerHidden = true; save(); this.paint(bid); break; case 'sharehide': m.shareHidden = true; save(); this.paint(bid); break; case 'start': this.start(bid); break; case 'pause': this.pause(bid); break; case 'resume': this.resume(bid); break; case 'stop': this.stop(bid); break; case 'retry': this.retry(bid); break; case 'regen': this.regen(bid); break; case 'reset': this.reset(bid); break; case 'cite': { var seq = parseInt(el.getAttribute('data-seq'), 10); m.tab = 'transcript'; save(); this.paint(bid); setTimeout(function () { var rt = FDMeeting._rt[bid]; var target = rt && rt.root && rt.root.querySelector('.fm-seg[data-seq="' + seq + '"]'); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'center' }); target.classList.add('fm-flash'); setTimeout(function () { target.classList.remove('fm-flash'); }, 2200); } }, 60); break; } case 'speaker': { var sq = parseInt(el.getAttribute('data-seq'), 10); var cur = ''; m.segments.forEach(function (s) { if (s.seq === sq) cur = s.speaker || ''; }); var name = window.prompt('Speaker name for segment ' + sq + ':', cur || 'You'); if (name === null) break; m.segments.forEach(function (s) { if (s.seq === sq) s.speaker = name.trim(); }); save(); if (m.transcriptId) api('/api/v2/meetings/transcripts/' + m.transcriptId + '/segments/' + sq + '/speaker', { method: 'PATCH', body: { speaker: name.trim() } }).catch(function () {}); this.paint(bid); break; } case 'share': this.share(bid, el.getAttribute('data-v')); break; } }, suggest: function (bid) { var b = ensure(bid); if (!b) return; var m = b.meeting; if (!m.notes.trim()) { m.notes = 'Agenda\n• …\n\nContext\n• …\n'; save(); this.paint(bid); toast('Agenda template added — the AI uses it in the summary.'); } else { toast('Tip: write decisions and names in Notes — they take priority in the summary.'); } }, playConsent: function () { try { var u = new SpeechSynthesisUtterance(CONSENT_TEXT); u.lang = 'en-US'; window.speechSynthesis.speak(u); } catch (e) { toast(CONSENT_TEXT); } }, /* ── cycle de vie ─────────────────────────────────────────── */ ensureServerRow: function (bid, m) { if (m.transcriptId) return Promise.resolve(m.transcriptId); var pid = (window.E && window.E.pid) || 0; if (!pid) return Promise.reject(new Error('Open the page first (no page id).')); return api('/api/v2/meetings/transcripts', { method: 'POST', body: { page_id: pid, language: m.lang, mode: m.mode, instruction_id: m.instruction } }).then(function (row) { m.transcriptId = row.id; save(); return row.id; }); }, start: function (bid) { var self = this, b = ensure(bid); if (!b) return; var m = b.meeting; m.menuOpen = false; if (m.state === 'recording' || m.state === 'processing') return; // Porte de consentement : le clic sur Start vaut attestation (§3A.2). this.ensureServerRow(bid, m).then(function (tid) { return api('/api/v2/meetings/transcripts/' + tid + '/consents', { method: 'POST', body: { method: 'start_attestation', participants: [] } }).then(function () { return tid; }); }).then(function (tid) { m.consent = { method: 'start_attestation', at: Date.now() }; return api('/api/v2/meetings/transcripts/' + tid + '/sessions', { method: 'POST', body: { mode: m.mode, language: m.lang, instruction_id: m.instruction, channels: m.mode === 'tab_plus_mic' ? [{ kind: 'self', label: 'My microphone' }, { kind: 'remote', label: 'Tab audio' }] : [{ kind: 'self', label: 'My audio' }] } }); }).then(function () { m.state = 'recording'; m.tab = m.tab === 'summary' ? 'transcript' : (m.tab || 'transcript'); m.startedAt = Date.now(); m.remoteSilentWarn = false; m.micLowWarn = false; m.remoteSilentWarned = false; m.steps = []; m.summaryMd = ''; m.error = ''; m.channels = m.mode === 'tab_plus_mic' ? [{ kind: 'self', label: 'My microphone' }, { kind: 'remote', label: 'Tab audio' }] : [{ kind: 'self', label: 'My audio' }]; if (m.consentAudio) self.playConsent(); save(); self.paint(bid); self.acquireAudio(bid); }).catch(function (err) { m.error = friendlyMeetingError(err); save(); self.paint(bid); toast(m.error); }); this.paint(bid); }, acquireAudio: function (bid) { var self = this, b = findBlock(bid); if (!b) return; var m = b.meeting, rt = this._rt[bid] || (this._rt[bid] = {}); teardownCaptureKeepTimer(rt); var wantTab = m.mode === 'tab_plus_mic'; function afterStreams() { if (rt.micStream) rt.analyserSelf = attachAnalyser(rt, rt.micStream); if (rt.tabStream) rt.analyserRemote = attachAnalyser(rt, rt.tabStream); startWave(bid, rt); var ok = startRecognition(bid, rt, m); if (!ok && !rt.micStream && !rt.tabStream) { toast('No audio capture available — use the manual transcript.'); } self.paint(bid); } function micOnly() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { afterStreams(); return; } navigator.mediaDevices.getUserMedia({ audio: true }).then(function (s) { rt.micStream = s; afterStreams(); }).catch(function () { afterStreams(); }); } if (wantTab) { if (!hasDisplayMedia()) { toast('Tab capture unsupported here — microphone mode used instead.'); m.mode = 'mic_only'; save(); micOnly(); return; } toast('Choose the call tab and tick “Share audio”.'); navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }) .then(function (tab) { rt.tabStream = tab; // Couper la vidéo : seul l'audio nous intéresse. try { tab.getVideoTracks().forEach(function (t) { t.stop(); }); } catch (e) {} if (!navigator.mediaDevices.getUserMedia) { afterStreams(); return; } navigator.mediaDevices.getUserMedia({ audio: true }).then(function (s) { rt.micStream = s; afterStreams(); }).catch(function () { afterStreams(); }); }) .catch(function () { toast('Tab share cancelled — microphone mode used instead.'); m.mode = 'mic_only'; save(); micOnly(); }); } else { micOnly(); } }, pause: function (bid) { var self = this, b = ensure(bid); if (!b || b.meeting.state !== 'recording') return; var m = b.meeting, rt = this._rt[bid] || {}; m.elapsedBase = elapsed(m); var delta = 0; if (m.transcriptId) api('/api/v2/meetings/transcripts/' + m.transcriptId + '/pause', { method: 'POST', body: {} }).catch(function () {}); stopRecog(rt); if (rt.waveRaf) try { cancelAnimationFrame(rt.waveRaf); } catch (e) {} rt.waveRaf = null; m.state = 'paused'; save(); self.paint(bid); }, resume: function (bid) { var self = this, b = ensure(bid); if (!b || b.meeting.state !== 'paused') return; var m = b.meeting, rt = this._rt[bid] || (this._rt[bid] = {}); if (m.transcriptId) api('/api/v2/meetings/transcripts/' + m.transcriptId + '/resume', { method: 'POST' }).catch(function () {}); m.startedAt = Date.now(); m.state = 'recording'; save(); self.paint(bid); // Reprendre la reconnaissance vocale (les flux coupés sont réacquis). if (!rt.micStream && !rt.tabStream) self.acquireAudio(bid); else { startWave(bid, rt); startRecognition(bid, rt, m); startTimer(bid); } }, stop: function (bid) { var self = this, b = ensure(bid); if (!b) return; var m = b.meeting; if (m.state !== 'recording' && m.state !== 'paused') return; var rt = this._rt[bid] || (this._rt[bid] = {}); // Garde-fou : arrêter à vide effacerait la prise — proposer d'abord // de vérifier le micro ou de coller un transcript (§ point 5). if (!hasCaptured(m) && !rt.allowEmpty) { if (!confirmStopEmpty()) return; rt.allowEmpty = true; } var allowEmpty = !!rt.allowEmpty; m.elapsedBase = elapsed(m); teardownCapture(bid); // Verser le manuel éventuel dans les segments avant l'analyse // (via pushSegment : tampon local + envoi serveur). if (m.manual.trim()) { var pasted = m.manual.trim(); m.manual = ''; pushSegment(bid, { channel: 'self', source: 'Pasted transcript', speaker: '', text: pasted, startMs: 0, endMs: m.elapsedBase, mid: false }); } self.flushManualNotes(bid); m.state = 'processing'; m.tab = 'summary'; m.steps = THINKING_STEPS.map(function (s, i) { return { key: s[0], label: s[1], state: i === 0 ? 'running' : 'pending' }; }); save(); self.paint(bid); function finishWith(out) { applyDone(bid, out); var bb = findBlock(bid); if (!bb) return; var mm = bb.meeting; // Étapes finales depuis le serveur (source de vérité). if (mm.transcriptId) api('/api/v2/meetings/transcripts/' + mm.transcriptId + '/processing-steps', {}) .then(function (st) { var bbb = findBlock(bid); if (bbb && st.steps && st.steps.length) { bbb.meeting.steps = st.steps; save(); self.paint(bid); } }).catch(function () {}); } if (!m.transcriptId) { // 100 % local (page non sauvegardée) : résumé via l'agent. this.summarizeLocal(bid); return; } // Drainer d'abord : appeler /stop avec un tampon non parti condamnait // à un 400 alors même que les segments existaient en local. drainOutbox(bid, 5000).then(function (drained) { if (!drained) { // Session serveur conservée (toujours live) : Retry renverra. applyFailed(bid, null, 'Could not upload the transcript — check your connection, then Retry. Nothing was lost.'); return; } api('/api/v2/meetings/transcripts/' + m.transcriptId + '/stop', { method: 'POST', body: { duration_ms: m.elapsedBase, allow_empty: allowEmpty } }).then(finishWith).catch(function (err) { applyFailed(bid, err); }); }); this.followProcessing(bid); }, followProcessing: function (bid) { var self = this, rt = this._rt[bid] || (this._rt[bid] = {}); if (rt.poller) try { clearInterval(rt.poller); } catch (e) {} var b = findBlock(bid); if (!b || !b.meeting.transcriptId) return; var tid = b.meeting.transcriptId; rt.poller = setInterval(function () { var bb = findBlock(bid); if (!bb || bb.meeting.state !== 'processing') { try { clearInterval(rt.poller); } catch (e2) {} return; } api('/api/v2/meetings/transcripts/' + tid + '/processing-steps', {}) .then(function (st) { var bbb = findBlock(bid); if (!bbb || bbb.meeting.state !== 'processing') return; if (st.steps && st.steps.length) { // Ne pas écraser par une liste vide ; animer running→done. var cur = {}; bbb.meeting.steps.forEach(function (s) { cur[s.key] = s.state; }); var changed = false; st.steps.forEach(function (s) { if (cur[s.key] !== s.state) changed = true; }); if (changed || !bbb.meeting.steps.length) { bbb.meeting.steps = st.steps; save(); self.paint(bid); } } if (st.status === 'done') { try { clearInterval(rt.poller); } catch (e3) {} api('/api/v2/meetings/transcripts/' + tid, {}).then(function (row) { var b4 = findBlock(bid); if (!b4) return; b4.meeting.state = 'done'; b4.meeting.summaryMd = row.summary || b4.meeting.summaryMd; b4.meeting.completeness = row.completeness_class || ''; b4.meeting.genTitle = row.generated_title || ''; save(); self.paint(bid); idbPut(bid, []); }).catch(function () {}); } else if (st.status === 'failed') { try { clearInterval(rt.poller); } catch (e4) {} } }).catch(function () {}); }, 1500); }, // Retry intelligent : verse d'abord le local (le manuel collé après un // échec part lui aussi — fin du 400 en boucle), puis termine par /stop // si la session serveur est encore en vie, sinon /processing:retry. retry: function (bid) { var self = this, b = ensure(bid); if (!b || !b.meeting.transcriptId) { this.summarizeLocal(bid); return; } var m = b.meeting; var rt = this._rt[bid] || (this._rt[bid] = {}); if (!hasCaptured(m) && !(m.manual || '').trim() && !rt.allowEmpty) { if (!confirmStopEmpty()) return; rt.allowEmpty = true; } var allowEmpty = !!rt.allowEmpty; var tid = m.transcriptId; m.state = 'processing'; m.tab = 'summary'; m.error = ''; save(); self.paint(bid); drainOutbox(bid, 5000).then(function (drained) { if (!drained) { applyFailed(bid, null, 'Could not upload the transcript — check your connection, then Retry. Nothing was lost.'); return; } api('/api/v2/meetings/transcripts/' + tid + '/processing-steps', {}) .then(function (st) { if (st.status === 'recording' || st.status === 'paused') { var bb = findBlock(bid); var dur = bb ? (bb.meeting.elapsedBase || 0) : 0; return api('/api/v2/meetings/transcripts/' + tid + '/stop', { method: 'POST', body: { duration_ms: dur, allow_empty: allowEmpty } }); } return api('/api/v2/meetings/transcripts/' + tid + '/processing:retry', { method: 'POST', body: { allow_empty: allowEmpty } }); }).then(function (out) { applyDone(bid, out); }).catch(function (err) { applyFailed(bid, err); }); }); this.followProcessing(bid); }, regen: function (bid) { var self = this, b = ensure(bid); if (!b) return; var m = b.meeting; if (!m.transcriptId) { this.summarizeLocal(bid); return; } var rt = this._rt[bid] || (this._rt[bid] = {}); if (!hasCaptured(m) && !(m.manual || '').trim() && !rt.allowEmpty) { if (!confirmStopEmpty()) return; rt.allowEmpty = true; } var allowEmpty = !!rt.allowEmpty; var tid = m.transcriptId; m.state = 'processing'; m.tab = 'summary'; m.steps = THINKING_STEPS.map(function (s) { return { key: s[0], label: s[1], state: 'pending' }; }); save(); self.paint(bid); // Le manuel collé entre-temps part d'abord, sinon le serveur // résume un transcript vide et répond 400. drainOutbox(bid, 5000).then(function (drained) { if (!drained) { applyFailed(bid, null, 'Could not upload the transcript — check your connection, then Regenerate. Nothing was lost.'); return; } api('/api/v2/meetings/transcripts/' + tid + '/summarize', { method: 'POST', body: { allow_empty: allowEmpty } }).then(function (out) { var bb = findBlock(bid); if (!bb) return; var rtx = FDMeeting._rt[bid]; if (rtx) rtx.allowEmpty = false; bb.meeting.state = 'done'; bb.meeting.summaryMd = out.summary || ''; bb.meeting.completeness = out.completeness || bb.meeting.completeness; bb.meeting.genTitle = out.generated_title || bb.meeting.genTitle; save(); self.paint(bid); }).catch(function (err) { applyFailed(bid, err); }); }); this.followProcessing(bid); }, /* Pre-recording microphone check : 4 s live level, then guidance. */ testMic: function (bid) { var self = this; if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { toast('Microphone API unavailable in this browser — use the manual transcript.'); return; } toast('Microphone test — speak for 4 seconds…'); navigator.mediaDevices.getUserMedia({ audio: true }).then(function (s) { function done(msg) { try { s.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {} toast(msg); self.paint(bid); } var Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) { done('Microphone granted (no level analysis available). You can Start.'); return; } var ctx, an; try { ctx = new Ctx(); an = ctx.createAnalyser(); an.fftSize = 512; ctx.createMediaStreamSource(s).connect(an); } catch (e) { done('Microphone granted. You can Start.'); return; } var buf = new Uint8Array(512), peak = 0, n = 0; var iv = setInterval(function () { try { an.getByteTimeDomainData(buf); } catch (e2) {} var sum = 0, i; for (i = 0; i < buf.length; i++) { var v = (buf[i] - 128) / 128; sum += v * v; } var lv = Math.sqrt(sum / buf.length); if (lv > peak) peak = lv; if (++n >= 40) { clearInterval(iv); try { ctx.close(); } catch (e3) {} if (peak > 0.02) done('Microphone OK — good level detected. You can Start.'); else done('Microphone granted but silent — check the input device, move closer and speak up.'); } }, 100); }).catch(function () { toast('Microphone refused — allow access in the browser address bar, or use the manual transcript.'); }); }, /* Résumé 100 % local (aucune ligne serveur) via l'agent IA. */ summarizeLocal: function (bid) { var self = this, b = ensure(bid); if (!b) return; var m = b.meeting; var content = transcriptText(m); if (!content.trim()) { m.state = 'failed'; m.error = 'No meeting content to summarize.'; save(); self.paint(bid); return; } if (!(window.fdAgent && window.fdAgent.generate)) { m.state = 'failed'; m.error = 'AI agent unavailable — configure it in Settings → Agent & AI.'; save(); self.paint(bid); return; } window.fdAgent.generate( 'Write the structured summary of this meeting (AI Meeting Note): one line' + ' with the date of ' + m.date + ', then “Overview”, “Decisions” and' + ' “Action Items” sections with concrete bullets. If the content is too' + ' short or fragmentary, say “No meaningful content could be extracted”.' + ' No preamble.', content ).then(function (text) { var bb = findBlock(bid); if (!bb) return; bb.meeting.state = 'done'; bb.meeting.summaryMd = (text || '').trim(); bb.meeting.completeness = 'complete'; save(); self.paint(bid); }).catch(function (err) { var bb2 = findBlock(bid); if (!bb2) return; bb2.meeting.state = 'failed'; bb2.meeting.error = (err && err.message) || 'Summary failed.'; save(); self.paint(bid); }); }, flushManualNotes: function (bid) { var b = findBlock(bid); if (!b || !b.meeting.transcriptId) return; var m = b.meeting; if (m.notes.trim()) api('/api/v2/meetings/transcripts/' + m.transcriptId + '/notes', { method: 'PATCH', body: { notes: m.notes } }).catch(function () {}); flushSegments(bid); }, reset: function (bid) { var b = ensure(bid); if (!b) return; teardownCapture(bid); var keepTitle = b.meeting.title, keepDate = b.meeting.date; b.meeting = defState(); b.meeting.title = keepTitle; b.meeting.date = keepDate; save(); this.paint(bid); }, share: function (bid, channel) { var b = ensure(bid); if (!b) return; var m = b.meeting; function record(status) { if (m.transcriptId) api('/api/v2/meetings/transcripts/' + m.transcriptId + '/distributions', { method: 'POST', body: { channel: channel, recipients: '', status: status || 'created' } }).catch(function () {}); } var url = location.href.split('#')[0] + '#meeting-' + bid; var title = displayTitle(m); if (channel === 'copy_link') { copyText(url); record('link_copied'); toast('Summary link copied.'); } else if (channel === 'email') { record('created'); var body = (m.summaryMd || '').replace(/ ⁠¹( \[seg \d+\])?/g, '').slice(0, 3000); location.href = 'mailto:?subject=' + encodeURIComponent('Meeting summary — ' + title) + '&body=' + encodeURIComponent(body + '\n\n' + url); } else if (channel === 'slack') { var txt = '*' + title + '*\n' + (m.summaryMd || '').replace(/ ⁠¹( \[seg \d+\])?/g, '').slice(0, 2500) + '\n' + url; copyText(txt); record('created'); toast('Summary formatted for Slack — copied, paste it in your channel.'); } }, importAudio: function (bid, file) { var self = this; if (!file) return; var b = ensure(bid); if (!b) return; var m = b.meeting; var pid = (window.E && window.E.pid) || 0; if (!pid) { toast('Save the page first, then import audio.'); return; } toast('Uploading audio for transcription…'); var fd = new FormData(); fd.append('audio', file); fd.append('page_id', String(pid)); fd.append('language', m.lang || 'fr'); fetch('/api/v2/meetings/transcribe', { method: 'POST', credentials: 'same-origin', headers: { 'X-CSRF-Token': csrf() }, body: fd }).then(function (r) { if (!r.ok) throw new Error('upload rejected (' + r.status + ')'); return r.json(); }).then(function (row) { var bb = ensure(bid); if (!bb) return; if (row.transcript) { bb.meeting.manual = row.transcript; bb.meeting.state = bb.meeting.state === 'idle' ? 'idle' : bb.meeting.state; save(); self.paint(bid); toast('Audio transcribed — review the manual transcript, then Start → Stop to summarize.'); } else { toast('Audio stored — no server transcription backend; paste the transcript manually.'); } }).catch(function (err) { toast((err && err.message) || 'Import failed.'); }); }, _pushSegment: pushSegment }; function teardownCaptureKeepTimer(rt) { stopRecog(rt); stopTracks(rt); } // Raw API failures (ex. "HTTP 400 {...}") → actionable guidance, because a // lost take is the worst possible outcome of this block. function friendlyMeetingError(err) { var raw = (err && err.message) || ''; if (/transcript is empty/i.test(raw)) return 'Nothing was captured — check the microphone (Start ▾ → Test microphone), ' + 'or paste a transcript in Transcript → Manual transcript, then Retry. Your notes are kept.'; if (/consent/i.test(raw)) return 'Consent is required before analysis — Start again to record it, then Retry.'; if (/^HTTP 5/i.test(raw)) return 'The AI service is unavailable — your transcript is kept, please Retry in a moment.'; var short = raw.replace(/^HTTP \d+ /, '').replace(/\{.*\}$/, '').trim(); return short || 'Processing failed — the transcript is kept, Retry when ready.'; } // True when the session holds nothing analyzable (local view). function hasCaptured(m) { return (m.segments && m.segments.length > 0) || ((m.manual || '').trim().length > 0); } function confirmStopEmpty() { return window.confirm( 'No speech has been captured yet.\n\n' + 'Before closing this take:\n' + '• check the microphone is allowed (Start ▾ → Test microphone),\n' + '• or paste a transcript in Transcript → Manual transcript.\n\n' + 'Stop anyway and file an empty diagnostic?'); } function copyText(t) { try { if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t).catch(function () {}); else { var ta = document.createElement('textarea'); ta.value = t; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) {} document.body.removeChild(ta); } } catch (e) {} } function transcriptText(m) { var out = []; (m.segments || []).forEach(function (s) { out.push('[' + fmtTs(s.startMs) + '] ' + (s.speaker || s.channel) + ' : ' + s.text); }); if ((m.manual || '').trim()) out.push('(pasted transcript) ' + m.manual.trim()); if ((m.notes || '').trim()) out.push('(notes) ' + m.notes.trim()); return out.join('\n'); } window.FDMeeting = FDMeeting; })();