@gotcos/glasses-server 6.98.1 → 6.99.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +149 -0
- package/package.json +2 -1
- package/server/index.ts +147 -9
- package/server/lib/api-auth.ts +31 -14
- package/server/lib/claude-bridge.ts +21 -2
- package/server/lib/codex-bridge.ts +21 -2
- package/server/lib/display-bus.ts +3 -0
- package/server/lib/drop-destinations.ts +134 -0
- package/server/lib/drop-history-search.ts +97 -0
- package/server/lib/drop-p2-boot.ts +60 -0
- package/server/lib/drop-p2-runtime.ts +237 -0
- package/server/lib/drop-p2-settings.ts +174 -0
- package/server/lib/drop-resolution.ts +168 -0
- package/server/lib/drop-router.ts +325 -0
- package/server/lib/drop-service.ts +739 -0
- package/server/lib/drop-settings.ts +45 -0
- package/server/lib/drop-store.ts +428 -0
- package/server/lib/drop-when.ts +265 -0
- package/server/lib/glasses-pairing.ts +15 -0
- package/server/lib/learning-needs-you.ts +111 -0
- package/server/lib/live-cues-v2-settings.ts +2 -1
- package/server/lib/live-cues-v2.ts +6 -4
- package/server/lib/meeting-consolidation-run.ts +169 -0
- package/server/lib/meeting-store.ts +61 -0
- package/server/lib/meeting-title-steer.ts +200 -0
- package/server/lib/network-policy.ts +84 -6
- package/server/lib/permission-broker.ts +11 -0
- package/server/lib/ping-budget.ts +149 -0
- package/server/lib/ping-engine.ts +245 -0
- package/server/lib/ping-feed-sink.ts +33 -0
- package/server/lib/ping-sources.ts +192 -0
- package/server/lib/query-job-coordinator.ts +5 -2
- package/server/lib/query-job-runtime.ts +45 -4
- package/server/lib/query-job-types.ts +10 -1
- package/server/lib/reminder-scheduler.ts +153 -0
- package/server/lib/tailnet-self-name.ts +103 -0
- package/server/lib/tailscale-nudge.ts +40 -2
- package/server/lib/task-store.ts +32 -0
- package/server/lib/web-ask-admission.ts +56 -0
- package/server/lib/web-live-chunks.ts +273 -0
- package/server/lib/web-memories-bridge.ts +323 -0
- package/server/lib/web-session-auth.ts +492 -0
- package/server/lib/web-usage-bridge.ts +154 -0
- package/server/lib/work-search.ts +14 -1
- package/server/lib/your-move-count.ts +58 -0
- package/server/routes/display.ts +11 -1
- package/server/routes/drops.ts +260 -0
- package/server/routes/health.ts +16 -0
- package/server/routes/live-cues.ts +5 -4
- package/server/routes/meeting.ts +61 -5
- package/server/routes/permission-broker.ts +23 -3
- package/server/routes/transcribe-stream.ts +34 -3
- package/server/routes/web-control.ts +569 -0
- package/server/routes/welcome-context.ts +2 -1
- package/server/web-control/app.css +519 -0
- package/server/web-control/app.js +3643 -0
- package/server/web-control/control.css +312 -0
- package/server/web-control/fonts/DMSans.ttf +0 -0
- package/server/web-control/fonts/Fraunces.ttf +0 -0
- package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
- package/server/web-control/index.html +275 -0
- package/server/web-control/mark.svg +1 -0
- package/server/web-control/memories/d3.min.js +2 -0
- package/server/web-control/memories/graph-explorer.css +200 -0
- package/server/web-control/memories/graph-explorer.js +748 -0
- package/server/web-control/memories/memories-app.js +1561 -0
- package/server/web-control/memories/memories-theme.css +73 -0
- package/server/web-control/memories/memories.html +72 -0
- package/server/web-control/memories/memory-stewardship.js +63 -0
- package/server/web-control/memories/memory-workspace.js +216 -0
- package/server/web-control/panes/cos-chat.css +157 -0
- package/server/web-control/panes/cos-chat.js +973 -0
- package/server/web-control/panes/dictation.js +173 -0
- package/server/web-control/panes/meetings.css +257 -0
- package/server/web-control/panes/meetings.js +2264 -0
- package/server/web-control/panes/messages.js +711 -0
- package/server/web-control/panes/questions.css +32 -0
- package/server/web-control/panes/questions.js +275 -0
- package/server/web-control/panes/sessions.css +452 -0
- package/server/web-control/panes/settings.js +292 -0
- package/server/web-control/panes/speakers.css +269 -0
- package/server/web-control/panes/speakers.js +2755 -0
- package/server/web-control/panes/threads.js +200 -0
- package/server/web-control/panes/work.css +261 -0
- package/server/web-control/panes/work.js +1667 -0
- package/server/web-control/shell.js +294 -0
- package/server/web-control/theme.js +25 -0
- package/server/web-control/vendor/qrcode-encoder.js +10 -0
|
@@ -0,0 +1,2264 @@
|
|
|
1
|
+
/* COS Control Web: Meetings, the same pane as Control's (ActivityMeetings.swift MeetingLibraryBody 7-398,
|
|
2
|
+
MeetingMonthCalendar 400-500, MeetingLibraryDetailPane 540-745, LiveNowRows 2110-2209, LiveTranscriptPane 2241-2719;
|
|
3
|
+
ActivityWindow.swift meetingsList and meetingsStats 2232-2287; MeetingAsk.swift LiveAskPanel 1388-1560,
|
|
4
|
+
MeetingSidePanel 2240-2300; LiveTranscript.swift; COSMarkdownParser.swift).
|
|
5
|
+
Data: GET /api/meetings?limit=200&domain=all&month=, /api/meetings/search, /api/meetings/detail,
|
|
6
|
+
/api/meetings/live-chunks, /api/meeting/sessions/:id/status, /api/meeting/orphans, /api/meeting-ask/*,
|
|
7
|
+
/api/live-cues/history, /api/context/status. Writes only on a tap: Meeting Ask, Stop and save, End and save,
|
|
8
|
+
Recover, Save, Save as follow-up.
|
|
9
|
+
Routes: #/meetings, #/meetings/<domain>/<month>/<filename>, #/meetings/live/<sessionId>. */
|
|
10
|
+
(function () {
|
|
11
|
+
'use strict';
|
|
12
|
+
var W = window.COSWeb;
|
|
13
|
+
var $ = W.$, esc = W.esc;
|
|
14
|
+
// W.api with one addition: a 401 (it has already shown the sign-in) halts every Meetings timer, so no later poll
|
|
15
|
+
// calls the sign-in again and wipes its reason.
|
|
16
|
+
function api(path, opts) {
|
|
17
|
+
return W.api(path, opts).catch(function (err) { if (err && err.status === 401) halt(); throw err; });
|
|
18
|
+
}
|
|
19
|
+
function halt() {
|
|
20
|
+
var first = !S.out;
|
|
21
|
+
stopTimers(); clearTimeout(S.stopTimer); S.stopTimer = null; S.view = ''; S.out = true;
|
|
22
|
+
// Once, so the sign-in says why; no later Meetings call reaches it to clear the line.
|
|
23
|
+
if (first && typeof W.showAuth === 'function') W.showAuth(SIGNED_OUT);
|
|
24
|
+
}
|
|
25
|
+
var pane = $('pane-meetings');
|
|
26
|
+
if (!pane) return;
|
|
27
|
+
|
|
28
|
+
// ── small helpers ─────────────────────────────────────
|
|
29
|
+
function pad2(n) { return (n < 10 ? '0' : '') + n; }
|
|
30
|
+
function monthKey(d) { return d.getFullYear() + '-' + pad2(d.getMonth() + 1); }
|
|
31
|
+
function dayKey(d) { return monthKey(d) + '-' + pad2(d.getDate()); }
|
|
32
|
+
function parseMonth(m) { var p = /^(\d{4})-(\d{2})$/.exec(m || ''); return p ? new Date(Number(p[1]), Number(p[2]) - 1, 1) : null; }
|
|
33
|
+
function monthTitle(m) { var d = parseMonth(m); return d ? d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) : m; }
|
|
34
|
+
function clockOf(d) { return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); }
|
|
35
|
+
// Control's clock.format("21:05"): "9:05 PM" on a 12-hour Mac.
|
|
36
|
+
function fmtTime(t) {
|
|
37
|
+
var m = /^(\d{1,2}):(\d{2})/.exec(t || '');
|
|
38
|
+
return m ? clockOf(new Date(2000, 0, 1, Number(m[1]), Number(m[2]))) : (t || '');
|
|
39
|
+
}
|
|
40
|
+
function num(v) { var n = Number(v); return isFinite(n) ? n : 0; }
|
|
41
|
+
function fmtCount(n) { return Number(n || 0).toLocaleString(); }
|
|
42
|
+
function plural(n, one, many) { return n === 1 ? one : many; }
|
|
43
|
+
function cap(s) { return String(s || '').replace(/_/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); }); }
|
|
44
|
+
function icon(name) { return '<svg class="icon"><use href="#i-' + name + '"/></svg>'; }
|
|
45
|
+
function safeId(s) { return String(s || '').replace(/[^A-Za-z0-9_-]/g, '_'); }
|
|
46
|
+
function uuid() {
|
|
47
|
+
// crypto.randomUUID needs a secure context; a plain-http tailnet page is not one.
|
|
48
|
+
var b = new Uint8Array(16);
|
|
49
|
+
crypto.getRandomValues(b);
|
|
50
|
+
b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80;
|
|
51
|
+
var h = Array.prototype.map.call(b, function (x) { return (x + 0x100).toString(16).slice(1); }).join('');
|
|
52
|
+
return h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20);
|
|
53
|
+
}
|
|
54
|
+
function routeArgs() { var r = W.parseRoute(); return r.pane === 'meetings' ? r.args : null; }
|
|
55
|
+
function readDrafts() { try { var v = JSON.parse(localStorage.getItem('cos_web_meeting_ask_drafts') || '{}'); return v && typeof v === 'object' ? v : {}; } catch (_e) { return {}; } }
|
|
56
|
+
function writeDraft(sid, text) {
|
|
57
|
+
try { var all = readDrafts(); if (text) all[sid] = String(text).slice(0, 2000); else delete all[sid]; localStorage.setItem('cos_web_meeting_ask_drafts', JSON.stringify(all)); } catch (_e) {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── sessions (PhoneMeetingCapture, G2MeetingStop) ─────
|
|
61
|
+
var PHONE_ID = /^phone_(?:[0-9]{13}_)?[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/;
|
|
62
|
+
var CONTEXT_KEY = /^(?:(?:g2|ff|mac):(?!phone_)[A-Za-z0-9:_-]{3,96}|phone:phone_(?:[0-9]{13}_)?[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})$/;
|
|
63
|
+
function isPhone(id, sourceType) { return String(id || '').indexOf('phone_') === 0 || sourceType === 'iphone_microphone'; }
|
|
64
|
+
function isMac(id) { return String(id || '').indexOf('mac_') === 0; }
|
|
65
|
+
function isGlasses(id) { return !isPhone(id) && !isMac(id) && /^[A-Za-z0-9:_-]{3,96}$/.test(String(id || '')); }
|
|
66
|
+
function askSessionForKey(key) {
|
|
67
|
+
if (!CONTEXT_KEY.test(key || '')) return null;
|
|
68
|
+
var at = key.indexOf(':'), prefix = key.slice(0, at), id = key.slice(at + 1);
|
|
69
|
+
if (prefix === 'phone') return PHONE_ID.test(id) ? id : null;
|
|
70
|
+
if (prefix === 'mac') return /^mac_[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}$/.test(id) ? id : null;
|
|
71
|
+
if (prefix === 'g2') return /^meeting_[A-Za-z0-9_-]{1,89}$/.test(id) ? id : null;
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
function captureKey(id) { if (isPhone(id)) return PHONE_ID.test(id) ? 'phone:' + id : ''; return (isMac(id) ? 'mac:' : 'g2:') + id; }
|
|
75
|
+
function contextKeys(keys, row) {
|
|
76
|
+
return (keys || []).filter(function (k) {
|
|
77
|
+
if (typeof k !== 'string' || !CONTEXT_KEY.test(k)) return false;
|
|
78
|
+
if (isPhone(row.sessionId, row.sourceType)) return row.sourceType === 'iphone_microphone' && PHONE_ID.test(row.sessionId) && k === 'phone:' + row.sessionId;
|
|
79
|
+
return k.indexOf('phone:') !== 0;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// LibraryMeeting.meetingAskSessions(keys:)
|
|
83
|
+
function askSessions(row, keys) {
|
|
84
|
+
var seen = {}, out = [];
|
|
85
|
+
var cands = keys.map(askSessionForKey).filter(Boolean).concat(row.g2SessionIds || []).concat([row.sourceSessionId || row.sessionId || '']);
|
|
86
|
+
cands.forEach(function (id) {
|
|
87
|
+
if (!id || seen[id]) return;
|
|
88
|
+
if (isPhone(row.sessionId, row.sourceType) && !isPhone(id)) return;
|
|
89
|
+
if (isPhone(id)) { if (row.sourceType === 'iphone_microphone' && id === row.sessionId && PHONE_ID.test(id)) { seen[id] = 1; out.push(id); } return; }
|
|
90
|
+
if (askSessionForKey(captureKey(id))) { seen[id] = 1; out.push(id); }
|
|
91
|
+
});
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── rows (LibraryMeeting) ─────────────────────────────
|
|
96
|
+
function domainLabel(m) {
|
|
97
|
+
if (m.domainAbbr) return m.domainAbbr;
|
|
98
|
+
var name = m.domain === 'imported' ? (m.originDomain || m.domain) : m.domain;
|
|
99
|
+
return name ? cap(name) : '';
|
|
100
|
+
}
|
|
101
|
+
// "2026-11-03 · 9:05 PM · P · 60 minutes · Mac System Audio"
|
|
102
|
+
function subtitle(m) {
|
|
103
|
+
var parts = [];
|
|
104
|
+
if (m.date) parts.push(m.date);
|
|
105
|
+
if (m.time) parts.push(fmtTime(m.time));
|
|
106
|
+
var d = domainLabel(m); if (d) parts.push(d);
|
|
107
|
+
if (m.duration) parts.push(m.duration); else if (num(m.durationMinutes) > 0) parts.push(num(m.durationMinutes) + ' min');
|
|
108
|
+
if (isPhone(m.sessionId, m.sourceType)) parts.push('iPhone Microphone'); else if (m.source) parts.push(m.source);
|
|
109
|
+
return parts.join(' · ');
|
|
110
|
+
}
|
|
111
|
+
function stampOf(m) {
|
|
112
|
+
var day = (m.date && m.date.length >= 10 ? m.date : m.filename || '').slice(0, 10);
|
|
113
|
+
var p = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day); if (!p) return null;
|
|
114
|
+
var t = /^(\d{1,2}):(\d{2})/.exec(m.time || '');
|
|
115
|
+
return new Date(Number(p[1]), Number(p[2]) - 1, Number(p[3]), t ? Number(t[1]) : 0, t ? Number(t[2]) : 0).getTime();
|
|
116
|
+
}
|
|
117
|
+
function canReviewVoices(m) { return !!m.sessionId && m.librarySource !== 'imported' && m.derivedKind !== 'split' && !isPhone(m.sessionId, m.sourceType); }
|
|
118
|
+
// MeetingStatusPills: the voice tag the row's own voiceReview carries (Control's voiceTag(for:)).
|
|
119
|
+
// Speakers' memory (Control's SpeakerListMemory), kept by the Speakers pane. Read here, never written.
|
|
120
|
+
function speakerMemory() { var v = W.readStore('cos_web_speaker_memory'); return v && typeof v === 'object' ? v : {}; }
|
|
121
|
+
function tagFor(un, finished) {
|
|
122
|
+
if (un === 0 && finished) return { cls: 'ok', text: 'REVIEWED' };
|
|
123
|
+
if (un != null && un > 0) return { cls: 'warn', text: un + ' TO NAME' };
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
// MeetingStatusPills' tag: Control's voiceTag(sessionId:). A row in the Speakers inbox (the newest 30 with a session)
|
|
127
|
+
// reads its review with the visit over it; any other row shows only a visit Speakers recorded.
|
|
128
|
+
function voiceTag(m) {
|
|
129
|
+
var sid = m && m.sessionId; if (!sid) return null;
|
|
130
|
+
var visits = speakerMemory().visits, visit = visits && typeof visits === 'object' ? visits[sid] : null;
|
|
131
|
+
var inbox = S.inbox && S.inbox[sid];
|
|
132
|
+
if (inbox) {
|
|
133
|
+
var r = inbox.voiceReview || m.voiceReview || {};
|
|
134
|
+
var un = visit && visit.unattributedVoices != null ? num(visit.unattributedVoices) : (r.unattributedVoices != null ? num(r.unattributedVoices) : null);
|
|
135
|
+
return tagFor(un, (visit && visit.humanTouched === true) || r.humanTouched === true);
|
|
136
|
+
}
|
|
137
|
+
if (!visit) return null;
|
|
138
|
+
return tagFor(num(visit.unattributedVoices), visit.humanTouched === true);
|
|
139
|
+
}
|
|
140
|
+
function pillsHtml(m, isNew) {
|
|
141
|
+
var out = '';
|
|
142
|
+
if (isNew) out += '<span class="cx-pill warn">NEW</span>';
|
|
143
|
+
var t = voiceTag(m); if (t) out += '<span class="cx-pill ' + t.cls + '">' + esc(t.text) + '</span>';
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
function matchLabel(m) { return m === 'both' ? 'Keyword + meaning' : m === 'semantic' ? 'Meaning' : 'Keyword'; }
|
|
147
|
+
|
|
148
|
+
// ── Markdown (COSMarkdownParser.swift) ────────────────
|
|
149
|
+
// Escape first, then add only our own tags, so server text can never become markup.
|
|
150
|
+
function emphasis(s) {
|
|
151
|
+
s = s.replace(/\*\*([^*\n]+?)\*\*/g, '<strong>$1</strong>').replace(/__([^_\n]+?)__/g, '<strong>$1</strong>');
|
|
152
|
+
s = s.replace(/(^|[^*\w])\*([^*\s][^*\n]*?)\*(?![*\w])/g, '$1<em>$2</em>');
|
|
153
|
+
s = s.replace(/(^|[^\w])_([^_\s][^_\n]*?)_(?!\w)/g, '$1<em>$2</em>');
|
|
154
|
+
return s.replace(/\[([THFW]\d+)\]/g, '<span class="mt-cite">[$1]</span>');
|
|
155
|
+
}
|
|
156
|
+
// Links are set aside before emphasis and citations run, then put back, so an href is never rewritten.
|
|
157
|
+
function inline(text) {
|
|
158
|
+
return String(text == null ? '' : text).replace(/[\uE000\uE001]/g, '').split(/(`[^`\n]+`)/g).map(function (p, i) {
|
|
159
|
+
if (i % 2 === 1) return '<code>' + esc(p.slice(1, -1)) + '</code>';
|
|
160
|
+
var links = [];
|
|
161
|
+
var s = esc(p).replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, function (_m, label, url) {
|
|
162
|
+
links.push('<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + emphasis(label) + '</a>');
|
|
163
|
+
return '\uE000' + (links.length - 1) + '\uE001';
|
|
164
|
+
});
|
|
165
|
+
return emphasis(s).replace(/\uE000(\d+)\uE001/g, function (_m, n) { return links[Number(n)] || ''; });
|
|
166
|
+
}).join('');
|
|
167
|
+
}
|
|
168
|
+
var MD = (function () {
|
|
169
|
+
function headingLevel(line) { var m = /^(#{1,6})(?: |$)/.exec(line); return m ? m[1].length : 0; }
|
|
170
|
+
function isRule(line) { var c = line.replace(/ /g, ''); return c.length >= 3 && (/^-+$/.test(c) || /^\*+$/.test(c) || /^_+$/.test(c)); }
|
|
171
|
+
function isSep(raw) { var l = raw.trim(); if (l.charAt(0) !== '|') return false; var inner = l.replace(/[|: ]/g, ''); return inner.length > 0 && /^-+$/.test(inner); }
|
|
172
|
+
function cells(line) { var t = line.trim(); if (t.charAt(0) === '|') t = t.slice(1); if (t.slice(-1) === '|') t = t.slice(0, -1); return t.split('|').map(function (c) { return c.trim(); }); }
|
|
173
|
+
function indent(raw) { var n = 0; for (var i = 0; i < raw.length; i++) { var c = raw.charAt(i); if (c === ' ') n++; else if (c === '\t') n += 4; else break; } return n; }
|
|
174
|
+
function bullet(raw) { var t = raw.trim(); if (/^[-*+] /.test(t)) return t.slice(0, 2); if (t === '-' || t === '*') return t; return null; }
|
|
175
|
+
function ordered(line) { var m = /^\d{1,3}[.)]\s+/.exec(line); return m ? m[0] : null; }
|
|
176
|
+
function itemText(raw) {
|
|
177
|
+
var line = raw.trim(), rest, b = bullet(raw);
|
|
178
|
+
if (b) rest = line.slice(b.length);
|
|
179
|
+
else { var o = ordered(line); if (!o) return null; rest = line.slice(o.length); }
|
|
180
|
+
rest = rest.trim();
|
|
181
|
+
if (rest.indexOf('[ ] ') === 0 || rest === '[ ]') return { text: rest.slice(4).trim(), checked: false };
|
|
182
|
+
if (/^\[x\]( |$)/i.test(rest)) return { text: rest.slice(4).trim(), checked: true };
|
|
183
|
+
return { text: rest, checked: null };
|
|
184
|
+
}
|
|
185
|
+
function stripTag(line, open, close) {
|
|
186
|
+
var s = line.toLowerCase().indexOf(open); if (s < 0) return null;
|
|
187
|
+
var inner = line.slice(s + open.length), e = inner.toLowerCase().indexOf(close);
|
|
188
|
+
if (e >= 0) inner = inner.slice(0, e);
|
|
189
|
+
return inner.trim();
|
|
190
|
+
}
|
|
191
|
+
function speakerLines(lines) {
|
|
192
|
+
var out = [];
|
|
193
|
+
for (var i = 0; i < lines.length; i++) {
|
|
194
|
+
var m = /^\[([^\]]{1,64})\]:\s?/.exec(lines[i]);
|
|
195
|
+
if (!m) return null;
|
|
196
|
+
out.push({ speaker: m[1], text: lines[i].slice(m[0].length) });
|
|
197
|
+
}
|
|
198
|
+
return out.length ? out : null;
|
|
199
|
+
}
|
|
200
|
+
function parseBlocks(lines) {
|
|
201
|
+
var blocks = [], i = 0, para = [];
|
|
202
|
+
function flush() {
|
|
203
|
+
if (!para.length) return;
|
|
204
|
+
var sp = speakerLines(para);
|
|
205
|
+
blocks.push(sp ? { t: 'speakers', lines: sp } : { t: 'p', text: para.join(' ') });
|
|
206
|
+
para = [];
|
|
207
|
+
}
|
|
208
|
+
while (i < lines.length) {
|
|
209
|
+
var raw = lines[i], line = raw.trim();
|
|
210
|
+
if (!line) { flush(); i++; continue; }
|
|
211
|
+
if (line.indexOf('```') === 0) {
|
|
212
|
+
flush();
|
|
213
|
+
var body = [], j = i + 1;
|
|
214
|
+
while (j < lines.length && lines[j].trim().indexOf('```') !== 0) { body.push(lines[j]); j++; }
|
|
215
|
+
blocks.push({ t: 'code', text: body.join('\n') }); i = Math.min(j + 1, lines.length); continue;
|
|
216
|
+
}
|
|
217
|
+
if (line.toLowerCase().indexOf('<details') === 0) {
|
|
218
|
+
flush();
|
|
219
|
+
var summary = stripTag(line, '<summary>', '</summary>') || 'Details', inner = [], k = i + 1, depth = 1;
|
|
220
|
+
while (k < lines.length) {
|
|
221
|
+
var t = lines[k].trim(), lower = t.toLowerCase();
|
|
222
|
+
if (lower.indexOf('<details') === 0) depth++;
|
|
223
|
+
if (lower.indexOf('</details>') === 0) { depth--; if (depth === 0) break; }
|
|
224
|
+
var s = depth === 1 && lower.indexOf('<summary>') === 0 ? stripTag(t, '<summary>', '</summary>') : null;
|
|
225
|
+
if (s != null) summary = s; else inner.push(lines[k]);
|
|
226
|
+
k++;
|
|
227
|
+
}
|
|
228
|
+
blocks.push({ t: 'details', summary: summary, blocks: parseBlocks(inner) }); i = Math.min(k + 1, lines.length); continue;
|
|
229
|
+
}
|
|
230
|
+
var level = headingLevel(line);
|
|
231
|
+
if (level) {
|
|
232
|
+
flush();
|
|
233
|
+
blocks.push({ t: 'h', level: level, text: line.replace(/^#+/, '').trim().replace(/\s+#+$/, '') }); i++; continue;
|
|
234
|
+
}
|
|
235
|
+
if (isRule(line)) { flush(); blocks.push({ t: 'hr' }); i++; continue; }
|
|
236
|
+
if (line.charAt(0) === '|' && i + 1 < lines.length && isSep(lines[i + 1])) {
|
|
237
|
+
flush();
|
|
238
|
+
var header = cells(line), rows = [], r = i + 2;
|
|
239
|
+
while (r < lines.length && lines[r].trim().charAt(0) === '|') { rows.push(cells(lines[r].trim())); r++; }
|
|
240
|
+
blocks.push({ t: 'table', header: header, rows: rows }); i = r; continue;
|
|
241
|
+
}
|
|
242
|
+
if (line.charAt(0) === '>') {
|
|
243
|
+
flush();
|
|
244
|
+
var q = [], qi = i;
|
|
245
|
+
while (qi < lines.length && lines[qi].trim().charAt(0) === '>') { q.push(lines[qi].trim().slice(1).trim()); qi++; }
|
|
246
|
+
blocks.push({ t: 'quote', blocks: parseBlocks(q) }); i = qi; continue;
|
|
247
|
+
}
|
|
248
|
+
if (bullet(raw) != null || ordered(line) != null) {
|
|
249
|
+
flush();
|
|
250
|
+
var res = parseList(lines, i); blocks.push(res.block); i = res.next; continue;
|
|
251
|
+
}
|
|
252
|
+
para.push(line); i++;
|
|
253
|
+
}
|
|
254
|
+
flush();
|
|
255
|
+
return blocks;
|
|
256
|
+
}
|
|
257
|
+
function parseList(lines, start) {
|
|
258
|
+
var base = indent(lines[start]), isOrdered = ordered(lines[start].trim()) != null, items = [], i = start;
|
|
259
|
+
while (i < lines.length) {
|
|
260
|
+
var raw = lines[i];
|
|
261
|
+
if (!raw.trim()) {
|
|
262
|
+
var j = i + 1; while (j < lines.length && !lines[j].trim()) j++;
|
|
263
|
+
if (j < lines.length && indent(lines[j]) === base && itemText(lines[j])) { i = j; continue; }
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
var it = indent(raw) === base ? itemText(raw) : null;
|
|
267
|
+
if (!it) break;
|
|
268
|
+
var children = [], n = i + 1;
|
|
269
|
+
while (n < lines.length) {
|
|
270
|
+
var next = lines[n];
|
|
271
|
+
if (!next.trim()) {
|
|
272
|
+
var k = n + 1; while (k < lines.length && !lines[k].trim()) k++;
|
|
273
|
+
if (k < lines.length && indent(lines[k]) > base) { children.push(''); n++; continue; }
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
if (indent(next) > base) { children.push(next.slice(Math.min(indent(next), base + 2))); n++; continue; }
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
var item = { text: it.text, checked: it.checked, children: [] };
|
|
280
|
+
if (children.length) {
|
|
281
|
+
var nestedHasList = children.some(function (c) { return bullet(c) != null || ordered(c.trim()) != null; });
|
|
282
|
+
parseBlocks(children).forEach(function (b) {
|
|
283
|
+
if (b.t === 'p' && !item.children.length && !nestedHasList) item.text += ' ' + b.text; else item.children.push(b);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
items.push(item); i = n;
|
|
287
|
+
}
|
|
288
|
+
return { block: { t: 'list', ordered: isOrdered, items: items }, next: i };
|
|
289
|
+
}
|
|
290
|
+
function parse(text, dropTitle) {
|
|
291
|
+
var lines = String(text || '').replace(/\r\n/g, '\n').split('\n');
|
|
292
|
+
if (dropTitle) {
|
|
293
|
+
for (var i = 0; i < lines.length; i++) { if (lines[i].trim()) { if (lines[i].indexOf('# ') === 0) lines.splice(i, 1); break; } }
|
|
294
|
+
}
|
|
295
|
+
return parseBlocks(lines);
|
|
296
|
+
}
|
|
297
|
+
function looksLikeDocument(text) {
|
|
298
|
+
var lines = String(text || '').split('\n').slice(0, 400);
|
|
299
|
+
for (var i = 0; i < lines.length; i++) {
|
|
300
|
+
var l = lines[i].trim();
|
|
301
|
+
if (l.charAt(0) === '#' && headingLevel(l)) return true;
|
|
302
|
+
if (l.charAt(0) === '|' && l.slice(-1) === '|' && l.length > 1) return true;
|
|
303
|
+
if (l.indexOf('```') === 0 || l.indexOf('<details') === 0) return true;
|
|
304
|
+
if (l.indexOf('- ') === 0 || l.indexOf('* ') === 0 || ordered(l)) return true;
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
// meetingParagraphs: bounded paragraphs, preferring a sentence end, never rewriting words.
|
|
309
|
+
function meetingParagraphs(text) {
|
|
310
|
+
var chars = Array.from(String(text)), start = 0, out = [];
|
|
311
|
+
while (start < chars.length) {
|
|
312
|
+
var end = Math.min(start + 420, chars.length), cut = end;
|
|
313
|
+
if (end < chars.length) {
|
|
314
|
+
var sentence = null, space = null;
|
|
315
|
+
for (var i = start; i < end; i++) {
|
|
316
|
+
if (/\s/.test(chars[i])) space = i;
|
|
317
|
+
if (i - start >= 160 && '.!?。!?'.indexOf(chars[i]) >= 0 && /\s/.test(chars[i + 1] || '')) sentence = i + 1;
|
|
318
|
+
}
|
|
319
|
+
cut = sentence != null ? sentence : (space != null && space > start ? space : end);
|
|
320
|
+
}
|
|
321
|
+
var part = chars.slice(start, cut).join('').trim();
|
|
322
|
+
if (part) out.push(part);
|
|
323
|
+
start = cut;
|
|
324
|
+
while (start < chars.length && /\s/.test(chars[start])) start++;
|
|
325
|
+
}
|
|
326
|
+
return out.join('\n\n');
|
|
327
|
+
}
|
|
328
|
+
function splitSpeakers(text) {
|
|
329
|
+
var re = /(?:^|\s)\[([^\]\n]{1,64})\]:\s*/g, matches = [], m;
|
|
330
|
+
while ((m = re.exec(text))) { matches.push({ index: m.index, end: re.lastIndex, speaker: m[1] }); if (m[0] === '') re.lastIndex++; }
|
|
331
|
+
if (!matches.length || matches[0].index !== 0) return null;
|
|
332
|
+
return matches.map(function (x, i) {
|
|
333
|
+
var stop = i + 1 < matches.length ? matches[i + 1].index : text.length;
|
|
334
|
+
return { speaker: x.speaker, text: text.slice(x.end, stop).trim() };
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
function formatMeeting(blocks, transcript) {
|
|
338
|
+
var inT = transcript, out = [], pending = [];
|
|
339
|
+
function add(line) {
|
|
340
|
+
var last = pending[pending.length - 1];
|
|
341
|
+
if (last && last.speaker === line.speaker && last.text.length + line.text.length + 1 <= 600) last.text += ' ' + line.text;
|
|
342
|
+
else pending.push({ speaker: line.speaker, text: line.text });
|
|
343
|
+
}
|
|
344
|
+
function flush() {
|
|
345
|
+
if (!pending.length) return;
|
|
346
|
+
out.push({ t: 'speakers', lines: pending.map(function (l) { return { speaker: l.speaker, text: meetingParagraphs(l.text) }; }) });
|
|
347
|
+
pending = [];
|
|
348
|
+
}
|
|
349
|
+
blocks.forEach(function (b) {
|
|
350
|
+
if (b.t === 'speakers') {
|
|
351
|
+
b.lines.forEach(function (row) { (splitSpeakers('[' + row.speaker + ']: ' + row.text) || [row]).forEach(add); });
|
|
352
|
+
} else if (b.t === 'p') {
|
|
353
|
+
var rows = splitSpeakers(b.text);
|
|
354
|
+
if (rows) rows.forEach(add);
|
|
355
|
+
else if (inT) {
|
|
356
|
+
flush();
|
|
357
|
+
var mm = /(?:^|\s)\[([^\]\n]{1,64})\]:\s*/.exec(b.text);
|
|
358
|
+
if (mm && mm.index > 0) {
|
|
359
|
+
out.push({ t: 'p', text: meetingParagraphs(b.text.slice(0, mm.index)) });
|
|
360
|
+
(splitSpeakers(b.text.slice(mm.index).trim()) || []).forEach(add);
|
|
361
|
+
} else out.push({ t: 'p', text: meetingParagraphs(b.text) });
|
|
362
|
+
} else { flush(); out.push(b); }
|
|
363
|
+
} else if (b.t === 'h') {
|
|
364
|
+
flush();
|
|
365
|
+
if (b.level <= 3) inT = b.text.toLowerCase().indexOf('transcript') >= 0;
|
|
366
|
+
out.push(b);
|
|
367
|
+
} else if (b.t === 'details') {
|
|
368
|
+
flush();
|
|
369
|
+
out.push({ t: 'details', summary: b.summary, blocks: formatMeeting(b.blocks, inT || b.summary.toLowerCase().indexOf('transcript') >= 0) });
|
|
370
|
+
} else { flush(); out.push(b); }
|
|
371
|
+
});
|
|
372
|
+
flush();
|
|
373
|
+
return out;
|
|
374
|
+
}
|
|
375
|
+
function paras(text) { return String(text).split(/\n{2,}/).map(function (p) { return '<p>' + inline(p) + '</p>'; }).join(''); }
|
|
376
|
+
function unnamed(s) { s = String(s).toLowerCase(); return s === 'ext' || s.indexOf('speaker ') === 0 || s.indexOf('unknown') === 0; }
|
|
377
|
+
function render(blocks) {
|
|
378
|
+
return blocks.map(function (b) {
|
|
379
|
+
switch (b.t) {
|
|
380
|
+
case 'h': return '<div class="md-h md-h' + Math.min(b.level, 4) + '">' + inline(b.text) + '</div>';
|
|
381
|
+
case 'p': return paras(b.text);
|
|
382
|
+
case 'code': return '<pre class="md-code"><code>' + esc(b.text) + '</code></pre>';
|
|
383
|
+
case 'hr': return '<hr class="md-hr">';
|
|
384
|
+
case 'quote': return '<blockquote class="md-quote">' + render(b.blocks) + '</blockquote>';
|
|
385
|
+
case 'list':
|
|
386
|
+
return '<' + (b.ordered ? 'ol' : 'ul') + ' class="md-list">' + b.items.map(function (it) {
|
|
387
|
+
var box = it.checked == null ? '' : '<span class="md-check' + (it.checked ? ' on' : '') + '" aria-label="' + (it.checked ? 'done' : 'open') + '"></span>';
|
|
388
|
+
return '<li' + (box ? ' class="md-task"' : '') + '>' + box + inline(it.text) + (it.children.length ? render(it.children) : '') + '</li>';
|
|
389
|
+
}).join('') + '</' + (b.ordered ? 'ol' : 'ul') + '>';
|
|
390
|
+
case 'table': {
|
|
391
|
+
var kv = b.header.length === 2 && b.rows.length && b.rows.every(function (r) { return r.length === 2 && r[0].indexOf('**') === 0; });
|
|
392
|
+
var cols = Math.max(b.header.length, b.rows.reduce(function (m, r) { return Math.max(m, r.length); }, 0));
|
|
393
|
+
function row(r, tag, key) {
|
|
394
|
+
var h = '<tr>';
|
|
395
|
+
for (var c = 0; c < cols; c++) h += '<' + tag + (key && c === 0 ? ' class="md-key"' : '') + '>' + inline(r[c] || '') + '</' + tag + '>';
|
|
396
|
+
return h + '</tr>';
|
|
397
|
+
}
|
|
398
|
+
return '<div class="md-table-wrap"><table class="md-table' + (kv ? ' kv' : '') + '">' + (kv ? '' : '<thead>' + row(b.header, 'th') + '</thead>') +
|
|
399
|
+
'<tbody>' + b.rows.map(function (r) { return row(r, 'td', kv); }).join('') + '</tbody></table></div>';
|
|
400
|
+
}
|
|
401
|
+
case 'details': {
|
|
402
|
+
var sp = b.blocks.filter(function (x) { return x.t === 'speakers'; })[0];
|
|
403
|
+
var note = '';
|
|
404
|
+
if (sp) { var who = {}; sp.lines.forEach(function (l) { who[l.speaker] = 1; }); var n = sp.lines.length, k = Object.keys(who).length; note = n + ' line' + (n === 1 ? '' : 's') + ' · ' + k + ' speaker' + (k === 1 ? '' : 's'); }
|
|
405
|
+
return '<details class="md-details"><summary><span>' + inline(b.summary) + '</span>' + (note ? '<span class="md-note">' + esc(note) + '</span>' : '') + '</summary><div class="md-details-body">' + render(b.blocks) + '</div></details>';
|
|
406
|
+
}
|
|
407
|
+
case 'speakers':
|
|
408
|
+
return '<div class="md-speakers">' + b.lines.map(function (l) {
|
|
409
|
+
return '<div class="md-turn"><div class="md-who' + (unnamed(l.speaker) ? ' unnamed' : '') + '">' + esc(l.speaker) + '</div>' + paras(l.text) + '</div>';
|
|
410
|
+
}).join('') + '</div>';
|
|
411
|
+
default: return '';
|
|
412
|
+
}
|
|
413
|
+
}).join('');
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
paragraphs: meetingParagraphs,
|
|
417
|
+
looksLikeDocument: looksLikeDocument,
|
|
418
|
+
doc: function (text, dropTitle) { return render(parse(text, dropTitle)); },
|
|
419
|
+
meeting: function (text, dropTitle) { return render(formatMeeting(parse(text, dropTitle), !looksLikeDocument(text))); }
|
|
420
|
+
};
|
|
421
|
+
})();
|
|
422
|
+
// MeetingAskText.transcript: the Transcript section of a scribe file, else the whole text (a document when it has headings).
|
|
423
|
+
function transcriptPart(raw) {
|
|
424
|
+
var lines = String(raw || '').split('\n'), start = null, level = 0, end = lines.length, fenced = false, doc = false;
|
|
425
|
+
for (var i = 0; i < lines.length; i++) {
|
|
426
|
+
var t = lines[i].trim();
|
|
427
|
+
if (t.indexOf('```') === 0 || t.indexOf('~~~') === 0) { fenced = !fenced; continue; }
|
|
428
|
+
var m = fenced ? null : /^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(lines[i]);
|
|
429
|
+
if (!m) continue;
|
|
430
|
+
doc = true;
|
|
431
|
+
var depth = m[1].length, title = m[2].toLowerCase().replace(/^[ *_:]+|[ *_:]+$/g, '');
|
|
432
|
+
if (start != null && depth <= level) { end = i; break; }
|
|
433
|
+
if (['transcript', 'full transcript', 'meeting transcript', 'raw transcript'].indexOf(title) >= 0) { start = i + 1; level = depth; }
|
|
434
|
+
}
|
|
435
|
+
if (start != null) return { text: lines.slice(start, end).join('\n').trim(), document: false };
|
|
436
|
+
return { text: String(raw || ''), document: doc };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── state ─────────────────────────────────────────────
|
|
440
|
+
var S = {
|
|
441
|
+
view: '', month: monthKey(new Date()), day: null, autoDay: false, domain: 'all', recency: 'newest', q: '',
|
|
442
|
+
rows: null, days: [], dayRows: {}, loading: false, error: '', loadSeq: 0,
|
|
443
|
+
hits: null, searching: false, ranFor: '', semantic: null, searchError: '', searchSeq: 0,
|
|
444
|
+
orphans: { items: [], stranded: [], recovering: [], loaded: false, busy: false, notice: '', confirm: '' },
|
|
445
|
+
files: [], feeds: {}, listTimer: null, liveTimer: null, liveSid: '', follow: true,
|
|
446
|
+
stop: null, confirm: '', liveNote: '',
|
|
447
|
+
detail: null, detailKey: '', detailSeq: 0, copyNote: '',
|
|
448
|
+
ask: {}, conf: { config: null, reach: 'checking', notice: '', pending: null }
|
|
449
|
+
};
|
|
450
|
+
function queryActive() { return S.q.trim().length >= 2; }
|
|
451
|
+
// A month read stops at 200 rows (October 2026 held more), so a day picked in a full month is read on its own.
|
|
452
|
+
function visibleRows() {
|
|
453
|
+
var src = S.day && S.dayRows[S.day] ? S.dayRows[S.day] : (S.rows || []);
|
|
454
|
+
return src.filter(function (m) { return (!S.day || m.date === S.day) && (S.domain === 'all' || m.domain === S.domain); });
|
|
455
|
+
}
|
|
456
|
+
// SearchRecency.sorted
|
|
457
|
+
function visibleHits() {
|
|
458
|
+
var hits = (S.hits || []).slice();
|
|
459
|
+
function score(h) { return h.score != null ? num(h.score) : Math.max(num(h.keywordScore), num(h.semanticScore)); }
|
|
460
|
+
hits.sort(function (a, b) {
|
|
461
|
+
var da = stampOf(a), db = stampOf(b);
|
|
462
|
+
if (S.recency === 'match') { if (score(a) !== score(b)) return score(b) - score(a); return (db || -Infinity) - (da || -Infinity); }
|
|
463
|
+
if (S.recency === 'oldest') { var x = da == null ? Infinity : da, y = db == null ? Infinity : db; if (x !== y) return x - y; return score(b) - score(a); }
|
|
464
|
+
var p = da == null ? -Infinity : da, q = db == null ? -Infinity : db; if (p !== q) return q - p; return score(b) - score(a);
|
|
465
|
+
});
|
|
466
|
+
return hits;
|
|
467
|
+
}
|
|
468
|
+
// The server reads at most 200 meetings for a month (and has no offset); days[] counts the whole month.
|
|
469
|
+
function monthTotal() { return S.days.reduce(function (n, d) { return n + num(d.count); }, 0); }
|
|
470
|
+
function monthCapped() { return !S.day && !!S.rows && S.rows.length >= 200 && monthTotal() > S.rows.length; }
|
|
471
|
+
function rowKey(m) { return [m.domain || '', m.month || '', m.filename || ''].join('|'); }
|
|
472
|
+
|
|
473
|
+
// iOS Safari has no scroll anchoring: a box above the reader that grows or shrinks would move the rows under the
|
|
474
|
+
// finger. Paint only when the HTML changed, and when the box starts above the viewport keep the toolbar where it was.
|
|
475
|
+
function paintAbove(el, key, html) {
|
|
476
|
+
if (!el || S[key] === html) return;
|
|
477
|
+
var ref = $('mt-toolbar'), top = el.getBoundingClientRect().top;
|
|
478
|
+
var keep = ref && window.scrollY > 0 && top < 0, before = keep ? ref.getBoundingClientRect().top : 0;
|
|
479
|
+
S[key] = html; el.innerHTML = html;
|
|
480
|
+
if (keep) { var moved = ref.getBoundingClientRect().top - before; if (moved) window.scrollBy(0, moved); }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ── list: hero ────────────────────────────────────────
|
|
484
|
+
function paintHero() {
|
|
485
|
+
var el = $('mt-hero'); if (!el) return;
|
|
486
|
+
var detail = queryActive() ? (S.searching ? 'Looking up…' : 'Lookup across stored calls')
|
|
487
|
+
: (S.loading ? 'Loading…' : (S.error || 'Saved calls by day · transcript and summary'));
|
|
488
|
+
var stats = [];
|
|
489
|
+
if (S.rows) {
|
|
490
|
+
var shown = visibleRows().length;
|
|
491
|
+
stats.push([monthCapped() && S.domain === 'all' ? fmtCount(shown) + ' of ' + fmtCount(monthTotal()) : fmtCount(shown), S.day ? 'ON ' + S.day : monthTitle(S.month).toUpperCase()]);
|
|
492
|
+
var withCalls = S.days.filter(function (d) { return num(d.count) > 0; }).length;
|
|
493
|
+
if (withCalls > 0) stats.push([fmtCount(withCalls), 'DAYS WITH CALLS']);
|
|
494
|
+
}
|
|
495
|
+
paintAbove(el, 'heroHtml', '<header class="cx-hero" data-tint="meetings"><span class="cx-glyph">' + icon('meetings') + '</span>' +
|
|
496
|
+
'<div class="cx-hero-text"><h1 class="cx-title">Meetings</h1><p class="cx-detail-line">' + esc(detail) + '</p></div>' +
|
|
497
|
+
'<div class="cx-actions"><button type="button" class="cx-btn quiet" data-mt="refresh"' + (S.loading ? ' disabled' : '') + '>' + icon('refresh') + 'Refresh</button></div>' +
|
|
498
|
+
(stats.length ? '<div class="cx-stats">' + stats.map(function (s) { return '<span class="cx-stat"><b>' + esc(s[0]) + '</b><span>' + esc(s[1]) + '</span></span>'; }).join('') + '</div>' : '') +
|
|
499
|
+
'</header>');
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ── list: month calendar (MeetingMonthCalendar) ───────
|
|
503
|
+
function weekStart() {
|
|
504
|
+
try {
|
|
505
|
+
var loc = new Intl.Locale(navigator.language || 'en-US');
|
|
506
|
+
var info = loc.weekInfo || (typeof loc.getWeekInfo === 'function' ? loc.getWeekInfo() : null);
|
|
507
|
+
if (info && info.firstDay) return info.firstDay % 7;
|
|
508
|
+
} catch (_e) {}
|
|
509
|
+
return 0;
|
|
510
|
+
}
|
|
511
|
+
function paintCal() {
|
|
512
|
+
var el = $('mt-cal'); if (!el) return;
|
|
513
|
+
if (queryActive()) { paintAbove(el, 'calHtml', ''); return; }
|
|
514
|
+
var start = parseMonth(S.month); if (!start) { paintAbove(el, 'calHtml', ''); return; }
|
|
515
|
+
var counts = {}; S.days.forEach(function (d) { counts[d.date] = num(d.count); });
|
|
516
|
+
var ws = weekStart(), lead = (start.getDay() - ws + 7) % 7;
|
|
517
|
+
var dim = new Date(start.getFullYear(), start.getMonth() + 1, 0).getDate();
|
|
518
|
+
var heads = '';
|
|
519
|
+
for (var w = 0; w < 7; w++) heads += '<span class="mt-wd" aria-hidden="true">' + esc(new Date(2023, 0, 1 + (ws + w) % 7).toLocaleDateString(undefined, { weekday: 'narrow' })) + '</span>';
|
|
520
|
+
var cells = '', n = 0;
|
|
521
|
+
for (var i = 0; i < lead; i++) { cells += '<span class="mt-blank"></span>'; n++; }
|
|
522
|
+
for (var day = 1; day <= dim; day++) {
|
|
523
|
+
var key = S.month + '-' + pad2(day), c = counts[key] || 0, on = S.day === key;
|
|
524
|
+
cells += '<button type="button" class="mt-day" data-mt="day" data-day="' + key + '" aria-pressed="' + on + '"' + (c === 0 ? ' disabled' : '') +
|
|
525
|
+
' aria-label="' + esc(key + (c === 0 ? ', no meetings' : ', ' + c + ' meetings')) + '"><span class="mt-dnum">' + day + '</span><span class="mt-dot' + (c > 0 ? ' on' : '') + '"></span></button>';
|
|
526
|
+
n++;
|
|
527
|
+
}
|
|
528
|
+
while (n % 7 !== 0) { cells += '<span class="mt-blank"></span>'; n++; }
|
|
529
|
+
paintAbove(el, 'calHtml', '<section class="mt-cal" aria-label="' + esc(monthTitle(S.month)) + '"><div class="mt-cal-head">' +
|
|
530
|
+
'<button type="button" class="mt-nav" data-mt="month" data-delta="-1" aria-label="Previous month">' + icon('back') + '</button>' +
|
|
531
|
+
'<span class="mt-cal-title">' + esc(monthTitle(S.month)) + '</span>' +
|
|
532
|
+
'<button type="button" class="mt-nav next" data-mt="month" data-delta="1" aria-label="Next month">' + icon('chev') + '</button></div>' +
|
|
533
|
+
'<div class="mt-grid">' + heads + cells + '</div>' +
|
|
534
|
+
(S.day ? '<button type="button" class="cx-btn quiet mt-all" data-mt="all-month">All month</button>' : '') + '</section>');
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ── list: toolbar ─────────────────────────────────────
|
|
538
|
+
function paintToolbar() {
|
|
539
|
+
var el = $('mt-toolbar'); if (!el) return;
|
|
540
|
+
var rec = [['newest', 'Newest'], ['oldest', 'Oldest'], ['match', 'Best match']];
|
|
541
|
+
el.innerHTML = '<div class="mt-toolbar"><form class="cx-search mt-search" data-mt-form role="search">' +
|
|
542
|
+
'<input class="field" type="search" id="mt-q" placeholder="Search topics, ideas…" value="' + esc(S.q) + '" autocomplete="off" enterkeyhint="search" aria-label="Search topics, ideas">' +
|
|
543
|
+
'<button type="button" class="cx-clear" id="mt-clear" data-mt="clear" aria-label="Clear search"' + (S.q ? '' : ' hidden') + '>Clear</button></form>' +
|
|
544
|
+
'<span id="mt-domain" class="mt-slot"></span>' +
|
|
545
|
+
'<select class="mt-select" id="mt-recency" aria-label="Recency">' + rec.map(function (r) { return '<option value="' + r[0] + '"' + (S.recency === r[0] ? ' selected' : '') + '>' + r[1] + '</option>'; }).join('') + '</select>' +
|
|
546
|
+
'<span class="mt-count" id="mt-count"></span></div><p class="mt-note" id="mt-note" hidden></p>';
|
|
547
|
+
paintToolbarBits();
|
|
548
|
+
}
|
|
549
|
+
function domainOptions() {
|
|
550
|
+
var set = {};
|
|
551
|
+
(S.rows || []).concat(queryActive() ? (S.hits || []) : []).forEach(function (m) { if (m.domain) set[m.domain] = 1; });
|
|
552
|
+
if (S.domain !== 'all') set[S.domain] = 1;
|
|
553
|
+
return Object.keys(set).sort();
|
|
554
|
+
}
|
|
555
|
+
function paintToolbarBits() {
|
|
556
|
+
var active = queryActive(), slot = $('mt-domain');
|
|
557
|
+
if (slot) {
|
|
558
|
+
var opts = domainOptions();
|
|
559
|
+
var dom = opts.length + 1 > 2 || active
|
|
560
|
+
? '<select class="mt-select" id="mt-dom" aria-label="Domain"><option value="all">All domains</option>' + opts.map(function (d) { return '<option value="' + esc(d) + '"' + (S.domain === d ? ' selected' : '') + '>' + esc(cap(d)) + '</option>'; }).join('') + '</select>'
|
|
561
|
+
: '';
|
|
562
|
+
if (S.domHtml !== dom) { S.domHtml = dom; slot.innerHTML = dom; }
|
|
563
|
+
}
|
|
564
|
+
var count = $('mt-count');
|
|
565
|
+
if (count) {
|
|
566
|
+
count.textContent = active
|
|
567
|
+
? (S.searching && !(S.hits || []).length ? 'Looking up…' : visibleHits().length + ' across stored calls')
|
|
568
|
+
: (S.day ? visibleRows().length + ' on ' + S.day
|
|
569
|
+
: visibleRows().length + (monthCapped() && S.domain === 'all' ? ' of ' + monthTotal() : '') + ' in ' + monthTitle(S.month));
|
|
570
|
+
}
|
|
571
|
+
var note = $('mt-note');
|
|
572
|
+
if (note) {
|
|
573
|
+
var show = active && S.ranFor === S.q.trim() && S.semantic === false;
|
|
574
|
+
note.hidden = !show;
|
|
575
|
+
note.textContent = show ? 'Keyword only. Meaning search needs the COS meeting index' : '';
|
|
576
|
+
}
|
|
577
|
+
var clear = $('mt-clear'); if (clear) clear.hidden = !S.q;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ── list: rows ────────────────────────────────────────
|
|
581
|
+
function rowHtml(m, hit) {
|
|
582
|
+
return '<button type="button" class="cx-row mt-row" data-mt="open" data-key="' + esc(rowKey(m)) + '">' +
|
|
583
|
+
'<span class="cx-row-glyph">' + icon('meetings') + '</span>' +
|
|
584
|
+
'<span class="cx-row-body"><span class="cx-row-title strong">' + esc(m.title || 'Untitled meeting') + '</span>' +
|
|
585
|
+
(hit && m.snippet ? '<span class="cx-row-sub mt-snippet">' + esc(m.snippet) + '</span>' : '') +
|
|
586
|
+
'<span class="mt-sub' + (hit ? ' dim' : '') + '">' + esc(subtitle(m)) + '</span></span>' +
|
|
587
|
+
'<span class="mt-side">' + (hit ? '<span class="mt-match">' + esc(matchLabel(m.match)) + '</span>' : '') +
|
|
588
|
+
(m.sessionId ? pillsHtml(m, isNew(m.sessionId)) : '') + '</span><span class="chev"></span></button>';
|
|
589
|
+
}
|
|
590
|
+
function emptyBox(text) { return '<div class="cx-list"><p class="cx-empty mt-empty">' + esc(text) + '</p></div>'; }
|
|
591
|
+
function paintList() {
|
|
592
|
+
var el = $('mt-list'); if (!el) return;
|
|
593
|
+
var html;
|
|
594
|
+
if (queryActive()) {
|
|
595
|
+
var hits = visibleHits();
|
|
596
|
+
if (S.searching && !hits.length && !S.searchError) html = '<p class="cx-note mt-wait">Looking up…</p>';
|
|
597
|
+
else if (S.searchError && !hits.length) html = emptyBox(S.searchError);
|
|
598
|
+
else if (!hits.length) html = S.ranFor === S.q.trim() ? emptyBox('No meetings match that lookup.') : '<p class="cx-note mt-wait">Looking up…</p>';
|
|
599
|
+
else html = '<div class="cx-list">' + hits.map(function (h) { return rowHtml(h, true); }).join('') + '</div>';
|
|
600
|
+
} else if (S.loading && !S.rows) {
|
|
601
|
+
html = '<p class="cx-note mt-wait">Loading meetings…</p>' + W.skeletonRows(6);
|
|
602
|
+
} else if (S.error && !(S.rows || []).length) {
|
|
603
|
+
html = emptyBox(S.error);
|
|
604
|
+
} else {
|
|
605
|
+
var rows = visibleRows();
|
|
606
|
+
html = rows.length ? '<div class="cx-list">' + rows.map(function (m) { return rowHtml(m, false); }).join('') + '</div>' +
|
|
607
|
+
(monthCapped() ? '<p class="mt-capnote">Showing the newest ' + S.rows.length + ' of ' + monthTotal() + ' in ' + esc(monthTitle(S.month)) + '. Pick a day in the calendar to see all of its meetings.</p>' : '')
|
|
608
|
+
: emptyBox(S.day ? 'No meetings stored on ' + S.day + '. Pick another day, or show the whole month.' : 'No meetings stored in ' + monthTitle(S.month) + '.');
|
|
609
|
+
}
|
|
610
|
+
if (S.listHtml === html) return;
|
|
611
|
+
// Keep the row under the reader still while rows above it change height (pills arriving, a reload).
|
|
612
|
+
var anchor = null, at = 0;
|
|
613
|
+
if (window.scrollY > 0) {
|
|
614
|
+
var rows = el.querySelectorAll('.cx-row[data-key]');
|
|
615
|
+
for (var i = 0; i < rows.length; i++) { var r = rows[i].getBoundingClientRect(); if (r.bottom > 0) { anchor = rows[i].getAttribute('data-key'); at = r.top; break; } }
|
|
616
|
+
}
|
|
617
|
+
S.listHtml = html; el.innerHTML = html;
|
|
618
|
+
if (anchor) {
|
|
619
|
+
var again = el.querySelector('.cx-row[data-key="' + anchor.replace(/["\\]/g, '\\$&') + '"]');
|
|
620
|
+
if (again) { var moved = again.getBoundingClientRect().top - at; if (moved) window.scrollBy(0, moved); }
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function paintBrowse() { paintHero(); paintCal(); paintToolbarBits(); paintList(); }
|
|
624
|
+
|
|
625
|
+
// quiet: a background reload (a meeting finished saving). The rows stay on screen and nothing says Loading.
|
|
626
|
+
function loadMonth(quiet) {
|
|
627
|
+
var id = ++S.loadSeq;
|
|
628
|
+
if (!quiet) { S.loading = true; if (S.view === 'list') paintBrowse(); }
|
|
629
|
+
api('/api/meetings?limit=200&domain=all&month=' + encodeURIComponent(S.month)).then(function (d) {
|
|
630
|
+
if (id !== S.loadSeq) return;
|
|
631
|
+
S.rows = (d && d.meetings) || [];
|
|
632
|
+
var days = (d && d.days) || [];
|
|
633
|
+
if (!days.length) {
|
|
634
|
+
var counts = {}; S.rows.forEach(function (m) { if (m.date) counts[m.date] = (counts[m.date] || 0) + 1; });
|
|
635
|
+
days = Object.keys(counts).sort().map(function (k) { return { date: k, count: counts[k] }; });
|
|
636
|
+
}
|
|
637
|
+
S.days = days;
|
|
638
|
+
var kept = S.dayRows; S.dayRows = {};
|
|
639
|
+
if (S.day && kept[S.day]) S.dayRows[S.day] = kept[S.day];
|
|
640
|
+
if (!S.autoDay && S.month === monthKey(new Date())) {
|
|
641
|
+
var today = dayKey(new Date());
|
|
642
|
+
if (days.some(function (x) { return x.date === today && num(x.count) > 0; })) S.day = today;
|
|
643
|
+
S.autoDay = true;
|
|
644
|
+
}
|
|
645
|
+
S.error = ''; S.loading = false;
|
|
646
|
+
if (S.view === 'list') paintBrowse();
|
|
647
|
+
ensureDay(quiet);
|
|
648
|
+
}, function (err) {
|
|
649
|
+
if (id !== S.loadSeq) return;
|
|
650
|
+
if (!S.rows || !quiet) S.rows = S.rows || [];
|
|
651
|
+
S.error = (err && err.message) || 'Meetings could not be loaded.'; S.loading = false;
|
|
652
|
+
if (S.view === 'list') paintBrowse();
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
// A saved meeting changes the month: reload it in place on the list, or on the way back to it.
|
|
656
|
+
function refreshRows() { if (S.view === 'list') loadMonth(true); else S.rowsStale = true; }
|
|
657
|
+
function ensureDay(again) {
|
|
658
|
+
var day = S.day;
|
|
659
|
+
if (!day || !S.rows || S.rows.length < 200 || (S.dayRows[day] && !again)) return;
|
|
660
|
+
api('/api/meetings?limit=200&domain=all&day=' + encodeURIComponent(day)).then(function (d) {
|
|
661
|
+
S.dayRows[day] = (d && d.meetings) || [];
|
|
662
|
+
if (S.view === 'list' && S.day === day) paintBrowse();
|
|
663
|
+
}, function () {});
|
|
664
|
+
}
|
|
665
|
+
function shiftMonth(delta) {
|
|
666
|
+
var d = parseMonth(S.month); if (!d) return;
|
|
667
|
+
S.month = monthKey(new Date(d.getFullYear(), d.getMonth() + delta, 1));
|
|
668
|
+
S.day = null; S.rows = null; S.days = []; S.dayRows = {};
|
|
669
|
+
loadMonth();
|
|
670
|
+
}
|
|
671
|
+
function runSearch() {
|
|
672
|
+
var q = S.q.trim();
|
|
673
|
+
if (q.length < 2) { S.hits = null; S.ranFor = ''; S.searching = false; S.searchError = ''; paintBrowse(); return; }
|
|
674
|
+
var id = ++S.searchSeq;
|
|
675
|
+
S.searching = true;
|
|
676
|
+
paintBrowse();
|
|
677
|
+
api('/api/meetings/search?q=' + encodeURIComponent(q) + '&limit=20' + (S.domain !== 'all' ? '&domain=' + encodeURIComponent(S.domain) : '')).then(function (d) {
|
|
678
|
+
if (id !== S.searchSeq) return;
|
|
679
|
+
S.searching = false; S.ranFor = q; S.searchError = '';
|
|
680
|
+
S.hits = (d && d.hits) || [];
|
|
681
|
+
S.semantic = !!(d && d.semanticAvailable === true);
|
|
682
|
+
if (S.view === 'list') paintBrowse();
|
|
683
|
+
}, function (err) {
|
|
684
|
+
if (id !== S.searchSeq) return;
|
|
685
|
+
S.searching = false; S.ranFor = q; S.hits = [];
|
|
686
|
+
S.searchError = (err && err.message) || 'The lookup failed.';
|
|
687
|
+
if (S.view === 'list') paintBrowse();
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// NEW is Speakers' memory (Control's isInboxNew). It is read here, never written.
|
|
692
|
+
// Control's isInboxNew: in the Speakers inbox and not yet acknowledged there (none before Speakers first seeds it).
|
|
693
|
+
function isNew(sessionId) {
|
|
694
|
+
if (!sessionId || !S.inbox || !S.inbox[sessionId]) return false;
|
|
695
|
+
var ack = speakerMemory().acknowledged;
|
|
696
|
+
return Array.isArray(ack) && ack.length > 0 && ack.indexOf(sessionId) < 0;
|
|
697
|
+
}
|
|
698
|
+
// The Speakers inbox: GET /api/meetings?limit=30, rows with a session (the helper's reviewable list).
|
|
699
|
+
function loadInbox() {
|
|
700
|
+
if (S.inboxBusy) return;
|
|
701
|
+
S.inboxBusy = true;
|
|
702
|
+
api('/api/meetings?limit=30').then(function (d) {
|
|
703
|
+
S.inboxBusy = false;
|
|
704
|
+
var map = {};
|
|
705
|
+
((d && d.meetings) || []).forEach(function (m) { if (m && m.sessionId) map[m.sessionId] = m; });
|
|
706
|
+
S.inbox = map;
|
|
707
|
+
if (S.view === 'list') paintList();
|
|
708
|
+
else if (S.view === 'detail') paintBar();
|
|
709
|
+
}, function () { S.inboxBusy = false; });
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// A sign-in that lapsed: stop polling and show the sign-in, as W.api does on a 401.
|
|
713
|
+
function signedOut() {
|
|
714
|
+
var first = !S.out;
|
|
715
|
+
halt();
|
|
716
|
+
if (first && typeof W.showAuth !== 'function') api('/api/meeting-ask/config').catch(function () {});
|
|
717
|
+
}
|
|
718
|
+
var SIGNED_OUT = 'Signed out. Connect this browser again.';
|
|
719
|
+
// A write whose refusal is an answer to read ({status, body}), never an error to throw. A 401 signs out.
|
|
720
|
+
function answer(path, opts) {
|
|
721
|
+
var o = opts || {};
|
|
722
|
+
var headers = { 'X-COS-Web': '1' };
|
|
723
|
+
if (o.body !== undefined) headers['Content-Type'] = 'application/json';
|
|
724
|
+
// A read gives up at 20 s, as W.api's do, so a stalled Mac never freezes a poll that waits on it.
|
|
725
|
+
var method = o.method || 'GET', ctrl = method === 'GET' && typeof AbortController === 'function' ? new AbortController() : null;
|
|
726
|
+
var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, 20000) : null;
|
|
727
|
+
return fetch(path, { method: method, headers: headers, credentials: 'same-origin', body: o.body !== undefined ? JSON.stringify(o.body) : undefined, signal: ctrl ? ctrl.signal : undefined })
|
|
728
|
+
.then(function (res) {
|
|
729
|
+
if (res.status === 401) signedOut();
|
|
730
|
+
return res.text().then(function (t) { clearTimeout(timer); var b = null; try { b = t ? JSON.parse(t) : {}; } catch (_e) { b = null; } return { status: res.status, body: b || {}, json: b !== null }; });
|
|
731
|
+
})
|
|
732
|
+
// Never rejects: a lost network, a timeout or a body cut off mid-read all read as status 0.
|
|
733
|
+
.catch(function () { clearTimeout(timer); return { status: 0, body: {}, json: false }; });
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ── live recordings (LiveTranscript.swift) ────────────
|
|
737
|
+
var QUIET_MS = 120000, RETENTION_MS = 4 * 3600000, STATUS_QUIET_MS = 15000, STATUS_ENDED_MS = 3000;
|
|
738
|
+
var UNSAVED_GRACE_MS = 20000, FINAL_DEADLINE_MS = 180000, ENDED_ROW_MS = 600000;
|
|
739
|
+
var LIVE_HEADER = 'Live transcript · preliminary · may change when saved';
|
|
740
|
+
function newFeed(sid) {
|
|
741
|
+
return { sid: sid, start: null, last: null, mtime: 0, stamp: null, cursor: -1, rev: null, chunks: {}, turns: [], turnOf: {},
|
|
742
|
+
endedAt: null, phase: 'live', serverState: null, statusAt: 0, statusBusy: false, leftover: false, seenLive: false, probed: false, titleSteer: null };
|
|
743
|
+
}
|
|
744
|
+
function feedFor(sid) { return S.feeds[sid] || (S.feeds[sid] = newFeed(sid)); }
|
|
745
|
+
function savesMeeting(st) { return st === 'saved' || st === 'recovered'; }
|
|
746
|
+
function markEnded(f, now) { if (f.endedAt) return false; f.endedAt = now; if (f.phase === 'live') f.phase = 'finalizing'; return true; }
|
|
747
|
+
function durationLabel(start, now, endedAt) {
|
|
748
|
+
if (!start) return null;
|
|
749
|
+
var end = endedAt ? Math.min(endedAt, now) : now, min = Math.max(0, Math.floor((end - start) / 60000));
|
|
750
|
+
if (min < 1) return 'Just started';
|
|
751
|
+
if (min < 60) return min + ' min';
|
|
752
|
+
return min % 60 === 0 ? (min / 60) + ' h' : Math.floor(min / 60) + ' h ' + (min % 60) + ' min';
|
|
753
|
+
}
|
|
754
|
+
// "Started 7:27 AM · 12 min", or "Starting" before the first read.
|
|
755
|
+
function liveSubtitle(start, now, endedAt) {
|
|
756
|
+
if (!start) return 'Starting';
|
|
757
|
+
var started = 'Started ' + clockOf(new Date(start)), d = durationLabel(start, now, endedAt);
|
|
758
|
+
return d ? started + ' · ' + d : started;
|
|
759
|
+
}
|
|
760
|
+
function quietLabel(last, now) { if (!last) return null; var q = now - last; return q >= QUIET_MS ? 'No audio for ' + Math.floor(q / 60000) + 'm' : null; }
|
|
761
|
+
function phaseLabel(p) {
|
|
762
|
+
return { finalizing: 'Finalizing', saved: 'Saved', endedUnsaved: 'Recording ended without saving', notFoundYet: 'Saved meeting not found yet', ended: 'Recording ended' }[p] || 'Live now';
|
|
763
|
+
}
|
|
764
|
+
function sourceLabel(sid, start) {
|
|
765
|
+
var kind = isPhone(sid) ? 'iPhone microphone' : isMac(sid) ? 'Mac recording' : 'Glasses meeting';
|
|
766
|
+
return start ? kind + ' ' + clockOf(new Date(start)) : kind;
|
|
767
|
+
}
|
|
768
|
+
function cleanTitle(raw) {
|
|
769
|
+
return String(raw || '').normalize('NFKC').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/[---]/g, '').replace(/\s+/g, ' ').trim();
|
|
770
|
+
}
|
|
771
|
+
function statusPath(sid) { return '/api/meeting/sessions/' + encodeURIComponent(sid) + '/status' + (isPhone(sid) ? '?sourceType=iphone_microphone&speakerPolicy=anonymous' : ''); }
|
|
772
|
+
// The server's word on one session: saved, active, closed, missing, receipted, recovered, quarantined, else unknown.
|
|
773
|
+
function askStatus(f, now) {
|
|
774
|
+
if (f.statusBusy) return;
|
|
775
|
+
f.statusBusy = true; f.statusAt = now;
|
|
776
|
+
answer(statusPath(f.sid)).then(function (a) {
|
|
777
|
+
f.statusBusy = false;
|
|
778
|
+
var st = null;
|
|
779
|
+
if (a.status === 404 && !isPhone(f.sid)) st = 'unknown';
|
|
780
|
+
else if (a.status === 200) {
|
|
781
|
+
var s = a.body.state;
|
|
782
|
+
st = ['saved', 'active', 'closed', 'missing', 'receipted', 'recovered', 'quarantined'].indexOf(s) >= 0 ? s : 'unknown';
|
|
783
|
+
}
|
|
784
|
+
if (st == null) return;
|
|
785
|
+
var t = Date.now();
|
|
786
|
+
if (!f.endedAt) {
|
|
787
|
+
if (st === 'active') { if (!f.seenLive) { f.seenLive = true; liveChanged(); } return; }
|
|
788
|
+
f.leftover = true; f.serverState = st; markEnded(f, t);
|
|
789
|
+
} else f.serverState = st;
|
|
790
|
+
handoff(f, t);
|
|
791
|
+
liveChanged();
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
// LiveTranscript.handoff, once the file is gone or the server finished it.
|
|
795
|
+
function handoff(f, now) {
|
|
796
|
+
if (!f.endedAt) return;
|
|
797
|
+
var st = f.serverState, waited = now - (f.retryAt || f.endedAt), before = f.phase;
|
|
798
|
+
var pastRetention = f.last && (f.endedAt - f.last) >= RETENTION_MS;
|
|
799
|
+
if (savesMeeting(st)) f.phase = 'saved';
|
|
800
|
+
else if (st === 'closed' || st === 'missing') f.phase = f.leftover || pastRetention ? 'ended' : (waited >= UNSAVED_GRACE_MS ? 'endedUnsaved' : 'finalizing');
|
|
801
|
+
else if (st === 'receipted') f.phase = 'ended';
|
|
802
|
+
else if (f.leftover && st && st !== 'active') f.phase = 'ended';
|
|
803
|
+
else f.phase = waited >= FINAL_DEADLINE_MS + (f.start ? Math.max(0, f.endedAt - f.start) / 12 : 0) ? 'notFoundYet' : 'finalizing';
|
|
804
|
+
if (f.phase === 'saved' && before !== 'saved' && f.seenLive && !f.leftover) refreshRows();
|
|
805
|
+
}
|
|
806
|
+
// One read of a listed file for its start and last activity, without moving the transcript cursor.
|
|
807
|
+
function probe(f) {
|
|
808
|
+
f.probed = true;
|
|
809
|
+
api('/api/meetings/live-chunks?sessionId=' + encodeURIComponent(f.sid) + '&since=1000000').then(function (d) {
|
|
810
|
+
var r = d && d.read; if (!r) return;
|
|
811
|
+
if (r.ended) { if (f.seenLive) { markEnded(f, Date.now()); handoff(f, Date.now()); } liveChanged(); return; }
|
|
812
|
+
if (r.startTime != null) f.start = r.startTime;
|
|
813
|
+
if (r.lastActivityAt != null) f.last = Math.max(f.last || 0, r.lastActivityAt);
|
|
814
|
+
if (typeof r.titleSteer === 'string') f.titleSteer = cleanTitle(r.titleSteer) || null;
|
|
815
|
+
liveChanged();
|
|
816
|
+
}, function () {});
|
|
817
|
+
}
|
|
818
|
+
function strandedIds() { var out = {}; S.orphans.stranded.forEach(function (s) { out[s.sessionId] = 1; }); return out; }
|
|
819
|
+
function pollFiles() {
|
|
820
|
+
if (document.hidden || S.filesBusy) return Promise.resolve();
|
|
821
|
+
S.filesBusy = true;
|
|
822
|
+
return api('/api/meetings/live-chunks').then(function (d) {
|
|
823
|
+
S.filesBusy = false;
|
|
824
|
+
var now = Date.now(), listed = {}, stranded = strandedIds();
|
|
825
|
+
S.files = (d && d.sessions) || [];
|
|
826
|
+
S.files.forEach(function (file) {
|
|
827
|
+
var f = feedFor(file.sessionId), mtime = num(file.mtimeMs);
|
|
828
|
+
listed[file.sessionId] = 1;
|
|
829
|
+
if (f.leftover && f.endedAt && mtime > f.endedAt) { f.leftover = false; f.endedAt = null; f.phase = 'live'; f.serverState = null; }
|
|
830
|
+
f.mtime = mtime;
|
|
831
|
+
var quiet = now - Math.max(f.last || 0, mtime);
|
|
832
|
+
if (quiet >= RETENTION_MS || stranded[file.sessionId] || f.leftover) return;
|
|
833
|
+
if (!f.probed) probe(f);
|
|
834
|
+
if (quiet < QUIET_MS) { if (!f.endedAt) f.seenLive = true; }
|
|
835
|
+
else if (!f.endedAt && now - f.statusAt >= STATUS_QUIET_MS) askStatus(f, now);
|
|
836
|
+
});
|
|
837
|
+
Object.keys(S.feeds).forEach(function (sid) {
|
|
838
|
+
var f = S.feeds[sid];
|
|
839
|
+
if (!listed[sid] && f.seenLive && !f.endedAt && S.liveSid !== sid) markEnded(f, now);
|
|
840
|
+
if (f.endedAt && f.seenLive && ['finalizing', 'notFoundYet'].indexOf(f.phase) >= 0) {
|
|
841
|
+
handoff(f, now);
|
|
842
|
+
if (now - f.statusAt >= STATUS_ENDED_MS) askStatus(f, now);
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
S.filesFailed = false;
|
|
846
|
+
liveChanged();
|
|
847
|
+
}, function (err) { S.filesBusy = false; if (!(err && err.status === 401)) { S.filesFailed = true; liveChanged(); } });
|
|
848
|
+
}
|
|
849
|
+
function liveRows(now) {
|
|
850
|
+
var stranded = strandedIds(), out = [], seen = {};
|
|
851
|
+
S.files.forEach(function (file) {
|
|
852
|
+
var f = S.feeds[file.sessionId];
|
|
853
|
+
if (!f || !f.seenLive || f.endedAt || f.leftover || stranded[f.sid]) return;
|
|
854
|
+
var last = Math.max(f.last || 0, num(file.mtimeMs));
|
|
855
|
+
if (now - last >= RETENTION_MS) return;
|
|
856
|
+
seen[f.sid] = 1;
|
|
857
|
+
out.push({ sid: f.sid, start: f.start, last: last, phase: 'live' });
|
|
858
|
+
});
|
|
859
|
+
Object.keys(S.feeds).forEach(function (sid) {
|
|
860
|
+
var f = S.feeds[sid];
|
|
861
|
+
if (seen[sid] || !f.endedAt || !f.seenLive || stranded[sid] || f.phase === 'saved' || f.phase === 'ended' || f.phase === 'live') return;
|
|
862
|
+
if (f.leftover && !savesMeeting(f.serverState)) return;
|
|
863
|
+
if (f.phase === 'endedUnsaved' && now - f.endedAt > ENDED_ROW_MS) return;
|
|
864
|
+
out.push({ sid: sid, start: f.start, last: f.last, phase: f.phase, endedAt: f.endedAt });
|
|
865
|
+
});
|
|
866
|
+
return out.sort(function (a, b) {
|
|
867
|
+
if ((a.phase === 'live') !== (b.phase === 'live')) return a.phase === 'live' ? -1 : 1;
|
|
868
|
+
var x = a.start || a.last || 0, y = b.start || b.last || 0;
|
|
869
|
+
return x !== y ? y - x : (a.sid < b.sid ? -1 : 1);
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
function confirmCard(title, line, yesAct, yesLabel, noLabel, sid) {
|
|
873
|
+
return '<div class="mt-confirm" role="group" aria-label="' + esc(title) + '"><p class="mt-confirm-title">' + esc(title) + '</p><p class="mt-confirm-line">' + esc(line) + '</p>' +
|
|
874
|
+
'<div class="mt-confirm-actions"><button type="button" class="cx-btn primary" data-mt="' + yesAct + '"' + (sid ? ' data-sid="' + esc(sid) + '"' : '') + '>' + esc(yesLabel) + '</button>' +
|
|
875
|
+
'<button type="button" class="cx-btn quiet" data-mt="confirm-no">' + esc(noLabel) + '</button></div></div>';
|
|
876
|
+
}
|
|
877
|
+
var STOP_TITLE = 'Stop and save this glasses meeting?';
|
|
878
|
+
var STOP_LINE = 'COS asks the glasses to stop recording and save the meeting. Nothing stops unless the glasses answer.';
|
|
879
|
+
var END_TITLE = 'End this recording and save it?';
|
|
880
|
+
var END_LINE = 'Saves everything the server received as a meeting. Audio the glasses or phone still hold is not added after this.';
|
|
881
|
+
function liveActionHtml(r, now) {
|
|
882
|
+
if (r.phase === 'notFoundYet') return '<button type="button" class="cx-btn quiet" data-mt="handoff-refresh" data-sid="' + esc(r.sid) + '">Refresh</button>';
|
|
883
|
+
if (r.phase !== 'live') return '';
|
|
884
|
+
if (isPhone(r.sid)) return '<span class="mt-side-note">Stop on iPhone</span>';
|
|
885
|
+
var quiet = r.last && now - r.last >= QUIET_MS;
|
|
886
|
+
if (quiet && isGlasses(r.sid)) return '<button type="button" class="cx-btn quiet" data-mt="end-ask" data-sid="' + esc(r.sid) + '">End and save</button>';
|
|
887
|
+
if (!quiet && isGlasses(r.sid)) return '<button type="button" class="cx-btn quiet" data-mt="stop-ask" data-sid="' + esc(r.sid) + '"' + (stopping(r.sid) ? ' disabled' : '') + '>Stop and save</button>';
|
|
888
|
+
return '';
|
|
889
|
+
}
|
|
890
|
+
function liveRowHtml(r, now) {
|
|
891
|
+
var quiet = r.phase === 'live' ? quietLabel(r.last, now) : null;
|
|
892
|
+
var html = '<div class="mt-liverow"><button type="button" class="cx-row mt-row" data-mt="live" data-sid="' + esc(r.sid) + '" aria-label="' + esc(r.phase === 'live' ? 'Live meeting. Open the live transcript' : phaseLabel(r.phase)) + '">' +
|
|
893
|
+
'<span class="cx-row-glyph mt-dotwrap"><span class="mt-livedot' + (r.phase === 'live' ? ' on' : '') + '"></span></span>' +
|
|
894
|
+
'<span class="cx-row-body"><span class="cx-row-title strong">' + esc(r.phase === 'live' ? 'Live now' : phaseLabel(r.phase)) + '</span>' +
|
|
895
|
+
'<span class="mt-sub">' + esc(liveSubtitle(r.start, now, r.endedAt)) + (quiet ? '<span class="mt-amber"> · ' + esc(quiet) + '</span>' : '') + '</span></span>' +
|
|
896
|
+
'<span class="mt-side"><span class="mt-side-note">Live transcript</span></span><span class="chev"></span></button>' +
|
|
897
|
+
'<span class="mt-liveact">' + liveActionHtml(r, now) + '</span></div>';
|
|
898
|
+
var line = stopLineFor(r.sid);
|
|
899
|
+
if (line) html += '<p class="mt-stopline' + (S.stop && S.stop.nothing ? ' bad' : '') + '">' + esc(line) + '</p>';
|
|
900
|
+
if (S.confirm === 'stop:' + r.sid) html += confirmCard(STOP_TITLE, STOP_LINE, 'stop-yes', 'Stop and save', 'Cancel', r.sid);
|
|
901
|
+
if (S.confirm === 'end:' + r.sid) html += confirmCard(END_TITLE, END_LINE, 'end-yes', 'End and save', 'Keep it open', r.sid);
|
|
902
|
+
return html;
|
|
903
|
+
}
|
|
904
|
+
function paintLive() {
|
|
905
|
+
var el = $('mt-live'); if (!el) return;
|
|
906
|
+
var now = Date.now(), rows = liveRows(now);
|
|
907
|
+
var html = rows.length || S.liveNote
|
|
908
|
+
? '<section class="mt-livebox">' + rows.map(function (r) { return liveRowHtml(r, now); }).join('') + (S.liveNote ? '<p class="mt-notice">' + esc(S.liveNote) + '</p>' : '') +
|
|
909
|
+
(S.filesFailed && rows.length ? '<p class="mt-notice mt-amber">Could not check live recordings. Showing the last answer.</p>' : '') + '</section>'
|
|
910
|
+
: '';
|
|
911
|
+
paintAbove(el, 'liveHtml', html);
|
|
912
|
+
}
|
|
913
|
+
function liveChanged() {
|
|
914
|
+
if (S.view === 'list') paintLive();
|
|
915
|
+
else if (S.view === 'live') paintLiveHead();
|
|
916
|
+
}
|
|
917
|
+
// End and save: POST /api/meeting/save, the helper's meeting-stranded-save.
|
|
918
|
+
function endAndSave(sid) {
|
|
919
|
+
S.confirm = ''; S.liveNote = 'Saving…'; liveChanged();
|
|
920
|
+
answer('/api/meeting/save', { method: 'POST', body: { sessionId: sid } }).then(function (a) {
|
|
921
|
+
var b = a.body;
|
|
922
|
+
if (a.status === 200 && b.filename) S.liveNote = 'Saved capture as a meeting.';
|
|
923
|
+
else if (a.status === 200 && b.alreadySaved === true) S.liveNote = 'Already saved';
|
|
924
|
+
else if (a.status === 409) S.liveNote = 'Save already in progress for this capture.';
|
|
925
|
+
else if (a.status === 404) S.liveNote = typeof b.error === 'string' ? b.error : 'No transcript for this capture. Session files were not deleted.';
|
|
926
|
+
else if (a.status === 0) S.liveNote = 'Server stopped';
|
|
927
|
+
else S.liveNote = (typeof b.error === 'string' && b.error) || (typeof b.reason === 'string' && b.reason) || 'Save failed (' + a.status + ')';
|
|
928
|
+
liveChanged(); loadOrphans(); pollFiles();
|
|
929
|
+
if (a.status === 200) refreshRows();
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// ── Stop and save on a glasses meeting (G2MeetingStop) ─
|
|
934
|
+
function stopLineText(p) {
|
|
935
|
+
switch (p.k) {
|
|
936
|
+
case 'asking': return 'Asking the glasses to stop…';
|
|
937
|
+
case 'stopping': return 'The glasses are stopping. Saving the meeting…';
|
|
938
|
+
case 'finalizing': return 'Saving the meeting…';
|
|
939
|
+
case 'saved': {
|
|
940
|
+
var name = p.filename ? String(p.filename).replace(/\.md$/, '') : null;
|
|
941
|
+
if (name && p.minutes != null) return 'Saved: ' + name + ', ' + p.minutes + ' min.';
|
|
942
|
+
if (name) return 'Saved: ' + name + '.';
|
|
943
|
+
if (p.minutes != null) return 'Saved, ' + p.minutes + ' min.';
|
|
944
|
+
return 'Saved.';
|
|
945
|
+
}
|
|
946
|
+
case 'notConnected': return 'Glasses not connected. Nothing was stopped. Stop it on the glasses (hold menu, Stop Meeting) or on the phone.';
|
|
947
|
+
case 'notRecordingHere': return 'The glasses are not recording this meeting. Nothing was stopped.';
|
|
948
|
+
case 'otherMeeting': return 'The glasses are recording a different meeting. Nothing was stopped.';
|
|
949
|
+
case 'alreadyStopped': return 'This meeting already stopped.';
|
|
950
|
+
case 'serverUnreachable': return 'COS server unreachable. Nothing was stopped.';
|
|
951
|
+
case 'serverTooOld': return 'This COS server cannot stop glasses meetings yet. Update the server, or stop it on the glasses or phone.';
|
|
952
|
+
case 'unauthorized': return SIGNED_OUT + ' Nothing was stopped.';
|
|
953
|
+
case 'stillSaving': return 'The glasses stopped. The save is still running; check Meetings in a minute.';
|
|
954
|
+
case 'serverRestarted': return 'The COS server restarted before the glasses answered. Nothing was stopped.';
|
|
955
|
+
default: return 'The server could not ask the glasses (HTTP ' + p.status + '). Nothing was stopped.';
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
var STOP_ACTIVE = { asking: 1, stopping: 1, finalizing: 1 };
|
|
959
|
+
var STOP_FOLLOW_MS = 210000;
|
|
960
|
+
var STOP_NOTHING = { notConnected: 1, notRecordingHere: 1, otherMeeting: 1, serverUnreachable: 1, serverTooOld: 1, unauthorized: 1, serverRestarted: 1, failed: 1 };
|
|
961
|
+
function stopping(sid) { return !!(S.stop && S.stop.sid === sid && STOP_ACTIVE[S.stop.phase.k]); }
|
|
962
|
+
function stopLineFor(sid) { return S.stop && S.stop.sid === sid ? stopLineText(S.stop.phase) : null; }
|
|
963
|
+
function savedPhase(b) { var r = b.saveReceipt || {}; return { k: 'saved', filename: r.filename || null, minutes: r.durationMin != null ? num(r.durationMin) : null }; }
|
|
964
|
+
function afterRequest(a) {
|
|
965
|
+
var b = a.body;
|
|
966
|
+
if (a.status === 0) return { phase: { k: 'serverUnreachable' } };
|
|
967
|
+
var win = b.claimWindowMs != null ? num(b.claimWindowMs) : 10000;
|
|
968
|
+
if (a.status === 202) return b.requestId ? { phase: { k: 'asking' }, requestId: String(b.requestId), win: win } : { phase: { k: 'serverTooOld' } };
|
|
969
|
+
if (a.status === 200 && b.state === 'saved') return { phase: savedPhase(b) };
|
|
970
|
+
if (a.status === 200 && b.state === 'finalizing') return { phase: { k: 'stillSaving' } };
|
|
971
|
+
if (a.status === 409 && b.reason === 'not_live') return { phase: { k: 'alreadyStopped' } };
|
|
972
|
+
if (a.status === 401 || a.status === 403) return { phase: { k: 'unauthorized' } };
|
|
973
|
+
if (a.status === 404) return { phase: { k: 'serverTooOld' } };
|
|
974
|
+
return { phase: { k: 'failed', status: a.status } };
|
|
975
|
+
}
|
|
976
|
+
function afterPoll(a, elapsed, win, claimed) {
|
|
977
|
+
var deadline = win + 3000, saveWait = 180000, b = a.body;
|
|
978
|
+
if (a.status === 0) { if (claimed) return { k: elapsed > saveWait ? 'stillSaving' : 'stopping' }; return { k: elapsed > deadline ? 'serverUnreachable' : 'asking' }; }
|
|
979
|
+
if (a.status === 404) return { k: claimed ? 'stillSaving' : 'serverRestarted' };
|
|
980
|
+
if (a.status === 401 || a.status === 403) return { k: claimed ? 'stillSaving' : 'unauthorized' };
|
|
981
|
+
if (a.status < 200 || a.status >= 300) return claimed ? { k: elapsed > saveWait ? 'stillSaving' : 'stopping' } : (elapsed > deadline ? { k: 'failed', status: a.status } : { k: 'asking' });
|
|
982
|
+
switch (b.state) {
|
|
983
|
+
case 'saved': return savedPhase(b);
|
|
984
|
+
case 'claimed': return elapsed > saveWait ? { k: 'stillSaving' } : { k: b.finalizing === true ? 'finalizing' : 'stopping' };
|
|
985
|
+
case 'refused':
|
|
986
|
+
if (b.refusal === 'not_recording') return { k: 'notRecordingHere' };
|
|
987
|
+
if (b.refusal === 'other_session') return { k: 'otherMeeting' };
|
|
988
|
+
if (b.refusal === 'stop_in_progress') return { k: elapsed > saveWait ? 'stillSaving' : 'finalizing' };
|
|
989
|
+
return { k: 'notConnected' };
|
|
990
|
+
case 'expired': return { k: 'notConnected' };
|
|
991
|
+
default: return { k: elapsed > deadline ? 'notConnected' : 'asking' };
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
function setStop(sid, phase) { S.stop = { sid: sid, phase: phase, nothing: !!STOP_NOTHING[phase.k] }; liveChanged(); }
|
|
995
|
+
function stopGlasses(sid) {
|
|
996
|
+
if (!isGlasses(sid) || (S.stop && STOP_ACTIVE[S.stop.phase.k])) return;
|
|
997
|
+
S.confirm = '';
|
|
998
|
+
setStop(sid, { k: 'asking' });
|
|
999
|
+
var base = '/api/meeting/sessions/' + encodeURIComponent(sid) + '/stop-request';
|
|
1000
|
+
answer(base, { method: 'POST', body: { source: 'web' } }).then(function (a) {
|
|
1001
|
+
var asked = afterRequest(a);
|
|
1002
|
+
setStop(sid, asked.phase);
|
|
1003
|
+
if (!STOP_ACTIVE[asked.phase.k] || !asked.requestId) { if (asked.phase.k === 'saved') pollFiles(); return; }
|
|
1004
|
+
var started = Date.now(), claimed = false;
|
|
1005
|
+
// Followed only while the pane shows (leave() clears the timer; load() resumes), never past STOP_FOLLOW_MS.
|
|
1006
|
+
function tick() {
|
|
1007
|
+
clearTimeout(S.stopTimer);
|
|
1008
|
+
S.stopTimer = setTimeout(function () {
|
|
1009
|
+
S.stopTimer = null;
|
|
1010
|
+
if (Date.now() - started > STOP_FOLLOW_MS) { setStop(sid, { k: 'stillSaving' }); S.stopResume = null; return; }
|
|
1011
|
+
answer(base + '/' + encodeURIComponent(asked.requestId)).then(function (p) {
|
|
1012
|
+
if (!S.stop || S.stop.sid !== sid || !STOP_ACTIVE[S.stop.phase.k]) return;
|
|
1013
|
+
var next = afterPoll(p, Date.now() - started, asked.win, claimed);
|
|
1014
|
+
if (next.k === 'stopping' || next.k === 'finalizing') claimed = true;
|
|
1015
|
+
setStop(sid, next);
|
|
1016
|
+
if (!STOP_ACTIVE[next.k]) { S.stopResume = null; if (next.k === 'saved') pollFiles(); }
|
|
1017
|
+
else if (S.view === 'list' || S.view === 'live') tick();
|
|
1018
|
+
});
|
|
1019
|
+
}, claimed ? 2000 : 1000);
|
|
1020
|
+
}
|
|
1021
|
+
S.stopResume = tick;
|
|
1022
|
+
tick();
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// ── Unsaved recordings (unsavedCaptureBanner) ─────────
|
|
1027
|
+
function orphanRecoverable(o) {
|
|
1028
|
+
var phoneOk = !isPhone(o.sessionId, o.sourceType) || (PHONE_ID.test(o.sessionId) && o.sourceType === 'iphone_microphone' && o.provenanceState === 'admitted');
|
|
1029
|
+
var rec = o.recoverable != null ? o.recoverable === true : (o.recovered !== true && num(o.chunkFiles) >= 2);
|
|
1030
|
+
return phoneOk && rec;
|
|
1031
|
+
}
|
|
1032
|
+
function shortId(id) { return id.length > 18 ? id.slice(0, 18) + '…' : id; }
|
|
1033
|
+
function orphanLabel(o, recovering) {
|
|
1034
|
+
var src = isPhone(o.sessionId, o.sourceType) ? 'iPhone microphone · ' : '', n = num(o.chunkFiles), chunks = n === 1 ? '1 chunk' : n + ' chunks';
|
|
1035
|
+
if (recovering) return src + shortId(o.sessionId) + ' · recovering · ' + chunks;
|
|
1036
|
+
if (o.ageHours != null) return src + shortId(o.sessionId) + ' · ' + chunks + ' · ' + o.ageHours + 'h';
|
|
1037
|
+
return src + shortId(o.sessionId) + ' · ' + chunks;
|
|
1038
|
+
}
|
|
1039
|
+
function strandedCanSave(s) { return !isPhone(s.sessionId, s.sourceType) && (s.canSave != null ? s.canSave === true : num(s.chunks) > 0); }
|
|
1040
|
+
function strandedLabel(s) {
|
|
1041
|
+
if (isPhone(s.sessionId, s.sourceType)) return 'iPhone microphone · audio retained · finish on iPhone';
|
|
1042
|
+
var idle = num(s.idleMinutes) === 1 ? '1 min idle' : num(s.idleMinutes) + ' min idle';
|
|
1043
|
+
var state = s.transcriptState || (num(s.chunks) > 0 ? 'ready' : 'unknown');
|
|
1044
|
+
if (state === 'no_speech') return shortId(s.sessionId) + ' · No usable speech detected; audio retained';
|
|
1045
|
+
if (!strandedCanSave(s)) return shortId(s.sessionId) + ' · Transcript unavailable; audio retained';
|
|
1046
|
+
return shortId(s.sessionId) + ' · ' + idle + ' · ready to save';
|
|
1047
|
+
}
|
|
1048
|
+
function loadOrphans() {
|
|
1049
|
+
if (S.orphansBusy) return Promise.resolve();
|
|
1050
|
+
S.orphansBusy = true;
|
|
1051
|
+
return api('/api/meeting/orphans').then(function (d) {
|
|
1052
|
+
S.orphansBusy = false;
|
|
1053
|
+
var O = S.orphans;
|
|
1054
|
+
O.recovering = (d && d.recovering) || [];
|
|
1055
|
+
O.items = ((d && d.items) || []).filter(function (o) { return o && o.sessionId; });
|
|
1056
|
+
O.stranded = ((d && d.stranded) || []).filter(function (s) { return s && s.sessionId; });
|
|
1057
|
+
O.loaded = true; O.failed = false;
|
|
1058
|
+
if (S.view === 'list') { paintOrphans(); paintLive(); }
|
|
1059
|
+
}, function (err) {
|
|
1060
|
+
S.orphansBusy = false;
|
|
1061
|
+
// Quiet when there is nothing to show (Control's loadOrphans(quiet: true)); otherwise the last rows stay, marked.
|
|
1062
|
+
if (!(err && err.status === 401)) { S.orphans.failed = true; if (S.view === 'list') paintOrphans(); }
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
function paintOrphans() {
|
|
1066
|
+
var el = $('mt-orphans'); if (!el) return;
|
|
1067
|
+
var O = S.orphans, rec = O.items.filter(orphanRecoverable), busy = O.busy;
|
|
1068
|
+
if (!rec.length && !O.stranded.length) { paintAbove(el, 'orphHtml', O.notice ? '<p class="mt-notice">' + esc(O.notice) + '</p>' : ''); return; }
|
|
1069
|
+
var html = '<section class="mt-orphans"><p class="mt-orphans-title">Unsaved recordings</p>' +
|
|
1070
|
+
'<p class="mt-orphans-line">Audio that never became a meeting. This is not Speakers’ Meetings to review.</p>';
|
|
1071
|
+
rec.forEach(function (o) {
|
|
1072
|
+
var recovering = O.recovering.indexOf(o.sessionId) >= 0;
|
|
1073
|
+
html += '<div class="mt-orow"><span class="mt-olabel">' + esc(orphanLabel(o, recovering)) + '</span>' +
|
|
1074
|
+
'<button type="button" class="cx-btn quiet" data-mt="recover" data-sid="' + esc(o.sessionId) + '"' + (busy || recovering ? ' disabled' : '') + '>Recover</button></div>';
|
|
1075
|
+
});
|
|
1076
|
+
if (rec.length > 1) html += '<div class="mt-orow"><button type="button" class="cx-btn quiet" data-mt="recover-all-ask"' + (busy ? ' disabled' : '') + '>Recover all</button></div>';
|
|
1077
|
+
if (O.confirm === 'recover-all') html += confirmCard('Recover all unsaved recordings?', 'Turns each unsaved recording into a meeting, one at a time. The files are kept.', 'recover-all-yes', 'Recover all', 'Cancel');
|
|
1078
|
+
O.stranded.forEach(function (s) {
|
|
1079
|
+
var act;
|
|
1080
|
+
if (isPhone(s.sessionId, s.sourceType)) act = '<span class="mt-side-note">Stop on iPhone</span>';
|
|
1081
|
+
else if (isMac(s.sessionId)) act = '<span class="mt-side-note">Finish on your Mac</span>';
|
|
1082
|
+
else act = '<button type="button" class="cx-btn quiet" data-mt="save-stranded" data-sid="' + esc(s.sessionId) + '"' + (busy ? ' disabled' : '') + '>Save</button>';
|
|
1083
|
+
html += '<div class="mt-orow"><span class="mt-olabel dim">' + esc(strandedLabel(s)) + '</span>' + act + '</div>';
|
|
1084
|
+
});
|
|
1085
|
+
if (O.stranded.length > 1) html += '<div class="mt-orow"><button type="button" class="cx-btn quiet" data-mt="save-all-ask"' + (busy ? ' disabled' : '') + '>Save all still-live</button></div>';
|
|
1086
|
+
if (O.confirm === 'save-all') html += confirmCard('Save live recordings as meetings?', 'Ends each live glasses recording and saves it, like Stop on the phone. The files are kept.', 'save-all-yes', 'Save all', 'Cancel');
|
|
1087
|
+
if (O.notice) html += '<p class="mt-notice">' + esc(O.notice) + '</p>';
|
|
1088
|
+
if (O.failed) html += '<p class="mt-notice mt-amber">Could not check unsaved recordings. Showing the last answer.</p>';
|
|
1089
|
+
paintAbove(el, 'orphHtml', html + '</section>');
|
|
1090
|
+
}
|
|
1091
|
+
function recoverOne(sid) {
|
|
1092
|
+
return answer('/api/meeting/orphans/' + encodeURIComponent(sid) + '/recover', { method: 'POST', body: {} }).then(function (a) {
|
|
1093
|
+
var b = a.body;
|
|
1094
|
+
if (a.status === 202 && b.accepted === true) return { ok: true, text: 'Recovery started' };
|
|
1095
|
+
if (a.status === 200 && b.alreadySaved === true) return { ok: true, text: 'Already saved' };
|
|
1096
|
+
if (a.status === 409) return { ok: false, skip: true, text: 'Recovery already in progress for this capture.' };
|
|
1097
|
+
if (a.status === 404) return { ok: false, text: 'No quarantined audio for this capture. Session files were not deleted.' };
|
|
1098
|
+
if (a.status === 503) return { ok: false, text: (typeof b.error === 'string' && b.error) || 'Server is busy with another recovery. Wait, then retry.' };
|
|
1099
|
+
if (a.status === 0) return { ok: false, text: 'Server stopped' };
|
|
1100
|
+
return { ok: false, text: (typeof b.error === 'string' && b.error) || (typeof b.reason === 'string' && b.reason) || 'Recovery failed (' + a.status + ')' };
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
function saveOne(sid) {
|
|
1104
|
+
return answer('/api/meeting/save', { method: 'POST', body: { sessionId: sid } }).then(function (a) {
|
|
1105
|
+
var b = a.body;
|
|
1106
|
+
if (a.status === 200 && (b.filename || b.alreadySaved === true)) return { ok: true, text: b.filename ? 'Saved capture as a meeting.' : 'Already saved' };
|
|
1107
|
+
if (a.status === 409) return { ok: false, text: 'Save already in progress for this capture.' };
|
|
1108
|
+
if (a.status === 404) return { ok: false, text: (typeof b.error === 'string' && b.error) || 'No transcript for this capture. Session files were not deleted.' };
|
|
1109
|
+
if (a.status === 0) return { ok: false, text: 'Server stopped' };
|
|
1110
|
+
return { ok: false, text: (typeof b.error === 'string' && b.error) || (typeof b.reason === 'string' && b.reason) || 'Save failed (' + a.status + ')' };
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
function orphanTask(run) {
|
|
1114
|
+
var O = S.orphans;
|
|
1115
|
+
if (O.busy) return;
|
|
1116
|
+
O.busy = true; O.confirm = ''; O.notice = ''; paintOrphans();
|
|
1117
|
+
run().then(function (text) {
|
|
1118
|
+
O.busy = false; O.notice = text; paintOrphans();
|
|
1119
|
+
loadOrphans(); pollFiles();
|
|
1120
|
+
refreshRows();
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
function recoverAll() {
|
|
1124
|
+
var ids = S.orphans.items.filter(orphanRecoverable).map(function (o) { return o.sessionId; }), done = 0, failed = 0;
|
|
1125
|
+
return ids.reduce(function (p, sid) {
|
|
1126
|
+
return p.then(function () { return recoverOne(sid).then(function (r) { if (r.ok) done++; else if (!r.skip) failed++; }); });
|
|
1127
|
+
}, Promise.resolve()).then(function () {
|
|
1128
|
+
return failed ? 'Recovered ' + done + '. ' + failed + ' failed. Session files were not deleted.' : (done === 1 ? 'Started recovery for 1 capture.' : 'Started recovery for ' + done + ' capture(s).');
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
function saveAllStranded() {
|
|
1132
|
+
var st = S.orphans.stranded, ids = st.filter(function (s) { return strandedCanSave(s) && !isMac(s.sessionId) && !isPhone(s.sessionId); }).map(function (s) { return s.sessionId; });
|
|
1133
|
+
var macLeft = st.filter(function (s) { return strandedCanSave(s) && isMac(s.sessionId); }).length;
|
|
1134
|
+
var phoneLeft = st.filter(function (s) { return isPhone(s.sessionId); }).length;
|
|
1135
|
+
if (!ids.length) return Promise.resolve(phoneLeft > 0 ? 'Stop on iPhone. Its retained uploads finish there.' : macLeft > 0 ? 'Mac recordings finish from Record on Mac, then Recover.' : 'No still-live captures');
|
|
1136
|
+
var saved = 0, failed = 0;
|
|
1137
|
+
return ids.reduce(function (p, sid) {
|
|
1138
|
+
return p.then(function () { return saveOne(sid).then(function (r) { if (r.ok) saved++; else failed++; }); });
|
|
1139
|
+
}, Promise.resolve()).then(function () {
|
|
1140
|
+
if (failed) return 'Saved ' + saved + '. ' + failed + ' failed. Session files were not deleted.';
|
|
1141
|
+
return (saved === 1 ? 'Saved 1 capture as a meeting.' : 'Saved ' + saved + ' captures as meetings.') +
|
|
1142
|
+
(macLeft > 0 ? ' Mac recordings finish from Record on Mac, then Recover.' : '') + (phoneLeft > 0 ? ' iPhone microphone captures finish on iPhone.' : '');
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// ── detail (MeetingLibraryDetailPane) ─────────────────
|
|
1147
|
+
function findRow(key) {
|
|
1148
|
+
var all = (S.rows || []).concat(S.hits || []);
|
|
1149
|
+
Object.keys(S.dayRows).forEach(function (k) { all = all.concat(S.dayRows[k]); });
|
|
1150
|
+
return all.filter(function (m) { return rowKey(m) === key; })[0] || null;
|
|
1151
|
+
}
|
|
1152
|
+
function detailRow() {
|
|
1153
|
+
var d = S.detail || {}, base = {};
|
|
1154
|
+
Object.keys(d).forEach(function (k) { if (['transcript', 'sourceContent', 'summary'].indexOf(k) < 0) base[k] = d[k]; });
|
|
1155
|
+
return Object.assign(base, S.detailRow || {});
|
|
1156
|
+
}
|
|
1157
|
+
function detailSessions() {
|
|
1158
|
+
if (!S.detail) return [];
|
|
1159
|
+
var row = detailRow(), keys = contextKeys(S.detail.contextKeys && S.detail.contextKeys.length ? S.detail.contextKeys : row.contextKeys, row);
|
|
1160
|
+
return askSessions(row, keys);
|
|
1161
|
+
}
|
|
1162
|
+
function openDetail(domain, month, filename) {
|
|
1163
|
+
var key = [domain, month, filename].join('|');
|
|
1164
|
+
S.view = 'detail';
|
|
1165
|
+
if (S.detailKey !== key) {
|
|
1166
|
+
S.detailKey = key; S.detail = null; S.detailError = ''; S.copyNote = ''; S.detailRow = findRow(key);
|
|
1167
|
+
} else if (!S.detailRow) S.detailRow = findRow(key);
|
|
1168
|
+
if (S.detailRow) W.setCrumb(S.detailRow.title || 'Meeting');
|
|
1169
|
+
paintDetail();
|
|
1170
|
+
if (!S.inbox) loadInbox();
|
|
1171
|
+
if (S.detail) { W.setCrumb(detailRow().title || 'Meeting'); detailSessions().forEach(function (sid) { panelOpen(sid); }); return; }
|
|
1172
|
+
var id = ++S.detailSeq;
|
|
1173
|
+
S.detailLoading = true;
|
|
1174
|
+
api('/api/meetings/detail?domain=' + encodeURIComponent(domain) + '&month=' + encodeURIComponent(month) + '&filename=' + encodeURIComponent(filename)).then(function (d) {
|
|
1175
|
+
if (id !== S.detailSeq) return;
|
|
1176
|
+
S.detailLoading = false; S.detail = d || {}; S.detailError = '';
|
|
1177
|
+
if (S.view !== 'detail' || S.detailKey !== key) return;
|
|
1178
|
+
W.setCrumb(detailRow().title || 'Meeting');
|
|
1179
|
+
paintDetail();
|
|
1180
|
+
detailSessions().forEach(function (sid) { panelOpen(sid); });
|
|
1181
|
+
// A deep link has no list row; Control always opens one (its session, voices and captures). Read that day's list.
|
|
1182
|
+
if (!S.detailRow && /^\d{4}-\d{2}-\d{2}$/.test(S.detail.date || '')) {
|
|
1183
|
+
api('/api/meetings?limit=200&domain=all&day=' + S.detail.date).then(function (l) {
|
|
1184
|
+
var hit = ((l && l.meetings) || []).filter(function (m) { return rowKey(m) === key; })[0];
|
|
1185
|
+
if (!hit || S.detailKey !== key || S.view !== 'detail') return;
|
|
1186
|
+
var before = detailSessions();
|
|
1187
|
+
S.detailRow = hit;
|
|
1188
|
+
paintDetail();
|
|
1189
|
+
detailSessions().forEach(function (sid) { if (before.indexOf(sid) < 0) panelOpen(sid); });
|
|
1190
|
+
}, function () {});
|
|
1191
|
+
}
|
|
1192
|
+
}, function (err) {
|
|
1193
|
+
if (id !== S.detailSeq) return;
|
|
1194
|
+
S.detailLoading = false;
|
|
1195
|
+
S.detailError = err && err.status === 404 ? 'That meeting is no longer stored.' : ((err && err.message) || 'The meeting could not be read.');
|
|
1196
|
+
if (S.view === 'detail' && S.detailKey === key) paintDetail();
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
function labeled(title, body) {
|
|
1200
|
+
return '<div class="mt-section"><div class="mt-label">' + esc(title.toUpperCase()) + '</div><div class="mt-md">' + MD.doc(body) + '</div></div>';
|
|
1201
|
+
}
|
|
1202
|
+
// 0.5.232: the scribe file is the body; a record that is not a document keeps its labelled fields.
|
|
1203
|
+
function documentHtml(d) {
|
|
1204
|
+
var tr = typeof d.transcript === 'string' ? d.transcript : '', sm = typeof d.summary === 'string' ? d.summary : '', out = '';
|
|
1205
|
+
if (MD.looksLikeDocument(tr)) out = '<div class="mt-md">' + MD.meeting(tr, true) + '</div>';
|
|
1206
|
+
else {
|
|
1207
|
+
var att = Array.isArray(d.attendees) ? d.attendees.filter(function (a) { return typeof a === 'string'; }) : [];
|
|
1208
|
+
if (att.length) out += labeled('Attendees', att.join(', '));
|
|
1209
|
+
if (sm) out += labeled('Summary', sm);
|
|
1210
|
+
if (tr) out += '<div class="mt-section"><div class="mt-tlabel">Transcript</div><div class="mt-md">' + MD.meeting(tr, false) + '</div></div>';
|
|
1211
|
+
}
|
|
1212
|
+
if (!sm && !tr) out += '<p class="mt-none">This record has no summary or transcript stored.</p>';
|
|
1213
|
+
return out;
|
|
1214
|
+
}
|
|
1215
|
+
function detailBarHtml(row, d) {
|
|
1216
|
+
var tr = transcriptPart(d.transcript || '');
|
|
1217
|
+
var html = '<div class="mt-bar"><button type="button" class="cx-btn primary" data-mt="copy-context">Copy full context</button>' +
|
|
1218
|
+
'<button type="button" class="cx-btn quiet" data-mt="copy-summary"' + (d.summary ? '' : ' disabled') + '>Copy summary</button>' +
|
|
1219
|
+
'<button type="button" class="cx-btn quiet" data-mt="copy-transcript"' + (d.transcript ? '' : ' disabled') + '>' + (tr.document ? 'Copy meeting document' : 'Copy transcript') + '</button>' +
|
|
1220
|
+
'<button type="button" class="cx-btn quiet" data-mt="download"' + (d.transcript || d.sourceContent ? '' : ' disabled') + '>' + icon('doc') + 'Download .md</button>';
|
|
1221
|
+
if (canReviewVoices(row)) {
|
|
1222
|
+
var fresh = isNew(row.sessionId);
|
|
1223
|
+
html += '<span class="mt-bar-gap"></span><button type="button" class="cx-btn quiet' + (fresh ? ' featured' : '') + '" data-mt="review" data-sid="' + esc(row.sessionId) + '">' + icon('speakers') + 'Review voices' + (fresh ? '<span class="cx-pill warn">NEW</span>' : '') + '</button>';
|
|
1224
|
+
var others = (row.g2SessionIds || []).filter(function (s) { return s !== row.sessionId; });
|
|
1225
|
+
if ((row.g2SessionIds || []).length > 1 && others.length) {
|
|
1226
|
+
html += '<details class="mt-menu"><summary class="cx-btn quiet">Other recordings</summary><div class="mt-menu-list">' +
|
|
1227
|
+
others.map(function (s) { return '<button type="button" data-mt="review" data-sid="' + esc(s) + '">' + esc(s) + '</button>'; }).join('') + '</div></details>';
|
|
1228
|
+
}
|
|
1229
|
+
html += pillsHtml(row, false);
|
|
1230
|
+
}
|
|
1231
|
+
return html + '</div>' + (S.copyNote ? '<p class="mt-copynote" role="status">' + esc(S.copyNote) + '</p>' : '');
|
|
1232
|
+
}
|
|
1233
|
+
function paintDetail() {
|
|
1234
|
+
if (S.view !== 'detail') return;
|
|
1235
|
+
var d = S.detail, row = d ? detailRow() : (S.detailRow || {});
|
|
1236
|
+
var head = '<div class="mt-dhead"><h2 class="mt-dtitle">' + esc(row.title || (S.detailError ? 'Meeting' : 'Loading meeting…')) + '</h2>' +
|
|
1237
|
+
(row.date || row.time ? '<p class="mt-dsub">' + esc(subtitle(row)) + '</p>' : '') + '</div>';
|
|
1238
|
+
var body;
|
|
1239
|
+
if (!d && S.detailError) body = '<p class="mt-none">' + esc(S.detailError) + '</p><button type="button" class="cx-btn quiet" data-mt="detail-retry">Retry</button>';
|
|
1240
|
+
else if (!d) body = '<p class="cx-note mt-wait">Loading meeting…</p>' + W.skeletonRows(5);
|
|
1241
|
+
else {
|
|
1242
|
+
var sessions = detailSessions();
|
|
1243
|
+
body = detailBarHtml(row, d) + '<div class="mt-dbody">' +
|
|
1244
|
+
(row.sourceSessionId && sessions.length ? '<p class="mt-note-line">Ask history covers the whole original capture, including its other split pieces.</p>' : '') +
|
|
1245
|
+
sessions.map(function (sid, i) {
|
|
1246
|
+
A(sid).ctx = { saved: true, meeting: row, captureLabel: 'Original recording ' + (i + 1) + '. Questions use its saved cutoff; each recording keeps its own history.' };
|
|
1247
|
+
return '<div class="mt-side-panel" id="mt-ask-' + safeId(sid) + '" data-sid="' + esc(sid) + '">' + sidePanelHtml(sid) + '</div>';
|
|
1248
|
+
}).join('') +
|
|
1249
|
+
'<article class="mt-doc">' + documentHtml(d) + '</article></div>';
|
|
1250
|
+
}
|
|
1251
|
+
pane.innerHTML = '<div class="cx mt mt-detail" data-tint="meetings">' + head + body + '</div>';
|
|
1252
|
+
}
|
|
1253
|
+
function paintBar() {
|
|
1254
|
+
if (S.view !== 'detail' || !S.detail) return;
|
|
1255
|
+
var bar = pane.querySelector('.mt-bar'); if (!bar) return;
|
|
1256
|
+
var note = pane.querySelector('.mt-copynote'); if (note) note.remove();
|
|
1257
|
+
bar.outerHTML = detailBarHtml(detailRow(), S.detail);
|
|
1258
|
+
}
|
|
1259
|
+
function setCopyNote(text) {
|
|
1260
|
+
S.copyNote = text;
|
|
1261
|
+
var bar = pane.querySelector('.mt-copynote');
|
|
1262
|
+
if (S.view === 'detail' && S.detail) {
|
|
1263
|
+
if (bar) bar.textContent = text;
|
|
1264
|
+
else { var b = pane.querySelector('.mt-bar'); if (b) b.insertAdjacentHTML('afterend', '<p class="mt-copynote" role="status">' + esc(text) + '</p>'); }
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
function copyOut(text, done) {
|
|
1268
|
+
W.copyText(text).then(function () { setCopyNote(done); }, function (err) { setCopyNote((err && err.message) || 'This browser would not copy.'); });
|
|
1269
|
+
}
|
|
1270
|
+
function copyDetail(kind) {
|
|
1271
|
+
var d = S.detail; if (!d) return;
|
|
1272
|
+
var row = detailRow();
|
|
1273
|
+
if (kind === 'summary' || kind === 'transcript') {
|
|
1274
|
+
var body = kind === 'summary' ? (d.summary || '') : transcriptPart(d.transcript || '').text;
|
|
1275
|
+
if (!body.trim()) { setCopyNote('Nothing to copy'); return; }
|
|
1276
|
+
copyOut(body.trim(), kind === 'summary' ? 'Copied summary' : 'Copied transcript');
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
// Copy full context: the transcript, every Meeting Ask answer with its sources, the cues and the files (MeetingAskText.fullContext).
|
|
1280
|
+
var source = transcriptPart(d.transcript || ''), sessions = detailSessions();
|
|
1281
|
+
var title = (row.title || 'Meeting') + (source.document ? ' · meeting document' : '') + (row.derivedKind === 'split' ? ' · Ask history covers the whole original capture' : '');
|
|
1282
|
+
function assemble() {
|
|
1283
|
+
var seen = {}, rows = [], files = [], cues = [], cueNote = null, coverage = [];
|
|
1284
|
+
sessions.forEach(function (sid) {
|
|
1285
|
+
var a = A(sid);
|
|
1286
|
+
a.history.forEach(function (e) { if (!seen[e.requestId]) { seen[e.requestId] = 1; rows.push(e); } });
|
|
1287
|
+
files = files.concat(a.files || []);
|
|
1288
|
+
if (a.error) coverage.push('Context coverage: ' + a.error);
|
|
1289
|
+
if (a.cues) cues = cues.concat(a.cues); else if (a.cuesState && a.cuesState !== 'older' && (isGlasses(sid) || isMac(sid))) cueNote = 'Context coverage: cues could not be read from the server.';
|
|
1290
|
+
});
|
|
1291
|
+
var text = fullContext(title, source.text, rows, evidenceFiles(rows, files), cues, cueNote) + (coverage.length ? '\n\n' + coverage.join('\n') : '');
|
|
1292
|
+
if (new Blob([text]).size > 4000000) { setCopyNote('Full context exceeds the 4 MB clipboard limit. Nothing was copied; saved history remains available.'); return; }
|
|
1293
|
+
copyOut(text, 'Copied full meeting context');
|
|
1294
|
+
}
|
|
1295
|
+
// Safari copies only inside the tap, so a panel that has already read its answers and cues copies at once.
|
|
1296
|
+
if (contextReady(sessions)) { assemble(); return; }
|
|
1297
|
+
setCopyNote('Preparing full context…');
|
|
1298
|
+
loadConfig().then(function () {
|
|
1299
|
+
if (!S.conf.config) { setCopyNote('Refresh meeting assistance before copying its full context.'); return; }
|
|
1300
|
+
return Promise.all(sessions.map(function (sid) { return askOpen(sid, true); })).then(function () {
|
|
1301
|
+
return Promise.all(sessions.filter(function (sid) { return isGlasses(sid) || isMac(sid); }).map(function (sid) { return loadCues(sid); }));
|
|
1302
|
+
}).then(function () { setCopyNote('Ready. Tap Copy full context again.'); });
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
function contextReady(sessions) {
|
|
1306
|
+
return !sessions.length || (!!S.conf.config && sessions.every(function (sid) {
|
|
1307
|
+
var a = A(sid);
|
|
1308
|
+
return !a.loading && !a.openP && (!!a.cuesState || !(isGlasses(sid) || isMac(sid)));
|
|
1309
|
+
}));
|
|
1310
|
+
}
|
|
1311
|
+
function downloadDetail() {
|
|
1312
|
+
var d = S.detail; if (!d) return;
|
|
1313
|
+
var text = d.sourceContent || d.transcript || '', row = detailRow();
|
|
1314
|
+
var name = String(row.filename || 'meeting.md').replace(/[\/\\]/g, '_');
|
|
1315
|
+
var url = URL.createObjectURL(new Blob([text], { type: 'text/markdown;charset=utf-8' }));
|
|
1316
|
+
var a = document.createElement('a');
|
|
1317
|
+
a.href = url; a.download = /\.md$/i.test(name) ? name : name + '.md';
|
|
1318
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
1319
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
|
|
1320
|
+
setCopyNote('Downloaded ' + a.download);
|
|
1321
|
+
}
|
|
1322
|
+
function stampMs(ms, hours) {
|
|
1323
|
+
var total = Math.floor(Math.max(0, num(ms)) / 1000), h = Math.floor(total / 3600), m = Math.floor((total % 3600) / 60), s = total % 60;
|
|
1324
|
+
return hours ? h + ':' + pad2(m) + ':' + pad2(s) : m + ':' + pad2(s);
|
|
1325
|
+
}
|
|
1326
|
+
function modelLabel(e) { var m = e.selectedModel || {}; return e.model || (cap(m.engine) + ' · ' + (m.model || '')); }
|
|
1327
|
+
function safeUrl(u) { try { var x = new URL(String(u || '')); return x.protocol === 'https:' && x.hostname ? x.href : null; } catch (_e) { return null; } }
|
|
1328
|
+
function exportStatusLine(e) {
|
|
1329
|
+
return 'Model: ' + modelLabel(e) + ' · Through ' + stampMs(e.throughMs, num(e.throughMs) >= 3600000) + ' · ' + e.status +
|
|
1330
|
+
(e.timings && e.timings.totalMs != null ? ' · Answer time: ' + (num(e.timings.totalMs) / 1000).toFixed(1) + ' s' : '');
|
|
1331
|
+
}
|
|
1332
|
+
function evidenceFiles(exchanges, current) {
|
|
1333
|
+
var seen = {}, out = [];
|
|
1334
|
+
exchanges.reduce(function (all, e) { return all.concat(e.files || []); }, []).concat(current).forEach(function (f) { var k = f.id + '@' + f.revision; if (!seen[k]) { seen[k] = 1; out.push(f); } });
|
|
1335
|
+
return out;
|
|
1336
|
+
}
|
|
1337
|
+
function fullContext(title, transcript, exchanges, files, cues, cueNote) {
|
|
1338
|
+
var sections = ['# ' + title, '## Transcript\n' + transcript];
|
|
1339
|
+
exchanges.forEach(function (e, i) {
|
|
1340
|
+
var ns = 'Q' + (i + 1), ans = e.answer != null ? e.answer : 'No answer (' + e.status + ').';
|
|
1341
|
+
(e.sources || []).forEach(function (s) { ans = ans.split('[' + s.id + ']').join('[' + ns + ':' + s.id + ']'); });
|
|
1342
|
+
var part = '## ' + ns + ' · ' + e.question + '\n' + ans + '\n' + exportStatusLine(e);
|
|
1343
|
+
(e.sources || []).forEach(function (s) { part += '\n[' + ns + ':' + s.id + '] ' + s.kind + ': ' + s.title + '\n' + s.text; var u = safeUrl(s.url); if (u) part += '\n' + u; });
|
|
1344
|
+
if ((e.notices || []).length) part += '\n' + e.notices.join('\n');
|
|
1345
|
+
sections.push(part);
|
|
1346
|
+
});
|
|
1347
|
+
var cueText = cuesSection(cues, cueNote); if (cueText) sections.push(cueText);
|
|
1348
|
+
if (files.length) sections.push('## Files\n' + files.map(function (f) { return '### ' + f.name + ' [' + f.id + '@' + f.revision + '] · ' + f.status + '\n' + (f.status === 'ready' ? f.text : 'Text unavailable: ' + (f.notice || f.status)); }).join('\n\n'));
|
|
1349
|
+
return sections.join('\n\n');
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// ── Meeting Ask (MeetingAskStore, LiveAskPanel) ───────
|
|
1353
|
+
var FACT_CHECK_Q = 'Fact check the latest public factual claim in this meeting. Find a readable source and say whether it supports the claim.';
|
|
1354
|
+
var CHALLENGE_Q = 'For my own thinking, challenge one assumption supported by this recording. Give one short reason. If no assumption is clear, say so.';
|
|
1355
|
+
var DECISION_Q = 'For my own thinking, identify one unresolved decision from this recording and one useful question to move it forward. Do not invent a decision.';
|
|
1356
|
+
var ACTIONS = [['catch_up', 'Catch me up'], ['summary', 'Summarize so far'], ['questions', 'What to ask'], ['actions', 'Action items']];
|
|
1357
|
+
var KNOWN_LABELS = ['So what', 'Decided', 'Open', 'You owe', 'Next', 'Covered', 'Others owe', 'Unowned', 'Proposed', 'Ask', 'Details', 'Confidence'];
|
|
1358
|
+
function A(sid) {
|
|
1359
|
+
return S.ask[sid] || (S.ask[sid] = { sid: sid, history: [], files: [], loading: false, error: '', note: '', running: false, outbox: null,
|
|
1360
|
+
snapshot: null, health: '', view: 'ask', cues: null, cuesState: '', cuesCount: null, follow: null, followText: '', followBusy: false,
|
|
1361
|
+
draft: readDrafts()[sid] || '', ctx: {}, openP: null });
|
|
1362
|
+
}
|
|
1363
|
+
function terminal(e) { return ['completed', 'failed', 'cancelled', 'interrupted'].indexOf(e.status) >= 0; }
|
|
1364
|
+
function online() { return S.conf.reach === 'online'; }
|
|
1365
|
+
function helperMsg(status, op) {
|
|
1366
|
+
if (status === 404) return op === 'config' ? 'Update the COS server to use saved meeting assistance.' : 'This request or meeting is unavailable.';
|
|
1367
|
+
if (status === 401 || status === 403) return SIGNED_OUT;
|
|
1368
|
+
if (status === 409) return 'Meeting assistant could not accept this request. Refresh its history before retrying.';
|
|
1369
|
+
if (status === 429) return 'Meeting assistant is busy or has reached its usage limit. Your question is retained.';
|
|
1370
|
+
if (!status) return 'Meeting assistant is offline. Your question is retained; nothing retries automatically.';
|
|
1371
|
+
return 'Meeting assistant could not finish. Check the selected model and server, then retry.';
|
|
1372
|
+
}
|
|
1373
|
+
function failureText(reason) {
|
|
1374
|
+
switch (reason) {
|
|
1375
|
+
case 'provider_timeout': return 'The selected model took too long. Retry when you’re ready.';
|
|
1376
|
+
case 'provider_unavailable': case 'model_unavailable': return 'The selected model is unavailable. Check Meeting assistant settings, then retry.';
|
|
1377
|
+
case 'invalid_answer': case 'invalid_provider_output': return 'The model returned an unreadable answer. Your question is saved; you can retry.';
|
|
1378
|
+
case 'journal_write_failed': return 'This answer could not be saved. Copy available context before restarting COS.';
|
|
1379
|
+
case 'server_restarted': return 'COS restarted before this answer finished. Retry to ask again.';
|
|
1380
|
+
case 'cancelled': case 'cancelled_by_user': return 'This answer was stopped. Your question remains saved.';
|
|
1381
|
+
default: return 'Meeting assistance could not finish. Check the connected model and retry.';
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
function loadConfig(force) {
|
|
1385
|
+
if (S.conf.pending) return S.conf.pending;
|
|
1386
|
+
if (S.conf.config && !force) return Promise.resolve(S.conf.config);
|
|
1387
|
+
S.conf.pending = api('/api/meeting-ask/config').then(function (c) {
|
|
1388
|
+
S.conf.pending = null;
|
|
1389
|
+
if (!c || c.protocolVersion !== 2 || !c.serverInstanceId) {
|
|
1390
|
+
S.conf.config = null; S.conf.reach = 'outdated'; S.conf.notice = 'Update COS Control and the server together to use meeting assistance.'; return null;
|
|
1391
|
+
}
|
|
1392
|
+
S.conf.config = c; S.conf.reach = 'online'; S.conf.notice = '';
|
|
1393
|
+
return c;
|
|
1394
|
+
}, function (err) {
|
|
1395
|
+
S.conf.pending = null;
|
|
1396
|
+
S.conf.reach = err && err.status === 404 ? 'outdated' : 'offline';
|
|
1397
|
+
S.conf.notice = helperMsg(err && err.status, 'config');
|
|
1398
|
+
return null;
|
|
1399
|
+
});
|
|
1400
|
+
return S.conf.pending;
|
|
1401
|
+
}
|
|
1402
|
+
function accept(sid, ex) {
|
|
1403
|
+
var a = A(sid), conf = S.conf.config;
|
|
1404
|
+
if (!ex || ex.sessionId !== sid || !conf || ex.serverInstanceId !== conf.serverInstanceId) return false;
|
|
1405
|
+
var i = -1;
|
|
1406
|
+
a.history.forEach(function (e, j) { if (e.requestId === ex.requestId) i = j; });
|
|
1407
|
+
if (i >= 0) { var cur = a.history[i]; if (cur.updatedAt > ex.updatedAt || (terminal(cur) && !terminal(ex))) return true; a.history[i] = ex; }
|
|
1408
|
+
else a.history.push(ex);
|
|
1409
|
+
return true;
|
|
1410
|
+
}
|
|
1411
|
+
// What the panel draws from the history read: each question's state, and the meeting's files.
|
|
1412
|
+
function askSig(a) {
|
|
1413
|
+
return a.history.map(function (e) { return e.requestId + ':' + e.status + ':' + e.updatedAt; }).join('|') + '#' +
|
|
1414
|
+
(a.files || []).map(function (x) { return x.id + ':' + x.revision + ':' + x.status; }).join('|') + '#' + a.error;
|
|
1415
|
+
}
|
|
1416
|
+
// quiet 'poll' is the background re-read: no loading state, no error on a failed read, and no repaint unless the
|
|
1417
|
+
// history or the files changed, so a panel nobody touched never redraws under the reader.
|
|
1418
|
+
function askOpen(sid, quiet) {
|
|
1419
|
+
var a = A(sid);
|
|
1420
|
+
if (a.openP) return a.openP;
|
|
1421
|
+
var polling = quiet === 'poll', before = polling ? askSig(a) : '';
|
|
1422
|
+
if (!polling) { a.loading = true; if (!quiet) repaintAsk(sid); }
|
|
1423
|
+
a.openP = loadConfig().then(function (conf) {
|
|
1424
|
+
if (!conf) return;
|
|
1425
|
+
var inst = encodeURIComponent(conf.serverInstanceId), offset = 0, all = [], files = [];
|
|
1426
|
+
function page() {
|
|
1427
|
+
return api('/api/meeting-ask/history?sessionId=' + encodeURIComponent(sid) + '&serverInstanceId=' + inst + '&offset=' + offset + '&limit=50').then(function (p) {
|
|
1428
|
+
all = all.concat((p && p.exchanges) || []); files = (p && p.files) || [];
|
|
1429
|
+
if (p && p.nextOffset != null && p.nextOffset > offset && all.length < 10000) { offset = p.nextOffset; return page(); }
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
return page().then(function () {
|
|
1433
|
+
all.forEach(function (e) { accept(sid, e); });
|
|
1434
|
+
a.history.sort(function (x, y) { return x.createdAt === y.createdAt ? (x.requestId < y.requestId ? -1 : 1) : (x.createdAt < y.createdAt ? -1 : 1); });
|
|
1435
|
+
a.files = files;
|
|
1436
|
+
// A background read clears only an error a history read left; a send's error stays until the person acts.
|
|
1437
|
+
if (!polling || a.histErr) a.error = '';
|
|
1438
|
+
a.histErr = false;
|
|
1439
|
+
if (a.outbox) {
|
|
1440
|
+
if (a.history.some(function (e) { return e.requestId === a.outbox.req.requestId; })) { if (a.outbox.consumes && a.draft.trim() === a.outbox.req.question) setDraft(sid, ''); a.outbox = null; }
|
|
1441
|
+
else a.error = 'The last question’s admission is uncertain. Check its status before sending another.';
|
|
1442
|
+
}
|
|
1443
|
+
var active = a.history.filter(function (e) { return !terminal(e); })[0];
|
|
1444
|
+
if (active && !a.running) poll(sid, active);
|
|
1445
|
+
}, function (err) { if (!polling) { a.error = helperMsg(err && err.status); a.histErr = true; } });
|
|
1446
|
+
}).then(function () {
|
|
1447
|
+
a.loading = false; a.openP = null; a.histAt = Date.now();
|
|
1448
|
+
if (!polling || askSig(a) !== before) repaintAsk(sid);
|
|
1449
|
+
paintLiveHead();
|
|
1450
|
+
});
|
|
1451
|
+
return a.openP;
|
|
1452
|
+
}
|
|
1453
|
+
// The gap review (2026-11-04): both clients read the Ask history once, so a question asked on the Mac never showed on
|
|
1454
|
+
// the phone. While a live panel shows, re-read it every ASK_HISTORY_POLL_MS, and at once when the page comes back.
|
|
1455
|
+
var ASK_HISTORY_POLL_MS = 12000;
|
|
1456
|
+
function refreshAskHistory(sid, now) {
|
|
1457
|
+
var a = A(sid);
|
|
1458
|
+
// Only after the panel's own first read (histAt), which shows its errors; this one never does.
|
|
1459
|
+
if (!a.histAt || !online() || a.openP || a.running || a.outbox || a.loading) return;
|
|
1460
|
+
if (Date.now() - (a.histAt || 0) < (now ? 2000 : ASK_HISTORY_POLL_MS)) return;
|
|
1461
|
+
askOpen(sid, 'poll');
|
|
1462
|
+
}
|
|
1463
|
+
function refreshShownAsk() {
|
|
1464
|
+
if (S.out) return;
|
|
1465
|
+
if (S.view === 'live' && S.liveSid) refreshAskHistory(S.liveSid, true);
|
|
1466
|
+
else if (S.view === 'detail') detailSessions().forEach(function (sid) { refreshAskHistory(sid, true); });
|
|
1467
|
+
}
|
|
1468
|
+
// The panel's open: history, then (saved) the saved transcript, then the source line, then the cue count.
|
|
1469
|
+
function panelOpen(sid) {
|
|
1470
|
+
var a = A(sid);
|
|
1471
|
+
return askOpen(sid).then(function () {
|
|
1472
|
+
if (!S.conf.config) return;
|
|
1473
|
+
return (a.ctx.saved ? loadSnapshot(sid) : Promise.resolve()).then(function () { return checkSources(sid); });
|
|
1474
|
+
}).then(function () { if (isGlasses(sid) || isMac(sid)) return loadCues(sid); });
|
|
1475
|
+
}
|
|
1476
|
+
function loadSnapshot(sid) {
|
|
1477
|
+
var a = A(sid), conf = S.conf.config; if (!conf) return Promise.resolve();
|
|
1478
|
+
return api('/api/meeting-ask/transcript?sessionId=' + encodeURIComponent(sid) + '&serverInstanceId=' + encodeURIComponent(conf.serverInstanceId)).then(function (s) {
|
|
1479
|
+
var turns = (s && s.turns) || [], ids = {}, elapsed = num(s && s.elapsedMs);
|
|
1480
|
+
var valid = s && s.ended === true && elapsed >= 0 && turns.length > 0 && turns.every(function (t) {
|
|
1481
|
+
if (ids[t.id] || !(t.id >= 0) || !(t.elapsedMs >= 0) || t.elapsedMs > elapsed || !String(t.text || '').trim()) return false;
|
|
1482
|
+
ids[t.id] = 1; return true;
|
|
1483
|
+
});
|
|
1484
|
+
if (valid) { a.snapshot = { turns: turns, elapsedMs: elapsed }; a.error = a.error === SNAP_ERR ? '' : a.error; }
|
|
1485
|
+
else { a.snapshot = null; a.error = SNAP_ERR; }
|
|
1486
|
+
repaintAsk(sid);
|
|
1487
|
+
}, function (err) { a.snapshot = null; a.error = helperMsg(err && err.status); repaintAsk(sid); });
|
|
1488
|
+
}
|
|
1489
|
+
var SNAP_ERR = 'This recording is not ready for saved-meeting questions. Open its live transcript or refresh after saving.';
|
|
1490
|
+
function checkSources(sid) {
|
|
1491
|
+
var a = A(sid), conf = S.conf.config; if (!conf) return Promise.resolve();
|
|
1492
|
+
return Promise.all([
|
|
1493
|
+
api('/api/context/status'),
|
|
1494
|
+
api('/api/meeting-ask/context?sessionId=' + encodeURIComponent(sid) + '&serverInstanceId=' + encodeURIComponent(conf.serverInstanceId))
|
|
1495
|
+
]).then(function (r) {
|
|
1496
|
+
var h = r[0] || {}, o = (r[1] && r[1].orientation) || {}, f = a.ctx.live ? S.feeds[sid] : null;
|
|
1497
|
+
if (f) { f.calendar = r[1] && Array.isArray(r[1].candidates) ? r[1].candidates : []; paintLiveHead(); }
|
|
1498
|
+
function part(x) { return x && x.available ? fmtCount(x.total) + ' available' : 'unavailable'; }
|
|
1499
|
+
a.health = 'Memory: ' + part(h.memory) + '. Threads: ' + part(h.threads) + '. Domain: ' + (o.domain || 'not confirmed') + '. Checked ' + clockOf(new Date()) + '. Last sync is not reported by this source.';
|
|
1500
|
+
repaintAsk(sid);
|
|
1501
|
+
}, function () { a.health = 'Source status could not be checked. Refresh to retry.'; repaintAsk(sid); });
|
|
1502
|
+
}
|
|
1503
|
+
function setDraft(sid, text) { var a = A(sid); a.draft = String(text || '').slice(0, 2000); writeDraft(sid, a.draft); }
|
|
1504
|
+
function askEnabled(sid) {
|
|
1505
|
+
var a = A(sid);
|
|
1506
|
+
if (a.ctx.live) { var f = S.feeds[sid]; return !!(f && f.turns.length); }
|
|
1507
|
+
return !!a.snapshot;
|
|
1508
|
+
}
|
|
1509
|
+
function askBlocked(sid) { var a = A(sid); return !online() || a.running || !!a.outbox || !askEnabled(sid); }
|
|
1510
|
+
function submit(sid, action, question, retryOf) {
|
|
1511
|
+
var a = A(sid), conf = S.conf.config;
|
|
1512
|
+
question = String(question || '').trim().slice(0, 2000);
|
|
1513
|
+
if (!conf || askBlocked(sid) || !question) return;
|
|
1514
|
+
var turns, elapsed;
|
|
1515
|
+
if (a.ctx.live) {
|
|
1516
|
+
var f = S.feeds[sid], end = f.endedAt || Date.now();
|
|
1517
|
+
turns = f.turns.map(function (t) { return { id: t.id, elapsedMs: t.elapsedMs, speaker: t.speaker, text: t.text }; });
|
|
1518
|
+
elapsed = Math.max(turns.reduce(function (m, t) { return Math.max(m, t.elapsedMs); }, 0), f.start ? Math.max(0, end - f.start) : 0);
|
|
1519
|
+
} else { turns = a.snapshot.turns; elapsed = a.snapshot.elapsedMs; }
|
|
1520
|
+
if (!turns.length) return;
|
|
1521
|
+
var req = { requestId: uuid(), sessionId: sid, serverInstanceId: conf.serverInstanceId, action: action, question: question, elapsedMs: Math.round(elapsed), turns: turns, model: conf.selected,
|
|
1522
|
+
files: (a.files || []).filter(function (x) { return x.status !== 'removed'; }).map(function (x) { return { id: x.id, revision: x.revision }; }) };
|
|
1523
|
+
if (retryOf) req.retryOf = retryOf;
|
|
1524
|
+
a.outbox = { req: req, consumes: action === 'ask' && !retryOf };
|
|
1525
|
+
a.error = ''; a.note = '';
|
|
1526
|
+
send(sid);
|
|
1527
|
+
}
|
|
1528
|
+
function send(sid) {
|
|
1529
|
+
var a = A(sid), box = a.outbox; if (!box) return;
|
|
1530
|
+
a.running = true; repaintAsk(sid);
|
|
1531
|
+
answer('/api/meeting-ask/requests', { method: 'POST', body: box.req }).then(function (res) {
|
|
1532
|
+
if (res.status >= 200 && res.status < 300) {
|
|
1533
|
+
var ex = res.body.exchange;
|
|
1534
|
+
if (!ex || ex.requestId !== box.req.requestId || !accept(sid, ex)) { a.running = false; a.error = 'Question identity did not match. Local recovery is preserved.'; repaintAsk(sid); return; }
|
|
1535
|
+
a.outbox = null;
|
|
1536
|
+
if (box.consumes && a.draft.trim() === box.req.question) setDraft(sid, '');
|
|
1537
|
+
a.running = false; poll(sid, ex);
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
a.running = false;
|
|
1541
|
+
if ([400, 401, 403, 404, 413, 422, 429].indexOf(res.status) >= 0) a.outbox = null;
|
|
1542
|
+
a.error = helperMsg(res.status);
|
|
1543
|
+
repaintAsk(sid);
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
function poll(sid, first) {
|
|
1547
|
+
var a = A(sid), row = first, deadline = Date.now() + 180000;
|
|
1548
|
+
a.running = true; repaintAsk(sid);
|
|
1549
|
+
(function tick() {
|
|
1550
|
+
if (terminal(row)) { a.running = false; repaintAsk(sid); return; }
|
|
1551
|
+
if (Date.now() > deadline) { a.running = false; a.error = 'Answer is still pending on the server. Refresh to check its status; nothing was resubmitted.'; repaintAsk(sid); return; }
|
|
1552
|
+
setTimeout(function () {
|
|
1553
|
+
api('/api/meeting-ask/requests/' + encodeURIComponent(row.requestId)).then(function (r) {
|
|
1554
|
+
var ex = r && r.exchange;
|
|
1555
|
+
if (!ex || ex.requestId !== row.requestId || !accept(sid, ex)) { a.running = false; a.error = 'Answer identity did not match. Saved context is preserved.'; repaintAsk(sid); return; }
|
|
1556
|
+
row = ex; repaintAsk(sid); tick();
|
|
1557
|
+
}, function (err) { a.running = false; a.error = helperMsg(err && err.status); repaintAsk(sid); });
|
|
1558
|
+
}, 2000);
|
|
1559
|
+
})();
|
|
1560
|
+
}
|
|
1561
|
+
function cancelAnswer(sid, requestId) {
|
|
1562
|
+
var e = A(sid).history.filter(function (x) { return x.requestId === requestId; })[0]; if (!e) return;
|
|
1563
|
+
answer('/api/meeting-ask/requests/' + encodeURIComponent(requestId) + '/cancel', { method: 'POST', body: { serverInstanceId: e.serverInstanceId } }).then(function (res) {
|
|
1564
|
+
if (res.status >= 200 && res.status < 300 && res.body.exchange) accept(sid, res.body.exchange); else A(sid).error = helperMsg(res.status);
|
|
1565
|
+
repaintAsk(sid);
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
function reconcile(sid) {
|
|
1569
|
+
var a = A(sid), box = a.outbox; if (!box) return;
|
|
1570
|
+
api('/api/meeting-ask/requests/' + encodeURIComponent(box.req.requestId)).then(function (r) {
|
|
1571
|
+
var ex = r && r.exchange;
|
|
1572
|
+
if (!ex || ex.requestId !== box.req.requestId || !accept(sid, ex)) { a.error = 'Question identity did not match. Local recovery is preserved.'; repaintAsk(sid); return; }
|
|
1573
|
+
a.outbox = null; if (box.consumes && a.draft.trim() === box.req.question) setDraft(sid, '');
|
|
1574
|
+
a.error = ''; if (!terminal(ex)) poll(sid, ex); else repaintAsk(sid);
|
|
1575
|
+
}, function (err) {
|
|
1576
|
+
a.error = err && err.status === 404
|
|
1577
|
+
? 'The server has not confirmed this question yet. Retry the same question to reconcile its original request; your draft is retained.'
|
|
1578
|
+
: helperMsg(err && err.status) + ' The question remains recoverable. No automatic resend occurred.';
|
|
1579
|
+
repaintAsk(sid);
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
function saveFollowUp(sid) {
|
|
1583
|
+
var a = A(sid), conf = S.conf.config, m = a.ctx.meeting;
|
|
1584
|
+
var e = a.history.filter(function (x) { return x.requestId === a.follow; })[0];
|
|
1585
|
+
var text = a.followText.trim();
|
|
1586
|
+
if (!e || !conf || !m || !text || text.length > 600 || !online()) return;
|
|
1587
|
+
a.followBusy = true; repaintAsk(sid);
|
|
1588
|
+
answer('/api/work-intake/meeting-ask', { method: 'POST', body: { serverInstanceId: conf.serverInstanceId, requestId: e.requestId, text: text,
|
|
1589
|
+
meeting: { domain: m.domain, month: m.month, filename: m.filename, recordId: m.recordId } } }).then(function (res) {
|
|
1590
|
+
a.followBusy = false;
|
|
1591
|
+
var b = res.body;
|
|
1592
|
+
if (res.status >= 200 && res.status < 300 && b.ok && b.item && b.item.id) {
|
|
1593
|
+
a.follow = null; a.error = '';
|
|
1594
|
+
a.note = b.item.status === 'review' ? 'Saved in Work Review. Review it before adding it to your work.' : 'This follow-up was already reviewed.';
|
|
1595
|
+
} else a.error = 'Save was not confirmed. Your follow-up draft is retained; retry the same words after checking COS.';
|
|
1596
|
+
repaintAsk(sid);
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// ── Cues (MeetingCuesPanel, read only) ────────────────
|
|
1601
|
+
function loadCues(sid, force) {
|
|
1602
|
+
var a = A(sid), days = a.ctx.live ? 2 : 30;
|
|
1603
|
+
if (a.cuesState === 'loaded' && !force) return Promise.resolve();
|
|
1604
|
+
return api('/api/live-cues/history?days=' + days + '&limit=200&sessionId=' + encodeURIComponent(sid)).then(function (d) {
|
|
1605
|
+
a.cues = ((d && d.cues) || []).filter(function (c) { return c && c.sessionId === sid; });
|
|
1606
|
+
var m = d && d.meetings && d.meetings[sid];
|
|
1607
|
+
a.cuesCount = { cues: m && m.cues != null ? num(m.cues) : a.cues.length, cap: m && m.cap != null ? num(m.cap) : null };
|
|
1608
|
+
a.cuesState = 'loaded'; repaintAsk(sid);
|
|
1609
|
+
}, function (err) { a.cuesState = err && err.status === 404 ? 'older' : 'failed'; a.cuesStatus = err && err.status; repaintAsk(sid); });
|
|
1610
|
+
}
|
|
1611
|
+
function cuesOffered(sid) { var a = A(sid); return a.cuesState === 'loaded' && (isGlasses(sid) || (isMac(sid) && (a.cues || []).length > 0)); }
|
|
1612
|
+
var KIND = { challenge: 'Challenge', reinforce: 'Reinforce', decision: 'Decision', promise: 'Promise', followUp: 'Follow-up', insight: 'Insight' };
|
|
1613
|
+
function cueStart(c) { if (c.armedAt) return num(c.armedAt); var m = /^meeting_(\d{13})_/.exec(c.sessionId || ''); return m ? Number(m[1]) : null; }
|
|
1614
|
+
function cueMinutes(c) { var s = cueStart(c); if (!s) return null; var sec = (num(c.createdAt) - s) / 1000; return sec >= 0 && sec < 43200 ? Math.floor(sec / 60) : null; }
|
|
1615
|
+
function cueSourceText(s) {
|
|
1616
|
+
var host = null; try { var u = new URL(s.url || ''); if (u.protocol === 'https:') host = u.hostname.replace(/^www\./, ''); } catch (_e) {}
|
|
1617
|
+
if (host) return (s.title || host) + ' (' + host + ')';
|
|
1618
|
+
return s.date ? (s.title || '') + ' (' + s.date + ')' : (s.title || '');
|
|
1619
|
+
}
|
|
1620
|
+
function cueSources(c) { var src = c.sources || []; return src.length ? 'Sources: ' + src.map(cueSourceText).join('; ') : 'Sources: none, from the room only'; }
|
|
1621
|
+
function cueRated(r) {
|
|
1622
|
+
if (!r || !r.verdict) return null;
|
|
1623
|
+
var label = cap(r.verdict);
|
|
1624
|
+
return r.note ? 'Rated ' + label + ' · “' + r.note + '”' : 'Rated ' + label;
|
|
1625
|
+
}
|
|
1626
|
+
function cueVote(r) { return r && r.vote === 'up' ? 'Voted up on the glasses' : r && r.vote === 'down' ? 'Voted down on the glasses' : null; }
|
|
1627
|
+
function cuesSection(cues, note) {
|
|
1628
|
+
var seen = {}, rows = cues.filter(function (c) { if (seen[c.cueId]) return false; seen[c.cueId] = 1; return true; })
|
|
1629
|
+
.sort(function (x, y) { return num(x.createdAt) - num(y.createdAt) || (x.cueId < y.cueId ? -1 : 1); });
|
|
1630
|
+
if (!rows.length && !note) return null;
|
|
1631
|
+
var parts = ['## Cues'];
|
|
1632
|
+
rows.forEach(function (c, i) {
|
|
1633
|
+
var mins = cueMinutes(c), d = new Date(num(c.createdAt));
|
|
1634
|
+
var when = mins != null ? mins + ' min in' : dayKey(d) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
|
|
1635
|
+
var lines = ['### C' + (i + 1) + ' · ' + (KIND[c.kind] || c.kind) + ' · ' + when];
|
|
1636
|
+
if (c.headline && c.headline !== c.detail) lines.push(c.headline);
|
|
1637
|
+
lines.push(c.detail || '');
|
|
1638
|
+
if (c.general && c.general.query) lines.push('Searched the web for: ' + c.general.query);
|
|
1639
|
+
lines.push(cueSources(c));
|
|
1640
|
+
(c.sources || []).forEach(function (s) { var u = safeUrl(s.url); if (u) lines.push(u); });
|
|
1641
|
+
var rated = cueRated(c.rating); if (rated) lines.push(rated);
|
|
1642
|
+
var vote = cueVote(c.rating); if (vote) lines.push(vote);
|
|
1643
|
+
parts.push(lines.join('\n'));
|
|
1644
|
+
});
|
|
1645
|
+
if (note) parts.push(note);
|
|
1646
|
+
return parts.join('\n\n');
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// ── side panel rendering ──────────────────────────────
|
|
1650
|
+
// MeetingAskAnswerRows: the server's sections (So what first), or **Label:** lines of an older answer, else nil.
|
|
1651
|
+
function answerRows(e) {
|
|
1652
|
+
var given = (e.sections || []).map(function (s) { return { label: String(s.label || '').trim(), text: String(s.text || '').trim() }; }).filter(function (r) { return r.label && r.text; });
|
|
1653
|
+
if (!given.length && typeof e.answer === 'string') {
|
|
1654
|
+
var rows = [], gap = false, ok = true;
|
|
1655
|
+
e.answer.split('\n').forEach(function (raw) {
|
|
1656
|
+
if (!ok) return;
|
|
1657
|
+
var line = raw.trim();
|
|
1658
|
+
if (!line) { gap = true; return; }
|
|
1659
|
+
var m = /^\*\*([^*]+?):\*\*\s*(.*)$/.exec(line) || /^\*\*([^*]+?)\*\*:\s*(.*)$/.exec(line);
|
|
1660
|
+
if (m && KNOWN_LABELS.indexOf(m[1].trim()) >= 0) rows.push({ label: m[1].trim(), lines: m[2] ? [m[2]] : [] });
|
|
1661
|
+
else if (!rows.length) ok = false;
|
|
1662
|
+
else { var last = rows[rows.length - 1]; if (gap && last.lines.length) last.lines.push(''); last.lines.push(raw.replace(/\s+$/, '')); }
|
|
1663
|
+
gap = false;
|
|
1664
|
+
});
|
|
1665
|
+
if (!ok || !rows.length) return null;
|
|
1666
|
+
given = rows.map(function (r) { return { label: r.label, text: r.lines.join('\n').trim() }; }).filter(function (r) { return r.text; });
|
|
1667
|
+
if (!given.length) return null;
|
|
1668
|
+
}
|
|
1669
|
+
if (!given.length) return null;
|
|
1670
|
+
var first = given.filter(function (r) { return r.label === 'So what'; });
|
|
1671
|
+
return first.concat(given.filter(function (r) { return r.label !== 'So what'; }));
|
|
1672
|
+
}
|
|
1673
|
+
function answerHtml(sid, e) {
|
|
1674
|
+
var a = A(sid), rows = answerRows(e), body = '';
|
|
1675
|
+
if (!terminal(e) && e.answer == null && !rows) body += '<p class="mt-working">Working: COS is answering.</p>';
|
|
1676
|
+
if (rows) {
|
|
1677
|
+
body += '<div class="mt-arows">' + rows.map(function (r) {
|
|
1678
|
+
var parts = r.text.split(/\n{2,}/), lead = parts.shift();
|
|
1679
|
+
return '<div class="mt-arow"><p><strong>' + esc(r.label) + ':</strong> ' + inline(lead) + '</p>' + (parts.length ? '<div class="mt-md">' + MD.doc(parts.join('\n\n')) + '</div>' : '') + '</div>';
|
|
1680
|
+
}).join('') + '</div>';
|
|
1681
|
+
} else if (e.answer != null) body += '<div class="mt-md">' + MD.doc(e.answer) + '</div>';
|
|
1682
|
+
else if (terminal(e)) body += '<p class="mt-muted">No answer · ' + esc(e.status) + '</p>';
|
|
1683
|
+
if (e.error) body += '<p class="mt-amber-line">' + esc(failureText(e.error)) + '</p>';
|
|
1684
|
+
(e.sources || []).forEach(function (s) {
|
|
1685
|
+
var url = s.kind === 'web' ? safeUrl(s.url) : null, host = '';
|
|
1686
|
+
if (url) { try { host = new URL(url).hostname; } catch (_e) {} }
|
|
1687
|
+
body += '<details class="mt-source" data-k="' + esc(e.requestId + ':' + s.id) + '"><summary>[' + esc(s.id) + '] ' + esc(cap(s.kind)) + ' · ' + esc(s.title) + '</summary>' +
|
|
1688
|
+
'<div class="mt-source-body"><p>' + esc(s.text) + '</p>' +
|
|
1689
|
+
(url ? '<p><a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(host || 'Open source') + '</a></p><p class="mt-muted">Retained search evidence' + (s.retrievedAt ? ' · ' + esc(s.retrievedAt) : '') + '</p>' : '') +
|
|
1690
|
+
(s.recordId ? '<p class="mt-muted">Retained source excerpt</p>' : '') + '</div></details>';
|
|
1691
|
+
});
|
|
1692
|
+
(e.notices || []).forEach(function (n) { body += '<p class="mt-amber-line">' + esc(n) + '</p>'; });
|
|
1693
|
+
var conf = S.conf.config, caps = (conf && conf.capabilities) || {};
|
|
1694
|
+
var acts = '';
|
|
1695
|
+
if (terminal(e) && ['failed', 'cancelled', 'interrupted'].indexOf(e.status) >= 0 && askEnabled(sid))
|
|
1696
|
+
acts += '<button type="button" class="mt-link" data-mt="ask-retry" data-sid="' + esc(sid) + '" data-req="' + esc(e.requestId) + '"' + (a.running || a.outbox || !online() ? ' disabled' : '') + '>Retry</button>';
|
|
1697
|
+
if (!terminal(e)) acts += '<button type="button" class="mt-link" data-mt="ask-stop" data-sid="' + esc(sid) + '" data-req="' + esc(e.requestId) + '">Stop</button>';
|
|
1698
|
+
if (e.status === 'completed' && caps.followUpReview === true)
|
|
1699
|
+
acts += '<button type="button" class="mt-link" data-mt="follow-open" data-sid="' + esc(sid) + '" data-req="' + esc(e.requestId) + '"' + (!a.ctx.saved || !a.ctx.meeting || !online() ? ' disabled title="Save this meeting before adding a follow-up"' : '') + '>Save as follow-up</button>';
|
|
1700
|
+
acts += '<button type="button" class="mt-link" data-mt="ask-copy" data-sid="' + esc(sid) + '" data-req="' + esc(e.requestId) + '">Copy</button>';
|
|
1701
|
+
body += '<div class="mt-afoot"><span class="mt-muted">Through ' + esc(stampMs(e.throughMs, false)) + ' · ' + esc(modelLabel(e)) + '</span><span class="mt-afoot-acts">' + acts + '</span></div>';
|
|
1702
|
+
if (a.follow === e.requestId) {
|
|
1703
|
+
var t = a.followText.trim();
|
|
1704
|
+
body += '<div class="mt-confirm mt-follow"><p class="mt-confirm-title">Save as follow-up</p>' +
|
|
1705
|
+
'<p class="mt-confirm-line">Review these words. This creates a Work Review item with the original answer and sources; it does not commit a task.</p>' +
|
|
1706
|
+
'<textarea class="field mt-follow-text" rows="4" data-sid="' + esc(sid) + '" aria-label="Follow-up"' + (a.followBusy ? ' disabled' : '') + '>' + esc(a.followText) + '</textarea>' +
|
|
1707
|
+
'<div class="mt-confirm-actions"><button type="button" class="cx-btn quiet" data-mt="follow-cancel" data-sid="' + esc(sid) + '"' + (a.followBusy ? ' disabled' : '') + '>Cancel</button>' +
|
|
1708
|
+
'<button type="button" class="cx-btn primary" data-mt="follow-save" data-sid="' + esc(sid) + '"' + (a.followBusy || !t || t.length > 600 || !online() ? ' disabled' : '') + '>' + (a.followBusy ? 'Saving…' : 'Save in Work Review') + '</button></div></div>';
|
|
1709
|
+
}
|
|
1710
|
+
return '<div class="mt-answer"><p class="mt-q">' + esc(e.question || e.action) + '</p>' + body + '</div>';
|
|
1711
|
+
}
|
|
1712
|
+
function askHtml(sid) {
|
|
1713
|
+
var a = A(sid), live = !!a.ctx.live, conf = S.conf.config, caps = (conf && conf.capabilities) || {}, blocked = askBlocked(sid);
|
|
1714
|
+
var dis = blocked ? ' disabled' : '';
|
|
1715
|
+
var html = '<div class="mt-ask-head"><h3 class="mt-ask-title">' + (live ? 'Live COS Ask' : 'Meeting Ask') + '</h3>' +
|
|
1716
|
+
'<button type="button" class="mt-iconbtn" data-mt="ask-refresh" data-sid="' + esc(sid) + '" title="Refresh saved answers" aria-label="Refresh saved answers">' + icon('refresh') + '</button></div>' +
|
|
1717
|
+
'<p class="mt-ask-line">Ask when you need it. Uses this meeting first.</p>' +
|
|
1718
|
+
'<details class="mt-ask-sources" data-k="sources"><summary>Sources and context</summary><p>' + esc(a.health || 'Source status has not been checked.') + '</p></details>' +
|
|
1719
|
+
(a.ctx.captureLabel ? '<p class="mt-muted">' + esc(a.ctx.captureLabel) + '</p>' : '') +
|
|
1720
|
+
(live ? '' : filesRowHtml(sid));
|
|
1721
|
+
html += '<div class="mt-ask-acts">' + ACTIONS.map(function (x) {
|
|
1722
|
+
return '<button type="button" class="cx-btn quiet" data-mt="ask-act" data-sid="' + esc(sid) + '" data-action="' + x[0] + '" data-q="' + esc(x[1] === 'Catch me up' ? 'Catch me up on this recording' : x[1]) + '"' + dis + '>' + esc(x[1]) + '</button>';
|
|
1723
|
+
}).join('') + (caps.factCheck === true ? '<button type="button" class="cx-btn quiet" data-mt="ask-act" data-sid="' + esc(sid) + '" data-action="ask" data-q="' + esc(FACT_CHECK_Q) + '"' + dis + '>Fact check</button>' : '') + '</div>';
|
|
1724
|
+
html += '<div class="mt-history">';
|
|
1725
|
+
if (!a.history.length) {
|
|
1726
|
+
var failed = !!a.error || !online();
|
|
1727
|
+
html += '<p class="mt-muted">' + esc(a.loading ? 'Loading saved questions…' : failed ? 'Saved questions could not be checked. Refresh to retry.' : live ? 'No questions yet. Answers are saved with this recording.' : 'No saved questions found for this recording.') + '</p>';
|
|
1728
|
+
}
|
|
1729
|
+
html += a.history.map(function (e) { return answerHtml(sid, e); }).join('') + '</div>';
|
|
1730
|
+
var err = a.error || (!online() && S.conf.reach !== 'checking' ? S.conf.notice : '');
|
|
1731
|
+
if (err) html += '<p class="mt-amber-line">' + esc(err) + '</p>';
|
|
1732
|
+
if (a.outbox) {
|
|
1733
|
+
html += '<div class="mt-ask-acts"><button type="button" class="cx-btn quiet" data-mt="ask-check" data-sid="' + esc(sid) + '">Check pending question</button>' +
|
|
1734
|
+
'<button type="button" class="cx-btn quiet" data-mt="ask-resend" data-sid="' + esc(sid) + '"' + (!online() || a.running ? ' disabled' : '') + '>Retry same question</button></div>';
|
|
1735
|
+
}
|
|
1736
|
+
if (a.note) html += '<p class="mt-muted">' + esc(a.note) + '</p>';
|
|
1737
|
+
html += '<form class="mt-composer" data-mt-ask="' + esc(sid) + '"><textarea class="field mt-ask-input" rows="1" data-sid="' + esc(sid) + '" placeholder="Ask about this meeting" aria-label="Ask about this meeting" enterkeyhint="send"' + (conf ? '' : ' disabled') + '>' + esc(a.draft) + '</textarea>' +
|
|
1738
|
+
'<button type="submit" class="cx-btn primary"' + (blocked || !a.draft.trim() ? ' disabled' : '') + '>Ask</button></form>' +
|
|
1739
|
+
'<div class="mt-ask-more"><button type="button" class="mt-link" data-mt="ask-act" data-sid="' + esc(sid) + '" data-action="ask" data-q="' + esc(CHALLENGE_Q) + '"' + dis + '>Challenge an assumption</button>' +
|
|
1740
|
+
'<button type="button" class="mt-link" data-mt="ask-act" data-sid="' + esc(sid) + '" data-action="ask" data-q="' + esc(DECISION_Q) + '"' + dis + '>Find my next decision</button></div>' +
|
|
1741
|
+
modelPickerHtml(conf, sid);
|
|
1742
|
+
return html;
|
|
1743
|
+
}
|
|
1744
|
+
// The Mac's Model menu (Control's meeting Ask panel): every model the server lists, the unavailable ones shown but
|
|
1745
|
+
// not pickable. The choice is the server's one meeting-assistance setting, so it changes for the Mac too.
|
|
1746
|
+
function modelPickerHtml(conf, sid) {
|
|
1747
|
+
if (!conf || !conf.selected) return '';
|
|
1748
|
+
var sel = conf.selected, models = (conf.models || (conf.capabilities || {}).models || []).filter(function (m) { return m && m.engine && m.model; });
|
|
1749
|
+
var label = function (m) { return cap(m.engine) + ' · ' + m.model; };
|
|
1750
|
+
if (!models.length) return '<p class="mt-muted mt-model">Model: ' + esc(label(sel)) + '</p>';
|
|
1751
|
+
var key = function (m) { return m.engine + '\u0001' + m.model; }, cur = key(sel), seen = false;
|
|
1752
|
+
var opts = models.map(function (m) {
|
|
1753
|
+
var k = key(m), on = k === cur; if (on) seen = true;
|
|
1754
|
+
return '<option value="' + esc(k) + '"' + (on ? ' selected' : '') + (m.available === false && !on ? ' disabled' : '') + '>' + esc(label(m) + (m.available === false ? ' (not available)' : '')) + '</option>';
|
|
1755
|
+
});
|
|
1756
|
+
if (!seen) opts.unshift('<option value="' + esc(cur) + '" selected>' + esc(label(sel)) + '</option>');
|
|
1757
|
+
var busy = !!S.conf.modelBusy || !!(sid && A(sid).running);
|
|
1758
|
+
return '<label class="mt-model mt-model-pick"><span class="mt-muted">Model</span><select class="field mt-model-select" data-mt-model' + (busy ? ' disabled' : '') + '>' + opts.join('') + '</select></label>' +
|
|
1759
|
+
(S.conf.modelNote ? '<p class="mt-muted mt-amber">' + esc(S.conf.modelNote) + '</p>' : '');
|
|
1760
|
+
}
|
|
1761
|
+
function setModel(value) {
|
|
1762
|
+
var parts = String(value || '').split('\u0001'); if (parts.length !== 2) return;
|
|
1763
|
+
S.conf.modelBusy = true; S.conf.modelNote = ''; repaintAllAsk();
|
|
1764
|
+
api('/api/meeting-ask/config', { method: 'PUT', body: { engine: parts[0], model: parts[1] } }).then(function (c) {
|
|
1765
|
+
S.conf.modelBusy = false;
|
|
1766
|
+
if (c && c.selected) S.conf.config = c;
|
|
1767
|
+
repaintAllAsk();
|
|
1768
|
+
}, function (err) {
|
|
1769
|
+
S.conf.modelBusy = false;
|
|
1770
|
+
var why = { invalid_model: 'this server does not offer that model', model_unavailable: 'that model is not available on your Mac right now', maintenance: 'COS is updating, try again in a minute' }[err && (err.code || err.message)];
|
|
1771
|
+
if (!(err && err.status === 401)) S.conf.modelNote = 'Could not change the model: ' + (why || (err && err.message) || 'the server did not answer') + '.';
|
|
1772
|
+
repaintAllAsk();
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
function repaintAllAsk() {
|
|
1776
|
+
pane.querySelectorAll('[id^="mt-ask-"]').forEach(function (el) { var sid = el.getAttribute('data-sid'); if (sid) repaintAsk(sid, true); });
|
|
1777
|
+
}
|
|
1778
|
+
function cuesHtml(sid) {
|
|
1779
|
+
var a = A(sid), cues = (a.cues || []).slice().sort(function (x, y) { return num(y.createdAt) - num(x.createdAt); });
|
|
1780
|
+
var count = a.cuesCount || { cues: cues.length, cap: null };
|
|
1781
|
+
var html = '<div class="mt-ask-head"><span class="mt-muted">' + esc(count.cap != null ? count.cues + ' of ' + count.cap + ' used' : count.cues + ' used') + '</span>' +
|
|
1782
|
+
'<button type="button" class="mt-iconbtn" data-mt="cues-refresh" data-sid="' + esc(sid) + '" title="Check again" aria-label="Check again">' + icon('refresh') + '</button></div>';
|
|
1783
|
+
if (a.cuesState === 'failed') html += '<p class="mt-amber-line">' + esc(a.cuesStatus ? 'The server could not list cues (HTTP ' + a.cuesStatus + '). Check again.' : 'COS server unreachable. Cues show here once it answers.') + '</p>';
|
|
1784
|
+
if (!cues.length) html += '<p class="mt-muted">' + (a.ctx.live ? 'No cues yet in this meeting.' : 'No cues were shown in this meeting.') + '</p>';
|
|
1785
|
+
html += cues.map(function (c) {
|
|
1786
|
+
var line = [clockOf(new Date(num(c.createdAt))), KIND[c.kind] || c.kind].concat(c.gate != null ? [num(c.gate).toFixed(2)] : []).join(' · ');
|
|
1787
|
+
var rated = cueRated(c.rating), vote = cueVote(c.rating);
|
|
1788
|
+
return '<div class="mt-cue"><p class="mt-cue-line">' + esc(line) + '</p>' +
|
|
1789
|
+
(c.headline && c.headline !== c.detail ? '<p class="mt-cue-head">' + esc(c.headline) + '</p>' : '') +
|
|
1790
|
+
'<p class="mt-cue-detail">' + esc(c.detail || '') + '</p><p class="mt-muted">' + esc(cueSources(c)) + '</p>' +
|
|
1791
|
+
(rated ? '<p class="mt-muted">' + esc(rated) + '</p>' : '') + (vote ? '<p class="mt-muted">' + esc(vote) + '</p>' : '') + '</div>';
|
|
1792
|
+
}).join('');
|
|
1793
|
+
return html;
|
|
1794
|
+
}
|
|
1795
|
+
var FILE_STATE = { pending: 'Reading', unsupported: 'No readable text', failed: 'Could not be read', 'secret-held': 'Held: it appears to contain credentials' };
|
|
1796
|
+
function filesRowHtml(sid) {
|
|
1797
|
+
var a = A(sid), shown = (a.files || []).filter(function (x) { return x && x.status !== 'removed' && String(x.name || '').trim(); });
|
|
1798
|
+
if (!shown.length) return '';
|
|
1799
|
+
var title = 'Files \u00b7 ' + shown.length;
|
|
1800
|
+
var html = '<div class="mt-files"><button type="button" class="mt-files-head" data-mt="files-toggle" data-sid="' + esc(sid) + '" aria-expanded="' + !!a.filesOpen + '">' +
|
|
1801
|
+
icon('clip') + '<span>' + esc(title) + '</span>' + icon('chev') + '</button>';
|
|
1802
|
+
if (a.filesOpen) {
|
|
1803
|
+
html += '<ul class="mt-files-list">' + shown.map(function (x) {
|
|
1804
|
+
var note = [x.status !== 'ready' ? (x.notice || FILE_STATE[x.status] || '') : '', x.origin === 'phone' ? 'Added from the phone' : ''].filter(Boolean).join(' \u00b7 ');
|
|
1805
|
+
return '<li><span class="mt-files-name">' + esc(x.name) + '</span>' + (note ? '<span class="mt-muted">' + esc(note) + '</span>' : '') + '</li>';
|
|
1806
|
+
}).join('') + '</ul><p class="mt-muted mt-files-note">Meeting Ask reads these files. Add or remove them in COS Control on your Mac.</p>';
|
|
1807
|
+
}
|
|
1808
|
+
return html + '</div>';
|
|
1809
|
+
}
|
|
1810
|
+
function sidePanelHtml(sid) {
|
|
1811
|
+
var a = A(sid), offered = cuesOffered(sid), view = offered ? a.view : 'ask';
|
|
1812
|
+
var sw = offered ? '<div class="cx-switch mt-pswitch" role="group" aria-label="Meeting panel">' +
|
|
1813
|
+
'<button type="button" data-mt="panel" data-view="ask" data-sid="' + esc(sid) + '" aria-pressed="' + (view === 'ask') + '">' + (a.ctx.live ? 'Live COS Ask' : 'Meeting Ask') + '</button>' +
|
|
1814
|
+
'<button type="button" data-mt="panel" data-view="cues" data-sid="' + esc(sid) + '" aria-pressed="' + (view === 'cues') + '">Cues ' + ((a.cues || []).length) + '</button></div>' : '';
|
|
1815
|
+
return '<section class="mt-panel">' + sw + (view === 'cues' ? cuesHtml(sid) : askHtml(sid)) + '</section>';
|
|
1816
|
+
}
|
|
1817
|
+
// Repaint one panel in place, keeping the caret in its box and which disclosures are open.
|
|
1818
|
+
function repaintAsk(sid, force) {
|
|
1819
|
+
var el = $('mt-ask-' + safeId(sid)); if (!el) return;
|
|
1820
|
+
// An open Model menu is never rebuilt under the finger (it would close the iOS picker); it repaints on blur.
|
|
1821
|
+
if (!force && document.activeElement && document.activeElement.hasAttribute && document.activeElement.hasAttribute('data-mt-model') && el.contains(document.activeElement)) { A(sid).panelDirty = true; return; }
|
|
1822
|
+
A(sid).panelDirty = false;
|
|
1823
|
+
var act = document.activeElement, inBox = act && el.contains(act) && act.tagName === 'TEXTAREA' ? act.className : null;
|
|
1824
|
+
var caret = inBox ? [act.selectionStart, act.selectionEnd] : null;
|
|
1825
|
+
var open = {}; el.querySelectorAll('details[open][data-k]').forEach(function (d) { open[d.getAttribute('data-k')] = 1; });
|
|
1826
|
+
el.innerHTML = sidePanelHtml(sid);
|
|
1827
|
+
el.querySelectorAll('details[data-k]').forEach(function (d) { if (open[d.getAttribute('data-k')]) d.open = true; });
|
|
1828
|
+
if (inBox) {
|
|
1829
|
+
var box = el.querySelector('textarea.' + inBox.split(' ').filter(Boolean).join('.'));
|
|
1830
|
+
if (box) { box.focus(); try { box.setSelectionRange(caret[0], caret[1]); } catch (_e) {} grow(box); }
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
function grow(ta) { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'; }
|
|
1834
|
+
|
|
1835
|
+
// ── live transcript view (LiveTranscriptPane) ─────────
|
|
1836
|
+
function displaySpeaker(raw) {
|
|
1837
|
+
var name = String(raw || '').trim();
|
|
1838
|
+
return ['', 'unknown', 'speaker', 'unidentified', '?', 'none', 'null'].indexOf(name.toLowerCase()) >= 0 ? 'Speaker' : name;
|
|
1839
|
+
}
|
|
1840
|
+
function sameChunk(a, b) { return !!a && !!b && a.i === b.i && a.elapsedMs === b.elapsedMs && a.speaker === b.speaker && a.text === b.text; }
|
|
1841
|
+
// LiveTranscriptFeed.build: index order, same-speaker chunks merged into 30 s / 600 character sections, stable ids.
|
|
1842
|
+
function buildTurns(f) {
|
|
1843
|
+
var runs = [];
|
|
1844
|
+
Object.keys(f.chunks).map(Number).sort(function (x, y) { return x - y; }).forEach(function (i) {
|
|
1845
|
+
var c = f.chunks[i], text = String(c.text || '').trim(); if (!text) return;
|
|
1846
|
+
var sp = displaySpeaker(c.speaker), last = runs[runs.length - 1];
|
|
1847
|
+
if (last && last.speaker === sp && c.elapsedMs >= last.members[0].elapsedMs && c.elapsedMs - last.members[0].elapsedMs < 30000 && last.chars + 1 + text.length <= 600) {
|
|
1848
|
+
last.members.push(c); last.chars += 1 + text.length;
|
|
1849
|
+
} else runs.push({ speaker: sp, members: [c], chars: text.length });
|
|
1850
|
+
});
|
|
1851
|
+
var used = {}, turns = [], map = {};
|
|
1852
|
+
runs.forEach(function (run) {
|
|
1853
|
+
var first = run.members[0], id = null;
|
|
1854
|
+
for (var k = 0; k < run.members.length; k++) { var p = f.turnOf[run.members[k].i]; if (p != null && !used[p]) { id = p; break; } }
|
|
1855
|
+
if (id == null) id = first.i;
|
|
1856
|
+
if (used[id]) { id = -(first.i + 1); while (used[id]) id--; }
|
|
1857
|
+
used[id] = 1;
|
|
1858
|
+
turns.push({ id: id, speaker: run.speaker, elapsedMs: num(first.elapsedMs), text: MD.paragraphs(run.members.map(function (m) { return String(m.text).trim(); }).join(' ')) });
|
|
1859
|
+
run.members.forEach(function (m) { map[m.i] = id; });
|
|
1860
|
+
});
|
|
1861
|
+
f.turns = turns; f.turnOf = map;
|
|
1862
|
+
}
|
|
1863
|
+
// LiveTranscriptFeed.apply: true when anything the pane draws changed.
|
|
1864
|
+
function applyRead(f, r, now) {
|
|
1865
|
+
if (!r || r.sessionId !== f.sid) return false;
|
|
1866
|
+
if (r.fullSnapshot === true && (r.speakerRevision == null || !r.turns)) return false;
|
|
1867
|
+
if (r.speakerRevision != null) {
|
|
1868
|
+
var rev = num(r.speakerRevision);
|
|
1869
|
+
if (!(rev >= 0 && rev < 1e7) || (f.rev != null && rev < f.rev)) return false;
|
|
1870
|
+
if (rev !== f.rev && r.fullSnapshot !== true) return false;
|
|
1871
|
+
} else if (f.rev != null && !r.ended && !r.unchanged) return false;
|
|
1872
|
+
if (r.ended) return markEnded(f, now);
|
|
1873
|
+
if (f.leftover) return false;
|
|
1874
|
+
var changed = false;
|
|
1875
|
+
if (f.endedAt) { f.endedAt = null; f.phase = 'live'; f.serverState = null; changed = true; }
|
|
1876
|
+
if (r.unchanged) return changed;
|
|
1877
|
+
f.stamp = r.stamp || f.stamp;
|
|
1878
|
+
var steer = cleanTitle(r.titleSteer || '') || null;
|
|
1879
|
+
if (steer !== f.titleSteer) { f.titleSteer = steer; changed = true; }
|
|
1880
|
+
if (r.startTime != null && r.startTime !== f.start) { f.start = num(r.startTime); changed = true; }
|
|
1881
|
+
if (r.lastActivityAt != null && r.lastActivityAt !== f.last) { f.last = num(r.lastActivityAt); changed = true; }
|
|
1882
|
+
if (r.settledThrough != null) f.cursor = Math.max(f.cursor, num(r.settledThrough));
|
|
1883
|
+
var dirty = false;
|
|
1884
|
+
if (r.fullSnapshot === true) {
|
|
1885
|
+
var rep = {};
|
|
1886
|
+
(r.turns || []).forEach(function (c) { if (c.i >= 0) rep[c.i] = c; });
|
|
1887
|
+
var a = Object.keys(rep), b = Object.keys(f.chunks);
|
|
1888
|
+
dirty = a.length !== b.length || a.some(function (k) { return !sameChunk(rep[k], f.chunks[k]); });
|
|
1889
|
+
f.chunks = rep;
|
|
1890
|
+
}
|
|
1891
|
+
if (r.speakerRevision != null) f.rev = num(r.speakerRevision);
|
|
1892
|
+
(r.turns || []).forEach(function (c) { if (c.i >= 0 && !sameChunk(f.chunks[c.i], c)) { f.chunks[c.i] = c; dirty = true; } });
|
|
1893
|
+
if (dirty) { buildTurns(f); changed = true; }
|
|
1894
|
+
return changed;
|
|
1895
|
+
}
|
|
1896
|
+
function liveCopyText(f) {
|
|
1897
|
+
var header = 'COS preliminary transcript';
|
|
1898
|
+
if (f.start) { var d = new Date(f.start); header += ' · ' + dayKey(d) + ' ' + clockOf(d); }
|
|
1899
|
+
var hours = f.turns.some(function (t) { return t.elapsedMs >= 3600000; });
|
|
1900
|
+
return [header].concat(f.turns.map(function (t) { return '[' + stampMs(t.elapsedMs, hours) + '] ' + t.speaker + ': ' + t.text; })).join('\n');
|
|
1901
|
+
}
|
|
1902
|
+
function openLive(sid) {
|
|
1903
|
+
S.view = 'live';
|
|
1904
|
+
if (S.liveSid !== sid) { S.liveSid = sid; S.follow = true; S.confirm = ''; S.liveCopy = ''; S.liveReason = ''; }
|
|
1905
|
+
var f = feedFor(sid);
|
|
1906
|
+
A(sid).ctx = { live: true };
|
|
1907
|
+
W.setCrumb(liveTitle(f));
|
|
1908
|
+
pane.innerHTML = '<div class="cx mt mt-livev" data-tint="meetings"><div id="mt-lhead"></div><div class="mt-lgrid"><div class="mt-lmain">' +
|
|
1909
|
+
'<div id="mt-turns" class="mt-turns" aria-live="polite"></div><div id="mt-lfoot"></div></div>' +
|
|
1910
|
+
'<aside class="mt-side-panel" id="mt-ask-' + safeId(sid) + '" data-sid="' + esc(sid) + '">' + sidePanelHtml(sid) + '</aside></div></div>';
|
|
1911
|
+
S.headHtml = S.footHtml = null;
|
|
1912
|
+
paintLiveHead(); paintTurns(true); paintLiveFoot();
|
|
1913
|
+
clearInterval(S.liveTimer);
|
|
1914
|
+
livePoll();
|
|
1915
|
+
S.liveTimer = setInterval(livePoll, 1500);
|
|
1916
|
+
panelOpen(sid);
|
|
1917
|
+
}
|
|
1918
|
+
// Control's LiveMeeting.title: the typed name, else a calendar event that started within CALENDAR_MATCH_MS of the
|
|
1919
|
+
// recording (the nearest, up to 160 characters), else the source label. The events come from the server's own match
|
|
1920
|
+
// (GET meeting-ask/context `candidates`: from 5 minutes before an event's start to its end), so a name shows only when
|
|
1921
|
+
// both rules agree; an event starting 5 to 20 minutes after the recording shows on the Mac and not here.
|
|
1922
|
+
var CALENDAR_MATCH_MS = 20 * 60000;
|
|
1923
|
+
function calendarTitle(f) {
|
|
1924
|
+
if (!f.start || !f.calendar || !f.calendar.length) return '';
|
|
1925
|
+
var best = '', gap = Infinity;
|
|
1926
|
+
f.calendar.forEach(function (c) {
|
|
1927
|
+
var name = cleanTitle(c && c.title), d = Math.abs(Date.parse(c && c.start) - f.start);
|
|
1928
|
+
if (name && d <= CALENDAR_MATCH_MS && d < gap) { best = name; gap = d; }
|
|
1929
|
+
});
|
|
1930
|
+
return Array.from(best).slice(0, 160).join('');
|
|
1931
|
+
}
|
|
1932
|
+
function liveTitle(f) { return f.titleSteer || calendarTitle(f) || sourceLabel(f.sid, f.start); }
|
|
1933
|
+
function livePoll() {
|
|
1934
|
+
if (document.hidden || S.livePolling || S.view !== 'live') return;
|
|
1935
|
+
var f = S.feeds[S.liveSid]; if (!f) return;
|
|
1936
|
+
var now = Date.now();
|
|
1937
|
+
refreshAskHistory(f.sid);
|
|
1938
|
+
if (f.endedAt) {
|
|
1939
|
+
handoff(f, now);
|
|
1940
|
+
if (now - f.statusAt >= STATUS_ENDED_MS && f.phase !== 'saved') askStatus(f, now);
|
|
1941
|
+
if (f.phase === 'saved') findSaved(f);
|
|
1942
|
+
paintLiveHead();
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
S.livePolling = true;
|
|
1946
|
+
answer('/api/meetings/live-chunks?sessionId=' + encodeURIComponent(f.sid) + '&since=' + f.cursor + (f.stamp ? '&stamp=' + encodeURIComponent(f.stamp) : '') + (f.rev != null ? '&speakerRevision=' + f.rev : '')).then(function (a) {
|
|
1947
|
+
S.livePolling = false;
|
|
1948
|
+
if (S.view !== 'live' || S.liveSid !== f.sid || a.status === 401) return;
|
|
1949
|
+
if (a.status !== 200) { S.liveReason = a.status === 0 ? 'server_stopped' : (typeof a.body.reason === 'string' && /^[a-z0-9_]{1,40}$/.test(a.body.reason) ? a.body.reason : 'unavailable'); paintLiveFoot(); return; }
|
|
1950
|
+
S.liveReason = '';
|
|
1951
|
+
var t = Date.now(), files = a.body.sessions || [];
|
|
1952
|
+
S.files = files;
|
|
1953
|
+
var file = files.filter(function (s) { return s.sessionId === f.sid; })[0];
|
|
1954
|
+
var changed = applyRead(f, a.body.read, t);
|
|
1955
|
+
if (file) { var m = num(file.mtimeMs); if (!f.last || m > f.last) f.last = m; }
|
|
1956
|
+
else if (!f.endedAt) changed = markEnded(f, t) || changed;
|
|
1957
|
+
if (!f.endedAt && f.last && t - f.last >= QUIET_MS && t - f.statusAt >= STATUS_QUIET_MS) askStatus(f, t);
|
|
1958
|
+
paintLiveHead(); paintLiveFoot();
|
|
1959
|
+
if (changed) paintTurns(false);
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
// Once saved, the saved meeting opens here (found by its session on the start day or today).
|
|
1963
|
+
function findSaved(f) {
|
|
1964
|
+
var now = Date.now();
|
|
1965
|
+
if (f.lookupBusy || now - (f.lookupAt || 0) < 5000) return;
|
|
1966
|
+
f.lookupBusy = true; f.lookupAt = now;
|
|
1967
|
+
var days = [];
|
|
1968
|
+
[f.start, now].forEach(function (v) { if (v) { var k = dayKey(new Date(v)); if (days.indexOf(k) < 0) days.push(k); } });
|
|
1969
|
+
Promise.all(days.map(function (day) { return api('/api/meetings?limit=200&domain=all&day=' + day).then(function (d) { return (d && d.meetings) || []; }, function () { return []; }); })).then(function (lists) {
|
|
1970
|
+
f.lookupBusy = false;
|
|
1971
|
+
var hit = lists.reduce(function (all, l) { return all.concat(l); }, []).filter(function (m) { return m.sessionId === f.sid || (m.g2SessionIds || []).indexOf(f.sid) >= 0; })[0];
|
|
1972
|
+
if (hit && S.view === 'live' && S.liveSid === f.sid) {
|
|
1973
|
+
S.rowsStale = true;
|
|
1974
|
+
location.replace('#/meetings/' + [hit.domain, hit.month, hit.filename].map(encodeURIComponent).join('/'));
|
|
1975
|
+
} else if (!hit && f.endedAt && now - (f.retryAt || f.endedAt) >= FINAL_DEADLINE_MS + (f.start ? Math.max(0, f.endedAt - f.start) / 12 : 0)) {
|
|
1976
|
+
f.notFound = true; paintLiveHead();
|
|
1977
|
+
}
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
function liveHandoffLine(f) {
|
|
1981
|
+
if (!f.endedAt) return '';
|
|
1982
|
+
if (f.phase === 'saved' && !f.notFound || f.phase === 'finalizing') return '<p class="mt-muted mt-handoff">Finalizing · the saved meeting opens here when it is ready</p>';
|
|
1983
|
+
if (f.phase === 'notFoundYet' || f.notFound) return '<p class="mt-handoff"><span class="mt-amber">Saved meeting not found yet</span> <button type="button" class="cx-btn quiet" data-mt="handoff-refresh" data-sid="' + esc(f.sid) + '">Refresh</button></p>';
|
|
1984
|
+
if (f.phase === 'endedUnsaved') return '<p class="mt-handoff mt-amber"><strong>Recording ended without saving</strong></p>';
|
|
1985
|
+
return '<p class="mt-muted mt-handoff">Recording ended</p>';
|
|
1986
|
+
}
|
|
1987
|
+
function paintLiveHead() {
|
|
1988
|
+
var el = $('mt-lhead'); if (!el || S.view !== 'live') return;
|
|
1989
|
+
var f = S.feeds[S.liveSid]; if (!f) return;
|
|
1990
|
+
var now = Date.now(), ended = !!f.endedAt, sid = f.sid;
|
|
1991
|
+
var status = isPhone(sid) ? (ended ? 'iPhone microphone recording ended' : 'Recording on iPhone microphone')
|
|
1992
|
+
: (ended ? '' : (isMac(sid) ? 'Recording on this Mac' : 'Recording on the glasses') + (f.titleSteer && f.start ? ' · started ' + clockOf(new Date(f.start)) : ''));
|
|
1993
|
+
var quiet = ended ? null : quietLabel(f.last, now);
|
|
1994
|
+
var sub = f.titleSteer && f.start && !isPhone(sid) ? (durationLabel(f.start, now, f.endedAt) || 'Starting') : liveSubtitle(f.start, now, f.endedAt);
|
|
1995
|
+
var controls = '';
|
|
1996
|
+
if (!ended && isGlasses(sid)) controls = '<button type="button" class="cx-btn quiet" data-mt="stop-ask" data-sid="' + esc(sid) + '"' + (stopping(sid) ? ' disabled' : '') + '>Stop and save</button>';
|
|
1997
|
+
else if (!ended && isPhone(sid)) controls = '<span class="mt-side-note">Stop on iPhone</span>';
|
|
1998
|
+
var line = stopLineFor(sid);
|
|
1999
|
+
var html = '<div class="mt-dhead mt-lhead"><div class="mt-ltitle"><span class="mt-livedot big' + (ended ? '' : ' on') + '"></span><h2 class="mt-dtitle">' + esc(liveTitle(f)) + '</h2></div>' +
|
|
2000
|
+
(status ? '<p class="mt-lstatus">' + esc(status) + '</p>' : '') +
|
|
2001
|
+
(isPhone(sid) ? '<p class="mt-muted">iPhone microphone recordings are anonymous. Names and voice training are unavailable.</p>' : '') +
|
|
2002
|
+
'<p class="mt-muted">' + esc(LIVE_HEADER) + '</p>' +
|
|
2003
|
+
(isMac(sid) ? '<p class="mt-muted">Mac playback · microphone off · voice numbers do not identify people</p>' : '') +
|
|
2004
|
+
'<p class="mt-lsub">' + esc(sub) + (quiet ? '<span class="mt-amber"> · ' + esc(quiet) + '</span>' : '') + '</p>' +
|
|
2005
|
+
liveHandoffLine(f) + filesRowHtml(sid) +
|
|
2006
|
+
(line ? '<p class="mt-stopline' + (S.stop && S.stop.nothing ? ' bad' : '') + '">' + esc(line) + '</p>' : '') +
|
|
2007
|
+
(controls ? '<div class="mt-bar">' + controls + '</div>' : '') +
|
|
2008
|
+
(S.confirm === 'stop:' + sid ? confirmCard(STOP_TITLE, STOP_LINE, 'stop-yes', 'Stop and save', 'Cancel', sid) : '') + '</div>';
|
|
2009
|
+
if (html !== S.headHtml) { S.headHtml = html; el.innerHTML = html; }
|
|
2010
|
+
if (W.crumb() !== liveTitle(f)) W.setCrumb(liveTitle(f));
|
|
2011
|
+
}
|
|
2012
|
+
function paintTurns(force) {
|
|
2013
|
+
var el = $('mt-turns'); if (!el) return;
|
|
2014
|
+
var sel = window.getSelection && window.getSelection();
|
|
2015
|
+
// A selection in the transcript holds it still, as Control does while you select.
|
|
2016
|
+
if (!force && sel && !sel.isCollapsed && sel.anchorNode && el.contains(sel.anchorNode)) { S.turnsDirty = true; return; }
|
|
2017
|
+
S.turnsDirty = false;
|
|
2018
|
+
var f = S.feeds[S.liveSid]; if (!f) return;
|
|
2019
|
+
var hours = f.turns.some(function (t) { return t.elapsedMs >= 3600000; });
|
|
2020
|
+
// Reading (not following): hold what is on screen still while turns above it are refined or new ones arrive.
|
|
2021
|
+
var anchor = S.follow ? null : turnsAnchor(el);
|
|
2022
|
+
if (!f.turns.length) {
|
|
2023
|
+
el.innerHTML = '<p class="mt-lempty">' + esc(f.endedAt ? 'No words were transcribed before the recording ended.' : 'Waiting for the first words. Text appears a few seconds after it is spoken.') + '</p>';
|
|
2024
|
+
} else {
|
|
2025
|
+
// Patch in place: only a turn whose words changed is rewritten, new turns are appended, so nothing else reflows.
|
|
2026
|
+
var empty = el.querySelector('.mt-lempty'); if (empty) empty.remove();
|
|
2027
|
+
var have = {}, want = {};
|
|
2028
|
+
Array.prototype.forEach.call(el.children, function (n) { var id = n.getAttribute('data-turn'); if (id != null) have[id] = n; });
|
|
2029
|
+
f.turns.forEach(function (t) { want[String(t.id)] = 1; });
|
|
2030
|
+
Object.keys(have).forEach(function (id) { if (!want[id]) have[id].remove(); });
|
|
2031
|
+
var prev = null;
|
|
2032
|
+
f.turns.forEach(function (t) {
|
|
2033
|
+
var id = String(t.id), html = turnInner(t, hours), node = have[id];
|
|
2034
|
+
if (!node) { node = document.createElement('div'); node.className = 'mt-lturn'; node.setAttribute('data-turn', id); }
|
|
2035
|
+
if (node.__cosHtml !== html) { node.innerHTML = html; node.__cosHtml = html; }
|
|
2036
|
+
var at = prev ? prev.nextSibling : el.firstChild;
|
|
2037
|
+
if (node !== at) el.insertBefore(node, at);
|
|
2038
|
+
prev = node;
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
if (S.follow) jumpToLive(); else { holdAnchor(anchor); paintLiveFoot(); }
|
|
2042
|
+
repaintAskEnabled(f.sid);
|
|
2043
|
+
}
|
|
2044
|
+
function turnInner(t, hours) {
|
|
2045
|
+
return '<div class="mt-lwho"><span class="mt-lname">' + esc(t.speaker) + '</span><span class="mt-lstamp">' + esc(stampMs(t.elapsedMs, hours)) + '</span></div>' +
|
|
2046
|
+
t.text.split(/\n{2,}/).map(function (p) { return '<p>' + esc(p) + '</p>'; }).join('');
|
|
2047
|
+
}
|
|
2048
|
+
// iOS Safari has no scroll anchoring. The anchor is the first turn still on screen; when every turn is above the
|
|
2049
|
+
// screen (reading Ask under the transcript) it is the transcript's bottom edge, so the panel below holds still.
|
|
2050
|
+
function turnsAnchor(el) {
|
|
2051
|
+
var kids = el.children;
|
|
2052
|
+
for (var i = 0; i < kids.length; i++) {
|
|
2053
|
+
var r = kids[i].getBoundingClientRect();
|
|
2054
|
+
if (r.bottom > 0) return r.top >= window.innerHeight ? null : { node: kids[i], top: r.top };
|
|
2055
|
+
}
|
|
2056
|
+
return kids.length ? { node: el, edge: true, top: el.getBoundingClientRect().bottom } : null;
|
|
2057
|
+
}
|
|
2058
|
+
function holdAnchor(a) {
|
|
2059
|
+
if (!a || !a.node.isConnected) return;
|
|
2060
|
+
var now = a.edge ? a.node.getBoundingClientRect().bottom : a.node.getBoundingClientRect().top;
|
|
2061
|
+
var dy = now - a.top;
|
|
2062
|
+
if (Math.abs(dy) >= 1) { S.autoScroll = Date.now(); window.scrollBy(0, dy); }
|
|
2063
|
+
}
|
|
2064
|
+
// A question needs words: the panel's buttons follow the transcript's first turn.
|
|
2065
|
+
function repaintAskEnabled(sid) {
|
|
2066
|
+
var a = A(sid), en = askEnabled(sid);
|
|
2067
|
+
if (a.lastEnabled !== en) { a.lastEnabled = en; repaintAsk(sid); }
|
|
2068
|
+
}
|
|
2069
|
+
function jumpToLive() {
|
|
2070
|
+
var el = $('mt-turns'), last = el && el.lastElementChild;
|
|
2071
|
+
if (last) { S.autoScroll = Date.now(); last.scrollIntoView({ block: 'end' }); }
|
|
2072
|
+
S.follow = true; paintLiveFoot();
|
|
2073
|
+
}
|
|
2074
|
+
function paintLiveFoot() {
|
|
2075
|
+
var el = $('mt-lfoot'); if (!el || S.view !== 'live') return;
|
|
2076
|
+
var f = S.feeds[S.liveSid], has = !!(f && f.turns.length);
|
|
2077
|
+
var html = '<div class="mt-bar"><button type="button" class="cx-btn primary" data-mt="live-copy"' + (has ? '' : ' disabled') + '>Copy transcript</button>' +
|
|
2078
|
+
'<button type="button" class="cx-btn quiet" data-mt="live-context">Copy full context</button>' +
|
|
2079
|
+
(!S.follow && has ? '<button type="button" class="cx-btn quiet" data-mt="jump">Jump to live</button>' : '') + '</div>' +
|
|
2080
|
+
(S.liveCopy ? '<p class="mt-copynote">' + esc(S.liveCopy) + '</p>' : S.liveReason ? '<p class="mt-copynote mt-amber">' + esc('Live transcript paused · ' + S.liveReason.replace(/_/g, ' ')) + '</p>' : '');
|
|
2081
|
+
if (html !== S.footHtml) { S.footHtml = html; el.innerHTML = html; }
|
|
2082
|
+
}
|
|
2083
|
+
function liveCopy(kind) {
|
|
2084
|
+
var f = S.feeds[S.liveSid]; if (!f) return;
|
|
2085
|
+
function done(text) { S.liveCopy = text; paintLiveFoot(); }
|
|
2086
|
+
if (kind === 'transcript') { W.copyText(liveCopyText(f)).then(function () { done('Copied transcript'); }, function (err) { done(err.message); }); return; }
|
|
2087
|
+
// Safari copies only inside the tap: copy what the panel has read, or read it and ask for a second tap.
|
|
2088
|
+
if (contextReady([f.sid])) {
|
|
2089
|
+
var a = A(f.sid);
|
|
2090
|
+
var cueNote = a.cues ? null : (a.cuesState && a.cuesState !== 'older' ? 'Context coverage: cues could not be read from the server.' : null);
|
|
2091
|
+
var text = fullContext('Live meeting', f.turns.length ? liveCopyText(f) : '', a.history, evidenceFiles(a.history, a.files || []), a.cues || [], cueNote) + (a.error ? '\n\nContext coverage: ' + a.error : '');
|
|
2092
|
+
W.copyText(text).then(function () { done('Copied full meeting context'); }, function (err) { done(err.message); });
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
done('Preparing full context…');
|
|
2096
|
+
loadConfig().then(function (conf) {
|
|
2097
|
+
if (!conf) { done('Refresh meeting assistance before copying its full context.'); return; }
|
|
2098
|
+
return askOpen(f.sid, true).then(function () { return loadCues(f.sid); }).then(function () { done('Ready. Tap Copy full context again.'); });
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
function onScroll() {
|
|
2102
|
+
if (S.view !== 'live' || Date.now() - (S.autoScroll || 0) < 600) return;
|
|
2103
|
+
var el = $('mt-turns'), last = el && el.lastElementChild;
|
|
2104
|
+
if (!last) return;
|
|
2105
|
+
// Following means the end of the transcript is on screen. Scrolled past it (reading Ask below) is not following:
|
|
2106
|
+
// a new chunk must not pull the reader back up.
|
|
2107
|
+
var b = last.getBoundingClientRect().bottom, atEnd = b <= window.innerHeight + 40 && b >= 0;
|
|
2108
|
+
if (atEnd !== S.follow) { S.follow = atEnd; paintLiveFoot(); if (atEnd && S.turnsDirty) paintTurns(false); }
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
// ── list shell ────────────────────────────────────────
|
|
2112
|
+
function renderList() {
|
|
2113
|
+
S.view = 'list';
|
|
2114
|
+
S.liveHtml = S.heroHtml = S.calHtml = S.listHtml = S.orphHtml = S.domHtml = null;
|
|
2115
|
+
pane.innerHTML = '<div class="cx mt" data-tint="meetings"><div id="mt-hero"></div><div id="mt-orphans"></div><div id="mt-live"></div>' +
|
|
2116
|
+
'<div id="mt-cal"></div><div id="mt-toolbar"></div><div id="mt-list"></div></div>';
|
|
2117
|
+
paintHero(); paintOrphans(); paintLive(); paintCal(); paintToolbar(); paintList();
|
|
2118
|
+
}
|
|
2119
|
+
function stopTimers() { clearInterval(S.listTimer); clearInterval(S.liveTimer); S.listTimer = null; S.liveTimer = null; }
|
|
2120
|
+
|
|
2121
|
+
// ── events ────────────────────────────────────────────
|
|
2122
|
+
function exchangeOf(sid, req) { return A(sid).history.filter(function (x) { return x.requestId === req; })[0]; }
|
|
2123
|
+
pane.addEventListener('click', function (e) {
|
|
2124
|
+
var t = e.target.closest('[data-mt]');
|
|
2125
|
+
if (!t || t.disabled || !pane.contains(t)) return;
|
|
2126
|
+
var act = t.getAttribute('data-mt'), sid = t.getAttribute('data-sid') || '', req = t.getAttribute('data-req') || '';
|
|
2127
|
+
var ex, a;
|
|
2128
|
+
switch (act) {
|
|
2129
|
+
case 'refresh': loadMonth(); ensureDay(true); loadOrphans(); pollFiles(); if (queryActive()) runSearch(); return;
|
|
2130
|
+
case 'month': shiftMonth(Number(t.getAttribute('data-delta')) || 0); return;
|
|
2131
|
+
case 'day': { var d = t.getAttribute('data-day'); S.day = S.day === d ? null : d; S.autoDay = true; paintBrowse(); ensureDay(); return; }
|
|
2132
|
+
case 'all-month': S.day = null; S.autoDay = true; paintBrowse(); return;
|
|
2133
|
+
case 'clear': {
|
|
2134
|
+
S.q = ''; S.hits = null; S.ranFor = ''; S.searching = false; S.searchError = ''; clearTimeout(S.qTimer);
|
|
2135
|
+
var q = $('mt-q'); if (q) { q.value = ''; q.focus(); }
|
|
2136
|
+
paintBrowse(); return;
|
|
2137
|
+
}
|
|
2138
|
+
case 'open': W.go('meetings', t.getAttribute('data-key').split('|')); return;
|
|
2139
|
+
case 'live': W.go('meetings', ['live', sid]); return;
|
|
2140
|
+
case 'stop-ask': S.confirm = 'stop:' + sid; S.liveHtml = null; liveChanged(); return;
|
|
2141
|
+
case 'end-ask': S.confirm = 'end:' + sid; S.liveHtml = null; liveChanged(); return;
|
|
2142
|
+
case 'confirm-no': S.confirm = ''; S.orphans.confirm = ''; S.liveHtml = null; liveChanged(); paintOrphans(); return;
|
|
2143
|
+
case 'stop-yes': stopGlasses(sid); return;
|
|
2144
|
+
case 'end-yes': endAndSave(sid); return;
|
|
2145
|
+
case 'handoff-refresh': {
|
|
2146
|
+
var f = S.feeds[sid]; if (!f) return;
|
|
2147
|
+
f.retryAt = Date.now(); f.notFound = false; f.lookupAt = 0; f.phase = 'finalizing'; f.statusAt = 0;
|
|
2148
|
+
askStatus(f, Date.now()); liveChanged(); return;
|
|
2149
|
+
}
|
|
2150
|
+
case 'recover': orphanTask(function () { return recoverOne(sid).then(function (r) { return r.text; }); }); return;
|
|
2151
|
+
case 'recover-all-ask': S.orphans.confirm = 'recover-all'; paintOrphans(); return;
|
|
2152
|
+
case 'recover-all-yes': orphanTask(recoverAll); return;
|
|
2153
|
+
case 'save-stranded': orphanTask(function () { return saveOne(sid).then(function (r) { return r.text; }); }); return;
|
|
2154
|
+
case 'save-all-ask': S.orphans.confirm = 'save-all'; paintOrphans(); return;
|
|
2155
|
+
case 'save-all-yes': orphanTask(saveAllStranded); return;
|
|
2156
|
+
case 'detail-retry': { var r = routeArgs() || []; S.detailKey = ''; if (r.length >= 3) openDetail(r[0], r[1], r.slice(2).join('/')); return; }
|
|
2157
|
+
case 'copy-context': copyDetail('context'); return;
|
|
2158
|
+
case 'copy-summary': copyDetail('summary'); return;
|
|
2159
|
+
case 'copy-transcript': copyDetail('transcript'); return;
|
|
2160
|
+
case 'download': downloadDetail(); return;
|
|
2161
|
+
case 'review': W.go('speakers', ['review', sid]); return;
|
|
2162
|
+
case 'panel': {
|
|
2163
|
+
a = A(sid); a.view = t.getAttribute('data-view') === 'cues' ? 'cues' : 'ask'; repaintAsk(sid);
|
|
2164
|
+
if (a.view === 'cues') loadCues(sid, true);
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
2167
|
+
case 'ask-refresh': loadConfig(true).then(function () { panelOpen(sid); }); return;
|
|
2168
|
+
case 'files-toggle': a = A(sid); a.filesOpen = !a.filesOpen; if (S.view === 'live' && S.liveSid === sid) paintLiveHead(); else repaintAsk(sid); return;
|
|
2169
|
+
case 'ask-act': submit(sid, t.getAttribute('data-action'), t.getAttribute('data-q')); return;
|
|
2170
|
+
case 'ask-retry': ex = exchangeOf(sid, req); if (ex) submit(sid, ex.action, ex.question, ex.requestId); return;
|
|
2171
|
+
case 'ask-stop': cancelAnswer(sid, req); return;
|
|
2172
|
+
case 'ask-copy': ex = exchangeOf(sid, req); if (ex) W.copyText(ex.answer != null ? ex.answer : ex.question).then(function () { W.toast('Copied'); }, function (err) { W.toast(err.message); }); return;
|
|
2173
|
+
case 'ask-check': reconcile(sid); return;
|
|
2174
|
+
case 'ask-resend': a = A(sid); if (a.outbox && online() && !a.running) { a.error = ''; send(sid); } return;
|
|
2175
|
+
case 'follow-open': {
|
|
2176
|
+
ex = exchangeOf(sid, req); if (!ex) return;
|
|
2177
|
+
a = A(sid);
|
|
2178
|
+
var sw = (ex.sections || []).filter(function (s) { return s.label === 'So what'; })[0];
|
|
2179
|
+
a.follow = ex.requestId; a.followText = String((sw && sw.text) || ex.answer || '').replace(/\[[A-Za-z][0-9]+\]/g, '').slice(0, 600);
|
|
2180
|
+
repaintAsk(sid); return;
|
|
2181
|
+
}
|
|
2182
|
+
case 'follow-cancel': A(sid).follow = null; repaintAsk(sid); return;
|
|
2183
|
+
case 'follow-save': saveFollowUp(sid); return;
|
|
2184
|
+
case 'cues-refresh': loadCues(sid, true); return;
|
|
2185
|
+
case 'live-copy': liveCopy('transcript'); return;
|
|
2186
|
+
case 'live-context': liveCopy('context'); return;
|
|
2187
|
+
case 'jump': jumpToLive(); return;
|
|
2188
|
+
}
|
|
2189
|
+
});
|
|
2190
|
+
pane.addEventListener('input', function (e) {
|
|
2191
|
+
var t = e.target, sid;
|
|
2192
|
+
if (t.id === 'mt-q') {
|
|
2193
|
+
var was = queryActive();
|
|
2194
|
+
S.q = t.value;
|
|
2195
|
+
clearTimeout(S.qTimer);
|
|
2196
|
+
if (!queryActive()) { S.hits = null; S.ranFor = ''; S.searching = false; S.searchError = ''; paintBrowse(); return; }
|
|
2197
|
+
if (!was) paintBrowse(); else paintToolbarBits();
|
|
2198
|
+
S.qTimer = setTimeout(runSearch, 350);
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
if (t.classList.contains('mt-ask-input')) {
|
|
2202
|
+
sid = t.getAttribute('data-sid'); setDraft(sid, t.value); grow(t);
|
|
2203
|
+
var btn = t.form && t.form.querySelector('button[type="submit"]');
|
|
2204
|
+
if (btn) btn.disabled = askBlocked(sid) || !t.value.trim();
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
if (t.classList.contains('mt-follow-text')) {
|
|
2208
|
+
sid = t.getAttribute('data-sid'); var a = A(sid); a.followText = t.value;
|
|
2209
|
+
var save = t.parentNode.querySelector('[data-mt="follow-save"]');
|
|
2210
|
+
if (save) save.disabled = a.followBusy || !t.value.trim() || t.value.trim().length > 600 || !online();
|
|
2211
|
+
}
|
|
2212
|
+
});
|
|
2213
|
+
pane.addEventListener('focusout', function (e) {
|
|
2214
|
+
if (!(e.target.hasAttribute && e.target.hasAttribute('data-mt-model'))) return;
|
|
2215
|
+
var panel = e.target.closest('.mt-side-panel'), sid = panel && panel.getAttribute('data-sid');
|
|
2216
|
+
if (sid && A(sid).panelDirty) setTimeout(function () { repaintAsk(sid); }, 0);
|
|
2217
|
+
});
|
|
2218
|
+
pane.addEventListener('change', function (e) {
|
|
2219
|
+
if (e.target.id === 'mt-recency') { S.recency = e.target.value; paintBrowse(); }
|
|
2220
|
+
else if (e.target.id === 'mt-dom') { S.domain = e.target.value; if (queryActive()) runSearch(); else paintBrowse(); }
|
|
2221
|
+
else if (e.target.hasAttribute && e.target.hasAttribute('data-mt-model')) setModel(e.target.value);
|
|
2222
|
+
});
|
|
2223
|
+
pane.addEventListener('submit', function (e) {
|
|
2224
|
+
var f = e.target;
|
|
2225
|
+
if (f.matches('[data-mt-form]')) { e.preventDefault(); clearTimeout(S.qTimer); var q = $('mt-q'); if (q) q.blur(); runSearch(); return; }
|
|
2226
|
+
if (f.hasAttribute('data-mt-ask')) { e.preventDefault(); var sid = f.getAttribute('data-mt-ask'); submit(sid, 'ask', A(sid).draft); }
|
|
2227
|
+
});
|
|
2228
|
+
// Return sends the question (Control's composer); Shift-Return is a new line.
|
|
2229
|
+
pane.addEventListener('keydown', function (e) {
|
|
2230
|
+
var t = e.target;
|
|
2231
|
+
if (t.classList && t.classList.contains('mt-ask-input') && e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
2232
|
+
e.preventDefault();
|
|
2233
|
+
var sid = t.getAttribute('data-sid'); submit(sid, 'ask', A(sid).draft);
|
|
2234
|
+
}
|
|
2235
|
+
});
|
|
2236
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
2237
|
+
document.addEventListener('visibilitychange', function () {
|
|
2238
|
+
if (document.hidden) return;
|
|
2239
|
+
if (S.out) return;
|
|
2240
|
+
refreshShownAsk();
|
|
2241
|
+
if (S.view === 'list') { pollFiles(); loadOrphans(); } else if (S.view === 'live') livePoll();
|
|
2242
|
+
});
|
|
2243
|
+
window.addEventListener('focus', refreshShownAsk);
|
|
2244
|
+
|
|
2245
|
+
W.registerPane('meetings', {
|
|
2246
|
+
load: function (args) {
|
|
2247
|
+
args = args || [];
|
|
2248
|
+
S.out = false;
|
|
2249
|
+
stopTimers();
|
|
2250
|
+
if (S.stop && STOP_ACTIVE[S.stop.phase.k] && S.stopResume && !S.stopTimer && (args[0] === 'live' || !args.length)) S.stopResume();
|
|
2251
|
+
if (args[0] === 'live' && args[1]) { openLive(args[1]); return; }
|
|
2252
|
+
if (args.length >= 3) { openDetail(args[0], args[1], args.slice(2).join('/')); return; }
|
|
2253
|
+
renderList();
|
|
2254
|
+
if (!S.rows && !S.loading) loadMonth();
|
|
2255
|
+
else if (S.rowsStale && !S.loading) { S.rowsStale = false; loadMonth(true); }
|
|
2256
|
+
loadOrphans();
|
|
2257
|
+
loadInbox();
|
|
2258
|
+
pollFiles();
|
|
2259
|
+
S.listTimer = setInterval(function () { if (S.view === 'list') pollFiles(); }, 3000);
|
|
2260
|
+
if (queryActive() && S.ranFor !== S.q.trim() && !S.searching) runSearch();
|
|
2261
|
+
},
|
|
2262
|
+
leave: function () { stopTimers(); clearTimeout(S.stopTimer); S.stopTimer = null; S.view = ''; }
|
|
2263
|
+
});
|
|
2264
|
+
})();
|