@sphoro/bugcatch-web-sdk 0.15.0 → 0.17.0
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/README.md +24 -2
- package/dist/bugcatch.es.js +1987 -1675
- package/dist/bugcatch.es.js.map +1 -1
- package/dist/bugcatch.umd.js +176 -112
- package/dist/bugcatch.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/agent-issues.js +30 -0
- package/src/identity.js +1 -1
- package/src/index.js +115 -10
- package/src/references.js +155 -3
- package/src/updates.js +36 -6
- package/src/widget.js +297 -49
package/package.json
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The predefined issues: questions the project's assistant can answer from the
|
|
3
|
+
* reporter's own account and close, offered in the widget as one-tap choices.
|
|
4
|
+
*
|
|
5
|
+
* The API decides the list (GET /ingest/agent-issues) — it is empty unless the
|
|
6
|
+
* project has the Sphoro Agent answering questions and a data source that can
|
|
7
|
+
* answer them. Every failure is an empty list: the tiles are a shortcut, and a
|
|
8
|
+
* widget that could not load them is still a widget that files reports.
|
|
9
|
+
*
|
|
10
|
+
* The agent ends its answer with SOLVED_QUESTION's wording; the widget draws
|
|
11
|
+
* Yes / No under it (widget.js messageHtml). Keep this pattern in step with
|
|
12
|
+
* agent-issues.ts SOLVED_QUESTION in the API and workers.
|
|
13
|
+
*/
|
|
14
|
+
export const SOLVED_PROMPT = /Did this answer your question\?/i;
|
|
15
|
+
|
|
16
|
+
export async function fetchAgentIssues({ apiBase, apiKey, projectId }) {
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(
|
|
19
|
+
`${apiBase}/ingest/agent-issues?projectId=${encodeURIComponent(projectId)}`,
|
|
20
|
+
{ headers: { 'X-BugCatch-Key': apiKey } },
|
|
21
|
+
);
|
|
22
|
+
if (!res.ok) return [];
|
|
23
|
+
const body = await res.json();
|
|
24
|
+
return (Array.isArray(body?.issues) ? body.issues : [])
|
|
25
|
+
.filter((i) => i && typeof i.key === 'string' && typeof i.title === 'string')
|
|
26
|
+
.map((i) => ({ key: i.key, title: i.title, hint: typeof i.hint === 'string' ? i.hint : '' }));
|
|
27
|
+
} catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/identity.js
CHANGED
|
@@ -202,7 +202,7 @@ export const KNOWN_OPTIONS = [
|
|
|
202
202
|
'attachments', 'maxAttachments', 'maxAttachmentBytes', 'maxRecordingMs', 'beforeSend',
|
|
203
203
|
'user', 'context', 'updates', 'updateToast', 'onUpdate', 'voice', 'onCallState',
|
|
204
204
|
'onIncomingCall', 'onReportSubmitted', 'onAssistantPhase', 'onError', 'debug',
|
|
205
|
-
'drafts', 'knownIssues', 'release', 'environment', 'buildId',
|
|
205
|
+
'drafts', 'knownIssues', 'agentIssues', 'release', 'environment', 'buildId',
|
|
206
206
|
];
|
|
207
207
|
|
|
208
208
|
const CALLBACK_OPTIONS = [
|
package/src/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { mountWidget } from './widget.js';
|
|
|
6
6
|
import { uploadAttachment, normalizeMime, REPORT_MIMES } from './capture.js';
|
|
7
7
|
import {
|
|
8
8
|
watchUpdates, createUpdateNotifier, fetchMyReports, fetchReport, writeToken, readToken,
|
|
9
|
-
clearToken, sendMessage, verifyFix, callKindsForProject,
|
|
9
|
+
clearToken, scopeReports, sendMessage, verifyFix, callKindsForProject,
|
|
10
10
|
reportByCall, liveCallAvailable, requestLiveCall, giveUpLiveCall,
|
|
11
11
|
answerIncomingCall, declineIncomingCall,
|
|
12
12
|
readUnread, rememberUpdate, forgetUpdate, forgetReportUpdates, forgetAllUpdates,
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { joinLiveCall, liveCallSupported, openMicrophone } from './live-call.js';
|
|
20
20
|
import { showIncomingCall } from './incoming-call.js';
|
|
21
21
|
import { referenceFields } from './references.js';
|
|
22
|
+
import { fetchAgentIssues } from './agent-issues.js';
|
|
22
23
|
import { normalizeUser, mergeContext, checkOptions, cleanApiBase } from './identity.js';
|
|
23
24
|
|
|
24
25
|
// Replaced at build time from package.json. The guard is for anyone importing
|
|
@@ -188,6 +189,9 @@ class BugCatchClient {
|
|
|
188
189
|
if (this.config.captureConsole) this.teardown.push(installConsoleCapture(this.consoleBuffer));
|
|
189
190
|
if (this.config.captureNetwork) this.teardown.push(installNetworkCapture(this.networkBuffer));
|
|
190
191
|
if (this.config.captureErrors) this.teardown.push(installErrorCapture(this.consoleBuffer));
|
|
192
|
+
// Before anything reads the watch token: it is kept, and asked about, per
|
|
193
|
+
// project. See scopeReports.
|
|
194
|
+
scopeReports(this.config.projectId);
|
|
191
195
|
if (this.config.widget) this.teardown.push(mountWidget(this));
|
|
192
196
|
|
|
193
197
|
// Only if this browser has actually filed something. A visitor who has
|
|
@@ -206,6 +210,7 @@ class BugCatchClient {
|
|
|
206
210
|
}
|
|
207
211
|
|
|
208
212
|
this.started = true;
|
|
213
|
+
this.#sayHello();
|
|
209
214
|
this.debugLog('initialised', {
|
|
210
215
|
version: SDK_IDENTITY.version, apiBase: this.config.apiBase, projectId: this.config.projectId,
|
|
211
216
|
user: this.config.user, context: this.context,
|
|
@@ -213,6 +218,41 @@ class BugCatchClient {
|
|
|
213
218
|
return this;
|
|
214
219
|
}
|
|
215
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Tell the project this site has the SDK — once a day per browser, a few
|
|
223
|
+
* seconds after init so identify() and setContext() have had their chance.
|
|
224
|
+
*
|
|
225
|
+
* What the Web SDK settings page shows under "Installed on": which site,
|
|
226
|
+
* which version, whether identify() is wired. Nothing personal goes: the
|
|
227
|
+
* origin comes from the browser's own Origin header, and the rest is a
|
|
228
|
+
* version string and four booleans. Every failure is silent — this must
|
|
229
|
+
* never cost a page anything but one small request a day.
|
|
230
|
+
*/
|
|
231
|
+
#sayHello() {
|
|
232
|
+
if (typeof window === 'undefined' || typeof fetch !== 'function') return;
|
|
233
|
+
const mark = `bugcatch:hello:${this.config.projectId}`;
|
|
234
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
235
|
+
try { if (window.localStorage.getItem(mark) === today) return; } catch { /* storage blocked: say it anyway */ }
|
|
236
|
+
const timer = setTimeout(() => {
|
|
237
|
+
try { window.localStorage.setItem(mark, today); } catch { /* fine */ }
|
|
238
|
+
fetch(`${this.config.apiBase}/ingest/hello`, {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
keepalive: true,
|
|
241
|
+
headers: { 'Content-Type': 'application/json', 'X-BugCatch-Key': this.config.apiKey },
|
|
242
|
+
body: JSON.stringify({
|
|
243
|
+
projectId: this.config.projectId,
|
|
244
|
+
version: SDK_IDENTITY.version,
|
|
245
|
+
identify: Boolean(this.config.user),
|
|
246
|
+
context: Boolean(this.context && Object.keys(this.context).length),
|
|
247
|
+
widget: Boolean(this.config.widget),
|
|
248
|
+
environment: this.config.environment || null,
|
|
249
|
+
release: this.config.release || null,
|
|
250
|
+
}),
|
|
251
|
+
}).catch(() => {});
|
|
252
|
+
}, 5000);
|
|
253
|
+
this.teardown.push(() => clearTimeout(timer));
|
|
254
|
+
}
|
|
255
|
+
|
|
216
256
|
/**
|
|
217
257
|
* Open the update stream. Idempotent — init() and a first report both reach
|
|
218
258
|
* here, and a second connection would double every notification.
|
|
@@ -461,7 +501,7 @@ class BugCatchClient {
|
|
|
461
501
|
* copies of this would drift, and the half that drifted would be the one that
|
|
462
502
|
* stopped attaching the console.
|
|
463
503
|
*/
|
|
464
|
-
async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [], references = [], context = null }) {
|
|
504
|
+
async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [], references = [], context = null, issue = null }) {
|
|
465
505
|
// This report's own context over the screen's, without keeping it.
|
|
466
506
|
const ctx = context ? mergeContext(this.context, context, (m) => this.warn(m.replace('setContext()', 'report({ context })'))) : this.context;
|
|
467
507
|
return {
|
|
@@ -502,6 +542,9 @@ class BugCatchClient {
|
|
|
502
542
|
// description it is being sent anyway. A payload is a claim, and there is
|
|
503
543
|
// no reason to make the join key one when the source text is right here.
|
|
504
544
|
...(references.length ? { references } : {}),
|
|
545
|
+
// A predefined issue the reporter picked (agentIssues()). The API keeps
|
|
546
|
+
// it only if this project offers it.
|
|
547
|
+
...(issue ? { issue } : {}),
|
|
505
548
|
};
|
|
506
549
|
}
|
|
507
550
|
|
|
@@ -514,16 +557,49 @@ class BugCatchClient {
|
|
|
514
557
|
* a project switched sign-in on, and then a signed-in reporter pressing Call
|
|
515
558
|
* was told to sign in.
|
|
516
559
|
*/
|
|
560
|
+
/**
|
|
561
|
+
* The host's `beforeSend`, applied to anything about to leave the browser.
|
|
562
|
+
*
|
|
563
|
+
* Its own method because it was written into `report()` alone, and a
|
|
564
|
+
* reporter pressing Call files a report by another route — carrying the same
|
|
565
|
+
* console buffer, the same network buffer and the same identify() fields,
|
|
566
|
+
* past a hook whose whole purpose is to strip them. A host that set this to
|
|
567
|
+
* scrub PII was scrubbed on one button and not the other.
|
|
568
|
+
*
|
|
569
|
+
* Returns the payload to send, or false when the host cancelled.
|
|
570
|
+
*/
|
|
571
|
+
#screen(payload) {
|
|
572
|
+
if (typeof this.config.beforeSend !== 'function') return payload;
|
|
573
|
+
let result;
|
|
574
|
+
try {
|
|
575
|
+
result = this.config.beforeSend(payload);
|
|
576
|
+
} catch (err) {
|
|
577
|
+
// A throwing hook is not permission to send what it was meant to clean.
|
|
578
|
+
this.warn(`beforeSend threw, so nothing was sent: ${err?.message || err}`);
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
if (result === false) return false;
|
|
582
|
+
return result && typeof result === 'object' ? result : payload;
|
|
583
|
+
}
|
|
584
|
+
|
|
517
585
|
async #fileByCall() {
|
|
518
586
|
const session = readSession(this.config.projectId);
|
|
519
587
|
|
|
588
|
+
// The same hook the form honours. Cancelling here cancels the call: there
|
|
589
|
+
// is nothing to ring about once the report has been refused.
|
|
590
|
+
const payload = this.#screen(await this.#payload({}));
|
|
591
|
+
if (payload === false) {
|
|
592
|
+
this.debugLog('call cancelled by beforeSend');
|
|
593
|
+
return { cancelled: true };
|
|
594
|
+
}
|
|
595
|
+
|
|
520
596
|
let filed;
|
|
521
597
|
try {
|
|
522
598
|
filed = await reportByCall({
|
|
523
599
|
apiBase: this.config.apiBase,
|
|
524
600
|
apiKey: this.config.apiKey,
|
|
525
601
|
token: session?.token,
|
|
526
|
-
payload
|
|
602
|
+
payload,
|
|
527
603
|
});
|
|
528
604
|
} catch (err) {
|
|
529
605
|
// A session the server no longer recognises, dropped exactly as
|
|
@@ -763,7 +839,12 @@ class BugCatchClient {
|
|
|
763
839
|
// Set after the await, not before: a call that never connected is not one
|
|
764
840
|
// this page is on, and leaving the slot taken would block the retry.
|
|
765
841
|
this.call = handle;
|
|
766
|
-
|
|
842
|
+
// Whether the workspace keeps this call. The widget says so before anybody
|
|
843
|
+
// speaks — the team's incoming card has always been told; the person
|
|
844
|
+
// placing the call was not, which is the wrong way round.
|
|
845
|
+
return Object.assign(handle, {
|
|
846
|
+
bugId, callId: started.call_id, live: true, filed, recorded: Boolean(started.recorded),
|
|
847
|
+
});
|
|
767
848
|
}
|
|
768
849
|
|
|
769
850
|
/**
|
|
@@ -1059,7 +1140,7 @@ class BugCatchClient {
|
|
|
1059
1140
|
/** File a bug with everything captured so far. */
|
|
1060
1141
|
async report({
|
|
1061
1142
|
title, description = '', priority = 'MEDIUM', metadata = {}, attachments = [],
|
|
1062
|
-
references = [], context = null,
|
|
1143
|
+
references = [], context = null, issue = null,
|
|
1063
1144
|
} = {}) {
|
|
1064
1145
|
if (!this.started) throw new Error('[BugCatch] call init() first.');
|
|
1065
1146
|
if (typeof title !== 'string' || !title.trim()) throw new Error('[BugCatch] a title is required.');
|
|
@@ -1094,13 +1175,12 @@ class BugCatchClient {
|
|
|
1094
1175
|
let payload = await this.#payload({
|
|
1095
1176
|
title: title.trim(), description: typeof description === 'string' ? description : String(description ?? ''),
|
|
1096
1177
|
priority: String(priority).toUpperCase(), metadata, media, references: refs, context,
|
|
1178
|
+
issue: typeof issue === 'string' && issue ? issue.slice(0, 32) : null,
|
|
1097
1179
|
});
|
|
1098
1180
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
if (result && typeof result === 'object') payload = result;
|
|
1103
|
-
}
|
|
1181
|
+
const screened = this.#screen(payload);
|
|
1182
|
+
if (screened === false) return { cancelled: true };
|
|
1183
|
+
payload = screened;
|
|
1104
1184
|
|
|
1105
1185
|
this.debugLog('sending report', {
|
|
1106
1186
|
title: payload.title, user: payload.metadata?.user, context: payload.metadata?.context,
|
|
@@ -1216,6 +1296,31 @@ class BugCatchClient {
|
|
|
1216
1296
|
return this.refFields;
|
|
1217
1297
|
}
|
|
1218
1298
|
|
|
1299
|
+
/**
|
|
1300
|
+
* The questions this project's assistant can answer and close by itself —
|
|
1301
|
+
* "When is my next EMI due?", "Is my KYC complete?" — for a form to offer
|
|
1302
|
+
* as one-tap choices.
|
|
1303
|
+
*
|
|
1304
|
+
* const issues = await BugCatch.agentIssues();
|
|
1305
|
+
* // [{ key: 'emi', title: 'When is my next EMI due?', hint: 'The date and the amount' }]
|
|
1306
|
+
* BugCatch.report({ title: issues[0].title, issue: issues[0].key });
|
|
1307
|
+
*
|
|
1308
|
+
* The assistant looks the reporter up (identify() makes that instant),
|
|
1309
|
+
* answers from their record, and asks whether that settled it: yes resolves
|
|
1310
|
+
* the report, no hands it to the team. Empty unless the project has turned
|
|
1311
|
+
* this on and connected a data source that can answer. The widget calls it
|
|
1312
|
+
* itself; cached for the life of the page.
|
|
1313
|
+
*/
|
|
1314
|
+
agentIssues() {
|
|
1315
|
+
if (this.agentIssueList) return this.agentIssueList;
|
|
1316
|
+
this.agentIssueList = fetchAgentIssues({
|
|
1317
|
+
apiBase: this.config.apiBase,
|
|
1318
|
+
apiKey: this.config.apiKey,
|
|
1319
|
+
projectId: this.config.projectId,
|
|
1320
|
+
});
|
|
1321
|
+
return this.agentIssueList;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1219
1324
|
/**
|
|
1220
1325
|
* Who this browser is signed in as on this project, from storage alone.
|
|
1221
1326
|
*
|
package/src/references.js
CHANGED
|
@@ -48,6 +48,24 @@ function compile(source) {
|
|
|
48
48
|
|
|
49
49
|
const tidy = (value) => String(value).replace(/\s+/g, ' ').trim().slice(0, MAX_VALUE_CHARS);
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The value as the customer's database stores it — the same rule as
|
|
53
|
+
* normalizeReferenceValue in bugcatch-api/src/settings/reference-extract.ts,
|
|
54
|
+
* driven by the `normalize` the API sends with each built-in field.
|
|
55
|
+
*/
|
|
56
|
+
export function normalizeValue(field, value) {
|
|
57
|
+
const how = field && field.normalize;
|
|
58
|
+
if (how === 'phone') {
|
|
59
|
+
const digits = value.replace(/\D/g, '');
|
|
60
|
+
if (digits.length === 10) return digits;
|
|
61
|
+
if (digits.length === 12 && digits.startsWith('91')) return digits.slice(2);
|
|
62
|
+
if (digits.length === 11 && digits.startsWith('0')) return digits.slice(1);
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
if (how === 'upper') return value.toUpperCase();
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
51
69
|
/** Group 1 where the pattern has one, the whole match otherwise. */
|
|
52
70
|
function valueOf(match) {
|
|
53
71
|
const raw = match.length > 1 && match[1] !== undefined ? match[1] : match[0];
|
|
@@ -93,10 +111,11 @@ export function matchReferences(fields, texts) {
|
|
|
93
111
|
const hit = valueOf(match);
|
|
94
112
|
if (hit && !overlaps(hit.start, hit.end)) {
|
|
95
113
|
claimed.push([hit.start, hit.end]);
|
|
96
|
-
const
|
|
114
|
+
const value = normalizeValue(field, hit.value);
|
|
115
|
+
const dedupe = `${field.key}:${value.toLowerCase()}`;
|
|
97
116
|
if (!seen.has(dedupe)) {
|
|
98
117
|
seen.add(dedupe);
|
|
99
|
-
found.push({ key: field.key, label: field.label, value
|
|
118
|
+
found.push({ key: field.key, label: field.label, value });
|
|
100
119
|
}
|
|
101
120
|
}
|
|
102
121
|
}
|
|
@@ -106,6 +125,139 @@ export function matchReferences(fields, texts) {
|
|
|
106
125
|
return found;
|
|
107
126
|
}
|
|
108
127
|
|
|
128
|
+
/** Letters the match read outside its value — the label words, roughly. */
|
|
129
|
+
function labelWeight(text, hit) {
|
|
130
|
+
const around = text.slice(hit.start, hit.valueStart) + text.slice(hit.valueEnd, hit.end);
|
|
131
|
+
return (around.match(/[a-z]/gi) || []).length;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Things in the text that look like an identifier nobody labelled: a UUID, a
|
|
136
|
+
* run of six or more digits, a code mixing letters and digits. Only ever used
|
|
137
|
+
* to pre-fill the question, never to skip it.
|
|
138
|
+
*/
|
|
139
|
+
// No lookbehind: older Safari throws on it at parse time, which here would
|
|
140
|
+
// take the whole SDK down rather than one field. Boundaries are checked by hand.
|
|
141
|
+
const LOOSE_ID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[A-Za-z0-9][\w-]{5,40}/gi;
|
|
142
|
+
const WORDISH = /[\w-]/;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* What the reporter's text says about which record this is, sorted by how
|
|
146
|
+
* sure we can be.
|
|
147
|
+
*
|
|
148
|
+
* found — each value one field reads best, and the only value for that
|
|
149
|
+
* field. Enough to send without asking.
|
|
150
|
+
* conflicts — a value two fields read equally well (two bare UUID rules), or
|
|
151
|
+
* a field that found two different values. Asking which is the
|
|
152
|
+
* only honest answer; picking the first is a coin toss filed as
|
|
153
|
+
* a fact.
|
|
154
|
+
* loose — ID-shaped strings no rule claimed, with the fields whose value
|
|
155
|
+
* shape they fit. Offered as the answer when we have to ask
|
|
156
|
+
* anyway, so the reporter confirms instead of retyping.
|
|
157
|
+
*
|
|
158
|
+
* "Reads best" is how many label letters the match consumed: `partner user id
|
|
159
|
+
* 4821` is matched by the partner rule and the plain user rule, and the one
|
|
160
|
+
* that read "partner" knows more. Two rules that read the same words tie, and
|
|
161
|
+
* a tie is a question.
|
|
162
|
+
*/
|
|
163
|
+
export function readReferences(fields, texts) {
|
|
164
|
+
const scannable = (fields || []).filter((f) => f && f.scan !== false && f.pattern);
|
|
165
|
+
const hits = [];
|
|
166
|
+
const loose = [];
|
|
167
|
+
const blocks = (texts || []).map((t) => (typeof t === 'string' ? t.slice(0, MAX_SCAN_CHARS) : ''));
|
|
168
|
+
|
|
169
|
+
blocks.forEach((text, block) => {
|
|
170
|
+
if (!text) return;
|
|
171
|
+
scannable.forEach((field, order) => {
|
|
172
|
+
const re = compile(field.pattern);
|
|
173
|
+
if (!re) return;
|
|
174
|
+
let match;
|
|
175
|
+
let guard = 0;
|
|
176
|
+
re.lastIndex = 0;
|
|
177
|
+
while ((match = re.exec(text)) !== null) {
|
|
178
|
+
if (++guard > MAX_MATCHES) break;
|
|
179
|
+
if (match[0] === '') { re.lastIndex += 1; continue; }
|
|
180
|
+
const hit = valueOf(match);
|
|
181
|
+
if (!hit) continue;
|
|
182
|
+
const raw = match.length > 1 && match[1] !== undefined ? match[1] : match[0];
|
|
183
|
+
const valueStart = match.index + Math.max(0, match[0].indexOf(raw));
|
|
184
|
+
const h = {
|
|
185
|
+
block, order, key: field.key, label: field.label, value: normalizeValue(field, hit.value),
|
|
186
|
+
start: hit.start, end: hit.end, valueStart, valueEnd: valueStart + raw.length,
|
|
187
|
+
};
|
|
188
|
+
h.weight = labelWeight(text, h);
|
|
189
|
+
hits.push(h);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// A hit loses to one that covers the same value and read more label.
|
|
195
|
+
const survivors = hits.filter((h) => !hits.some((o) => o !== h && o.block === h.block
|
|
196
|
+
&& o.key !== h.key && o.valueStart <= h.valueStart && o.valueEnd >= h.valueEnd
|
|
197
|
+
&& o.weight > h.weight));
|
|
198
|
+
|
|
199
|
+
// Group what survived by value: one value, several fields = which kind is it?
|
|
200
|
+
const byValue = new Map();
|
|
201
|
+
for (const h of survivors) {
|
|
202
|
+
const id = h.value.toLowerCase();
|
|
203
|
+
if (!byValue.has(id)) byValue.set(id, { value: h.value, keys: new Map() });
|
|
204
|
+
const entry = byValue.get(id);
|
|
205
|
+
if (!entry.keys.has(h.key)) entry.keys.set(h.key, { key: h.key, label: h.label, order: h.order });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const found = [];
|
|
209
|
+
const conflicts = [];
|
|
210
|
+
const settledKey = new Map(); // key -> values found for it
|
|
211
|
+
for (const entry of byValue.values()) {
|
|
212
|
+
const kinds = [...entry.keys.values()].sort((a, b) => a.order - b.order);
|
|
213
|
+
if (kinds.length > 1) {
|
|
214
|
+
conflicts.push({ value: entry.value, kinds: kinds.map(({ key, label }) => ({ key, label })) });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const [{ key, label }] = kinds;
|
|
218
|
+
if (!settledKey.has(key)) settledKey.set(key, []);
|
|
219
|
+
settledKey.get(key).push({ key, label, value: entry.value });
|
|
220
|
+
}
|
|
221
|
+
for (const list of settledKey.values()) {
|
|
222
|
+
if (list.length === 1) found.push(list[0]);
|
|
223
|
+
else for (const r of list) conflicts.push({ value: r.value, kinds: [{ key: r.key, label: r.label }] });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Loose candidates: ID-shaped, untouched by any rule, and fitting at least
|
|
227
|
+
// one field's value shape. Nothing that is a URL path or an email.
|
|
228
|
+
const seen = new Set(byValue.keys());
|
|
229
|
+
blocks.forEach((text, block) => {
|
|
230
|
+
if (!text) return;
|
|
231
|
+
const taken = hits.filter((h) => h.block === block);
|
|
232
|
+
const re = new RegExp(LOOSE_ID.source, 'gi');
|
|
233
|
+
let m;
|
|
234
|
+
let guard = 0;
|
|
235
|
+
while ((m = re.exec(text)) !== null && ++guard <= MAX_MATCHES) {
|
|
236
|
+
const s = m.index;
|
|
237
|
+
const e = s + m[0].length;
|
|
238
|
+
if (taken.some((h) => s < h.end && e > h.start)) continue;
|
|
239
|
+
const before = text[s - 1] || '';
|
|
240
|
+
const after = text[e] || '';
|
|
241
|
+
if (WORDISH.test(before) || WORDISH.test(after)) continue;
|
|
242
|
+
// Part of a URL, an address or a file name, not a thing someone quoted.
|
|
243
|
+
if (before === '/' || before === '@' || after === '@' || before === '.') continue;
|
|
244
|
+
if (after === '.' && /\w/.test(text[e + 1] || '')) continue;
|
|
245
|
+
if (!/\d/.test(m[0])) continue;
|
|
246
|
+
const value = tidy(m[0]);
|
|
247
|
+
const id = value.toLowerCase();
|
|
248
|
+
if (seen.has(id)) continue;
|
|
249
|
+
const kinds = (fields || [])
|
|
250
|
+
.filter((f) => f && validateReference(f, value))
|
|
251
|
+
.map(({ key, label }) => ({ key, label }));
|
|
252
|
+
if (!kinds.length) continue;
|
|
253
|
+
seen.add(id);
|
|
254
|
+
loose.push({ value, kinds });
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
return { found, conflicts, loose };
|
|
259
|
+
}
|
|
260
|
+
|
|
109
261
|
/**
|
|
110
262
|
* Whether what somebody typed into the box is the shape that field asks for.
|
|
111
263
|
*
|
|
@@ -140,7 +292,7 @@ export function validateReference(field, raw) {
|
|
|
140
292
|
const atEdge = match.index === 0 || match.index === 1;
|
|
141
293
|
if (toEnd && atEdge) {
|
|
142
294
|
const hit = valueOf(match);
|
|
143
|
-
if (hit) return hit.value;
|
|
295
|
+
if (hit) return normalizeValue(field, hit.value);
|
|
144
296
|
}
|
|
145
297
|
}
|
|
146
298
|
return null;
|
package/src/updates.js
CHANGED
|
@@ -38,10 +38,39 @@ const BACKOFF_MS = [2000, 5000, 15000, 30000, 60000];
|
|
|
38
38
|
*/
|
|
39
39
|
const STABLE_MS = 5000;
|
|
40
40
|
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* The project this page's widget reports to. Set once by init().
|
|
43
|
+
*
|
|
44
|
+
* The token used to live under one key for the whole origin, and the server
|
|
45
|
+
* answered it with everything its reporter had filed across the workspace — so
|
|
46
|
+
* "Your reports" on project A listed project B's bugs, and two projects on one
|
|
47
|
+
* origin overwrote each other's token. It is now kept per project, and sent
|
|
48
|
+
* with the project so the server can narrow tokens minted before that.
|
|
49
|
+
*/
|
|
50
|
+
let project = null;
|
|
51
|
+
|
|
52
|
+
export function scopeReports(projectId) {
|
|
53
|
+
project = projectId || null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const tokenKey = () => (project ? `${TOKEN_KEY}:${project}` : TOKEN_KEY);
|
|
57
|
+
|
|
58
|
+
/** `?projectId=` for the watch routes that list or open reports. */
|
|
59
|
+
export function projectQuery() {
|
|
60
|
+
return project ? `?projectId=${encodeURIComponent(project)}` : '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Where the watch token lives. Storage can throw in private modes — never fatal.
|
|
65
|
+
*
|
|
66
|
+
* Falls back to the unscoped key an older SDK wrote, so an upgrade does not
|
|
67
|
+
* lose someone's history. That token may belong to another project on this
|
|
68
|
+
* origin; the `projectId` sent with every read is what keeps it to this one.
|
|
69
|
+
*/
|
|
42
70
|
export function readToken() {
|
|
43
71
|
try {
|
|
44
|
-
|
|
72
|
+
const store = window.localStorage;
|
|
73
|
+
return store?.getItem(tokenKey()) || store?.getItem(TOKEN_KEY) || null;
|
|
45
74
|
} catch {
|
|
46
75
|
return null;
|
|
47
76
|
}
|
|
@@ -50,12 +79,13 @@ export function readToken() {
|
|
|
50
79
|
export function writeToken(token) {
|
|
51
80
|
if (!token) return;
|
|
52
81
|
try {
|
|
53
|
-
window.localStorage?.setItem(
|
|
82
|
+
window.localStorage?.setItem(tokenKey(), token);
|
|
54
83
|
} catch { /* storage unavailable — updates just won't survive a reload */ }
|
|
55
84
|
}
|
|
56
85
|
|
|
57
86
|
export function clearToken() {
|
|
58
87
|
try {
|
|
88
|
+
window.localStorage?.removeItem(tokenKey());
|
|
59
89
|
window.localStorage?.removeItem(TOKEN_KEY);
|
|
60
90
|
} catch { /* ignore */ }
|
|
61
91
|
// Anything still waiting to be read goes with it. Unread updates are about
|
|
@@ -200,7 +230,7 @@ export async function fetchMyReports({ apiBase }) {
|
|
|
200
230
|
const token = readToken();
|
|
201
231
|
if (!token) return { reporter: null, reports: [] };
|
|
202
232
|
|
|
203
|
-
const res = await fetch(`${apiBase}/ingest/watch/reports`, {
|
|
233
|
+
const res = await fetch(`${apiBase}/ingest/watch/reports${projectQuery()}`, {
|
|
204
234
|
headers: { Authorization: `Bearer ${token}` },
|
|
205
235
|
});
|
|
206
236
|
|
|
@@ -226,7 +256,7 @@ export async function fetchReport({ apiBase, bugId }) {
|
|
|
226
256
|
const token = readToken();
|
|
227
257
|
if (!token) return null;
|
|
228
258
|
|
|
229
|
-
const res = await fetch(`${apiBase}/ingest/watch/reports/${encodeURIComponent(bugId)}`, {
|
|
259
|
+
const res = await fetch(`${apiBase}/ingest/watch/reports/${encodeURIComponent(bugId)}${projectQuery()}`, {
|
|
230
260
|
headers: { Authorization: `Bearer ${token}` },
|
|
231
261
|
});
|
|
232
262
|
if (res.status === 401) {
|
|
@@ -549,7 +579,7 @@ export function watchUpdates({ apiBase, onUpdate }) {
|
|
|
549
579
|
if (!token) throw new Error('nothing filed from this browser yet');
|
|
550
580
|
|
|
551
581
|
controller = new AbortController();
|
|
552
|
-
const res = await fetch(`${apiBase}/ingest/watch/stream`, {
|
|
582
|
+
const res = await fetch(`${apiBase}/ingest/watch/stream${projectQuery()}`, {
|
|
553
583
|
headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
|
|
554
584
|
signal: controller.signal,
|
|
555
585
|
});
|