1/* Document tab â Running the build: first build, rebuild, backfilling requesters, starting over. 2 Loaded by app.html after app.js; see ../README.md. */ 3 4// Backfills the requester (submitterName) into existing minute item title blocks for a meeting. 5// Safe for in-progress minutes: only rewrites title blocks whose content still matches the 6// original generated format ("Title" or "Title \u2014 Department"). Blocks that have been 7// hand-edited are skipped. 8async function backfillRequesterInMinutes(meetingId) { 9 meetingId = meetingId || getActiveMeetingId(); 10 if (!meetingId) { showToast('No active meeting'); return { updated: 0, skipped: 0 }; } 11 12 const items = (_cachedData.agendaItems || []).filter(function(i) { return i.meetingId === meetingId; }); 13 const itemById = {}; 14 items.forEach(function(i) { itemById[i.id] = i; }); 15 16 const snap = await minutesBlocksRef().where('meetingId', '==', meetingId).where('sourceType', '==', 'item').get(); 17 18 const batch = db.batch(); 19 let updated = 0, skipped = 0; 20 snap.forEach(function(doc) { 21 const b = doc.data(); 22 const item = itemById[b.sourceItemId]; 23 if (!item) { skipped++; return; } 24 if (!item.submitterName) { skipped++; return; } 25 26 const title = item.title || ''; 27 const dept = item.department || ''; 28 const origNoMeta = title; 29 const origWithDept = dept ? (title + ' \u2014 ' + dept) : title; 30 // Also accept a prior backfilled state that used the old ' \u00b7 ' separator so 31 // re-running migrates it to the new comma-separated format. 32 const priorBullet = title + ' \u2014 ' + [item.submitterName, dept].filter(Boolean).join(' \u00b7 '); 33 const current = b.content || ''; 34 35 if (current !== origNoMeta && current !== origWithDept && current !== priorBullet) { skipped++; return; } 36 37 const newContent = title + ' \u2014 ' + [item.submitterName, dept].filter(Boolean).join(', '); 38 if (newContent === current) { skipped++; return; } 39 40 // The rich copy described the old text â it can't survive a rewrite of it. 41 batch.update(doc.ref, { content: newContent, contentHtml: '', updatedAt: firebase.firestore.FieldValue.serverTimestamp() }); 42 updated++; 43 }); 44 45 if (updated > 0) await batch.commit(); 46 await loadMeetingData(meetingId); 47 showToast('Requester backfill: ' + updated + ' updated, ' + skipped + ' skipped'); 48 return { updated: updated, skipped: skipped }; 49} 50 51window.backfillRequesterInMinutes = backfillRequesterInMinutes; 52 53async function initAndRenderMinutesBlocks(meetingId, event) { 54 const btn = event ? event.target.closest('button') : null; 55 const btnHTML = btn ? btn.innerHTML : null; 56 57 if (btn) { 58 btn.disabled = true; 59 btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Generatingâ¦'; 60 } 61 62 // Counted before the build so a rebuild is distinguishable from a first build. The 63 // empty-state button only appears when there are none, but this is also reachable 64 // after Uncreate minutes, and "built the template again over 30 existing blocks" is 65 // a different event from "built it for the first time". 66 var _blocksBefore = 0; 67 try { _blocksBefore = minutesBlocksForMeeting(meetingId, getData()).length; } catch (e) {} 68 69 try { 70 await initializeMinutesBlocks(meetingId); 71 // Surface each recorded vote as an "Action" line immediately â the actions come 72 // through on the first (template) build, without needing the AI pass. 73 try { await mbBuildPerItemVoteSentences(meetingId); } catch (e) { console.warn('vote sentences failed', e); } 74 // One event for the whole build. initializeMinutesBlocks writes a batch of blocks; 75 // that is one act, not thirty (same reasoning as minutes.generated). 76 if (typeof trackAudit === 'function') { 77 var _blocksAfter = 0; 78 try { _blocksAfter = minutesBlocksForMeeting(meetingId, getData()).length; } catch (e) {} 79 trackAudit('minutes.template.built', meetingId, { 80 meta: { blocks: _blocksAfter, replacedBlocks: _blocksBefore } 81 }); 82 } 83 const container = mbGetActiveContainer(); 84 if (container) { 85 await renderMinutesBuilder(meetingId, container); 86 } 87 } catch (err) { 88 console.error('initAndRenderMinutesBlocks error:', err); 89 showToast('Error building minutes: ' + err.message); 90 if (btn) { btn.disabled = false; btn.innerHTML = btnHTML; } 91 } 92} 93 94/* Build the minutes â the skeleton and the AI narrative, in one press (#481). 95 *
96 * These were two buttons: Generate Template built the sections, the item titles and the 97 * vote action lines, and then the bar swapped to Write minutes, whose menu asked for a 98 * level of detail and ran the AI over every section. A clerk building minutes for the 99 * first time wants both essentially every time, so the first press landed them on a 100 * screen whose only remaining job was to press the second button â and the level of 101 * detail, the one real decision in the flow, was hidden behind that second press rather 102 * than asked when they decided to build. 103 * 104 * So the level is asked up front (mbDocBuildMenu) and comes in here. A falsy detailLevel 105 * is the menu's "No minutes" row: the template and nothing else, which is exactly what 106 * Generate Template did on its own and is still what a clerk who does not want the AI 107 * near their minutes should get. 108 * 109 * _mbBuildRunning guards THIS function's own re-entry, and it is belt-and-braces rather 110 * than a fix for an observed race: the only way in is a row of mbDocBuildMenu, and GV.menu 111 * removes itself on the activating click, so a double-click cannot fire the row twice. It 112 * earns its place because this is a global an inline handler can reach, the run is now 113 * long enough to click into, and the cost of being wrong here is #323. 114 * 115 * It is NOT what stops two AI sweeps colliding. That door is the tab switch: the progress 116 * card outlives the screen by design, and coming back re-renders the bar with a freshly 117 * enabled Write minutes. The guard for it is on mbGenerateWholeDocument, where the flag 118 * that tracks a live sweep already lives; see the comment there. 119 * 120 * (#323 still owns the rest: what a deliberate REBUILD should do to blocks that are 121 * already there is a question this does not answer.) */ 122var _mbBuildRunning = false; 123 124async function mbBuildMinutes(meetingId, detailLevel) { 125 if (readonlyBlock()) return; 126 if (_mbBuildRunning) { showToast('Already building these minutes â one moment'); return; } 127 _mbBuildRunning = true; 128 /* The bar button wears the run, found by id rather than off the event: the click that 129 got here came from a MENU ROW on <body>, so event.target is that row, not the button 130 the clerk is looking at. It is only worn for the template half â renderMinutesBuilder 131 swaps the whole bar for the built-state one part-way through, taking this button with 132 it â and that is the half with no other feedback. From there mbGenerateWholeDocument's 133 progress card and its own button take over. */ 134 var _bbtn = document.getElementById('mb-gen-template-btn'); 135 var _bwas = mbAiBusy(_bbtn, 'Buildingâ¦'); 136 try { 137 await initAndRenderMinutesBlocks(meetingId, null); 138 // initAndRenderMinutesBlocks catches its own failures and toasts them, so a thrown 139 // error is not how we learn the template did not get built. An empty document is. 140 // Going on to the AI pass here would toast a second, less true message on top of 141 // the first ("No sections to generate yet" when the real fault was the write). 142 var _built = 0; 143 try { _built = minutesBlocksForMeeting(meetingId, getData()).length; } catch (e) {} 144 if (!_built) return; 145 if (!detailLevel) return; 146 await mbGenerateWholeDocument(meetingId, detailLevel, { skipConfirm: true }); 147 } finally { 148 _mbBuildRunning = false; 149 // Only if it survived â the usual path is that the re-render replaced it, and 150 // putting a label back on a detached node is writing to nothing. 151 if (_bbtn && _bbtn.isConnected) mbAiIdle(_bbtn, _bwas); 152 } 153} 154 155/* The Document tab before anything has been built. 156 * 157 * THE EMPTY STATE IS THE PAGE (#665, Dom 2026-09-22), the way every step asks to be run 158 * (.gv-start, #671): the nib, a line saying what the AI does, one button, and the steps 159 * under it. Before that it 160 * was a step bar with Build minutes as its one action and the explanation beside it â a 161 * control row across the top of a column with nothing in it, which underplays the only 162 * thing there is to do on the screen. 163 * 164 * The rest of the stage is real: the recording and the Agenda/Transcript panel are beside 165 * it, because they are what the minutes will be written from. Pressing Build changes what 166 * is in the document column and nothing else moves. 167 * 168 * NOTHING ON THE TAB ROW. There is no draft to be a draft of, so a Draft pill over an 169 * empty column would be a status report about nothing â renderMinutesBuilder paints the 170 * pill and Approve only once blocks exist, and switchMeetingWorksp
170ace has already cleared 171 * whatever the last step left there. */ 172function renderMBInitBanner(meetingId, hasLegacy, meeting) { 173 var mid = String(meetingId).replace(/'/g, "\\'"); 174 var ro = (typeof canEdit === 'function') && !canEdit(); 175 176 /* A statement, not a warning: nothing is at risk, and building does not delete the old 177 minutes. One of the panel's own notes, inside the card (#671) â drawn above it, the 178 note became the column's first child and .mw-dock pinned it to the tab row instead. */ 179 var legacy = hasLegacy 180 ? '<p class="gv-start__note"><i class="fas fa-circle-info" aria-hidden="true"></i>' 181 + 'This meeting has legacy minutes. Building structured minutes will not delete them.</p>' 182 : ''; 183 184 /* The shared start panel (#671) â the same classes as Review Agenda, Recording, 185 Speakers and Motions, so the five empty steps change together. */ 186 var start = '<div class="gv-start-card"><div class="gv-start">' 187 + GV.startNib() 188 + '<h3 class="gv-start__title">Build the minutes</h3>' 189 + (ro 190 ? '<p class="gv-start__note"><i class="fas fa-lock" aria-hidden="true"></i>' 191 + 'Ask an admin to make changes.</p>' 192 : GV.stepBarAction({ 193 /* "Build minutes", not "Generate Template" (#481). The press now builds 194 the skeleton AND writes the narrative, so the old name describes half 195 of what happens â which is the confusion #246 reported from the other 196 side, when the two buttons gave no clue which of them ran the AI. 197 There is one button, and it opens the menu that asks. */ 198 start: true, icon: 'fa-file-lines', 199 label: 'Build minutes', ai: true, id: 'mb-gen-template-btn', 200 busyLabel: 'Buildingâ¦', 201 // Not "and write them": the menu's own No minutes row deliberately does not. 202 title: 'Build the minutes from this meetingâs data, and choose how theyâre written', 203 onclick: "mbDocBuildMenu(event,'" + mid + "')" 204 }) 205 + '<ol class="gv-start__steps">' 206 + '<li><span>1</span> Choose the detail</li>' 207 + '<li><span>2</span> Govably AI writes it</li>' 208 + '<li><span>3</span> Edit before approving</li>' 209 + '</ol>') 210 + legacy 211 + '</div></div>'; 212 213 var h = '<div class="mbdoc" id="mbdoc-root"><div class="gv-stage mw-dock">'; 214 h += '<div class="gv-stage__main">'; 215 // No #mbdoc-fmt slot: the format bar floats over the box it is editing and reserves no 216 // space. There is also nothing to edit on this screen yet. 217 // --start: no grey desk behind the start panel (#671) â it sits in the same white card 218 // as the other steps' panels. The desk comes back with the first page. 219 h += '<div class="mbdoc-doc mbdoc-doc--start gv-scroll mw-dock" id="mbdoc-doc">' + start + '</div>'; 220 h += '</div>'; 221 h += GV.stage.railHtml(); 222 h += mbDocSideHtml(meetingId, meeting); 223 return h + '</div></div>'; 224} 225 226async function mbUncreateMinutes(meetingId) { 227 if (readonlyBlock()) return; 228 if (!confirm('Uncreate minutes for this meeting?\n\nAll generated minutes blocks (sections, votes, signatures, edits) will be deleted. The meeting will return to the unbuilt state â you can re-build later with the "Build minutes" button.\n\nThis cannot be undone. Are you sure?')) return; 229 try { 230 var data = getData(); 231 var existing = (data.minutesBlocks || []).filter(function(b) { return b.meetingId === meetingId; }); 232 var batchSize = 499; 233 for (var i = 0; i < existing.length; i += batchSize) { 234 var batch = db.batch(); 235 existing.slice(i, i + batchSize).forEach(function(b) { batch.delete(minutesBlocksRef().doc(b.id)); }); 236 await batch.commit(); 237 } 238 data.minutesBlocks = (data.minutesBlocks || []).filter(function(b) { return b.meetingId !== meetingId; }); 239 await fbLogActivity('uncreated minutes', meetingId); 240 var container = mbGetActiveContainer(); 241 if (container) await renderMinutesBuilder(meetingId, container); 242 showToast('Minutes deleted â click Build minutes when youâre ready to start over'); 243 } catch (err) { showToast('Error: ' + err.message); } 244} 245 246async function mbResetBlocks(meetingId) { 247 if (mbBlockIfApproved(meetingId)) return; 248 if (readonlyBlock()) return; 249 if (!confirm('Erase & regenerate minutes?\n\nThis will delete all your current edits, custom notes, and formatting and regenerate everything from the meeting data.\n\nThis cannot be undone. Are you sure?')) return; 250 // Show progress on the button so the operation never looks like it did nothing. 251 var btn = (typeof event !== 'undefined' && event && event.target) ? event.target.closest('button') : null; 252 var origHtml = btn ? btn.innerHTML : null; 253 if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin text-xs mr-1.5"></i><span>Regeneratingâ¦</span>'; } 254 try { 255 var data = getData(); 256 var existing = (data.minutesBlocks || []).filter(function(b) { return b.meetingId === meetingId; }); 257 var batchSize = 499; 258 for (var i = 0; i < existing.length; i += batchSize) { 259 var batch = db.batch(); 260 existing.slice(i, i + batchSize).forEach(function(b) { batch.delete(minutesBlocksRef().doc(b.id)); }); 261 await batch.commit(); 262 } 263 var d2 = getData(); 264 d2.minutesBlocks = (d2.minutesBlocks || []).filter(function(b) { return b.meetingId !== meetingId; }); 265 await initializeMinutesBlocks(meetingId); 266 var container = mbGetActiveContainer(); 267 if (container) { 268 await renderMinutesBuilder(meetingId, container); 269 } 270 var rebuilt = (getData().minutesBlocks || []).filter(function(b) { return b.meetingId === meetingId; }).length; 271 showToast('Minutes regenerated â ' + rebuilt + ' block' + (rebuilt === 1 ? '' : 's') + (container ? '' : ' (no view to refresh)'), 4000); 272 } catch (err) { 273 console.error('mbResetBlocks error:', err); 274 showToast('Could not regenerate minutes: ' + ((err && err.message) ? err.message :
274String(err)), 6000); 275 } finally { 276 if (btn && origHtml !== null) { btn.disabled = false; btn.innerHTML = origHtml; } 277 } 278}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.