@gotcos/glasses-server 6.99.12 → 6.99.20
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 +124 -0
- package/package.json +1 -1
- package/server/index.ts +44 -2
- package/server/lib/ask-work-plan.ts +116 -0
- package/server/lib/batch-transcript-quality.ts +2 -0
- package/server/lib/cos-thread.ts +582 -0
- package/server/lib/drop-attachments.ts +55 -0
- package/server/lib/drop-service.ts +104 -13
- package/server/lib/drop-settings.ts +13 -2
- package/server/lib/drop-store.ts +38 -2
- package/server/lib/media-store.ts +80 -0
- package/server/lib/meeting-ask-runtime.ts +1 -1
- package/server/lib/meeting-batch-transcribe.ts +54 -2
- package/server/lib/permission-broker.ts +6 -4
- package/server/lib/phone-recording.ts +1 -1
- package/server/lib/ping-sources.ts +6 -2
- package/server/lib/prep-runtime.ts +915 -0
- package/server/lib/prep-store.ts +226 -0
- package/server/lib/stt-resolver.ts +26 -2
- package/server/lib/transcript-cleanup.ts +11 -2
- package/server/lib/work-orchestrator/runtime.ts +8 -2
- package/server/routes/ask-work-plan.ts +114 -0
- package/server/routes/cos-thread.ts +79 -0
- package/server/routes/drops.ts +4 -2
- package/server/routes/health.ts +8 -0
- package/server/routes/permission-broker.ts +2 -2
- package/server/routes/prep.ts +81 -0
- package/server/routes/transcribe-stream.ts +9 -1
- package/server/routes/welcome-context.ts +29 -1
- package/server/routes/work-orchestrator.ts +57 -0
- package/server/scripts/web-live-cls/cdp.mjs +57 -0
- package/server/scripts/web-live-cls/measure.mjs +80 -0
- package/server/scripts/web-live-cls/mock.mjs +97 -0
- package/server/scripts/web-live-cls/render.mjs +26 -0
- package/server/web-control/control.css +13 -0
- package/server/web-control/panes/cos-chat.css +39 -0
- package/server/web-control/panes/cos-chat.js +799 -41
- package/server/web-control/panes/meetings.css +91 -4
- package/server/web-control/panes/meetings.js +585 -58
- package/server/web-control/panes/messages.js +25 -18
- package/shared/meeting-ask.ts +6 -2
- package/shared/prep.ts +317 -0
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
Data: GET /api/meetings?limit=200&domain=all&month=, /api/meetings/search, /api/meetings/detail,
|
|
6
6
|
/api/meetings/live-chunks, /api/meeting/sessions/:id/status, /api/meeting/orphans, /api/meeting-ask/*,
|
|
7
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.
|
|
8
|
+
Recover, Save, Save as follow-up, and (6.99.14) Add file, paste or drop in the live view: /api/media or
|
|
9
|
+
/api/media/file, then POST /api/meeting-ask/files/media.
|
|
9
10
|
Routes: #/meetings, #/meetings/<domain>/<month>/<filename>, #/meetings/live/<sessionId>. */
|
|
10
11
|
(function () {
|
|
11
12
|
'use strict';
|
|
@@ -721,6 +722,7 @@
|
|
|
721
722
|
var o = opts || {};
|
|
722
723
|
var headers = { 'X-COS-Web': '1' };
|
|
723
724
|
if (o.body !== undefined) headers['Content-Type'] = 'application/json';
|
|
725
|
+
if (o.headers) Object.keys(o.headers).forEach(function (k) { headers[k] = o.headers[k]; });
|
|
724
726
|
// 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
727
|
var method = o.method || 'GET', ctrl = method === 'GET' && typeof AbortController === 'function' ? new AbortController() : null;
|
|
726
728
|
var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, 20000) : null;
|
|
@@ -812,6 +814,7 @@
|
|
|
812
814
|
if (r.startTime != null) f.start = r.startTime;
|
|
813
815
|
if (r.lastActivityAt != null) f.last = Math.max(f.last || 0, r.lastActivityAt);
|
|
814
816
|
if (typeof r.titleSteer === 'string') f.titleSteer = cleanTitle(r.titleSteer) || null;
|
|
817
|
+
setLevels(f, r.levels);
|
|
815
818
|
liveChanged();
|
|
816
819
|
}, function () {});
|
|
817
820
|
}
|
|
@@ -1168,7 +1171,7 @@
|
|
|
1168
1171
|
if (S.detailRow) W.setCrumb(S.detailRow.title || 'Meeting');
|
|
1169
1172
|
paintDetail();
|
|
1170
1173
|
if (!S.inbox) loadInbox();
|
|
1171
|
-
if (S.detail) { W.setCrumb(detailRow().title || 'Meeting'); detailSessions().forEach(function (sid) { panelOpen(sid); }); return; }
|
|
1174
|
+
if (S.detail) { W.setCrumb(detailRow().title || 'Meeting'); detailSessions().forEach(function (sid) { panelOpen(sid); }); loadDetailPrep(); return; }
|
|
1172
1175
|
var id = ++S.detailSeq;
|
|
1173
1176
|
S.detailLoading = true;
|
|
1174
1177
|
api('/api/meetings/detail?domain=' + encodeURIComponent(domain) + '&month=' + encodeURIComponent(month) + '&filename=' + encodeURIComponent(filename)).then(function (d) {
|
|
@@ -1178,6 +1181,7 @@
|
|
|
1178
1181
|
W.setCrumb(detailRow().title || 'Meeting');
|
|
1179
1182
|
paintDetail();
|
|
1180
1183
|
detailSessions().forEach(function (sid) { panelOpen(sid); });
|
|
1184
|
+
loadDetailPrep();
|
|
1181
1185
|
// A deep link has no list row; Control always opens one (its session, voices and captures). Read that day's list.
|
|
1182
1186
|
if (!S.detailRow && /^\d{4}-\d{2}-\d{2}$/.test(S.detail.date || '')) {
|
|
1183
1187
|
api('/api/meetings?limit=200&domain=all&day=' + S.detail.date).then(function (l) {
|
|
@@ -1187,6 +1191,7 @@
|
|
|
1187
1191
|
S.detailRow = hit;
|
|
1188
1192
|
paintDetail();
|
|
1189
1193
|
detailSessions().forEach(function (sid) { if (before.indexOf(sid) < 0) panelOpen(sid); });
|
|
1194
|
+
loadDetailPrep();
|
|
1190
1195
|
}, function () {});
|
|
1191
1196
|
}
|
|
1192
1197
|
}, function (err) {
|
|
@@ -1246,10 +1251,79 @@
|
|
|
1246
1251
|
A(sid).ctx = { saved: true, meeting: row, captureLabel: 'Original recording ' + (i + 1) + '. Questions use its saved cutoff; each recording keeps its own history.' };
|
|
1247
1252
|
return '<div class="mt-side-panel" id="mt-ask-' + safeId(sid) + '" data-sid="' + esc(sid) + '">' + sidePanelHtml(sid) + '</div>';
|
|
1248
1253
|
}).join('') +
|
|
1254
|
+
'<div id="mt-dprep">' + detailPrepHtml() + '</div>' +
|
|
1249
1255
|
'<article class="mt-doc">' + documentHtml(d) + '</article></div>';
|
|
1250
1256
|
}
|
|
1251
1257
|
pane.innerHTML = '<div class="cx mt mt-detail" data-tint="meetings">' + head + body + '</div>';
|
|
1252
1258
|
}
|
|
1259
|
+
// ── Prep in a saved meeting (6.99.19) ─────────────────
|
|
1260
|
+
// GET /api/prep/by-session/:sessionId for each of the meeting's recordings: what the prep focused on and its files,
|
|
1261
|
+
// each opening from the Prep store. Nothing shows when there is no prep, or on a server without Prep.
|
|
1262
|
+
function detailPrepKey() { return S.detailKey + '|' + detailSessions().join(','); }
|
|
1263
|
+
function loadDetailPrep() {
|
|
1264
|
+
var key = detailPrepKey(), sessions = detailSessions(), cc = W.cosChat;
|
|
1265
|
+
if (!sessions.length || !cc || typeof cc.ensureCaps !== 'function') return;
|
|
1266
|
+
if (S.dprep && S.dprep.key === key) { paintDetailPrep(); return; }
|
|
1267
|
+
S.dprep = { key: key, drafts: [] };
|
|
1268
|
+
cc.ensureCaps().then(function () {
|
|
1269
|
+
if (!prepCapsNow()) return;
|
|
1270
|
+
return Promise.all(sessions.map(function (sid) { return answer('/api/prep/by-session/' + encodeURIComponent(sid)); })).then(function (list) {
|
|
1271
|
+
list.forEach(function (r) { if (r.status === 200) notePrepInstance(r.body); });
|
|
1272
|
+
if (!S.dprep || S.dprep.key !== key) return;
|
|
1273
|
+
var seen = {}, out = [];
|
|
1274
|
+
list.forEach(function (r) {
|
|
1275
|
+
// Amendment 21: {serverInstanceId, snapshots}, one per binding to this meeting, newest first, each frozen at bind
|
|
1276
|
+
// time (title, startsAt, steer, files), never the draft as it is now. A binding taken back off (removedAt) is
|
|
1277
|
+
// not shown. Open links name the draft and the file.
|
|
1278
|
+
if (r.status !== 200 || !Array.isArray(r.body.snapshots)) return;
|
|
1279
|
+
// Several binds of one draft are kept, newest first; `current` marks the one the meeting's copies hold now.
|
|
1280
|
+
// Only that one is shown when the server marks it. A 'removed' bind (or removedAt) is never shown.
|
|
1281
|
+
var rows = r.body.snapshots.filter(function (sn) { return sn && typeof sn.draftId === 'string' && sn.state !== 'removed' && !sn.removedAt; });
|
|
1282
|
+
var hasCurrent = {};
|
|
1283
|
+
r.body.snapshots.forEach(function (sn) { if (sn && sn.current === true) hasCurrent[sn.draftId] = 1; });
|
|
1284
|
+
rows.forEach(function (sn) {
|
|
1285
|
+
if (hasCurrent[sn.draftId] && sn.current !== true) return;
|
|
1286
|
+
var k = sn.draftId + '@' + sn.draftRevision;
|
|
1287
|
+
if (seen[k]) return;
|
|
1288
|
+
seen[k] = 1;
|
|
1289
|
+
out.push({ id: sn.draftId, title: sn.title, startsAt: sn.startsAt, steer: typeof sn.steer === 'string' ? sn.steer : '', files: Array.isArray(sn.files) ? sn.files : [],
|
|
1290
|
+
applying: sn.state === 'applying', held: sn.held === true });
|
|
1291
|
+
});
|
|
1292
|
+
});
|
|
1293
|
+
S.dprep.drafts = out;
|
|
1294
|
+
paintDetailPrep();
|
|
1295
|
+
});
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
function prepItemHtml(d) {
|
|
1299
|
+
var files = (d.files || []).filter(function (x) { return x && x.status !== 'removed' && String(x.name || '').trim(); });
|
|
1300
|
+
var steer = String(d.steer || '').trim();
|
|
1301
|
+
if (!steer && !files.length && !d.held && !d.applying) return '';
|
|
1302
|
+
var at = d.startsAt ? Date.parse(d.startsAt) : NaN;
|
|
1303
|
+
var when = isFinite(at) ? new Date(at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ', ' + clockOf(new Date(at)) : '';
|
|
1304
|
+
return '<div class="mt-prep-item"><p class="mt-q">' + esc((cleanTitle(d.title) || 'Prep') + (when ? ' · ' + when : '')) + '</p>' +
|
|
1305
|
+
// Amendment 11: a bind is written 'applying' before the meeting's files change. Said as it is, never as adopted.
|
|
1306
|
+
(d.applying ? '<p class="mt-amber-line mt-prep-state">Still being added to this meeting. These notes and files are not in its files yet.</p>' : '') +
|
|
1307
|
+
// A held note (it looked like a password or key) was not adopted; its words are not shown.
|
|
1308
|
+
(d.held ? '<p class="mt-muted mt-prep-held">Notes held: they look like a password or key.</p>' : '') +
|
|
1309
|
+
(steer ? '<div class="mt-prep-steer">' + steer.split(/\n{2,}/).map(function (para) { return '<p>' + esc(para) + '</p>'; }).join('') + '</div>' : '') +
|
|
1310
|
+
(files.length ? '<ul class="mt-files-list mt-prep-files">' + files.map(function (x) {
|
|
1311
|
+
var meta = x.status === 'ready' ? (x.notice || '') : (x.notice || FILE_STATE[x.status] || '');
|
|
1312
|
+
return '<li class="mt-up"><span class="mt-files-name">' + esc(x.name) + '</span>' + (meta ? '<span class="mt-up-meta mt-muted">' + esc(meta) + '</span>' : '') +
|
|
1313
|
+
'<span class="mt-up-acts"><a class="mt-link" href="/api/prep/drafts/' + encodeURIComponent(d.id) + '/files/' + encodeURIComponent(x.id) + '" target="_blank" rel="noopener" aria-label="' + esc('Open ' + x.name) + '">Open</a></span></li>';
|
|
1314
|
+
}).join('') + '</ul>' : '') + '</div>';
|
|
1315
|
+
}
|
|
1316
|
+
function detailPrepHtml() {
|
|
1317
|
+
var dp = S.dprep;
|
|
1318
|
+
if (!dp || !S.detail || dp.key !== detailPrepKey()) return '';
|
|
1319
|
+
var items = dp.drafts.map(prepItemHtml).filter(Boolean);
|
|
1320
|
+
return items.length ? '<section class="mt-panel mt-prep" aria-label="Prep"><h3 class="mt-ask-title">Prep</h3>' + items.join('') + '</section>' : '';
|
|
1321
|
+
}
|
|
1322
|
+
function paintDetailPrep() {
|
|
1323
|
+
var el = $('mt-dprep'); if (!el || S.view !== 'detail') return;
|
|
1324
|
+
var html = detailPrepHtml();
|
|
1325
|
+
if (el.innerHTML !== html) el.innerHTML = html;
|
|
1326
|
+
}
|
|
1253
1327
|
function paintBar() {
|
|
1254
1328
|
if (S.view !== 'detail' || !S.detail) return;
|
|
1255
1329
|
var bar = pane.querySelector('.mt-bar'); if (!bar) return;
|
|
@@ -1334,8 +1408,8 @@
|
|
|
1334
1408
|
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
1409
|
return out;
|
|
1336
1410
|
}
|
|
1337
|
-
function fullContext(title, transcript, exchanges, files, cues, cueNote) {
|
|
1338
|
-
var sections = ['# ' + title, '## Transcript\n' + transcript];
|
|
1411
|
+
function fullContext(title, transcript, exchanges, files, cues, cueNote, plainTitle) {
|
|
1412
|
+
var sections = [(plainTitle ? '' : '# ') + title, '## Transcript\n' + transcript];
|
|
1339
1413
|
exchanges.forEach(function (e, i) {
|
|
1340
1414
|
var ns = 'Q' + (i + 1), ans = e.answer != null ? e.answer : 'No answer (' + e.status + ').';
|
|
1341
1415
|
(e.sources || []).forEach(function (s) { ans = ans.split('[' + s.id + ']').join('[' + ns + ':' + s.id + ']'); });
|
|
@@ -1793,17 +1867,32 @@
|
|
|
1793
1867
|
return html;
|
|
1794
1868
|
}
|
|
1795
1869
|
var FILE_STATE = { pending: 'Reading', unsupported: 'No readable text', failed: 'Could not be read', 'secret-held': 'Held: it appears to contain credentials' };
|
|
1796
|
-
|
|
1870
|
+
// `live`: the live view while it records, where this page adds files itself (Add file, paste, drop). There the row is
|
|
1871
|
+
// always drawn, one line tall whatever it says, so files that arrive with the Ask history, an upload that starts and
|
|
1872
|
+
// one that finishes never move what is under it. Open, the files and the uploads are rows of the same height: a
|
|
1873
|
+
// finished upload becomes its file in place. { items, note } are the uploads and the last refusal.
|
|
1874
|
+
function filesRowHtml(sid, live) {
|
|
1797
1875
|
var a = A(sid), shown = (a.files || []).filter(function (x) { return x && x.status !== 'removed' && String(x.name || '').trim(); });
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
var
|
|
1801
|
-
|
|
1802
|
-
|
|
1876
|
+
var ups = live ? live.items : [], note = live ? live.note : '';
|
|
1877
|
+
if (!shown.length && !(live && live.on) && !ups.length && !note) return '';
|
|
1878
|
+
var busy = ups.filter(function (t) { return t.state !== 'failed'; }).length, failed = ups.length - busy;
|
|
1879
|
+
var title = shown.length ? 'Files \u00b7 ' + shown.length : (live && live.on ? 'No files yet. Add file, paste or drop one here.' : 'Files');
|
|
1880
|
+
var extra = busy ? ' \u00b7 adding ' + busy : '';
|
|
1881
|
+
var bad = failed ? (failed === 1 ? '1 did not upload' : failed + ' did not upload') : '';
|
|
1882
|
+
var openable = shown.length + ups.length > 0 || !!note;
|
|
1883
|
+
var html = '<div class="mt-files' + (live ? ' live' : '') + '">' + (openable
|
|
1884
|
+
? '<button type="button" class="mt-files-head" data-mt="files-toggle" data-sid="' + esc(sid) + '" aria-expanded="' + !!a.filesOpen + '">'
|
|
1885
|
+
: '<div class="mt-files-head quiet">') +
|
|
1886
|
+
icon('clip') + '<span>' + esc(title + extra) + (bad ? '<span class="mt-amber"> \u00b7 ' + esc(bad) + '</span>' : '') + '</span>' + (openable ? icon('chev') + '</button>' : '</div>');
|
|
1887
|
+
if (a.filesOpen && openable) {
|
|
1803
1888
|
html += '<ul class="mt-files-list">' + shown.map(function (x) {
|
|
1804
|
-
var
|
|
1805
|
-
return '<li><span class="mt-files-name">' + esc(x.name) + '</span>' + (
|
|
1806
|
-
}).join('') + '</ul
|
|
1889
|
+
var line = [x.status !== 'ready' ? (x.notice || FILE_STATE[x.status] || '') : (x.notice || ''), x.origin === 'phone' ? 'Added from a phone or browser' : x.origin === 'prep' ? 'From prep' : ''].filter(Boolean).join(' \u00b7 ');
|
|
1890
|
+
return '<li><span class="mt-files-name">' + esc(x.name) + '</span>' + (line ? '<span class="mt-muted">' + esc(line) + '</span>' : '') + '</li>';
|
|
1891
|
+
}).join('') + ups.map(uploadRowHtml).join('') + '</ul>' +
|
|
1892
|
+
(note ? '<p class="mt-amber-line mt-files-note" role="status">' + esc(note) + '</p>' : '') +
|
|
1893
|
+
'<p class="mt-muted mt-files-note">' + (live && live.on
|
|
1894
|
+
? 'Meeting Ask reads these files. Add more here; remove them in COS Control on your Mac.'
|
|
1895
|
+
: 'Meeting Ask reads these files. Add or remove them in COS Control on your Mac.') + '</p>';
|
|
1807
1896
|
}
|
|
1808
1897
|
return html + '</div>';
|
|
1809
1898
|
}
|
|
@@ -1899,20 +1988,31 @@
|
|
|
1899
1988
|
var hours = f.turns.some(function (t) { return t.elapsedMs >= 3600000; });
|
|
1900
1989
|
return [header].concat(f.turns.map(function (t) { return '[' + stampMs(t.elapsedMs, hours) + '] ' + t.speaker + ': ' + t.text; })).join('\n');
|
|
1901
1990
|
}
|
|
1991
|
+
// The full context's first line for a live meeting: "Live meeting: <its name>", never a bare "# Live meeting".
|
|
1992
|
+
function liveContextTitle(f) { return 'Live meeting: ' + liveTitle(f); }
|
|
1902
1993
|
function openLive(sid) {
|
|
1903
1994
|
S.view = 'live';
|
|
1904
|
-
if (S.liveSid !== sid) { S.liveSid = sid; S.
|
|
1995
|
+
if (S.liveSid !== sid) { S.liveSid = sid; S.confirm = ''; S.liveCopy = ''; S.liveReason = ''; }
|
|
1996
|
+
// A new box starts at its newest line, following.
|
|
1997
|
+
S.follow = true;
|
|
1905
1998
|
var f = feedFor(sid);
|
|
1906
1999
|
A(sid).ctx = { live: true };
|
|
1907
2000
|
W.setCrumb(liveTitle(f));
|
|
1908
|
-
|
|
1909
|
-
|
|
2001
|
+
// 6.99.14: the transcript scrolls inside its own box, as Control's does, so a new line never moves the page: the
|
|
2002
|
+
// title, the recording bar, the files and the Ask panel stay where the reader left them. Everything that changes
|
|
2003
|
+
// height while it records (the stop line, the files, the confirm card) sits under the box, never above it.
|
|
2004
|
+
pane.innerHTML = '<div class="cx mt mt-livev" data-tint="meetings"><div id="mt-lhead"></div><div class="mt-lgrid"><div class="mt-lmain" id="mt-lmain">' +
|
|
2005
|
+
'<div class="mt-tbox"><div id="mt-turns" class="mt-turns" tabindex="0" role="log" aria-live="polite" aria-label="Live transcript"></div>' +
|
|
2006
|
+
'<button type="button" class="cx-btn quiet mt-jump" id="mt-jump" data-mt="jump" hidden>Jump to live</button></div>' +
|
|
2007
|
+
'<div id="mt-lbar" class="mt-lbarslot"></div><div id="mt-lfiles"></div><div id="mt-lprep"></div><div id="mt-lfoot"></div>' +
|
|
2008
|
+
'<input type="file" id="mt-lpick" class="mt-pick" multiple accept="' + esc(LIVE_ACCEPT) + '" tabindex="-1" aria-hidden="true"></div>' +
|
|
1910
2009
|
'<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;
|
|
2010
|
+
S.headHtml = S.footHtml = S.barHtml = S.filesHtml = S.prepHtml = null;
|
|
1912
2011
|
paintLiveHead(); paintTurns(true); paintLiveFoot();
|
|
1913
|
-
clearInterval(S.liveTimer);
|
|
2012
|
+
clearInterval(S.liveTimer); clearInterval(S.clockTimer);
|
|
1914
2013
|
livePoll();
|
|
1915
2014
|
S.liveTimer = setInterval(livePoll, 1500);
|
|
2015
|
+
S.clockTimer = setInterval(tickClock, 1000);
|
|
1916
2016
|
panelOpen(sid);
|
|
1917
2017
|
}
|
|
1918
2018
|
// Control's LiveMeeting.title: the typed name, else a calendar event that started within CALENDAR_MATCH_MS of the
|
|
@@ -1951,7 +2051,9 @@
|
|
|
1951
2051
|
var t = Date.now(), files = a.body.sessions || [];
|
|
1952
2052
|
S.files = files;
|
|
1953
2053
|
var file = files.filter(function (s) { return s.sessionId === f.sid; })[0];
|
|
1954
|
-
var
|
|
2054
|
+
var r = a.body.read;
|
|
2055
|
+
if (r && r.sessionId === f.sid && !r.unchanged) setLevels(f, r.levels);
|
|
2056
|
+
var changed = applyRead(f, r, t);
|
|
1955
2057
|
if (file) { var m = num(file.mtimeMs); if (!f.last || m > f.last) f.last = m; }
|
|
1956
2058
|
else if (!f.endedAt) changed = markEnded(f, t) || changed;
|
|
1957
2059
|
if (!f.endedAt && f.last && t - f.last >= QUIET_MS && t - f.statusAt >= STATUS_QUIET_MS) askStatus(f, t);
|
|
@@ -1992,22 +2094,422 @@
|
|
|
1992
2094
|
: (ended ? '' : (isMac(sid) ? 'Recording on this Mac' : 'Recording on the glasses') + (f.titleSteer && f.start ? ' · started ' + clockOf(new Date(f.start)) : ''));
|
|
1993
2095
|
var quiet = ended ? null : quietLabel(f.last, now);
|
|
1994
2096
|
var sub = f.titleSteer && f.start && !isPhone(sid) ? (durationLabel(f.start, now, f.endedAt) || 'Starting') : liveSubtitle(f.start, now, f.endedAt);
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
else if (!ended && isPhone(sid)) controls = '<span class="mt-side-note">Stop on iPhone</span>';
|
|
1998
|
-
var line = stopLineFor(sid);
|
|
2097
|
+
// Every line here is one line tall whatever it says (the CSS keeps each to one line), so the header never changes
|
|
2098
|
+
// height while it records and the transcript under it never moves.
|
|
1999
2099
|
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
2100
|
(status ? '<p class="mt-lstatus">' + esc(status) + '</p>' : '') +
|
|
2001
2101
|
(isPhone(sid) ? '<p class="mt-muted">iPhone microphone recordings are anonymous. Names and voice training are unavailable.</p>' : '') +
|
|
2002
2102
|
'<p class="mt-muted">' + esc(LIVE_HEADER) + '</p>' +
|
|
2003
2103
|
(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>';
|
|
2104
|
+
'<p class="mt-lsub">' + esc(sub) + (quiet ? '<span class="mt-amber"> · ' + esc(quiet) + '</span>' : '') + '</p></div>';
|
|
2009
2105
|
if (html !== S.headHtml) { S.headHtml = html; el.innerHTML = html; }
|
|
2010
2106
|
if (W.crumb() !== liveTitle(f)) W.setCrumb(liveTitle(f));
|
|
2107
|
+
paintLiveBar(); paintLiveFiles();
|
|
2108
|
+
suggestPrep(f); paintLivePrep();
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
// ── the recording bar (LiveRecordingBar, 0.5.339) ──────
|
|
2112
|
+
// The red dot, the elapsed time on its own one-second clock, the level strip, Add file and Stop and save, in one row
|
|
2113
|
+
// of fixed height under the transcript. Under it: the stop line and the confirm card. Once it ends: the handoff line.
|
|
2114
|
+
// The row is rebuilt only when what it offers changes; the clock and the strip draw in place.
|
|
2115
|
+
function elapsedLabel(ms) {
|
|
2116
|
+
var t = Math.max(0, Math.floor((isFinite(ms) ? ms : 0) / 1000)), h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60;
|
|
2117
|
+
return h > 0 ? h + ':' + pad2(m) + ':' + pad2(s) : m + ':' + pad2(s);
|
|
2118
|
+
}
|
|
2119
|
+
function clockText(f, now) { return f && f.start ? elapsedLabel(now - f.start) : 'Starting'; }
|
|
2120
|
+
// The Meeting Ask files route takes the ids Meeting Ask does (server/lib/ask-session-id.ts ASK_SESSION).
|
|
2121
|
+
var ASK_SESSION = /^(?:meeting_[A-Za-z0-9_-]{1,89}|(?:mac_|phone_(?:[0-9]{13}_)?)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
|
|
2122
|
+
function canAddFiles(f) { return !!f && !f.endedAt && ASK_SESSION.test(f.sid); }
|
|
2123
|
+
function liveStopHtml(sid) {
|
|
2124
|
+
if (isGlasses(sid)) return '<button type="button" class="cx-btn quiet mt-stopbtn" data-mt="stop-ask" data-sid="' + esc(sid) + '"' + (stopping(sid) ? ' disabled' : '') + '>Stop and save</button>';
|
|
2125
|
+
if (isPhone(sid)) return '<span class="mt-side-note">Stop on iPhone</span>';
|
|
2126
|
+
return '<span class="mt-side-note">Stop on your Mac</span>';
|
|
2127
|
+
}
|
|
2128
|
+
function liveBarHtml(f) {
|
|
2129
|
+
var sid = f.sid;
|
|
2130
|
+
if (f.endedAt) return liveHandoffLine(f);
|
|
2131
|
+
var line = stopLineFor(sid);
|
|
2132
|
+
return '<div class="mt-lbar" role="group" aria-label="Recording">' +
|
|
2133
|
+
'<span class="mt-livedot on" aria-hidden="true"></span>' +
|
|
2134
|
+
'<span class="mt-ltime' + (f.start ? '' : ' starting') + '" id="mt-ltime" role="timer" aria-label="Recording time">' + esc(clockText(f, Date.now())) + '</span>' +
|
|
2135
|
+
'<span class="mt-meterwrap"><canvas class="mt-meter" id="mt-meter" role="img" aria-label="' + esc(LEVEL_LABEL) + '"></canvas>' +
|
|
2136
|
+
'<span class="mt-glyph" aria-hidden="true"><i></i><i></i><i></i></span></span>' +
|
|
2137
|
+
(canAddFiles(f) ? '<button type="button" class="cx-btn quiet mt-addfile" data-mt="add-file" aria-label="Add file" title="Add a screenshot or files to this meeting">' + icon('clip') + '<span class="mt-addlabel">Add file</span></button>' : '') +
|
|
2138
|
+
liveStopHtml(sid) + '</div>' +
|
|
2139
|
+
(line ? '<p class="mt-stopline' + (S.stop && S.stop.nothing ? ' bad' : '') + '">' + esc(line) + '</p>' : '') +
|
|
2140
|
+
(S.confirm === 'stop:' + sid ? confirmCard(STOP_TITLE, STOP_LINE, 'stop-yes', 'Stop and save', 'Cancel', sid) : '');
|
|
2141
|
+
}
|
|
2142
|
+
function paintLiveBar() {
|
|
2143
|
+
var el = $('mt-lbar'); if (!el || S.view !== 'live') return;
|
|
2144
|
+
var f = S.feeds[S.liveSid]; if (!f) return;
|
|
2145
|
+
// The clock's own text is left out of the comparison, so a second ticking never rebuilds the row (or the strip).
|
|
2146
|
+
var html = liveBarHtml(f), key = html.replace(/(id="mt-ltime"[^>]*>)[^<]*/, '$1');
|
|
2147
|
+
if (key !== S.barHtml) { S.barHtml = key; el.innerHTML = html; drawMeter(); }
|
|
2148
|
+
}
|
|
2149
|
+
function tickClock() {
|
|
2150
|
+
if (S.view !== 'live' || document.hidden) return;
|
|
2151
|
+
var f = S.feeds[S.liveSid], el = $('mt-ltime');
|
|
2152
|
+
if (!f || !el) return;
|
|
2153
|
+
var text = clockText(f, Date.now());
|
|
2154
|
+
if (el.textContent !== text) el.textContent = text;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
// ── the level strip (LiveChunkLevelStripView) ─────────
|
|
2158
|
+
// One bar per chunk from the live read's `levels` (server 6.87.0: the quiet gate's RMS and Silero speech seconds,
|
|
2159
|
+
// kept on the active-session record), newest at the right, a dot where the chunk held almost no speech, the oldest
|
|
2160
|
+
// clipped first when narrow. It trails the room by a chunk. Reduce Motion: one still bar for the newest chunk.
|
|
2161
|
+
var LEVEL_STEP = 5, SPEECH_FLOOR = 0.5, LEVEL_FLOOR = 0.27;
|
|
2162
|
+
var LEVEL_LABEL = 'Audio level, a few seconds behind';
|
|
2163
|
+
function levelSilent(l) { return l.speechSec != null ? l.speechSec < SPEECH_FLOOR : l.level <= LEVEL_FLOOR; }
|
|
2164
|
+
function cleanLevels(v) {
|
|
2165
|
+
if (!Array.isArray(v)) return null;
|
|
2166
|
+
var seen = {}, out = [];
|
|
2167
|
+
v.forEach(function (l) {
|
|
2168
|
+
if (!l || typeof l !== 'object' || !Number.isInteger(l.i) || l.i < 0 || seen[l.i]) return;
|
|
2169
|
+
if (typeof l.level !== 'number' || !(l.level >= 0 && l.level <= 1)) return;
|
|
2170
|
+
if (l.speechSec != null && !(typeof l.speechSec === 'number' && l.speechSec >= 0)) return;
|
|
2171
|
+
seen[l.i] = 1; out.push({ i: l.i, level: l.level, speechSec: l.speechSec == null ? null : l.speechSec });
|
|
2172
|
+
});
|
|
2173
|
+
return out.sort(function (a, b) { return a.i - b.i; }).slice(-120);
|
|
2174
|
+
}
|
|
2175
|
+
function visibleLevels(levels, width) { var fit = Math.floor(width / LEVEL_STEP); return fit < 1 ? [] : levels.slice(-fit); }
|
|
2176
|
+
function reduceMotion() { try { return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); } catch (_e) { return false; } }
|
|
2177
|
+
function drawMeter() {
|
|
2178
|
+
var c = $('mt-meter'); if (!c || !c.getContext) return;
|
|
2179
|
+
var f = S.feeds[S.liveSid], levels = (f && f.levels) || [];
|
|
2180
|
+
var wrap = c.parentNode; if (wrap && wrap.classList) wrap.classList.toggle('mt-nolevels', !levels.length);
|
|
2181
|
+
var last = levels[levels.length - 1];
|
|
2182
|
+
c.setAttribute('aria-label', LEVEL_LABEL + ': ' + (!last ? 'waiting for the first chunk' : levelSilent(last) ? 'quiet' : 'speech'));
|
|
2183
|
+
var w = c.clientWidth, h = c.clientHeight; if (!w || !h) return;
|
|
2184
|
+
var dpr = window.devicePixelRatio || 1;
|
|
2185
|
+
if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) { c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); }
|
|
2186
|
+
var g = c.getContext('2d'); if (!g) return;
|
|
2187
|
+
g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, w, h);
|
|
2188
|
+
if (!levels.length) return;
|
|
2189
|
+
var css = getComputedStyle(c), bar = css.getPropertyValue('--mt-bar').trim() || '#c2410c', dot = css.getPropertyValue('--mt-dot').trim() || '#8a8a8a';
|
|
2190
|
+
var mid = h / 2;
|
|
2191
|
+
if (reduceMotion()) {
|
|
2192
|
+
var lv = levelSilent(last) ? 0 : last.level;
|
|
2193
|
+
g.fillStyle = dot; g.globalAlpha = 0.35; g.fillRect(0, mid - 2, w, 4); g.globalAlpha = 1;
|
|
2194
|
+
g.fillStyle = bar; g.fillRect(0, mid - 2, Math.max(2, w * lv), 4);
|
|
2195
|
+
return;
|
|
2196
|
+
}
|
|
2197
|
+
var shown = visibleLevels(levels, w);
|
|
2198
|
+
for (var back = 0; back < shown.length; back++) {
|
|
2199
|
+
var l = shown[shown.length - 1 - back], x = w - (back + 0.5) * LEVEL_STEP;
|
|
2200
|
+
// The oldest fade out at the left edge, so clipped history reads as older, not cut.
|
|
2201
|
+
g.globalAlpha = Math.min(1, Math.max(0.15, x / 28));
|
|
2202
|
+
if (levelSilent(l)) { g.fillStyle = dot; g.beginPath(); g.arc(x, mid, 1, 0, Math.PI * 2); g.fill(); }
|
|
2203
|
+
else { var bh = Math.max(3, l.level * h); g.fillStyle = bar; g.fillRect(x - 1.5, mid - bh / 2, 3, bh); }
|
|
2204
|
+
}
|
|
2205
|
+
g.globalAlpha = 1;
|
|
2206
|
+
}
|
|
2207
|
+
function setLevels(f, raw) {
|
|
2208
|
+
var next = cleanLevels(raw); if (!next) return;
|
|
2209
|
+
var sig = next.map(function (l) { return l.i + ':' + l.level + ':' + l.speechSec; }).join('|');
|
|
2210
|
+
if (sig === f.levelSig) return;
|
|
2211
|
+
f.levelSig = sig; f.levels = next;
|
|
2212
|
+
if (S.view === 'live' && S.liveSid === f.sid) drawMeter();
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
// ── files during a live meeting (LiveMeetingFiles) ─────
|
|
2216
|
+
// Add file, paste and drop: each file goes to the media store (W.cosChat.uploadMedia, the composer's own upload) and
|
|
2217
|
+
// then joins the meeting's files through POST /api/meeting-ask/files/media, the route the phone uses, so Meeting Ask
|
|
2218
|
+
// reads it and Copy full context carries it. A file never becomes text: a paste or a drop that holds a file is taken
|
|
2219
|
+
// as the file, and one the browser will not hand over is refused out loud instead of landing as its path.
|
|
2220
|
+
var LIVE_ACCEPT = 'image/*,.heic,.heif,application/pdf,.pdf,text/plain,.txt,text/markdown,.md,text/csv,.csv,application/json,.json,.xlsx';
|
|
2221
|
+
var MAX_LIVE_UPLOADS = 10;
|
|
2222
|
+
function LU() { return S.lup || (S.lup = { sid: '', items: [], note: '' }); }
|
|
2223
|
+
function liveUploads(sid) { var u = LU(); if (u.sid !== sid) { u.items.forEach(abortUpload); S.lup = { sid: sid, items: [], note: '' }; } return S.lup; }
|
|
2224
|
+
function abortUpload(t) { if (t.xhr) { try { t.xhr.abort(); } catch (_e) {} } }
|
|
2225
|
+
/** The files a paste or a drop carries: its File list, else the items of kind file. */
|
|
2226
|
+
function filesOf(dt) {
|
|
2227
|
+
if (!dt) return [];
|
|
2228
|
+
var list = Array.prototype.slice.call(dt.files || []);
|
|
2229
|
+
if (!list.length && dt.items) Array.prototype.forEach.call(dt.items, function (it) { if (it && it.kind === 'file' && it.getAsFile) { var x = it.getAsFile(); if (x) list.push(x); } });
|
|
2230
|
+
return list;
|
|
2231
|
+
}
|
|
2232
|
+
/** True when a paste or a drop is a file (or a file's link) rather than words: it must never be inserted as text. */
|
|
2233
|
+
function carriesFile(dt) {
|
|
2234
|
+
if (!dt) return false;
|
|
2235
|
+
var types = Array.prototype.slice.call(dt.types || []);
|
|
2236
|
+
if (types.indexOf('Files') >= 0 || filesOf(dt).length) return true;
|
|
2237
|
+
var uri = '';
|
|
2238
|
+
try { uri = types.indexOf('text/uri-list') >= 0 ? String(dt.getData('text/uri-list') || '') : ''; } catch (_e) { uri = ''; }
|
|
2239
|
+
return /^\s*file:/i.test(uri);
|
|
2240
|
+
}
|
|
2241
|
+
function liveFileKind(file) {
|
|
2242
|
+
var cc = W.cosChat;
|
|
2243
|
+
if (!cc || !cc.classify || !cc.uploadMedia) return { refuse: 'This page could not load its upload code. Reload the page, then try again.' };
|
|
2244
|
+
var c = cc.classify(file);
|
|
2245
|
+
if (c.refuse) return c;
|
|
2246
|
+
if (c.kind === 'video') return { refuse: (file.name || 'That video') + ' is a video. Meeting Ask reads documents and the text in images, so attach a PDF, a text file or a screenshot.' };
|
|
2247
|
+
return c;
|
|
2248
|
+
}
|
|
2249
|
+
function addLiveFiles(list) {
|
|
2250
|
+
var f = S.feeds[S.liveSid];
|
|
2251
|
+
if (S.view !== 'live' || !f) return;
|
|
2252
|
+
var u = liveUploads(f.sid), files = Array.prototype.slice.call(list || []), refusals = [];
|
|
2253
|
+
if (!canAddFiles(f)) { liveNote(f.endedAt ? 'This recording has ended. Files can be added while it records.' : 'Meeting Ask does not take files for this recording.'); return; }
|
|
2254
|
+
files.forEach(function (file) {
|
|
2255
|
+
if (u.items.filter(function (t) { return t.state !== 'failed'; }).length >= MAX_LIVE_UPLOADS) { refusals.push('Up to 10 files at a time. ' + (file.name || 'A file') + ' was not added.'); return; }
|
|
2256
|
+
var c = liveFileKind(file);
|
|
2257
|
+
if (c.refuse) { refusals.push(c.refuse); return; }
|
|
2258
|
+
var t = { id: W.cosChat.uuid(), file: file, name: file.name || (c.kind === 'photo' ? 'Screenshot' : 'File'), size: file.size, kind: c.kind, mime: c.mime || '', state: 'reading', progress: 0, mediaId: '', error: '' };
|
|
2259
|
+
u.items.push(t);
|
|
2260
|
+
sendLiveFile(f.sid, t, false);
|
|
2261
|
+
});
|
|
2262
|
+
u.note = refusals.join(' ');
|
|
2263
|
+
// Opened by the person's own add, in the same task as the paste or drop, never later when an upload lands.
|
|
2264
|
+
A(f.sid).filesOpen = true;
|
|
2265
|
+
paintLiveFiles();
|
|
2266
|
+
}
|
|
2267
|
+
var ATTACH_COPY = {
|
|
2268
|
+
file_context_limit: 'This meeting holds as many files as Meeting Ask reads. Remove one in COS Control on your Mac, then retry.',
|
|
2269
|
+
file_context_storage_limit: 'COS is out of room for meeting files. Remove some in COS Control on your Mac, then retry.',
|
|
2270
|
+
file_revision_conflict: 'The meeting’s files changed while this one was read. Retry.',
|
|
2271
|
+
media_not_found: 'The upload expired before it was added. Retry.', media_expired: 'The upload expired before it was added. Retry.',
|
|
2272
|
+
media_conflict: 'The upload is still being read. Retry in a moment.', media_unavailable: 'COS could not read the upload. Retry.',
|
|
2273
|
+
maintenance: 'COS is updating. Retry in a minute.', invalid_session_id: 'Meeting Ask does not take files for this recording.',
|
|
2274
|
+
invalid_media: 'COS did not accept that file. Retry, or attach it from COS Control on your Mac.'
|
|
2275
|
+
};
|
|
2276
|
+
// The media store has it; now the meeting. A changed server (it restarted) is read again once before giving up.
|
|
2277
|
+
function attachLiveFile(sid, t, again) {
|
|
2278
|
+
t.state = 'attaching'; paintLiveFiles();
|
|
2279
|
+
return loadConfig(again).then(function (conf) {
|
|
2280
|
+
if (!conf) { var e = new Error(S.conf.notice || 'Meeting assistance is not available on this server.'); e.code = 'config'; throw e; }
|
|
2281
|
+
return answer('/api/meeting-ask/files/media', { method: 'POST', body: { sessionId: sid, serverInstanceId: conf.serverInstanceId, media: [{ id: t.mediaId, name: t.name.slice(0, 240) }] } });
|
|
2282
|
+
}).then(function (a) {
|
|
2283
|
+
var b = a.body || {}, code = typeof b.error === 'string' ? b.error : (b.error && b.error.code) || b.reason || '';
|
|
2284
|
+
if (a.status === 200 && Array.isArray(b.files)) return b.files;
|
|
2285
|
+
if (a.status === 409 && code === 'server_instance_mismatch' && !again) return attachLiveFile(sid, t, true);
|
|
2286
|
+
var err = new Error(a.status === 0 ? 'COS on your Mac did not answer. Retry when it is awake.' : a.status === 401 ? SIGNED_OUT + ' The file is kept here; sign in, then retry.' : ATTACH_COPY[code] || 'COS could not add the file to this meeting (HTTP ' + a.status + '). Retry.');
|
|
2287
|
+
err.code = 'attach'; throw err;
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
function sendLiveFile(sid, t, retry) {
|
|
2291
|
+
var u = liveUploads(sid);
|
|
2292
|
+
t.state = t.mediaId ? 'attaching' : 'reading'; t.progress = 0; t.error = '';
|
|
2293
|
+
var step = t.mediaId ? Promise.resolve({ id: t.mediaId }) : W.cosChat.uploadMedia(t, {
|
|
2294
|
+
uploading: function () { t.state = 'uploading'; paintLiveFiles(); },
|
|
2295
|
+
progress: function (p) { t.progress = p; paintUploadProgress(t); }
|
|
2296
|
+
});
|
|
2297
|
+
if (retry) paintLiveFiles();
|
|
2298
|
+
step.then(function (ref) {
|
|
2299
|
+
if (u.items.indexOf(t) < 0) return null;
|
|
2300
|
+
if (!ref || !ref.id) { var e = new Error('COS did not keep the file. Retry.'); e.code = 'attach'; throw e; }
|
|
2301
|
+
t.mediaId = ref.id;
|
|
2302
|
+
return attachLiveFile(sid, t, false);
|
|
2303
|
+
}).then(function (files) {
|
|
2304
|
+
if (!files || u.items.indexOf(t) < 0) return;
|
|
2305
|
+
u.items = u.items.filter(function (x) { return x !== t; });
|
|
2306
|
+
var a = A(sid); a.files = files;
|
|
2307
|
+
repaintAsk(sid); paintLiveFiles();
|
|
2308
|
+
}).catch(function (err) {
|
|
2309
|
+
if (u.items.indexOf(t) < 0 || (err && err.code === 'aborted')) return;
|
|
2310
|
+
t.state = 'failed';
|
|
2311
|
+
t.error = err && (err.code === 'attach' || err.code === 'config') ? err.message
|
|
2312
|
+
: err && err.status === 401 ? SIGNED_OUT + ' The file is kept here; sign in, then retry.'
|
|
2313
|
+
: W.cosChat.uploadErrorText(err);
|
|
2314
|
+
paintLiveFiles();
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
function uploadMeta(t) {
|
|
2318
|
+
if (t.state === 'reading') return t.kind === 'photo' ? 'Preparing the image' : 'Preparing';
|
|
2319
|
+
if (t.state === 'uploading') return 'Uploading ' + Math.round(t.progress * 100) + '%';
|
|
2320
|
+
if (t.state === 'attaching') return t.kind === 'photo' ? 'Reading the text in the image' : 'Adding to the meeting';
|
|
2321
|
+
return t.error || 'Did not upload';
|
|
2322
|
+
}
|
|
2323
|
+
function uploadRowHtml(t) {
|
|
2324
|
+
return '<li class="mt-up ' + t.state + '" data-up="' + esc(t.id) + '"><span class="mt-files-name">' + esc(t.name) + '</span>' +
|
|
2325
|
+
'<span class="mt-up-meta ' + (t.state === 'failed' ? 'mt-amber' : 'mt-muted') + '" data-up-meta>' + esc(uploadMeta(t)) + '</span>' +
|
|
2326
|
+
'<span class="mt-up-acts">' + (t.state === 'failed' ? '<button type="button" class="mt-link" data-mt="up-retry" data-up="' + esc(t.id) + '">Retry</button>' : '') +
|
|
2327
|
+
'<button type="button" class="mt-link" data-mt="up-remove" data-up="' + esc(t.id) + '" aria-label="Remove ' + esc(t.name) + '">' + (t.state === 'failed' ? 'Remove' : 'Cancel') + '</button></span>' +
|
|
2328
|
+
(t.state === 'uploading' || t.state === 'reading' ? '<span class="mt-up-bar" data-up-bar style="width:' + Math.round(t.progress * 100) + '%"></span>' : '') + '</li>';
|
|
2329
|
+
}
|
|
2330
|
+
// Progress moves the bar and its words only, so a tap on Cancel never misses.
|
|
2331
|
+
function paintUploadProgress(t) {
|
|
2332
|
+
var row = pane.querySelector('[data-up="' + t.id + '"]'); if (!row) return;
|
|
2333
|
+
var meta = row.querySelector('[data-up-meta]'); if (meta) meta.textContent = uploadMeta(t);
|
|
2334
|
+
var bar = row.querySelector('[data-up-bar]'); if (bar) bar.style.width = Math.round(t.progress * 100) + '%';
|
|
2335
|
+
}
|
|
2336
|
+
function paintLiveFiles() {
|
|
2337
|
+
var el = $('mt-lfiles'); if (!el || S.view !== 'live') return;
|
|
2338
|
+
var f = S.feeds[S.liveSid]; if (!f) return;
|
|
2339
|
+
var u = liveUploads(f.sid);
|
|
2340
|
+
var html = filesRowHtml(f.sid, { on: canAddFiles(f), items: u.items, note: u.note });
|
|
2341
|
+
if (html !== S.filesHtml) { S.filesHtml = html; el.innerHTML = html; }
|
|
2342
|
+
}
|
|
2343
|
+
function liveNote(text) { LU().note = text; if (S.liveSid) A(S.liveSid).filesOpen = true; paintLiveFiles(); }
|
|
2344
|
+
// ── Prep in the live view (6.99.19, PREP_CONTRACT_v1) ──
|
|
2345
|
+
// A prep made before the call (the chat's Prep me) can join this meeting. GET /api/prep/suggest lists up to five
|
|
2346
|
+
// drafts, closest to this recording's start first. One area under the files offers them: one prep as a line, several
|
|
2347
|
+
// as a short list, each with its own Add to this meeting. Only that tap binds (POST .../bind); its notes and files
|
|
2348
|
+
// then join the meeting's files, each marked From prep.
|
|
2349
|
+
// The area's height is reserved from the first paint whenever a suggestion is possible (Prep on, or not known yet,
|
|
2350
|
+
// and a recording that takes files), and every state (looking, none, one, several, adding, added, ended) fills the
|
|
2351
|
+
// same fixed box, so nothing under it moves when the answer arrives. The transcript box sits above it.
|
|
2352
|
+
function prepCapsNow() { var cc = W.cosChat; return cc && typeof cc.prepCaps === 'function' ? cc.prepCaps() : null; }
|
|
2353
|
+
function livePrep(sid) { if (!S.lprep || S.lprep.sid !== sid) S.lprep = { sid: sid, asked: false, state: 'idle', drafts: [], busy: '', done: null, note: '', changed: '' }; return S.lprep; }
|
|
2354
|
+
function notePrepInstance(b) { var cc = W.cosChat; if (b && cc && typeof cc.notePrepInstance === 'function') cc.notePrepInstance(b.serverInstanceId); }
|
|
2355
|
+
function prepBoundTo(d, sid) { return ((d && d.bindings) || []).some(function (b) { return b && b.sessionId === sid && !b.removedAt; }); }
|
|
2356
|
+
function prepReadyFiles(d) { return ((d && d.files) || []).filter(function (x) { return x && x.status === 'ready'; }); }
|
|
2357
|
+
function prepHasNotes(d) { return !!String((d && d.steer) || '').trim(); }
|
|
2358
|
+
// Whether this live view may offer a prep at all: the area is reserved exactly when this is true.
|
|
2359
|
+
function prepPossible(f) {
|
|
2360
|
+
var cc = W.cosChat;
|
|
2361
|
+
if (!f || !canAddFiles(f) && !(S.lprep && S.lprep.sid === f.sid && (S.lprep.done || S.lprep.state !== 'idle'))) return false;
|
|
2362
|
+
if (!cc || typeof cc.known !== 'function') return false;
|
|
2363
|
+
if (!cc.known()) return true;
|
|
2364
|
+
var caps = prepCapsNow();
|
|
2365
|
+
return !!(caps && caps.suggest && caps.bind);
|
|
2366
|
+
}
|
|
2367
|
+
function suggestPrep(f) {
|
|
2368
|
+
if (!f || f.endedAt || !f.start || !canAddFiles(f)) return;
|
|
2369
|
+
var p = livePrep(f.sid), cc = W.cosChat;
|
|
2370
|
+
if (p.asked || !cc || typeof cc.ensureCaps !== 'function') return;
|
|
2371
|
+
p.asked = true; p.state = 'checking';
|
|
2372
|
+
cc.ensureCaps().then(function () {
|
|
2373
|
+
var caps = prepCapsNow();
|
|
2374
|
+
if (!caps || !caps.suggest || !caps.bind) { p.state = 'off'; paintLivePrep(); return; }
|
|
2375
|
+
return answer('/api/prep/suggest?sessionId=' + encodeURIComponent(f.sid) + '&startedAt=' + Math.round(f.start)).then(function (r) {
|
|
2376
|
+
if (S.lprep !== p) return;
|
|
2377
|
+
notePrepInstance(r.body);
|
|
2378
|
+
p.drafts = r.status === 200 && Array.isArray(r.body.drafts) ? r.body.drafts.filter(function (d) {
|
|
2379
|
+
return d && typeof d.id === 'string' && !prepBoundTo(d, f.sid) && (prepHasNotes(d) || prepReadyFiles(d).length > 0);
|
|
2380
|
+
}).slice(0, 5) : [];
|
|
2381
|
+
p.state = r.status === 200 ? 'ready' : 'failed';
|
|
2382
|
+
paintLivePrep();
|
|
2383
|
+
});
|
|
2384
|
+
});
|
|
2385
|
+
}
|
|
2386
|
+
function prepWhat(d) {
|
|
2387
|
+
var n = prepReadyFiles(d).length, files = n + (n === 1 ? ' file' : ' files');
|
|
2388
|
+
return prepHasNotes(d) ? (n ? 'its notes and ' + files : 'its notes') : 'its ' + files;
|
|
2389
|
+
}
|
|
2390
|
+
function prepAddBtn(p, d) {
|
|
2391
|
+
return '<button type="button" class="cx-btn quiet" data-mt="prep-bind" data-id="' + esc(d.id) + '"' + (p.busy ? ' disabled' : '') + '>' + (p.busy === d.id ? 'Adding…' : 'Add to this meeting') + '</button>';
|
|
2392
|
+
}
|
|
2393
|
+
function prepQuiet(text, warn) { return '<div class="mt-prepline" role="status"><span class="mt-prepline-text' + (warn ? ' mt-amber' : '') + '">' + esc(text) + '</span></div>'; }
|
|
2394
|
+
function livePrepHtml() {
|
|
2395
|
+
var f = S.feeds[S.liveSid];
|
|
2396
|
+
if (!prepPossible(f)) return '';
|
|
2397
|
+
var p = livePrep(f.sid), html;
|
|
2398
|
+
if (p.done) html = prepQuiet('Added ' + (cleanTitle(p.done.title) ? 'the prep for ' + cleanTitle(p.done.title) : 'the prep') + ' to this meeting. Its files say From prep.');
|
|
2399
|
+
else if (f.endedAt) html = prepQuiet(p.drafts.length ? 'The recording ended, so prep can no longer be added here.' : 'No prep was added to this meeting.');
|
|
2400
|
+
else if (p.state === 'failed') html = prepQuiet('Prep for this meeting could not be checked. It is checked again when you reopen this view.', true);
|
|
2401
|
+
else if (p.state !== 'ready') html = prepQuiet('Looking for a prep for this meeting…');
|
|
2402
|
+
else if (!p.drafts.length) html = prepQuiet('No prep for this meeting. Make one from a Prep me card in COS chat.');
|
|
2403
|
+
else if (p.drafts.length === 1) {
|
|
2404
|
+
var d = p.drafts[0], title = cleanTitle(d.title) || 'this meeting';
|
|
2405
|
+
var text = p.note || 'Prep for ' + title + (p.changed === d.id ? ' changed since it was offered: add ' : ' is ready: add ') + prepWhat(d) + ' to this meeting';
|
|
2406
|
+
html = '<div class="mt-prepline"><span class="mt-prepline-text' + (p.note ? ' mt-amber' : '') + '" title="' + esc(text) + '">' + esc(text) + '</span>' + prepAddBtn(p, d) + '</div>';
|
|
2407
|
+
} else {
|
|
2408
|
+
// Several: the person picks. Each row says what it adds; the list scrolls inside the same box.
|
|
2409
|
+
html = '<div class="mt-prepline list"><ul class="mt-preplist" aria-label="' + esc(p.drafts.length + ' preps are ready for this meeting') + '">' +
|
|
2410
|
+
(p.note ? '<li class="mt-prepnote"><span class="mt-prepline-text mt-amber">' + esc(p.note) + '</span></li>' : '') +
|
|
2411
|
+
p.drafts.map(function (d) {
|
|
2412
|
+
var label = (cleanTitle(d.title) || 'Prep') + (p.changed === d.id ? ' (changed)' : '') + ': ' + prepWhat(d).replace(/^its /, '');
|
|
2413
|
+
return '<li><span class="mt-prepline-text" title="' + esc(label) + '">' + esc(label) + '</span>' + prepAddBtn(p, d) + '</li>';
|
|
2414
|
+
}).join('') + '</ul></div>';
|
|
2415
|
+
}
|
|
2416
|
+
return '<div class="mt-prepslot">' + html + '</div>';
|
|
2417
|
+
}
|
|
2418
|
+
function paintLivePrep() {
|
|
2419
|
+
var el = $('mt-lprep'); if (!el || S.view !== 'live') return;
|
|
2420
|
+
var html = livePrepHtml();
|
|
2421
|
+
if (html !== S.prepHtml) { S.prepHtml = html; el.innerHTML = html; }
|
|
2422
|
+
}
|
|
2423
|
+
var PREP_SERVER_CHANGED = 'A different COS server answered than the one that offered this prep. It was read again; tap Add to this meeting again.';
|
|
2424
|
+
var PREP_BIND_COPY = {
|
|
2425
|
+
maintenance: 'COS is updating. Try again in a minute.', invalid_session_id: 'This recording does not take prep files.',
|
|
2426
|
+
file_context_limit: 'This meeting holds as many files as Meeting Ask reads. Remove one in COS Control on your Mac, then try again.',
|
|
2427
|
+
file_context_storage_limit: 'COS is out of room for meeting files. Remove some in COS Control on your Mac, then try again.',
|
|
2428
|
+
not_found: 'That prep is no longer on your Mac.', unknown_session: 'COS does not know this recording yet. Try again in a moment.',
|
|
2429
|
+
request_reused: 'COS saw a different request with the same id. Reload the page, then try again.'
|
|
2430
|
+
};
|
|
2431
|
+
function kb(n) { return Math.max(1, Math.round(num(n) / 1024)).toLocaleString() + ' KB'; }
|
|
2432
|
+
// v1.1: the bind names the revision the offer described ({revision, sessionId, serverInstanceId, surface}), so a newer
|
|
2433
|
+
// version nobody has seen is never attached. The server id is the newest one read: capabilities.prep or the most
|
|
2434
|
+
// recent Prep answer (W.cosChat.prepInstance), else Meeting Ask's, which is the same id.
|
|
2435
|
+
function bindOnce(sid, d) {
|
|
2436
|
+
var cc = W.cosChat, known = cc && typeof cc.prepInstance === 'function' ? cc.prepInstance() : '';
|
|
2437
|
+
return (known ? Promise.resolve(known) : loadConfig().then(function (conf) { return conf && conf.serverInstanceId; })).then(function (inst) {
|
|
2438
|
+
if (!inst) throw new Error(S.conf.notice || 'Meeting assistance is not available on this server.');
|
|
2439
|
+
// Amendment 19: the header is what authorizes the write; the body carries the same id.
|
|
2440
|
+
return answer('/api/prep/drafts/' + encodeURIComponent(d.id) + '/bind', { method: 'POST', headers: { 'X-COS-Expected-Server-Instance': inst }, body: { revision: d.revision, sessionId: sid, serverInstanceId: inst, surface: 'web' } });
|
|
2441
|
+
}).then(function (a) {
|
|
2442
|
+
var b = a.body || {}, code = typeof b.error === 'string' ? b.error : (b.error && b.error.code) || b.reason || '';
|
|
2443
|
+
if (a.status >= 200 && a.status < 300 || a.status === 409) notePrepInstance(b);
|
|
2444
|
+
if (a.status >= 200 && a.status < 300) return b;
|
|
2445
|
+
// A different server than the one that offered it: the prep is read again (amendment 19) and the line says so.
|
|
2446
|
+
// Adding it takes another tap, against the server that answers now.
|
|
2447
|
+
if (a.status === 409 && code === 'server_instance_mismatch') {
|
|
2448
|
+
return answer('/api/prep/drafts/' + encodeURIComponent(d.id)).then(function (g) {
|
|
2449
|
+
if (g.status === 200) notePrepInstance(g.body);
|
|
2450
|
+
var e = new Error(PREP_SERVER_CHANGED);
|
|
2451
|
+
e.draft = g.status === 200 && g.body.draft ? g.body.draft : d;
|
|
2452
|
+
e.changedServer = true;
|
|
2453
|
+
throw e;
|
|
2454
|
+
});
|
|
2455
|
+
}
|
|
2456
|
+
if (a.status === 428 && typeof console !== 'undefined') console.error('[prep] bind went without X-COS-Expected-Server-Instance');
|
|
2457
|
+
var err = new Error(a.status === 0 ? 'COS on your Mac did not answer. Try again when it is awake.' : a.status === 401 ? SIGNED_OUT
|
|
2458
|
+
: a.status === 428 ? 'This page did not name the Mac’s COS server, so nothing was added. Reload the page, then try again.'
|
|
2459
|
+
: code === 'file_context_limit' && b.needBytes != null ? 'This meeting has too much file text for this prep: it needs ' + kb(b.needBytes) + ' and the meeting has room for ' + kb(b.haveBytes) + '. Remove a large file from the meeting in COS Control on your Mac, then try again.'
|
|
2460
|
+
: PREP_BIND_COPY[code] || (a.status === 404 ? PREP_BIND_COPY.not_found : 'COS could not add the prep (HTTP ' + a.status + '). Try again.'));
|
|
2461
|
+
// Changed since it was offered: the area now describes the newer version, and adding it takes another tap.
|
|
2462
|
+
if (a.status === 409 && code === 'revision_conflict' && b.draft) err.draft = b.draft;
|
|
2463
|
+
throw err;
|
|
2464
|
+
});
|
|
2465
|
+
}
|
|
2466
|
+
// Only from a tap on Add to this meeting: nothing here binds on its own.
|
|
2467
|
+
function bindPrep(id) {
|
|
2468
|
+
var f = S.feeds[S.liveSid], p = S.lprep;
|
|
2469
|
+
if (!f || !p || p.sid !== f.sid || p.busy || p.done || f.endedAt) return;
|
|
2470
|
+
var d = p.drafts.filter(function (x) { return x.id === id; })[0]; if (!d) return;
|
|
2471
|
+
p.busy = id; p.note = ''; paintLivePrep();
|
|
2472
|
+
bindOnce(f.sid, d).then(function (b) {
|
|
2473
|
+
p.busy = ''; p.done = (b && b.draft) || d;
|
|
2474
|
+
var a = A(f.sid);
|
|
2475
|
+
// The meeting's files as the bind answered them, when it answered Meeting Ask files; and read again either way.
|
|
2476
|
+
if (Array.isArray(b && b.files) && b.files.every(function (x) { return x && typeof x.id === 'string' && x.revision != null; })) a.files = b.files;
|
|
2477
|
+
a.filesOpen = true;
|
|
2478
|
+
paintLivePrep(); paintLiveFiles();
|
|
2479
|
+
(a.openP || Promise.resolve()).then(function () { return askOpen(f.sid, 'poll'); });
|
|
2480
|
+
}, function (err) {
|
|
2481
|
+
p.busy = '';
|
|
2482
|
+
if (err && err.draft && typeof err.draft.id === 'string') {
|
|
2483
|
+
p.drafts = p.drafts.map(function (x) { return x.id === err.draft.id ? err.draft : x; });
|
|
2484
|
+
if (err.changedServer) p.note = err.message; else { p.changed = err.draft.id; p.note = ''; }
|
|
2485
|
+
} else p.note = 'Not added. ' + ((err && err.message) || 'Try again.');
|
|
2486
|
+
paintLivePrep();
|
|
2487
|
+
});
|
|
2488
|
+
}
|
|
2489
|
+
function findUpload(id) { var u = LU(); for (var i = 0; i < u.items.length; i++) if (u.items[i].id === id) return u.items[i]; return null; }
|
|
2490
|
+
function onLivePaste(e) {
|
|
2491
|
+
if (S.view !== 'live') return;
|
|
2492
|
+
var dt = e.clipboardData;
|
|
2493
|
+
if (!carriesFile(dt)) return;
|
|
2494
|
+
// Taken as the file, never as its name or path in a text box.
|
|
2495
|
+
e.preventDefault();
|
|
2496
|
+
var files = filesOf(dt);
|
|
2497
|
+
if (files.length) addLiveFiles(files);
|
|
2498
|
+
else liveNote('This browser did not hand over the file. Use Add file to choose it.');
|
|
2499
|
+
}
|
|
2500
|
+
function onLiveDrag(e) {
|
|
2501
|
+
if (S.view !== 'live') return;
|
|
2502
|
+
var dt = e.dataTransfer;
|
|
2503
|
+
if (!carriesFile(dt)) return;
|
|
2504
|
+
// Every file drag is ours: the browser must not open the file, or drop its path into the Ask box.
|
|
2505
|
+
e.preventDefault();
|
|
2506
|
+
var main = $('mt-lmain');
|
|
2507
|
+
if (e.type === 'dragenter' || e.type === 'dragover') { if (dt) dt.dropEffect = 'copy'; if (main) main.classList.add('dropping'); S.dragAt = Date.now(); return; }
|
|
2508
|
+
if (e.type === 'dragleave') { if (main && (!e.relatedTarget || !pane.contains(e.relatedTarget))) main.classList.remove('dropping'); return; }
|
|
2509
|
+
if (main) main.classList.remove('dropping');
|
|
2510
|
+
var files = filesOf(dt);
|
|
2511
|
+
if (files.length) addLiveFiles(files);
|
|
2512
|
+
else liveNote('This browser did not hand over the file. Use Add file to choose it.');
|
|
2011
2513
|
}
|
|
2012
2514
|
function paintTurns(force) {
|
|
2013
2515
|
var el = $('mt-turns'); if (!el) return;
|
|
@@ -2017,7 +2519,8 @@
|
|
|
2017
2519
|
S.turnsDirty = false;
|
|
2018
2520
|
var f = S.feeds[S.liveSid]; if (!f) return;
|
|
2019
2521
|
var hours = f.turns.some(function (t) { return t.elapsedMs >= 3600000; });
|
|
2020
|
-
// Reading (not following): hold
|
|
2522
|
+
// Reading (not following): hold the first line on screen still while turns above it are refined. A new turn lands
|
|
2523
|
+
// below the reader, inside the box, so it moves nothing and needs no scroll at all.
|
|
2021
2524
|
var anchor = S.follow ? null : turnsAnchor(el);
|
|
2022
2525
|
if (!f.turns.length) {
|
|
2023
2526
|
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>';
|
|
@@ -2038,45 +2541,52 @@
|
|
|
2038
2541
|
prev = node;
|
|
2039
2542
|
});
|
|
2040
2543
|
}
|
|
2041
|
-
if (S.follow)
|
|
2544
|
+
if (S.follow) pinBottom(el); else holdAnchor(el, anchor);
|
|
2545
|
+
paintJump();
|
|
2042
2546
|
repaintAskEnabled(f.sid);
|
|
2043
2547
|
}
|
|
2044
2548
|
function turnInner(t, hours) {
|
|
2045
2549
|
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
2550
|
t.text.split(/\n{2,}/).map(function (p) { return '<p>' + esc(p) + '</p>'; }).join('');
|
|
2047
2551
|
}
|
|
2048
|
-
// iOS Safari has no scroll anchoring
|
|
2049
|
-
//
|
|
2050
|
-
function turnsAnchor(
|
|
2051
|
-
var kids =
|
|
2552
|
+
// iOS Safari has no scroll anchoring, and Chrome's would fight this one (the box sets overflow-anchor: none), so the
|
|
2553
|
+
// anchor is ours on every browser: the first turn still showing in the box, and its distance from the box's top.
|
|
2554
|
+
function turnsAnchor(box) {
|
|
2555
|
+
var kids = box.children, top = box.scrollTop;
|
|
2052
2556
|
for (var i = 0; i < kids.length; i++) {
|
|
2053
|
-
var
|
|
2054
|
-
if (
|
|
2557
|
+
var n = kids[i];
|
|
2558
|
+
if (n.offsetTop + n.offsetHeight > top) return { node: n, off: n.offsetTop - top };
|
|
2055
2559
|
}
|
|
2056
|
-
return
|
|
2560
|
+
return null;
|
|
2057
2561
|
}
|
|
2058
|
-
|
|
2562
|
+
// Moves the box only when a turn above the reader changed height; a write to scrollTop during an iOS fling stops it.
|
|
2563
|
+
function holdAnchor(box, a) {
|
|
2059
2564
|
if (!a || !a.node.isConnected) return;
|
|
2060
|
-
var
|
|
2061
|
-
|
|
2062
|
-
if (Math.abs(dy) >= 1) { S.autoScroll = Date.now(); window.scrollBy(0, dy); }
|
|
2565
|
+
var dy = (a.node.offsetTop - box.scrollTop) - a.off;
|
|
2566
|
+
if (Math.abs(dy) >= 1) box.scrollTop += dy;
|
|
2063
2567
|
}
|
|
2568
|
+
function atBottom(box) { return box.scrollHeight - box.scrollTop - box.clientHeight <= 40; }
|
|
2569
|
+
function pinBottom(box) { var end = box.scrollHeight - box.clientHeight; if (end - box.scrollTop > 0.5) box.scrollTop = end; }
|
|
2064
2570
|
// A question needs words: the panel's buttons follow the transcript's first turn.
|
|
2065
2571
|
function repaintAskEnabled(sid) {
|
|
2066
2572
|
var a = A(sid), en = askEnabled(sid);
|
|
2067
2573
|
if (a.lastEnabled !== en) { a.lastEnabled = en; repaintAsk(sid); }
|
|
2068
2574
|
}
|
|
2069
2575
|
function jumpToLive() {
|
|
2070
|
-
var el = $('mt-turns')
|
|
2071
|
-
if (
|
|
2072
|
-
S.follow = true;
|
|
2576
|
+
var el = $('mt-turns');
|
|
2577
|
+
if (el) pinBottom(el);
|
|
2578
|
+
S.follow = true; paintJump();
|
|
2579
|
+
}
|
|
2580
|
+
// Jump to live floats over the box's bottom edge: showing it never moves a line.
|
|
2581
|
+
function paintJump() {
|
|
2582
|
+
var b = $('mt-jump'), f = S.feeds[S.liveSid];
|
|
2583
|
+
if (b) b.hidden = S.follow || !(f && f.turns.length);
|
|
2073
2584
|
}
|
|
2074
2585
|
function paintLiveFoot() {
|
|
2075
2586
|
var el = $('mt-lfoot'); if (!el || S.view !== 'live') return;
|
|
2076
2587
|
var f = S.feeds[S.liveSid], has = !!(f && f.turns.length);
|
|
2077
2588
|
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>' +
|
|
2589
|
+
'<button type="button" class="cx-btn quiet" data-mt="live-context">Copy full context</button></div>' +
|
|
2080
2590
|
(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
2591
|
if (html !== S.footHtml) { S.footHtml = html; el.innerHTML = html; }
|
|
2082
2592
|
}
|
|
@@ -2088,7 +2598,9 @@
|
|
|
2088
2598
|
if (contextReady([f.sid])) {
|
|
2089
2599
|
var a = A(f.sid);
|
|
2090
2600
|
var cueNote = a.cues ? null : (a.cuesState && a.cuesState !== 'older' ? 'Context coverage: cues could not be read from the server.' : null);
|
|
2091
|
-
|
|
2601
|
+
// The first line names the meeting in plain words ("Live meeting: <title>"), so a session started from this paste
|
|
2602
|
+
// is titled by it; the sections under it are what they were.
|
|
2603
|
+
var text = fullContext(liveContextTitle(f), f.turns.length ? liveCopyText(f) : '', a.history, evidenceFiles(a.history, a.files || []), a.cues || [], cueNote, true) + (a.error ? '\n\nContext coverage: ' + a.error : '');
|
|
2092
2604
|
W.copyText(text).then(function () { done('Copied full meeting context'); }, function (err) { done(err.message); });
|
|
2093
2605
|
return;
|
|
2094
2606
|
}
|
|
@@ -2098,14 +2610,12 @@
|
|
|
2098
2610
|
return askOpen(f.sid, true).then(function () { return loadCues(f.sid); }).then(function () { done('Ready. Tap Copy full context again.'); });
|
|
2099
2611
|
});
|
|
2100
2612
|
}
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
if (!
|
|
2105
|
-
|
|
2106
|
-
|
|
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); }
|
|
2613
|
+
// Following means the end of the transcript shows in its box. Scrolled up is reading: a new line must not pull the
|
|
2614
|
+
// reader down. The page's own scroll is never touched.
|
|
2615
|
+
function onBoxScroll(e) {
|
|
2616
|
+
if (S.view !== 'live' || !e.target || e.target.id !== 'mt-turns') return;
|
|
2617
|
+
var end = atBottom(e.target);
|
|
2618
|
+
if (end !== S.follow) { S.follow = end; paintJump(); if (end && S.turnsDirty) paintTurns(false); }
|
|
2109
2619
|
}
|
|
2110
2620
|
|
|
2111
2621
|
// ── list shell ────────────────────────────────────────
|
|
@@ -2116,7 +2626,7 @@
|
|
|
2116
2626
|
'<div id="mt-cal"></div><div id="mt-toolbar"></div><div id="mt-list"></div></div>';
|
|
2117
2627
|
paintHero(); paintOrphans(); paintLive(); paintCal(); paintToolbar(); paintList();
|
|
2118
2628
|
}
|
|
2119
|
-
function stopTimers() { clearInterval(S.listTimer); clearInterval(S.liveTimer); S.listTimer = null; S.liveTimer = null; }
|
|
2629
|
+
function stopTimers() { clearInterval(S.listTimer); clearInterval(S.liveTimer); clearInterval(S.clockTimer); S.listTimer = null; S.liveTimer = null; S.clockTimer = null; }
|
|
2120
2630
|
|
|
2121
2631
|
// ── events ────────────────────────────────────────────
|
|
2122
2632
|
function exchangeOf(sid, req) { return A(sid).history.filter(function (x) { return x.requestId === req; })[0]; }
|
|
@@ -2165,7 +2675,10 @@
|
|
|
2165
2675
|
return;
|
|
2166
2676
|
}
|
|
2167
2677
|
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)
|
|
2678
|
+
case 'files-toggle': a = A(sid); a.filesOpen = !a.filesOpen; if (S.view === 'live' && S.liveSid === sid) paintLiveFiles(); else repaintAsk(sid); return;
|
|
2679
|
+
case 'add-file': { var pick = $('mt-lpick'); if (pick) pick.click(); return; }
|
|
2680
|
+
case 'up-retry': { var ru = findUpload(t.getAttribute('data-up')); if (ru && ru.state === 'failed' && S.liveSid) sendLiveFile(S.liveSid, ru, true); return; }
|
|
2681
|
+
case 'up-remove': { var xu = findUpload(t.getAttribute('data-up')); if (xu) { abortUpload(xu); LU().items = LU().items.filter(function (q) { return q !== xu; }); paintLiveFiles(); } return; }
|
|
2169
2682
|
case 'ask-act': submit(sid, t.getAttribute('data-action'), t.getAttribute('data-q')); return;
|
|
2170
2683
|
case 'ask-retry': ex = exchangeOf(sid, req); if (ex) submit(sid, ex.action, ex.question, ex.requestId); return;
|
|
2171
2684
|
case 'ask-stop': cancelAnswer(sid, req); return;
|
|
@@ -2185,6 +2698,7 @@
|
|
|
2185
2698
|
case 'live-copy': liveCopy('transcript'); return;
|
|
2186
2699
|
case 'live-context': liveCopy('context'); return;
|
|
2187
2700
|
case 'jump': jumpToLive(); return;
|
|
2701
|
+
case 'prep-bind': bindPrep(t.getAttribute('data-id')); return;
|
|
2188
2702
|
}
|
|
2189
2703
|
});
|
|
2190
2704
|
pane.addEventListener('input', function (e) {
|
|
@@ -2216,6 +2730,7 @@
|
|
|
2216
2730
|
if (sid && A(sid).panelDirty) setTimeout(function () { repaintAsk(sid); }, 0);
|
|
2217
2731
|
});
|
|
2218
2732
|
pane.addEventListener('change', function (e) {
|
|
2733
|
+
if (e.target.id === 'mt-lpick') { addLiveFiles(e.target.files); e.target.value = ''; return; }
|
|
2219
2734
|
if (e.target.id === 'mt-recency') { S.recency = e.target.value; paintBrowse(); }
|
|
2220
2735
|
else if (e.target.id === 'mt-dom') { S.domain = e.target.value; if (queryActive()) runSearch(); else paintBrowse(); }
|
|
2221
2736
|
else if (e.target.hasAttribute && e.target.hasAttribute('data-mt-model')) setModel(e.target.value);
|
|
@@ -2233,7 +2748,13 @@
|
|
|
2233
2748
|
var sid = t.getAttribute('data-sid'); submit(sid, 'ask', A(sid).draft);
|
|
2234
2749
|
}
|
|
2235
2750
|
});
|
|
2236
|
-
|
|
2751
|
+
// scroll does not bubble: the transcript box's own scroll is caught on the way down.
|
|
2752
|
+
pane.addEventListener('scroll', onBoxScroll, true);
|
|
2753
|
+
pane.addEventListener('paste', onLivePaste);
|
|
2754
|
+
// The strip is drawn at the box's own pixel size and in the theme's colours: draw again when either changes.
|
|
2755
|
+
window.addEventListener('resize', function () { if (S.view === 'live') drawMeter(); });
|
|
2756
|
+
if (typeof MutationObserver === 'function') new MutationObserver(function () { if (S.view === 'live') drawMeter(); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
2757
|
+
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (type) { pane.addEventListener(type, onLiveDrag); });
|
|
2237
2758
|
document.addEventListener('visibilitychange', function () {
|
|
2238
2759
|
if (document.hidden) return;
|
|
2239
2760
|
if (S.out) return;
|
|
@@ -2242,6 +2763,12 @@
|
|
|
2242
2763
|
});
|
|
2243
2764
|
window.addEventListener('focus', refreshShownAsk);
|
|
2244
2765
|
|
|
2766
|
+
// Test hooks (server/web-control/live-meeting-view.test.ts drives the real pane through these and through its events).
|
|
2767
|
+
W.meetingsLive = {
|
|
2768
|
+
_state: S, _elapsedLabel: elapsedLabel, _cleanLevels: cleanLevels, _visibleLevels: visibleLevels, _levelSilent: levelSilent,
|
|
2769
|
+
_carriesFile: carriesFile, _filesOf: filesOf, _addLiveFiles: addLiveFiles, _drawMeter: drawMeter, _tickClock: tickClock,
|
|
2770
|
+
_livePoll: livePoll, _feed: function (sid) { return S.feeds[sid]; }, _uploads: function () { return LU(); }
|
|
2771
|
+
};
|
|
2245
2772
|
W.registerPane('meetings', {
|
|
2246
2773
|
load: function (args) {
|
|
2247
2774
|
args = args || [];
|