langcoach 0.1.0 → 0.4.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/LICENSE +21 -21
- package/README.md +129 -66
- package/dist/index.js +1834 -64
- package/extension/_locales/en/messages.json +1117 -0
- package/extension/_locales/es/messages.json +1117 -0
- package/extension/_locales/fr/messages.json +1117 -0
- package/extension/_locales/pt_BR/messages.json +1117 -0
- package/extension/_locales/tr/messages.json +1117 -0
- package/extension/assets/sidepanel-BBkB7IY_.css +1988 -0
- package/extension/background.js +1584 -0
- package/extension/chunks/translate-BpGu9iuQ.js +9167 -0
- package/extension/content-isolated.js +934 -0
- package/extension/content-main.js +140 -0
- package/extension/icons/128.png +0 -0
- package/extension/icons/16.png +0 -0
- package/extension/icons/32.png +0 -0
- package/extension/icons/48.png +0 -0
- package/extension/manifest.json +63 -0
- package/extension/sidepanel.html +14 -0
- package/extension/sidepanel.js +8278 -0
- package/package.json +3 -2
|
@@ -0,0 +1,1584 @@
|
|
|
1
|
+
import { C as toLocalDate, S as isoWeek, T as emptyState, _ as parseState, b as NotFoundError, c as version, g as isDue, l as DEFAULT_SETTINGS, m as progressOf, p as Coach, r as translationPair, s as suggestThemes, u as isMessage, v as CorruptDataError, y as InvalidDataError } from "./chunks/translate-BpGu9iuQ.js";
|
|
2
|
+
//#region src/shared/dev-hosts.ts
|
|
3
|
+
/**
|
|
4
|
+
* Local hosts the e2e build (and unit tests) accept in place of labs.google. Production builds define
|
|
5
|
+
* `__E2E__` as false, so this folds to an empty list and the host names are compiled out of the bundle
|
|
6
|
+
* (the zip script refuses any bundle that still contains them).
|
|
7
|
+
*/
|
|
8
|
+
var DEV_HOSTNAMES = [];
|
|
9
|
+
/** http(s) URL on one of the dev hosts (always false in production builds). */
|
|
10
|
+
function isDevHostUrl(url) {
|
|
11
|
+
if (DEV_HOSTNAMES.length === 0) return false;
|
|
12
|
+
try {
|
|
13
|
+
const u = new URL(url);
|
|
14
|
+
return (u.protocol === "http:" || u.protocol === "https:") && DEV_HOSTNAMES.includes(u.hostname);
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/lib/parse.ts
|
|
21
|
+
var isObj$3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
22
|
+
var str = (v) => typeof v === "string" ? v : void 0;
|
|
23
|
+
var nonEmpty = (v) => {
|
|
24
|
+
const s = str(v)?.trim();
|
|
25
|
+
return s ? s : void 0;
|
|
26
|
+
};
|
|
27
|
+
/** Gendered pairs arrive newline-separated; keep them as one entry joined by " / ". */
|
|
28
|
+
var joinLines = (s) => s.split("\n").map((p) => p.trim()).filter(Boolean).join(" / ");
|
|
29
|
+
function header(request) {
|
|
30
|
+
if (!isObj$3(request)) return null;
|
|
31
|
+
const sourceLanguage = nonEmpty(request.sourceLanguage);
|
|
32
|
+
const targetLanguage = nonEmpty(request.targetLanguage);
|
|
33
|
+
if (!sourceLanguage || !targetLanguage) return null;
|
|
34
|
+
return {
|
|
35
|
+
sourceLanguage,
|
|
36
|
+
targetLanguage,
|
|
37
|
+
req: request
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function termItems(list, termKey) {
|
|
41
|
+
if (!Array.isArray(list)) return [];
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const e of list) {
|
|
44
|
+
if (!isObj$3(e)) continue;
|
|
45
|
+
const term = joinLines(str(e[termKey]) ?? "");
|
|
46
|
+
const translation = nonEmpty(e.translation);
|
|
47
|
+
if (!term || translation === void 0) continue;
|
|
48
|
+
const pron = joinLines(str(e.transliteration) ?? "");
|
|
49
|
+
out.push({
|
|
50
|
+
term,
|
|
51
|
+
translation,
|
|
52
|
+
example: "",
|
|
53
|
+
...pron ? { pronunciation: pron } : {}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
function parseInner(kind, requestBody, responseBody) {
|
|
59
|
+
const h = header(requestBody);
|
|
60
|
+
if (!h || !isObj$3(responseBody)) return null;
|
|
61
|
+
const { sourceLanguage, targetLanguage, req } = h;
|
|
62
|
+
const base = {
|
|
63
|
+
sourceLanguage,
|
|
64
|
+
targetLanguage
|
|
65
|
+
};
|
|
66
|
+
if (kind === "terms") {
|
|
67
|
+
const vocab = termItems(responseBody.vocabulary, "term");
|
|
68
|
+
const phrases = termItems(responseBody.phrases, "phrase");
|
|
69
|
+
if (!vocab || !phrases) return null;
|
|
70
|
+
const items = [...vocab, ...phrases];
|
|
71
|
+
if (items.length === 0) return null;
|
|
72
|
+
const purpose = nonEmpty(req.purpose);
|
|
73
|
+
return {
|
|
74
|
+
lessonKind: "tiny_lesson",
|
|
75
|
+
...base,
|
|
76
|
+
...purpose ? { purpose } : {},
|
|
77
|
+
situation: purpose ?? "Tiny Lesson",
|
|
78
|
+
items
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (kind === "grammar") {
|
|
82
|
+
const rg = responseBody.relevantGrammar;
|
|
83
|
+
if (!Array.isArray(rg) || rg.length === 0) return null;
|
|
84
|
+
const grammar = [];
|
|
85
|
+
for (const g of rg) {
|
|
86
|
+
if (!isObj$3(g)) continue;
|
|
87
|
+
const topic = nonEmpty(g.topic);
|
|
88
|
+
const description = str(g.description);
|
|
89
|
+
if (!topic || description === void 0 || !Array.isArray(g.examples)) continue;
|
|
90
|
+
const examples = [];
|
|
91
|
+
for (const x of g.examples) {
|
|
92
|
+
if (!isObj$3(x)) continue;
|
|
93
|
+
const sentence = nonEmpty(x.sentence);
|
|
94
|
+
const translation = nonEmpty(x.translation);
|
|
95
|
+
if (!sentence || translation === void 0) continue;
|
|
96
|
+
const tr = nonEmpty(x.transliteration);
|
|
97
|
+
const ex = nonEmpty(x.explanation);
|
|
98
|
+
examples.push({
|
|
99
|
+
sentence,
|
|
100
|
+
translation,
|
|
101
|
+
...tr ? { transliteration: tr } : {},
|
|
102
|
+
...ex ? { explanation: ex } : {}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
grammar.push({
|
|
106
|
+
topic,
|
|
107
|
+
description,
|
|
108
|
+
examples
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (grammar.length === 0) return null;
|
|
112
|
+
const purpose = nonEmpty(req.purpose);
|
|
113
|
+
return {
|
|
114
|
+
lessonKind: "grammar",
|
|
115
|
+
...base,
|
|
116
|
+
...purpose ? { purpose } : {},
|
|
117
|
+
situation: purpose ?? "Grammar",
|
|
118
|
+
items: [],
|
|
119
|
+
grammar
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (kind === "conversation") {
|
|
123
|
+
const context = str(responseBody.context);
|
|
124
|
+
const dialogue = responseBody.dialogue;
|
|
125
|
+
if (context === void 0 || !Array.isArray(dialogue) || dialogue.length === 0) return null;
|
|
126
|
+
const items = [];
|
|
127
|
+
for (const line of dialogue) {
|
|
128
|
+
if (!isObj$3(line)) continue;
|
|
129
|
+
const message = str(line.message);
|
|
130
|
+
const notes = line.notes;
|
|
131
|
+
if (message === void 0 || !isObj$3(notes)) continue;
|
|
132
|
+
const pron = nonEmpty(line.transliteration);
|
|
133
|
+
for (const [key, expl] of Object.entries(notes)) {
|
|
134
|
+
const explanation = nonEmpty(expl);
|
|
135
|
+
if (!key.trim() || explanation === void 0) continue;
|
|
136
|
+
items.push({
|
|
137
|
+
term: key,
|
|
138
|
+
translation: explanation,
|
|
139
|
+
example: message,
|
|
140
|
+
...pron ? { pronunciation: pron } : {}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (items.length === 0) return null;
|
|
145
|
+
return {
|
|
146
|
+
lessonKind: "slang_hang",
|
|
147
|
+
...base,
|
|
148
|
+
situation: Array.from(context).slice(0, 200).join("") || "Slang Hang",
|
|
149
|
+
items
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const descriptors = responseBody.descriptors;
|
|
153
|
+
if (!Array.isArray(descriptors) || descriptors.length === 0) return null;
|
|
154
|
+
const items = [];
|
|
155
|
+
for (const d of descriptors) {
|
|
156
|
+
if (!isObj$3(d)) continue;
|
|
157
|
+
const term = nonEmpty(d.descriptor);
|
|
158
|
+
const translation = nonEmpty(d.translation);
|
|
159
|
+
if (!term || translation === void 0) continue;
|
|
160
|
+
const pron = nonEmpty(d.pronunciation);
|
|
161
|
+
items.push({
|
|
162
|
+
term,
|
|
163
|
+
translation,
|
|
164
|
+
example: str(d.exampleSentence) ?? "",
|
|
165
|
+
...pron ? { pronunciation: pron } : {}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (items.length === 0) return null;
|
|
169
|
+
return {
|
|
170
|
+
lessonKind: "word_cam",
|
|
171
|
+
...base,
|
|
172
|
+
situation: nonEmpty(req.object) ?? "Word Cam",
|
|
173
|
+
items
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** Pure; returns null (never throws) on any unexpected shape. */
|
|
177
|
+
function parseCapture(kind, requestBody, responseBody) {
|
|
178
|
+
try {
|
|
179
|
+
return parseInner(kind, requestBody, responseBody);
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/background/capture.ts
|
|
186
|
+
var LAST_CAPTURE_KEY = "langcoach:lastCapture";
|
|
187
|
+
var PENDING_GRAMMAR_KEY = "langcoach:pendingGrammar";
|
|
188
|
+
function jsonSize(v) {
|
|
189
|
+
try {
|
|
190
|
+
return JSON.stringify(v)?.length ?? 0;
|
|
191
|
+
} catch {
|
|
192
|
+
return Infinity;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
var isObj$2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
196
|
+
var pairKey = (targetLanguage, purpose) => JSON.stringify([targetLanguage, purpose ?? ""]);
|
|
197
|
+
function asLastCapture(v) {
|
|
198
|
+
if (!isObj$2(v) || !Array.isArray(v.lessonIds) || typeof v.at !== "string") return null;
|
|
199
|
+
if (!v.lessonIds.every((x) => typeof x === "string")) return null;
|
|
200
|
+
return v;
|
|
201
|
+
}
|
|
202
|
+
/** Promise-chain mutex: captures and undos run one at a time (terms and grammar arrive in parallel). */
|
|
203
|
+
function serializer() {
|
|
204
|
+
let tail = Promise.resolve();
|
|
205
|
+
return (task) => {
|
|
206
|
+
const next = tail.then(task, task);
|
|
207
|
+
tail = next.catch(() => void 0);
|
|
208
|
+
return next;
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function createCaptureService(deps) {
|
|
212
|
+
const { coach, store, area, clock, log } = deps;
|
|
213
|
+
const run = serializer();
|
|
214
|
+
/** The translation pair for this capture, or null (no profile, unknown/same native language). */
|
|
215
|
+
async function nativePair(p, nativeLanguage) {
|
|
216
|
+
try {
|
|
217
|
+
const pair = translationPair(p.sourceLanguage, nativeLanguage);
|
|
218
|
+
if (!pair) return null;
|
|
219
|
+
const overlay = deps.getShowNativeOnPage ? await deps.getShowNativeOnPage() : true;
|
|
220
|
+
return {
|
|
221
|
+
...pair,
|
|
222
|
+
overlay
|
|
223
|
+
};
|
|
224
|
+
} catch (e) {
|
|
225
|
+
log("LangCoach: native translation hint failed", e);
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Card id, term and English meaning of every item of the capture (new or already in the deck). One load. */
|
|
230
|
+
async function nativeHint(p) {
|
|
231
|
+
const s = await store.load();
|
|
232
|
+
const pair = await nativePair(p, s.profile?.nativeLanguage);
|
|
233
|
+
if (!pair) return void 0;
|
|
234
|
+
const byKey = new Map(s.cards.map((c) => [c.term.trim().toLocaleLowerCase(), c]));
|
|
235
|
+
const seen = /* @__PURE__ */ new Set();
|
|
236
|
+
const items = [];
|
|
237
|
+
for (const it of p.items) {
|
|
238
|
+
const card = byKey.get(it.term.trim().toLocaleLowerCase());
|
|
239
|
+
if (!card || seen.has(card.id)) continue;
|
|
240
|
+
seen.add(card.id);
|
|
241
|
+
items.push({
|
|
242
|
+
cardId: card.id,
|
|
243
|
+
term: card.term,
|
|
244
|
+
meaning: card.translation,
|
|
245
|
+
...card.nativeTranslation ? { native: card.nativeTranslation } : {}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return items.length ? {
|
|
249
|
+
...pair,
|
|
250
|
+
items: items.slice(0, 500)
|
|
251
|
+
} : void 0;
|
|
252
|
+
}
|
|
253
|
+
/** Pending grammar entries that have not expired. */
|
|
254
|
+
async function readPending(now) {
|
|
255
|
+
const raw = (await area.get(PENDING_GRAMMAR_KEY))[PENDING_GRAMMAR_KEY];
|
|
256
|
+
const map = {};
|
|
257
|
+
let pruned = false;
|
|
258
|
+
if (isObj$2(raw)) for (const [k, e] of Object.entries(raw)) if (isObj$2(e) && Array.isArray(e.grammar) && typeof e.at === "string" && now - Date.parse(e.at) <= 3e5) map[k] = e;
|
|
259
|
+
else pruned = true;
|
|
260
|
+
return {
|
|
261
|
+
map,
|
|
262
|
+
pruned
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
async function writePending(map) {
|
|
266
|
+
if (Object.keys(map).length === 0) await area.remove(PENDING_GRAMMAR_KEY);
|
|
267
|
+
else await area.set({ [PENDING_GRAMMAR_KEY]: map });
|
|
268
|
+
}
|
|
269
|
+
async function handleGrammar(p) {
|
|
270
|
+
const grammar = p.grammar ?? [];
|
|
271
|
+
const now = clock().getTime();
|
|
272
|
+
const s = await store.load();
|
|
273
|
+
let match;
|
|
274
|
+
for (const l of s.lessons) {
|
|
275
|
+
const age = now - Date.parse(l.createdAt);
|
|
276
|
+
if (l.kind === "tiny_lesson" && l.source?.targetLanguage === p.targetLanguage && l.source?.purpose === p.purpose && age >= 0 && age <= 3e5) match = l;
|
|
277
|
+
}
|
|
278
|
+
if (match) {
|
|
279
|
+
const lc = asLastCapture((await area.get(LAST_CAPTURE_KEY))[LAST_CAPTURE_KEY]);
|
|
280
|
+
const id = lc?.lessonIds.includes(match.id) ? lc.lessonIds[0] : match.id;
|
|
281
|
+
await coach.attachGrammar(id, grammar);
|
|
282
|
+
return grammarResult(p, s.profile?.nativeLanguage);
|
|
283
|
+
}
|
|
284
|
+
const { map } = await readPending(now);
|
|
285
|
+
map[pairKey(p.targetLanguage, p.purpose)] = {
|
|
286
|
+
grammar,
|
|
287
|
+
at: new Date(now).toISOString()
|
|
288
|
+
};
|
|
289
|
+
await writePending(map);
|
|
290
|
+
return grammarResult(p, s.profile?.nativeLanguage);
|
|
291
|
+
}
|
|
292
|
+
async function grammarResult(p, nativeLanguage) {
|
|
293
|
+
const pair = await nativePair(p, nativeLanguage);
|
|
294
|
+
const examples = [...new Set((p.grammar ?? []).flatMap((g) => g.examples.map((x) => x.translation.trim()).filter(Boolean)))].slice(0, 100);
|
|
295
|
+
return pair && pair.overlay && examples.length ? {
|
|
296
|
+
status: "grammar",
|
|
297
|
+
native: {
|
|
298
|
+
...pair,
|
|
299
|
+
examples
|
|
300
|
+
}
|
|
301
|
+
} : { status: "grammar" };
|
|
302
|
+
}
|
|
303
|
+
async function saveItems(p, ctx) {
|
|
304
|
+
const kind = p.lessonKind;
|
|
305
|
+
const now = clock().getTime();
|
|
306
|
+
let grammar;
|
|
307
|
+
let pending;
|
|
308
|
+
let pendingDirty = false;
|
|
309
|
+
if (kind === "tiny_lesson") {
|
|
310
|
+
const r = await readPending(now);
|
|
311
|
+
pending = r.map;
|
|
312
|
+
pendingDirty = r.pruned;
|
|
313
|
+
const key = pairKey(p.targetLanguage, p.purpose);
|
|
314
|
+
if (pending[key]) {
|
|
315
|
+
grammar = pending[key].grammar;
|
|
316
|
+
delete pending[key];
|
|
317
|
+
pendingDirty = true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const source = {
|
|
321
|
+
app: "lll",
|
|
322
|
+
targetLanguage: p.targetLanguage,
|
|
323
|
+
sourceLanguage: p.sourceLanguage,
|
|
324
|
+
...p.purpose ? { purpose: p.purpose } : {}
|
|
325
|
+
};
|
|
326
|
+
const saved = [];
|
|
327
|
+
try {
|
|
328
|
+
for (let i = 0; i * 50 < p.items.length; i++) {
|
|
329
|
+
const items = p.items.slice(i * 50, (i + 1) * 50);
|
|
330
|
+
const r = await coach.saveLesson({
|
|
331
|
+
kind,
|
|
332
|
+
situation: i === 0 ? p.situation : `${p.situation} (${i + 1})`,
|
|
333
|
+
items,
|
|
334
|
+
...i === 0 && grammar ? { grammar } : {},
|
|
335
|
+
source
|
|
336
|
+
});
|
|
337
|
+
saved.push({
|
|
338
|
+
id: r.lesson.id,
|
|
339
|
+
newCards: r.newCards,
|
|
340
|
+
duplicates: r.duplicates.length
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
} catch (e) {
|
|
344
|
+
for (const s of saved) try {
|
|
345
|
+
await coach.undoLesson(s.id);
|
|
346
|
+
} catch (e2) {
|
|
347
|
+
log("LangCoach: rollback failed", e2);
|
|
348
|
+
}
|
|
349
|
+
throw e;
|
|
350
|
+
}
|
|
351
|
+
if (pending && pendingDirty) await writePending(pending);
|
|
352
|
+
const lessonIds = saved.map((s) => s.id);
|
|
353
|
+
const newCards = saved.reduce((n, s) => n + s.newCards, 0);
|
|
354
|
+
const duplicates = saved.reduce((n, s) => n + s.duplicates, 0);
|
|
355
|
+
let xp = 0;
|
|
356
|
+
try {
|
|
357
|
+
xp = (await coach.autoComplete(kind, { lessonId: lessonIds[0] })).xpAwarded;
|
|
358
|
+
} catch (e) {
|
|
359
|
+
log("LangCoach: could not record the activity", e);
|
|
360
|
+
}
|
|
361
|
+
const lc = {
|
|
362
|
+
lessonIds,
|
|
363
|
+
newCards,
|
|
364
|
+
duplicates,
|
|
365
|
+
at: new Date(now).toISOString(),
|
|
366
|
+
tabId: ctx.tabId ?? null
|
|
367
|
+
};
|
|
368
|
+
await area.set({ [LAST_CAPTURE_KEY]: lc });
|
|
369
|
+
let native;
|
|
370
|
+
try {
|
|
371
|
+
native = await nativeHint(p);
|
|
372
|
+
} catch (e) {
|
|
373
|
+
log("LangCoach: native translation hint failed", e);
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
status: "saved",
|
|
377
|
+
lessonIds,
|
|
378
|
+
newCards,
|
|
379
|
+
duplicates,
|
|
380
|
+
xp,
|
|
381
|
+
...native ? { native } : {}
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
capture(msg, ctx = {}) {
|
|
386
|
+
if (jsonSize(msg.response) > 2097152) {
|
|
387
|
+
log(`LangCoach: ignored a ${msg.kind} response larger than 2 MB`);
|
|
388
|
+
return Promise.resolve(null);
|
|
389
|
+
}
|
|
390
|
+
const parsed = parseCapture(msg.kind, msg.request, msg.response);
|
|
391
|
+
if (parsed && parsed.items.length > 500) {
|
|
392
|
+
log(`LangCoach: ignored a ${msg.kind} response with more than 500 items`);
|
|
393
|
+
return Promise.resolve(null);
|
|
394
|
+
}
|
|
395
|
+
if (!parsed) {
|
|
396
|
+
log(`LangCoach: ignored a ${msg.kind} response with an unknown shape`);
|
|
397
|
+
return Promise.resolve(null);
|
|
398
|
+
}
|
|
399
|
+
return run(async () => {
|
|
400
|
+
try {
|
|
401
|
+
if (!(await store.load()).profile) await deps.notePendingTarget(parsed.targetLanguage);
|
|
402
|
+
if (parsed.lessonKind === "grammar") return await handleGrammar(parsed);
|
|
403
|
+
if (!msg.force && !await deps.getAutoSave()) return {
|
|
404
|
+
status: "pending",
|
|
405
|
+
count: parsed.items.length
|
|
406
|
+
};
|
|
407
|
+
return await saveItems(parsed, ctx);
|
|
408
|
+
} catch (e) {
|
|
409
|
+
log("LangCoach: capture failed", e);
|
|
410
|
+
throw e;
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
},
|
|
414
|
+
undo(lessonIds) {
|
|
415
|
+
return run(async () => {
|
|
416
|
+
const lc = asLastCapture((await area.get(LAST_CAPTURE_KEY))[LAST_CAPTURE_KEY]);
|
|
417
|
+
const age = lc ? clock().getTime() - Date.parse(lc.at) : NaN;
|
|
418
|
+
if (!lc || !(age >= 0 && age <= 6e5)) throw new InvalidDataError("Nothing to undo: the last capture is gone or older than 10 minutes");
|
|
419
|
+
if (!lessonIds.every((id) => lc.lessonIds.includes(id))) throw new InvalidDataError("Only the most recent capture can be undone");
|
|
420
|
+
let removedCards = 0;
|
|
421
|
+
for (const id of lessonIds) try {
|
|
422
|
+
removedCards += (await coach.undoLesson(id)).removedCards;
|
|
423
|
+
} catch (e) {
|
|
424
|
+
if (!(e instanceof NotFoundError)) throw e;
|
|
425
|
+
}
|
|
426
|
+
const rest = lc.lessonIds.filter((id) => !lessonIds.includes(id));
|
|
427
|
+
if (rest.length) await area.set({ [LAST_CAPTURE_KEY]: {
|
|
428
|
+
...lc,
|
|
429
|
+
lessonIds: rest
|
|
430
|
+
} });
|
|
431
|
+
else await area.remove(LAST_CAPTURE_KEY);
|
|
432
|
+
return { removedCards };
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region src/background/router.ts
|
|
439
|
+
var SETTINGS_KEY = "langcoach:settings";
|
|
440
|
+
/** Pending one-time rollover banners (Banner[]), removed when dismissed. */
|
|
441
|
+
var BANNERS_KEY = "langcoach:banners";
|
|
442
|
+
/** Local date (YYYY-MM-DD) of the last rollover, so the panel triggers it at most once per day. */
|
|
443
|
+
var ROLLOVER_KEY = "langcoach:rolloverDate";
|
|
444
|
+
function toErrorInfo(e) {
|
|
445
|
+
if (e instanceof CorruptDataError) return {
|
|
446
|
+
name: e.name,
|
|
447
|
+
message: e.message,
|
|
448
|
+
preservedAt: e.preservedAt
|
|
449
|
+
};
|
|
450
|
+
if (e instanceof InvalidDataError) return {
|
|
451
|
+
name: e.name,
|
|
452
|
+
message: e.message,
|
|
453
|
+
code: "invalid"
|
|
454
|
+
};
|
|
455
|
+
if (e instanceof Error) return {
|
|
456
|
+
name: e.name,
|
|
457
|
+
message: e.message
|
|
458
|
+
};
|
|
459
|
+
return {
|
|
460
|
+
name: "Error",
|
|
461
|
+
message: String(e)
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
var MAX_NATIVE_ENTRIES = 500;
|
|
465
|
+
var MAX_PRACTICE_CARDS = 500;
|
|
466
|
+
var DEFAULT_PRACTICE_CARDS = 200;
|
|
467
|
+
var MAX_PRACTICE_RESULTS = 50;
|
|
468
|
+
var PRACTICE_OUTCOMES = [
|
|
469
|
+
"correct",
|
|
470
|
+
"hard",
|
|
471
|
+
"wrong"
|
|
472
|
+
];
|
|
473
|
+
var MAX_NATIVE_CHARS = 500;
|
|
474
|
+
var CAPTURE_KINDS = [
|
|
475
|
+
"terms",
|
|
476
|
+
"grammar",
|
|
477
|
+
"conversation",
|
|
478
|
+
"object-descriptors"
|
|
479
|
+
];
|
|
480
|
+
var MAX_JSON = 20971520;
|
|
481
|
+
var bad = (m) => {
|
|
482
|
+
throw new InvalidDataError(m);
|
|
483
|
+
};
|
|
484
|
+
var isObj$1 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
485
|
+
var id = (v, name) => {
|
|
486
|
+
if (typeof v !== "string" || v === "" || v.length > 200) bad(`${name} must be a non-empty string up to 200 characters`);
|
|
487
|
+
};
|
|
488
|
+
var optStr = (v, name) => {
|
|
489
|
+
if (v !== void 0 && typeof v !== "string") bad(`${name} must be a string`);
|
|
490
|
+
};
|
|
491
|
+
/** Runtime payload guard per message type; throws InvalidDataError (code 'invalid'). */
|
|
492
|
+
function validateMessage(m) {
|
|
493
|
+
switch (m.type) {
|
|
494
|
+
case "capture":
|
|
495
|
+
if (!CAPTURE_KINDS.includes(m.kind)) bad("kind is not a known capture kind");
|
|
496
|
+
if (typeof m.pageUrl !== "string") bad("pageUrl must be a string");
|
|
497
|
+
if (m.force !== void 0 && typeof m.force !== "boolean") bad("force must be a boolean");
|
|
498
|
+
break;
|
|
499
|
+
case "undo":
|
|
500
|
+
if (!Array.isArray(m.lessonIds) || m.lessonIds.length < 1 || m.lessonIds.length > 100) bad("lessonIds must be an array of 1 to 100 ids");
|
|
501
|
+
m.lessonIds.forEach((x, i) => id(x, `lessonIds[${i}]`));
|
|
502
|
+
break;
|
|
503
|
+
case "setProfile":
|
|
504
|
+
if (!isObj$1(m.profile)) bad("profile must be an object");
|
|
505
|
+
if (m.theme !== void 0) id(m.theme, "theme");
|
|
506
|
+
break;
|
|
507
|
+
case "dismissBanner":
|
|
508
|
+
id(m.id, "id");
|
|
509
|
+
break;
|
|
510
|
+
case "setFavorite":
|
|
511
|
+
id(m.cardId, "cardId");
|
|
512
|
+
if (typeof m.favorite !== "boolean") bad("favorite must be a boolean");
|
|
513
|
+
break;
|
|
514
|
+
case "markMastered":
|
|
515
|
+
id(m.cardId, "cardId");
|
|
516
|
+
break;
|
|
517
|
+
case "createPlan":
|
|
518
|
+
id(m.theme, "theme");
|
|
519
|
+
break;
|
|
520
|
+
case "completeReview":
|
|
521
|
+
if (!Number.isInteger(m.cardsReviewed) || m.cardsReviewed < 1 || m.cardsReviewed > 1e3) bad("cardsReviewed must be an integer from 1 to 1000");
|
|
522
|
+
break;
|
|
523
|
+
case "getDue":
|
|
524
|
+
if (!Number.isInteger(m.limit) || m.limit < 1 || m.limit > 200) bad("limit must be an integer from 1 to 200");
|
|
525
|
+
break;
|
|
526
|
+
case "review":
|
|
527
|
+
id(m.cardId, "cardId");
|
|
528
|
+
if (![
|
|
529
|
+
1,
|
|
530
|
+
2,
|
|
531
|
+
3,
|
|
532
|
+
4
|
|
533
|
+
].includes(m.rating)) bad("rating must be 1, 2, 3 or 4");
|
|
534
|
+
break;
|
|
535
|
+
case "updateCard":
|
|
536
|
+
id(m.cardId, "cardId");
|
|
537
|
+
if (!isObj$1(m.patch)) bad("patch must be an object");
|
|
538
|
+
optStr(m.patch.translation, "translation");
|
|
539
|
+
optStr(m.patch.example, "example");
|
|
540
|
+
break;
|
|
541
|
+
case "saveManual":
|
|
542
|
+
id(m.term, "term");
|
|
543
|
+
optStr(m.translation, "translation");
|
|
544
|
+
optStr(m.example, "example");
|
|
545
|
+
optStr(m.pronunciation, "pronunciation");
|
|
546
|
+
break;
|
|
547
|
+
case "importData":
|
|
548
|
+
if (typeof m.json !== "string") bad("json must be a string");
|
|
549
|
+
else if (m.json.length > MAX_JSON) bad("json is too large (20 MB maximum)");
|
|
550
|
+
break;
|
|
551
|
+
case "setSettings":
|
|
552
|
+
if (!isObj$1(m.settings)) bad("settings must be an object");
|
|
553
|
+
break;
|
|
554
|
+
case "getPracticeCards":
|
|
555
|
+
if (m.limit !== void 0 && (!Number.isInteger(m.limit) || m.limit < 1 || m.limit > MAX_PRACTICE_CARDS)) bad(`limit must be an integer from 1 to ${MAX_PRACTICE_CARDS}`);
|
|
556
|
+
break;
|
|
557
|
+
case "recordPractice":
|
|
558
|
+
if (typeof m.sessionId !== "string" || m.sessionId.trim() === "" || m.sessionId.length > 100) bad("sessionId must be a non-empty string of at most 100 characters");
|
|
559
|
+
if (!Array.isArray(m.results) || m.results.length < 1 || m.results.length > MAX_PRACTICE_RESULTS) bad(`results must be an array of 1 to ${MAX_PRACTICE_RESULTS} items`);
|
|
560
|
+
m.results.forEach((r, i) => {
|
|
561
|
+
if (!isObj$1(r)) bad(`results[${i}] must be an object`);
|
|
562
|
+
id(r.cardId, `results[${i}].cardId`);
|
|
563
|
+
if (!PRACTICE_OUTCOMES.includes(r.outcome)) bad(`results[${i}].outcome must be one of ${PRACTICE_OUTCOMES.join(", ")}`);
|
|
564
|
+
});
|
|
565
|
+
if (m.matchGames !== void 0 && (!Number.isInteger(m.matchGames) || m.matchGames < 0 || m.matchGames > MAX_PRACTICE_RESULTS)) bad(`matchGames must be an integer from 0 to ${MAX_PRACTICE_RESULTS}`);
|
|
566
|
+
break;
|
|
567
|
+
case "setNativeTranslations":
|
|
568
|
+
if (!Array.isArray(m.entries) || m.entries.length > MAX_NATIVE_ENTRIES) bad(`entries must be an array of at most ${MAX_NATIVE_ENTRIES} items`);
|
|
569
|
+
m.entries.forEach((e, i) => {
|
|
570
|
+
if (!isObj$1(e)) bad(`entries[${i}] must be an object`);
|
|
571
|
+
id(e.cardId, `entries[${i}].cardId`);
|
|
572
|
+
const text = e.text;
|
|
573
|
+
const len = typeof text === "string" ? text.trim().length : 0;
|
|
574
|
+
if (len < 1 || len > MAX_NATIVE_CHARS) bad(`entries[${i}].text must be 1 to ${MAX_NATIVE_CHARS} characters`);
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
var TIME_RE$1 = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
579
|
+
var PAIR_RE = /^[a-z]{2,3}(?:-[A-Za-z]{2,4})?:[a-z]{2,3}(?:-[A-Za-z]{2,4})?$/;
|
|
580
|
+
var MAX_DISMISSED_PAIRS = 20;
|
|
581
|
+
var isPairList = (v) => Array.isArray(v) && v.length <= MAX_DISMISSED_PAIRS && v.every((x) => typeof x === "string" && PAIR_RE.test(x));
|
|
582
|
+
function mergeSettings(current, patch) {
|
|
583
|
+
const next = { ...current };
|
|
584
|
+
if (patch.autoSave !== void 0) {
|
|
585
|
+
if (typeof patch.autoSave !== "boolean") throw new InvalidDataError("autoSave must be a boolean");
|
|
586
|
+
next.autoSave = patch.autoSave;
|
|
587
|
+
}
|
|
588
|
+
if (patch.reminderTime !== void 0) {
|
|
589
|
+
if (patch.reminderTime !== null && !(typeof patch.reminderTime === "string" && TIME_RE$1.test(patch.reminderTime))) throw new InvalidDataError("reminderTime must be \"HH:MM\" or null");
|
|
590
|
+
next.reminderTime = patch.reminderTime;
|
|
591
|
+
}
|
|
592
|
+
if (patch.uiLanguage !== void 0) {
|
|
593
|
+
if (typeof patch.uiLanguage !== "string" || patch.uiLanguage === "") throw new InvalidDataError("uiLanguage must be a non-empty string");
|
|
594
|
+
next.uiLanguage = patch.uiLanguage;
|
|
595
|
+
}
|
|
596
|
+
if (patch.hasCamera !== void 0) {
|
|
597
|
+
if (typeof patch.hasCamera !== "boolean") throw new InvalidDataError("hasCamera must be a boolean");
|
|
598
|
+
next.hasCamera = patch.hasCamera;
|
|
599
|
+
}
|
|
600
|
+
if (patch.showNativeOnPage !== void 0) {
|
|
601
|
+
if (typeof patch.showNativeOnPage !== "boolean") throw new InvalidDataError("showNativeOnPage must be a boolean");
|
|
602
|
+
next.showNativeOnPage = patch.showNativeOnPage;
|
|
603
|
+
}
|
|
604
|
+
if (patch.dismissedNativePairs !== void 0) {
|
|
605
|
+
if (!isPairList(patch.dismissedNativePairs)) throw new InvalidDataError(`dismissedNativePairs must be up to ${MAX_DISMISSED_PAIRS} pair keys like "en:tr"`);
|
|
606
|
+
next.dismissedNativePairs = [...new Set(patch.dismissedNativePairs)];
|
|
607
|
+
}
|
|
608
|
+
if (patch.soundEffects !== void 0) {
|
|
609
|
+
if (typeof patch.soundEffects !== "boolean") throw new InvalidDataError("soundEffects must be a boolean");
|
|
610
|
+
next.soundEffects = patch.soundEffects;
|
|
611
|
+
}
|
|
612
|
+
if (patch.bridgeEnabled !== void 0) {
|
|
613
|
+
if (typeof patch.bridgeEnabled !== "boolean") throw new InvalidDataError("bridgeEnabled must be a boolean");
|
|
614
|
+
next.bridgeEnabled = patch.bridgeEnabled;
|
|
615
|
+
}
|
|
616
|
+
return next;
|
|
617
|
+
}
|
|
618
|
+
function createRouter(deps) {
|
|
619
|
+
const { coach, store, area } = deps;
|
|
620
|
+
const clock = deps.clock ?? (() => /* @__PURE__ */ new Date());
|
|
621
|
+
let settingsTail = Promise.resolve();
|
|
622
|
+
const serialized = (task) => {
|
|
623
|
+
const next = settingsTail.then(task, task);
|
|
624
|
+
settingsTail = next.catch(() => void 0);
|
|
625
|
+
return next;
|
|
626
|
+
};
|
|
627
|
+
async function readSettings() {
|
|
628
|
+
const raw = (await area.get(SETTINGS_KEY))[SETTINGS_KEY];
|
|
629
|
+
const st = isObj$1(raw) ? raw : {};
|
|
630
|
+
return {
|
|
631
|
+
autoSave: typeof st.autoSave === "boolean" ? st.autoSave : DEFAULT_SETTINGS.autoSave,
|
|
632
|
+
reminderTime: st.reminderTime === null || typeof st.reminderTime === "string" && TIME_RE$1.test(st.reminderTime) ? st.reminderTime : DEFAULT_SETTINGS.reminderTime,
|
|
633
|
+
uiLanguage: typeof st.uiLanguage === "string" && st.uiLanguage !== "" ? st.uiLanguage : DEFAULT_SETTINGS.uiLanguage,
|
|
634
|
+
hasCamera: typeof st.hasCamera === "boolean" ? st.hasCamera : DEFAULT_SETTINGS.hasCamera,
|
|
635
|
+
showNativeOnPage: typeof st.showNativeOnPage === "boolean" ? st.showNativeOnPage : DEFAULT_SETTINGS.showNativeOnPage,
|
|
636
|
+
soundEffects: typeof st.soundEffects === "boolean" ? st.soundEffects : DEFAULT_SETTINGS.soundEffects,
|
|
637
|
+
bridgeEnabled: typeof st.bridgeEnabled === "boolean" ? st.bridgeEnabled : DEFAULT_SETTINGS.bridgeEnabled,
|
|
638
|
+
...isPairList(st.dismissedNativePairs) && st.dismissedNativePairs.length ? { dismissedNativePairs: st.dismissedNativePairs } : {},
|
|
639
|
+
...typeof st.pendingTargetLanguage === "string" && st.pendingTargetLanguage !== "" ? { pendingTargetLanguage: st.pendingTargetLanguage } : {}
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
const log = deps.log ?? ((...a) => console.warn(...a));
|
|
643
|
+
const captures = createCaptureService({
|
|
644
|
+
coach,
|
|
645
|
+
store,
|
|
646
|
+
area,
|
|
647
|
+
clock,
|
|
648
|
+
log,
|
|
649
|
+
getAutoSave: async () => (await readSettings()).autoSave,
|
|
650
|
+
getShowNativeOnPage: async () => (await readSettings()).showNativeOnPage,
|
|
651
|
+
notePendingTarget: (targetLanguage) => serialized(async () => {
|
|
652
|
+
const cur = await readSettings();
|
|
653
|
+
if (cur.pendingTargetLanguage) return;
|
|
654
|
+
await area.set({ [SETTINGS_KEY]: {
|
|
655
|
+
...cur,
|
|
656
|
+
pendingTargetLanguage: targetLanguage
|
|
657
|
+
} });
|
|
658
|
+
})
|
|
659
|
+
});
|
|
660
|
+
const browserUiLanguage = deps.uiLanguage ?? (() => typeof chrome !== "undefined" && chrome.i18n?.getUILanguage?.() || "en");
|
|
661
|
+
/**
|
|
662
|
+
* Creates the current ISO week's plan when a profile exists and the week has none (a new week started).
|
|
663
|
+
* Returns the state as it is afterwards, so callers need not load it again.
|
|
664
|
+
*/
|
|
665
|
+
async function ensurePlan() {
|
|
666
|
+
const state = await store.load();
|
|
667
|
+
const week = isoWeek(clock());
|
|
668
|
+
if (!state.profile || state.plans[week]) return state;
|
|
669
|
+
const settings = await readSettings();
|
|
670
|
+
const uiLang = settings.uiLanguage === "auto" ? browserUiLanguage() : settings.uiLanguage;
|
|
671
|
+
const seed = Number(week.split("-W")[1]) || 0;
|
|
672
|
+
const theme = suggestThemes(state.profile.goal, state.profile.level, uiLang, seed)[0] ?? "everyday conversation";
|
|
673
|
+
try {
|
|
674
|
+
await coach.weekPlan(theme);
|
|
675
|
+
} catch (e) {
|
|
676
|
+
log("LangCoach: could not create the weekly plan", e);
|
|
677
|
+
return state;
|
|
678
|
+
}
|
|
679
|
+
return store.load();
|
|
680
|
+
}
|
|
681
|
+
const isBanner = (b) => isObj$1(b) && typeof b.id === "string" && (b.kind === "freezeUsed" && typeof b.date === "string" || b.kind === "freezeEarned" && typeof b.count === "number");
|
|
682
|
+
async function readBanners() {
|
|
683
|
+
const raw = (await area.get(BANNERS_KEY))[BANNERS_KEY];
|
|
684
|
+
return Array.isArray(raw) ? raw.filter(isBanner) : [];
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Applies streak freezes for today (Coach.rollover) and queues one-time banners for what changed.
|
|
688
|
+
* Idempotent per day; never throws (startup/alarm callers must not crash the worker).
|
|
689
|
+
*/
|
|
690
|
+
async function rollover() {
|
|
691
|
+
try {
|
|
692
|
+
const today = toLocalDate(clock());
|
|
693
|
+
const r = await coach.rollover();
|
|
694
|
+
const added = [...r.consumed.map((date) => ({
|
|
695
|
+
id: `freezeUsed:${date}`,
|
|
696
|
+
kind: "freezeUsed",
|
|
697
|
+
date
|
|
698
|
+
})), ...r.earned > 0 ? [{
|
|
699
|
+
id: `freezeEarned:${today}`,
|
|
700
|
+
kind: "freezeEarned",
|
|
701
|
+
count: r.earned
|
|
702
|
+
}] : []];
|
|
703
|
+
await serialized(async () => {
|
|
704
|
+
const items = { [ROLLOVER_KEY]: today };
|
|
705
|
+
if (added.length > 0) {
|
|
706
|
+
const cur = await readBanners();
|
|
707
|
+
const ids = new Set(cur.map((b) => b.id));
|
|
708
|
+
items[BANNERS_KEY] = [...cur, ...added.filter((b) => !ids.has(b.id))].slice(-10);
|
|
709
|
+
}
|
|
710
|
+
await area.set(items);
|
|
711
|
+
});
|
|
712
|
+
return added;
|
|
713
|
+
} catch (e) {
|
|
714
|
+
log("LangCoach: daily rollover failed", e);
|
|
715
|
+
return [];
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
async function snapshot() {
|
|
719
|
+
const now0 = clock();
|
|
720
|
+
if ((await area.get("langcoach:rolloverDate"))["langcoach:rolloverDate"] !== toLocalDate(now0)) await rollover();
|
|
721
|
+
const [state, lc, banners] = await Promise.all([
|
|
722
|
+
ensurePlan(),
|
|
723
|
+
area.get(LAST_CAPTURE_KEY),
|
|
724
|
+
readBanners()
|
|
725
|
+
]);
|
|
726
|
+
const now = clock();
|
|
727
|
+
const date = toLocalDate(now);
|
|
728
|
+
const plan = state.plans[isoWeek(now)] ?? null;
|
|
729
|
+
const p = progressOf(state, now);
|
|
730
|
+
return {
|
|
731
|
+
profile: state.profile,
|
|
732
|
+
today: state.profile ? {
|
|
733
|
+
date,
|
|
734
|
+
tasks: plan ? plan.tasks.filter((t) => t.date === date) : [],
|
|
735
|
+
planExists: plan !== null
|
|
736
|
+
} : null,
|
|
737
|
+
plan,
|
|
738
|
+
progress: {
|
|
739
|
+
streak: p.streak,
|
|
740
|
+
totalXp: p.totalXp,
|
|
741
|
+
weekly: p.weekly,
|
|
742
|
+
dueCount: p.dueCount,
|
|
743
|
+
todayXp: p.todayXp,
|
|
744
|
+
dailyGoalXp: p.dailyGoalXp,
|
|
745
|
+
streakFreezes: p.streakFreezes,
|
|
746
|
+
weekXp: p.weekXp
|
|
747
|
+
},
|
|
748
|
+
dueCount: p.dueCount,
|
|
749
|
+
lastCapture: lc["langcoach:lastCapture"] ?? null,
|
|
750
|
+
banners,
|
|
751
|
+
state
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
const handlers = {
|
|
755
|
+
capture: async (m, ctx) => {
|
|
756
|
+
const r = await captures.capture({
|
|
757
|
+
kind: m.kind,
|
|
758
|
+
request: m.request,
|
|
759
|
+
response: m.response,
|
|
760
|
+
pageUrl: m.pageUrl,
|
|
761
|
+
force: m.force
|
|
762
|
+
}, ctx);
|
|
763
|
+
if (r?.status !== "saved" || !deps.bridge) return r;
|
|
764
|
+
try {
|
|
765
|
+
const { sent } = await deps.bridge.flush();
|
|
766
|
+
if (r.lessonIds.every((id) => sent.includes(id))) return {
|
|
767
|
+
...r,
|
|
768
|
+
forwarded: true
|
|
769
|
+
};
|
|
770
|
+
} catch (e) {
|
|
771
|
+
log("LangCoach: forwarding to the agent failed", e);
|
|
772
|
+
}
|
|
773
|
+
return r;
|
|
774
|
+
},
|
|
775
|
+
undo: (m) => captures.undo(m.lessonIds),
|
|
776
|
+
openPanel: async (_m, ctx) => {
|
|
777
|
+
if (ctx.tabId === void 0) throw new InvalidDataError("openPanel must come from a tab");
|
|
778
|
+
await (deps.sidePanel ?? chrome.sidePanel).open({ tabId: ctx.tabId });
|
|
779
|
+
return null;
|
|
780
|
+
},
|
|
781
|
+
getSnapshot: () => snapshot(),
|
|
782
|
+
setProfile: (m) => coach.setProfile(m.profile, m.theme !== void 0 ? { theme: m.theme } : {}),
|
|
783
|
+
createPlan: (m) => coach.weekPlan(m.theme),
|
|
784
|
+
completeReview: (m) => coach.autoComplete("review", { cardsReviewed: m.cardsReviewed }),
|
|
785
|
+
getDue: (m) => coach.dueReviews(m.limit),
|
|
786
|
+
review: (m) => coach.submitReview(m.cardId, m.rating),
|
|
787
|
+
updateCard: (m) => coach.updateCard(m.cardId, m.patch),
|
|
788
|
+
saveManual: (m) => coach.saveManualCard({
|
|
789
|
+
term: m.term,
|
|
790
|
+
translation: m.translation,
|
|
791
|
+
example: m.example,
|
|
792
|
+
pronunciation: m.pronunciation
|
|
793
|
+
}),
|
|
794
|
+
exportData: async () => ({ json: JSON.stringify(await coach.exportData(), null, 2) }),
|
|
795
|
+
importData: async (m) => {
|
|
796
|
+
let raw;
|
|
797
|
+
try {
|
|
798
|
+
raw = JSON.parse(m.json);
|
|
799
|
+
} catch {
|
|
800
|
+
throw new InvalidDataError("The file is not valid JSON");
|
|
801
|
+
}
|
|
802
|
+
return coach.importData(raw);
|
|
803
|
+
},
|
|
804
|
+
reset: async () => {
|
|
805
|
+
await coach.deleteAll();
|
|
806
|
+
await area.remove(LAST_CAPTURE_KEY);
|
|
807
|
+
return null;
|
|
808
|
+
},
|
|
809
|
+
getSettings: () => readSettings(),
|
|
810
|
+
setSettings: (m) => serialized(async () => {
|
|
811
|
+
const next = mergeSettings(await readSettings(), m.settings);
|
|
812
|
+
await area.set({ [SETTINGS_KEY]: next });
|
|
813
|
+
if (deps.onSettingsChanged) await deps.onSettingsChanged(next);
|
|
814
|
+
return next;
|
|
815
|
+
}),
|
|
816
|
+
setNativeTranslations: (m) => coach.setNativeTranslations(m.entries),
|
|
817
|
+
getPracticeCards: async (m) => {
|
|
818
|
+
const state = await store.load();
|
|
819
|
+
const now = clock();
|
|
820
|
+
const rank = (c) => isDue(c.srs, now) ? c.srs.reps > 0 ? 0 : 1 : 2;
|
|
821
|
+
return state.cards.map((c, i) => ({
|
|
822
|
+
c,
|
|
823
|
+
i,
|
|
824
|
+
r: rank(c)
|
|
825
|
+
})).sort((a, b) => a.r - b.r || (a.r === 0 ? Date.parse(a.c.srs.due) - Date.parse(b.c.srs.due) : 0) || a.i - b.i).slice(0, m.limit ?? DEFAULT_PRACTICE_CARDS).map((x) => x.c);
|
|
826
|
+
},
|
|
827
|
+
recordPractice: (m) => coach.recordPractice({
|
|
828
|
+
sessionId: m.sessionId,
|
|
829
|
+
results: m.results,
|
|
830
|
+
...m.matchGames !== void 0 ? { matchGames: m.matchGames } : {}
|
|
831
|
+
}),
|
|
832
|
+
setFavorite: (m) => coach.setFavorite(m.cardId, m.favorite),
|
|
833
|
+
markMastered: (m) => coach.markMastered(m.cardId),
|
|
834
|
+
bridgeStatus: async () => deps.bridge ? deps.bridge.status() : {
|
|
835
|
+
state: "off",
|
|
836
|
+
pending: 0,
|
|
837
|
+
lastSentAt: null
|
|
838
|
+
},
|
|
839
|
+
dismissBanner: (m) => serialized(async () => {
|
|
840
|
+
const cur = await readBanners();
|
|
841
|
+
await area.set({ [BANNERS_KEY]: cur.filter((b) => b.id !== m.id) });
|
|
842
|
+
return null;
|
|
843
|
+
})
|
|
844
|
+
};
|
|
845
|
+
return {
|
|
846
|
+
/** Applies streak freezes for today and queues one-time banners. Never throws. */
|
|
847
|
+
rollover,
|
|
848
|
+
/** Creates this week's plan if a profile exists and the week has none (startup, alarms). Never throws. */
|
|
849
|
+
async ensurePlan() {
|
|
850
|
+
try {
|
|
851
|
+
await ensurePlan();
|
|
852
|
+
} catch (e) {
|
|
853
|
+
log("LangCoach: weekly plan check failed", e);
|
|
854
|
+
}
|
|
855
|
+
},
|
|
856
|
+
/** `ctx.tabId` is the sender tab (content-script messages). */
|
|
857
|
+
async handle(msg, ctx = {}) {
|
|
858
|
+
try {
|
|
859
|
+
validateMessage(msg);
|
|
860
|
+
const h = handlers[msg.type];
|
|
861
|
+
return {
|
|
862
|
+
ok: true,
|
|
863
|
+
data: await h(msg, ctx)
|
|
864
|
+
};
|
|
865
|
+
} catch (e) {
|
|
866
|
+
return {
|
|
867
|
+
ok: false,
|
|
868
|
+
error: toErrorInfo(e)
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
//#endregion
|
|
875
|
+
//#region src/background/bridge.ts
|
|
876
|
+
/**
|
|
877
|
+
* The local langcoach MCP server (loopback only, token-paired; protocol in packages/mcp/src/bridge.ts).
|
|
878
|
+
* The e2e build talks to a stub on another port, so tests never reach a real agent's data on this machine.
|
|
879
|
+
*/
|
|
880
|
+
var BRIDGE_ORIGIN = "http://127.0.0.1:47821";
|
|
881
|
+
var BRIDGE_ALARM = "langcoach-bridge";
|
|
882
|
+
/** Packaged by the agent's bridge_prepare_extension; never part of the store build. */
|
|
883
|
+
var PAIRING_FILE = "pairing.json";
|
|
884
|
+
/** chrome.storage.session cache of the pairing token (cleared when the browser closes; never synced). */
|
|
885
|
+
var PAIRING_SESSION_KEY = "langcoach:pairing";
|
|
886
|
+
/** Lesson ids the agent confirmed (saved or duplicate), oldest first. */
|
|
887
|
+
var SENT_KEY = "langcoach:bridgeSent";
|
|
888
|
+
/** Rejections per lesson id; a lesson rejected MAX_REJECTS times is not sent again. */
|
|
889
|
+
var ERRORS_KEY = "langcoach:bridgeErrors";
|
|
890
|
+
/** { state, lastSentAt, retryAt, failures } */
|
|
891
|
+
var BRIDGE_STATE_KEY = "langcoach:bridgeState";
|
|
892
|
+
/** After a 401 the token is re-tried at most this often. */
|
|
893
|
+
var UNPAIRED_RETRY_MS = 6e5;
|
|
894
|
+
/** Unverified server: back off 2, 4, 8, 16, then 30 minutes. */
|
|
895
|
+
var MAX_BACKOFF_MS = 18e5;
|
|
896
|
+
var BATCH = 10;
|
|
897
|
+
var TOKEN_RE = /^[0-9a-f]{32,256}$/i;
|
|
898
|
+
var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
899
|
+
var hex = (b) => Array.from(b instanceof Uint8Array ? b : new Uint8Array(b), (x) => x.toString(16).padStart(2, "0")).join("");
|
|
900
|
+
var STATES = [
|
|
901
|
+
"connected",
|
|
902
|
+
"waiting",
|
|
903
|
+
"unpaired",
|
|
904
|
+
"unverified",
|
|
905
|
+
"off"
|
|
906
|
+
];
|
|
907
|
+
/** Constant-time string comparison (length is not secret: both are SHA-256 hex). */
|
|
908
|
+
function ctEqual(a, b) {
|
|
909
|
+
if (a.length !== b.length) return false;
|
|
910
|
+
let d = 0;
|
|
911
|
+
for (let i = 0; i < a.length; i++) d |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
912
|
+
return d === 0;
|
|
913
|
+
}
|
|
914
|
+
/** Lowercase hex HMAC-SHA256 with the UTF-8 bytes of `token` as key and of `nonce` as message. */
|
|
915
|
+
async function hmacHex(token, nonce) {
|
|
916
|
+
const enc = new TextEncoder();
|
|
917
|
+
const key = await crypto.subtle.importKey("raw", enc.encode(token), {
|
|
918
|
+
name: "HMAC",
|
|
919
|
+
hash: "SHA-256"
|
|
920
|
+
}, false, ["sign"]);
|
|
921
|
+
return hex(await crypto.subtle.sign("HMAC", key, enc.encode(nonce)));
|
|
922
|
+
}
|
|
923
|
+
/** The token of a valid pairing file ({"token":"<hex>","port":47821,"v":1}), else null. */
|
|
924
|
+
function parsePairing(v) {
|
|
925
|
+
if (!isObj(v) || v.v !== 1 || typeof v.token !== "string" || !TOKEN_RE.test(v.token)) return null;
|
|
926
|
+
if (v.port !== Number(new URL("http://127.0.0.1:47821").port)) return null;
|
|
927
|
+
return v.token;
|
|
928
|
+
}
|
|
929
|
+
/** Server field caps: it rejects (never truncates) a lesson over them, so the client never sends one. */
|
|
930
|
+
var CAPS = {
|
|
931
|
+
term: 200,
|
|
932
|
+
translation: 500,
|
|
933
|
+
example: 500,
|
|
934
|
+
note: 500,
|
|
935
|
+
pronunciation: 200
|
|
936
|
+
};
|
|
937
|
+
var within = (v, max) => v === void 0 || typeof v === "string" && v.length <= max;
|
|
938
|
+
var itemFits = (i) => Object.keys(CAPS).every((k) => within(i[k], CAPS[k]));
|
|
939
|
+
/** The lesson as sent: items over the caps are skipped; null when nothing sendable is left. */
|
|
940
|
+
function payload(l) {
|
|
941
|
+
const items = l.items.filter(itemFits);
|
|
942
|
+
const src = l.source;
|
|
943
|
+
if (!items.length || l.situation.length > 200) return null;
|
|
944
|
+
if (src && (!within(src.purpose, 100) || src.targetLanguage.length > 80 || src.sourceLanguage.length > 80)) return null;
|
|
945
|
+
return {
|
|
946
|
+
id: l.id,
|
|
947
|
+
kind: l.kind,
|
|
948
|
+
situation: l.situation,
|
|
949
|
+
items: items.map((i) => ({
|
|
950
|
+
term: i.term,
|
|
951
|
+
translation: i.translation,
|
|
952
|
+
example: i.example,
|
|
953
|
+
...i.note !== void 0 ? { note: i.note } : {},
|
|
954
|
+
...i.pronunciation !== void 0 ? { pronunciation: i.pronunciation } : {}
|
|
955
|
+
})),
|
|
956
|
+
...l.grammar ? { grammar: l.grammar } : {},
|
|
957
|
+
...l.source ? { source: l.source } : {},
|
|
958
|
+
capturedAt: l.createdAt
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
var defaultReadPairing = async () => {
|
|
962
|
+
const res = await fetch(chrome.runtime.getURL(PAIRING_FILE));
|
|
963
|
+
return res.ok ? res.json() : null;
|
|
964
|
+
};
|
|
965
|
+
/** Bridge client. Never throws: every failure is logged (never the token) and leaves the lesson in the backlog. */
|
|
966
|
+
function createBridge(deps) {
|
|
967
|
+
const { store, area, alarms } = deps;
|
|
968
|
+
const doFetch = deps.fetch ?? ((...a) => fetch(...a));
|
|
969
|
+
const log = deps.log ?? ((...a) => console.warn(...a));
|
|
970
|
+
const clock = deps.clock ?? (() => /* @__PURE__ */ new Date());
|
|
971
|
+
const timeoutMs = deps.timeoutMs ?? 4e3;
|
|
972
|
+
let token;
|
|
973
|
+
let tail = Promise.resolve();
|
|
974
|
+
const serialized = (task) => {
|
|
975
|
+
const next = tail.then(task, task);
|
|
976
|
+
tail = next.catch(() => void 0);
|
|
977
|
+
return next;
|
|
978
|
+
};
|
|
979
|
+
/** Memory, then the session cache, then the packaged file. */
|
|
980
|
+
async function getToken() {
|
|
981
|
+
if (token !== void 0) return token;
|
|
982
|
+
try {
|
|
983
|
+
const cached = deps.session ? (await deps.session.get(PAIRING_SESSION_KEY))[PAIRING_SESSION_KEY] : void 0;
|
|
984
|
+
if (typeof cached === "string" && TOKEN_RE.test(cached)) return token = cached;
|
|
985
|
+
} catch {}
|
|
986
|
+
try {
|
|
987
|
+
token = parsePairing(await (deps.readPairing ?? defaultReadPairing)());
|
|
988
|
+
} catch {
|
|
989
|
+
token = null;
|
|
990
|
+
}
|
|
991
|
+
if (token && deps.session) await deps.session.set({ [PAIRING_SESSION_KEY]: token }).catch(() => void 0);
|
|
992
|
+
return token;
|
|
993
|
+
}
|
|
994
|
+
/** Status code and JSON body, or null on network failure / timeout / bad JSON. */
|
|
995
|
+
async function request(path, init) {
|
|
996
|
+
const ctrl = new AbortController();
|
|
997
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
998
|
+
try {
|
|
999
|
+
const res = await doFetch(`${BRIDGE_ORIGIN}${path}`, {
|
|
1000
|
+
...init,
|
|
1001
|
+
signal: ctrl.signal
|
|
1002
|
+
});
|
|
1003
|
+
let body = null;
|
|
1004
|
+
try {
|
|
1005
|
+
body = await res.json();
|
|
1006
|
+
} catch {}
|
|
1007
|
+
return {
|
|
1008
|
+
status: res.status,
|
|
1009
|
+
body: isObj(body) ? body : null
|
|
1010
|
+
};
|
|
1011
|
+
} catch {
|
|
1012
|
+
return null;
|
|
1013
|
+
} finally {
|
|
1014
|
+
clearTimeout(timer);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
async function enabled() {
|
|
1018
|
+
const st = (await area.get(SETTINGS_KEY))[SETTINGS_KEY];
|
|
1019
|
+
return !(isObj(st) && st.bridgeEnabled === false);
|
|
1020
|
+
}
|
|
1021
|
+
async function readState() {
|
|
1022
|
+
const got = await area.get([
|
|
1023
|
+
SENT_KEY,
|
|
1024
|
+
ERRORS_KEY,
|
|
1025
|
+
BRIDGE_STATE_KEY
|
|
1026
|
+
]);
|
|
1027
|
+
const sent = Array.isArray(got["langcoach:bridgeSent"]) ? got[SENT_KEY].filter((x) => typeof x === "string") : [];
|
|
1028
|
+
const errors = {};
|
|
1029
|
+
if (isObj(got["langcoach:bridgeErrors"])) {
|
|
1030
|
+
for (const [k, v] of Object.entries(got[ERRORS_KEY])) if (typeof v === "number") errors[k] = v;
|
|
1031
|
+
}
|
|
1032
|
+
const b = isObj(got["langcoach:bridgeState"]) ? got[BRIDGE_STATE_KEY] : {};
|
|
1033
|
+
return {
|
|
1034
|
+
sent,
|
|
1035
|
+
errors,
|
|
1036
|
+
p: {
|
|
1037
|
+
state: STATES.includes(b.state) ? b.state : null,
|
|
1038
|
+
lastSentAt: typeof b.lastSentAt === "string" ? b.lastSentAt : null,
|
|
1039
|
+
retryAt: typeof b.retryAt === "number" ? b.retryAt : 0,
|
|
1040
|
+
failures: typeof b.failures === "number" ? b.failures : 0
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
/** LLL lessons not confirmed yet and not given up on, newest BACKLOG_LIMIT in time order. */
|
|
1045
|
+
async function backlog(sent, errors) {
|
|
1046
|
+
const done = new Set(sent);
|
|
1047
|
+
return (await store.load()).lessons.filter((l) => l.source?.app === "lll" && !done.has(l.id) && (errors[l.id] ?? 0) < 3 && payload(l) !== null).slice(-200);
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Authenticates the server before anything is sent: it must answer a fresh nonce with
|
|
1051
|
+
* HMAC-SHA256(token, nonce). 'waiting' = nobody answered; 'unverified' = something answered without proof.
|
|
1052
|
+
*/
|
|
1053
|
+
async function ping() {
|
|
1054
|
+
const tk = await getToken();
|
|
1055
|
+
if (!tk) return "unpaired";
|
|
1056
|
+
const nonce = hex(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16)));
|
|
1057
|
+
const r = await request(`/v1/ping?nonce=${nonce}`, {
|
|
1058
|
+
method: "GET",
|
|
1059
|
+
headers: { "X-LangCoach": "1" }
|
|
1060
|
+
});
|
|
1061
|
+
if (!r) return "waiting";
|
|
1062
|
+
const b = r.body;
|
|
1063
|
+
if (r.status !== 200 || !b || b.ok !== true || b.app !== "langcoach" || typeof b.proof !== "string") return "unverified";
|
|
1064
|
+
return ctEqual(b.proof.toLowerCase(), await hmacHex(tk, nonce)) ? "connected" : "unverified";
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Posts in batches of BATCH to an already verified server; stops at the first failed batch.
|
|
1068
|
+
* `results` is null when nothing reached the agent; `unauthorized` on a 401 (token not accepted).
|
|
1069
|
+
*/
|
|
1070
|
+
async function sendLessons(lessons) {
|
|
1071
|
+
const tk = await getToken();
|
|
1072
|
+
if (!tk) return {
|
|
1073
|
+
results: null,
|
|
1074
|
+
unauthorized: true
|
|
1075
|
+
};
|
|
1076
|
+
const out = [];
|
|
1077
|
+
for (let i = 0; i < lessons.length; i += BATCH) {
|
|
1078
|
+
const r = await request("/v1/lessons", {
|
|
1079
|
+
method: "POST",
|
|
1080
|
+
headers: {
|
|
1081
|
+
"Content-Type": "application/json",
|
|
1082
|
+
"X-LangCoach": "1",
|
|
1083
|
+
"X-LangCoach-Token": tk
|
|
1084
|
+
},
|
|
1085
|
+
body: JSON.stringify({
|
|
1086
|
+
client: {
|
|
1087
|
+
name: "langcoach-extension",
|
|
1088
|
+
version
|
|
1089
|
+
},
|
|
1090
|
+
lessons: lessons.slice(i, i + BATCH).flatMap((l) => payload(l) ?? [])
|
|
1091
|
+
})
|
|
1092
|
+
});
|
|
1093
|
+
if (r?.status === 401) return {
|
|
1094
|
+
results: out.length ? out : null,
|
|
1095
|
+
unauthorized: true
|
|
1096
|
+
};
|
|
1097
|
+
if (!r || r.status !== 200 || !r.body || r.body.ok !== true || !Array.isArray(r.body.results)) return {
|
|
1098
|
+
results: out.length ? out : null,
|
|
1099
|
+
unauthorized: false
|
|
1100
|
+
};
|
|
1101
|
+
for (const x of r.body.results) if (isObj(x) && typeof x.id === "string" && [
|
|
1102
|
+
"saved",
|
|
1103
|
+
"duplicate",
|
|
1104
|
+
"rejected"
|
|
1105
|
+
].includes(x.status)) out.push(x);
|
|
1106
|
+
}
|
|
1107
|
+
return {
|
|
1108
|
+
results: out,
|
|
1109
|
+
unauthorized: false
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
async function syncAlarm(on) {
|
|
1113
|
+
if (!on) await alarms.clear(BRIDGE_ALARM);
|
|
1114
|
+
else if (!await alarms.get("langcoach-bridge")) await alarms.create(BRIDGE_ALARM, { periodInMinutes: 2 });
|
|
1115
|
+
}
|
|
1116
|
+
async function flushOnce() {
|
|
1117
|
+
if (!await enabled()) {
|
|
1118
|
+
await syncAlarm(false);
|
|
1119
|
+
return {
|
|
1120
|
+
sent: [],
|
|
1121
|
+
pending: 0,
|
|
1122
|
+
state: "off"
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
const { sent, errors, p } = await readState();
|
|
1126
|
+
const todo = await backlog(sent, errors);
|
|
1127
|
+
if (!await getToken()) {
|
|
1128
|
+
await syncAlarm(false);
|
|
1129
|
+
await area.set({ [BRIDGE_STATE_KEY]: {
|
|
1130
|
+
...p,
|
|
1131
|
+
state: "unpaired"
|
|
1132
|
+
} });
|
|
1133
|
+
return {
|
|
1134
|
+
sent: [],
|
|
1135
|
+
pending: todo.length,
|
|
1136
|
+
state: "unpaired"
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
const now = clock().getTime();
|
|
1140
|
+
if (p.retryAt > now && p.state) {
|
|
1141
|
+
await syncAlarm(true);
|
|
1142
|
+
return {
|
|
1143
|
+
sent: [],
|
|
1144
|
+
pending: todo.length,
|
|
1145
|
+
state: p.state
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
let state = await ping();
|
|
1149
|
+
const confirmed = [];
|
|
1150
|
+
const next = {
|
|
1151
|
+
...p,
|
|
1152
|
+
retryAt: 0,
|
|
1153
|
+
failures: 0
|
|
1154
|
+
};
|
|
1155
|
+
if (state === "connected" && todo.length) {
|
|
1156
|
+
const { results, unauthorized } = await sendLessons(todo);
|
|
1157
|
+
if (unauthorized) state = "unpaired";
|
|
1158
|
+
else if (results === null) state = "waiting";
|
|
1159
|
+
const known = new Set(todo.map((l) => l.id));
|
|
1160
|
+
for (const r of results ?? []) {
|
|
1161
|
+
if (!known.has(r.id)) continue;
|
|
1162
|
+
if (r.status === "rejected") {
|
|
1163
|
+
errors[r.id] = (errors[r.id] ?? 0) + 1;
|
|
1164
|
+
log(`LangCoach: the agent rejected lesson ${r.id}: ${r.error ?? "no reason given"}`);
|
|
1165
|
+
} else {
|
|
1166
|
+
confirmed.push(r.id);
|
|
1167
|
+
delete errors[r.id];
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
if (state === "unpaired") {
|
|
1172
|
+
next.retryAt = now + UNPAIRED_RETRY_MS;
|
|
1173
|
+
log("LangCoach: the agent did not accept this extension's pairing; reinstall it from your agent");
|
|
1174
|
+
} else if (state === "unverified") {
|
|
1175
|
+
next.failures = p.failures + 1;
|
|
1176
|
+
next.retryAt = now + Math.min(12e4 * 2 ** (next.failures - 1), MAX_BACKOFF_MS);
|
|
1177
|
+
log("LangCoach: something on the bridge port did not prove it is your LangCoach agent; nothing was sent");
|
|
1178
|
+
}
|
|
1179
|
+
next.state = state;
|
|
1180
|
+
if (confirmed.length) next.lastSentAt = clock().toISOString();
|
|
1181
|
+
const items = { [BRIDGE_STATE_KEY]: next };
|
|
1182
|
+
if (confirmed.length) items[SENT_KEY] = [.../* @__PURE__ */ new Set([...sent, ...confirmed])].slice(-1e3);
|
|
1183
|
+
if (todo.length) items[ERRORS_KEY] = Object.fromEntries(Object.entries(errors).filter(([id]) => todo.some((l) => l.id === id)));
|
|
1184
|
+
await area.set(items);
|
|
1185
|
+
const pending = todo.filter((l) => !confirmed.includes(l.id) && (errors[l.id] ?? 0) < 3).length;
|
|
1186
|
+
await syncAlarm(pending > 0 || state !== "connected");
|
|
1187
|
+
return {
|
|
1188
|
+
sent: confirmed,
|
|
1189
|
+
pending,
|
|
1190
|
+
state
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
async function flush() {
|
|
1194
|
+
return serialized(async () => {
|
|
1195
|
+
try {
|
|
1196
|
+
return await flushOnce();
|
|
1197
|
+
} catch (e) {
|
|
1198
|
+
log("LangCoach: agent bridge flush failed", e);
|
|
1199
|
+
return {
|
|
1200
|
+
sent: [],
|
|
1201
|
+
pending: 0,
|
|
1202
|
+
state: "waiting"
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
async function status() {
|
|
1208
|
+
try {
|
|
1209
|
+
const { sent, errors, p } = await readState();
|
|
1210
|
+
if (!await enabled()) return {
|
|
1211
|
+
state: "off",
|
|
1212
|
+
pending: 0,
|
|
1213
|
+
lastSentAt: p.lastSentAt
|
|
1214
|
+
};
|
|
1215
|
+
const pending = (await backlog(sent, errors)).length;
|
|
1216
|
+
return {
|
|
1217
|
+
state: p.state === "unpaired" && p.retryAt > clock().getTime() ? "unpaired" : await ping(),
|
|
1218
|
+
pending,
|
|
1219
|
+
lastSentAt: p.lastSentAt
|
|
1220
|
+
};
|
|
1221
|
+
} catch (e) {
|
|
1222
|
+
log("LangCoach: agent bridge status failed", e);
|
|
1223
|
+
return {
|
|
1224
|
+
state: "waiting",
|
|
1225
|
+
pending: 0,
|
|
1226
|
+
lastSentAt: null
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return {
|
|
1231
|
+
ping,
|
|
1232
|
+
sendLessons,
|
|
1233
|
+
flush,
|
|
1234
|
+
status,
|
|
1235
|
+
/** chrome.alarms listener: flushes on the bridge alarm only. */
|
|
1236
|
+
async onAlarm(alarm) {
|
|
1237
|
+
if (alarm.name === "langcoach-bridge") await flush();
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
//#endregion
|
|
1242
|
+
//#region src/background/menu.ts
|
|
1243
|
+
var MENU_ID = "langcoach-save-selection";
|
|
1244
|
+
var LLL_PATTERNS = ["https://labs.google/lll/*"];
|
|
1245
|
+
var DEV_PATTERNS = DEV_HOSTNAMES.map((h) => `http://${h}/*`);
|
|
1246
|
+
/** Idempotent: safe on every install/startup. */
|
|
1247
|
+
async function registerMenus(menus, t, e2e) {
|
|
1248
|
+
await menus.removeAll();
|
|
1249
|
+
menus.create({
|
|
1250
|
+
id: MENU_ID,
|
|
1251
|
+
title: t("menuSave"),
|
|
1252
|
+
contexts: ["selection"],
|
|
1253
|
+
documentUrlPatterns: e2e ? [...LLL_PATTERNS, ...DEV_PATTERNS] : LLL_PATTERNS
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
/** "Save to LangCoach" on selected text: a card with the selection as its term, then a toast in the tab. */
|
|
1257
|
+
function createMenuClickHandler(deps) {
|
|
1258
|
+
return async (info, tab) => {
|
|
1259
|
+
if (info.menuItemId !== "langcoach-save-selection") return;
|
|
1260
|
+
const term = (info.selectionText ?? "").trim().slice(0, 200);
|
|
1261
|
+
if (!term) return;
|
|
1262
|
+
try {
|
|
1263
|
+
const r = await deps.coach.saveManualCard({ term });
|
|
1264
|
+
if (tab?.id === void 0 || tab.id < 0) return;
|
|
1265
|
+
await deps.tabs.sendMessage(tab.id, {
|
|
1266
|
+
type: "toast",
|
|
1267
|
+
variant: r.duplicate ? "already" : "manual"
|
|
1268
|
+
});
|
|
1269
|
+
} catch (e) {
|
|
1270
|
+
deps.log("LangCoach: manual save failed", e);
|
|
1271
|
+
}
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
//#endregion
|
|
1275
|
+
//#region src/background/policy.ts
|
|
1276
|
+
/** Messages content scripts on the lesson site may send. */
|
|
1277
|
+
var CONTENT_TYPES = /* @__PURE__ */ new Set([
|
|
1278
|
+
"capture",
|
|
1279
|
+
"undo",
|
|
1280
|
+
"saveManual",
|
|
1281
|
+
"openPanel",
|
|
1282
|
+
"setNativeTranslations"
|
|
1283
|
+
]);
|
|
1284
|
+
/** Messages extension pages (side panel) may send. */
|
|
1285
|
+
var PAGE_TYPES = /* @__PURE__ */ new Set([
|
|
1286
|
+
"getSnapshot",
|
|
1287
|
+
"setProfile",
|
|
1288
|
+
"createPlan",
|
|
1289
|
+
"completeReview",
|
|
1290
|
+
"getDue",
|
|
1291
|
+
"review",
|
|
1292
|
+
"updateCard",
|
|
1293
|
+
"undo",
|
|
1294
|
+
"saveManual",
|
|
1295
|
+
"exportData",
|
|
1296
|
+
"importData",
|
|
1297
|
+
"reset",
|
|
1298
|
+
"getSettings",
|
|
1299
|
+
"setSettings",
|
|
1300
|
+
"setNativeTranslations",
|
|
1301
|
+
"getPracticeCards",
|
|
1302
|
+
"recordPractice",
|
|
1303
|
+
"dismissBanner",
|
|
1304
|
+
"setFavorite",
|
|
1305
|
+
"markMastered",
|
|
1306
|
+
"bridgeStatus"
|
|
1307
|
+
]);
|
|
1308
|
+
var LLL_PREFIX = "https://labs.google/lll/";
|
|
1309
|
+
/** Pure sender policy for runtime messages. */
|
|
1310
|
+
function isAllowed(type, sender, runtimeId, extensionOrigin, e2e) {
|
|
1311
|
+
if (!sender || sender.id !== runtimeId) return false;
|
|
1312
|
+
const url = typeof sender.url === "string" ? sender.url : "";
|
|
1313
|
+
if (PAGE_TYPES.has(type) && url !== "" && url.startsWith(extensionOrigin)) return true;
|
|
1314
|
+
if (CONTENT_TYPES.has(type)) {
|
|
1315
|
+
if (!sender.tab) return false;
|
|
1316
|
+
return url.startsWith(LLL_PREFIX) || e2e && isDevHostUrl(url);
|
|
1317
|
+
}
|
|
1318
|
+
return false;
|
|
1319
|
+
}
|
|
1320
|
+
//#endregion
|
|
1321
|
+
//#region src/background/reminder.ts
|
|
1322
|
+
var REMINDER_ALARM = "langcoach-reminder";
|
|
1323
|
+
var REMINDER_NOTIFICATION_ID = "langcoach-daily-reminder";
|
|
1324
|
+
/** Daily alarm that applies streak freezes shortly after local midnight. */
|
|
1325
|
+
var ROLLOVER_ALARM = "langcoach-rollover";
|
|
1326
|
+
var ROLLOVER_TIME = "00:01";
|
|
1327
|
+
var LLL_HOME = "https://labs.google/lll/en";
|
|
1328
|
+
var LLL_TABS = "https://labs.google/lll/*";
|
|
1329
|
+
var TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
1330
|
+
/**
|
|
1331
|
+
* Computes the next occurrence (epoch ms) of `time` ("HH:MM") in local time:
|
|
1332
|
+
* today if strictly ahead of `now`, otherwise tomorrow at the same local wall-clock time (DST-safe).
|
|
1333
|
+
*/
|
|
1334
|
+
function nextReminderAt(now, time) {
|
|
1335
|
+
const m = TIME_RE.exec(time);
|
|
1336
|
+
if (!m) throw new Error(`Invalid reminder time: ${time}`);
|
|
1337
|
+
const hours = Number(m[1]);
|
|
1338
|
+
const minutes = Number(m[2]);
|
|
1339
|
+
const candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hours, minutes, 0, 0);
|
|
1340
|
+
if (candidate.getTime() > now.getTime()) return candidate.getTime();
|
|
1341
|
+
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, hours, minutes, 0, 0).getTime();
|
|
1342
|
+
}
|
|
1343
|
+
async function scheduleReminder(settings, alarms = chrome.alarms, now = /* @__PURE__ */ new Date()) {
|
|
1344
|
+
if (!settings.reminderTime || !TIME_RE.test(settings.reminderTime)) {
|
|
1345
|
+
await alarms.clear(REMINDER_ALARM);
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
const when = nextReminderAt(now, settings.reminderTime);
|
|
1349
|
+
await alarms.create(REMINDER_ALARM, { when });
|
|
1350
|
+
}
|
|
1351
|
+
/** Schedules the next daily rollover (00:01 local, one-shot; re-created when it fires). */
|
|
1352
|
+
async function scheduleRollover(alarms = chrome.alarms, now = /* @__PURE__ */ new Date()) {
|
|
1353
|
+
await alarms.create(ROLLOVER_ALARM, { when: nextReminderAt(now, ROLLOVER_TIME) });
|
|
1354
|
+
}
|
|
1355
|
+
function createRolloverAlarmHandler(deps) {
|
|
1356
|
+
return async (alarm) => {
|
|
1357
|
+
if (alarm.name !== "langcoach-rollover") return;
|
|
1358
|
+
try {
|
|
1359
|
+
await deps.rollover();
|
|
1360
|
+
} catch {}
|
|
1361
|
+
try {
|
|
1362
|
+
await deps.reschedule();
|
|
1363
|
+
} catch {}
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
function createReminderAlarmHandler(deps) {
|
|
1367
|
+
return async (alarm) => {
|
|
1368
|
+
if (alarm.name !== "langcoach-reminder") return;
|
|
1369
|
+
try {
|
|
1370
|
+
await notifyIfNeeded();
|
|
1371
|
+
} finally {
|
|
1372
|
+
if (deps.reschedule) await deps.reschedule().catch(() => void 0);
|
|
1373
|
+
}
|
|
1374
|
+
};
|
|
1375
|
+
async function notifyIfNeeded() {
|
|
1376
|
+
try {
|
|
1377
|
+
if (deps.ensurePlan) await deps.ensurePlan();
|
|
1378
|
+
const state = await deps.coach.exportData();
|
|
1379
|
+
if (!state.profile) return;
|
|
1380
|
+
const today = await deps.coach.today();
|
|
1381
|
+
if (state.log.some((e) => e.date === today.date)) return;
|
|
1382
|
+
if (!today.tasks.some((task) => task.status === "todo")) return;
|
|
1383
|
+
await deps.notifications.create(REMINDER_NOTIFICATION_ID, {
|
|
1384
|
+
type: "basic",
|
|
1385
|
+
iconUrl: deps.iconUrl,
|
|
1386
|
+
title: deps.t("reminderTitle"),
|
|
1387
|
+
message: deps.t("reminderBody")
|
|
1388
|
+
});
|
|
1389
|
+
} catch {}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Notification click. Chrome only lets `sidePanel.open()` run in response to a user gesture, and a
|
|
1394
|
+
* notification click does not always count as one (it throws "may only be called in response to a
|
|
1395
|
+
* user gesture"). So: try the side panel in the current window; if Chrome refuses, focus an open LLL
|
|
1396
|
+
* tab (the learner opens the panel from the toolbar there), else open LLL in a new tab.
|
|
1397
|
+
*/
|
|
1398
|
+
function createReminderClickHandler(deps) {
|
|
1399
|
+
return async (notificationId) => {
|
|
1400
|
+
if (notificationId !== "langcoach-daily-reminder") return;
|
|
1401
|
+
try {
|
|
1402
|
+
await deps.notifications.clear(notificationId);
|
|
1403
|
+
} catch {}
|
|
1404
|
+
try {
|
|
1405
|
+
const winId = (await deps.tabs.query({
|
|
1406
|
+
active: true,
|
|
1407
|
+
currentWindow: true
|
|
1408
|
+
}))[0]?.windowId;
|
|
1409
|
+
if (winId !== void 0) {
|
|
1410
|
+
await deps.sidePanel.open({ windowId: winId });
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
} catch {}
|
|
1414
|
+
try {
|
|
1415
|
+
const lll = (await deps.tabs.query({ url: LLL_TABS })).find((t) => t.id !== void 0);
|
|
1416
|
+
if (lll?.id !== void 0 && deps.tabs.update) {
|
|
1417
|
+
await deps.tabs.update(lll.id, { active: true });
|
|
1418
|
+
if (lll.windowId !== void 0 && deps.windows) await deps.windows.update(lll.windowId, { focused: true });
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
} catch {}
|
|
1422
|
+
try {
|
|
1423
|
+
await deps.tabs.create({ url: LLL_HOME });
|
|
1424
|
+
} catch {}
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
//#endregion
|
|
1428
|
+
//#region src/background/store.ts
|
|
1429
|
+
var STATE_KEY = "langcoach:state";
|
|
1430
|
+
var BACKUP_PREFIX = "langcoach:backup:";
|
|
1431
|
+
/** Promise-chain mutex: runs tasks one at a time, in call order. */
|
|
1432
|
+
var Mutex = class {
|
|
1433
|
+
tail = Promise.resolve();
|
|
1434
|
+
run(task) {
|
|
1435
|
+
const next = this.tail.then(task, task);
|
|
1436
|
+
this.tail = next.catch(() => void 0);
|
|
1437
|
+
return next;
|
|
1438
|
+
}
|
|
1439
|
+
};
|
|
1440
|
+
var seq = 0;
|
|
1441
|
+
/** Sortable, unique-per-process suffix: ISO time then a monotonic counter. */
|
|
1442
|
+
var stamp = () => `${(/* @__PURE__ */ new Date()).toISOString()}-${String(++seq % 1e6).padStart(6, "0")}`;
|
|
1443
|
+
/** State kept as a JSON string in chrome.storage.local; the background service worker is its only writer. */
|
|
1444
|
+
var ChromeStorageStore = class {
|
|
1445
|
+
area;
|
|
1446
|
+
mutex = new Mutex();
|
|
1447
|
+
constructor(area = chrome.storage.local) {
|
|
1448
|
+
this.area = area;
|
|
1449
|
+
}
|
|
1450
|
+
async load() {
|
|
1451
|
+
const raw = (await this.area.get(STATE_KEY))[STATE_KEY];
|
|
1452
|
+
if (raw === void 0) return emptyState();
|
|
1453
|
+
let parsed;
|
|
1454
|
+
try {
|
|
1455
|
+
if (typeof raw !== "string") throw new Error("stored state is not a string");
|
|
1456
|
+
parsed = JSON.parse(raw);
|
|
1457
|
+
} catch {
|
|
1458
|
+
const all = await this.area.get(null);
|
|
1459
|
+
const existing = Object.keys(all).find((k) => k.startsWith("langcoach:corrupt:") && all[k] === raw);
|
|
1460
|
+
const preservedAt = existing ?? `langcoach:corrupt:${stamp()}`;
|
|
1461
|
+
if (!existing) await this.area.set({ [preservedAt]: raw });
|
|
1462
|
+
throw new CorruptDataError(`Stored data is not valid JSON. The original was preserved under ${preservedAt}. Import a backup or reset to start fresh.`, preservedAt);
|
|
1463
|
+
}
|
|
1464
|
+
return parseState(parsed);
|
|
1465
|
+
}
|
|
1466
|
+
async save(s) {
|
|
1467
|
+
parseState(s);
|
|
1468
|
+
await this.area.set({ [STATE_KEY]: JSON.stringify(s) });
|
|
1469
|
+
}
|
|
1470
|
+
async backup() {
|
|
1471
|
+
const raw = (await this.area.get(STATE_KEY))[STATE_KEY];
|
|
1472
|
+
if (raw === void 0) return null;
|
|
1473
|
+
const key = `${BACKUP_PREFIX}${stamp()}`;
|
|
1474
|
+
await this.area.set({ [key]: raw });
|
|
1475
|
+
const all = Object.keys(await this.area.get(null)).filter((k) => k.startsWith(BACKUP_PREFIX)).sort();
|
|
1476
|
+
if (all.length > 3) await this.area.remove(all.slice(0, all.length - 3));
|
|
1477
|
+
return key;
|
|
1478
|
+
}
|
|
1479
|
+
transaction(fn) {
|
|
1480
|
+
return this.mutex.run(async () => {
|
|
1481
|
+
const out = await fn(await this.load());
|
|
1482
|
+
if (out.state !== void 0) await this.save(out.state);
|
|
1483
|
+
return out.result;
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
async replace(next) {
|
|
1487
|
+
parseState(next);
|
|
1488
|
+
return this.mutex.run(async () => {
|
|
1489
|
+
const key = await this.backup();
|
|
1490
|
+
await this.save(next);
|
|
1491
|
+
return key;
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1495
|
+
//#endregion
|
|
1496
|
+
//#region src/background/index.ts
|
|
1497
|
+
var store = new ChromeStorageStore();
|
|
1498
|
+
var coach = new Coach(store);
|
|
1499
|
+
var log = (...a) => console.warn(...a);
|
|
1500
|
+
var bridge = createBridge({
|
|
1501
|
+
store,
|
|
1502
|
+
area: chrome.storage.local,
|
|
1503
|
+
session: chrome.storage.session,
|
|
1504
|
+
alarms: chrome.alarms,
|
|
1505
|
+
log
|
|
1506
|
+
});
|
|
1507
|
+
var router = createRouter({
|
|
1508
|
+
coach,
|
|
1509
|
+
store,
|
|
1510
|
+
area: chrome.storage.local,
|
|
1511
|
+
sidePanel: chrome.sidePanel,
|
|
1512
|
+
onSettingsChanged: (settings) => {
|
|
1513
|
+
bridge.flush();
|
|
1514
|
+
return scheduleReminder(settings, chrome.alarms);
|
|
1515
|
+
},
|
|
1516
|
+
log,
|
|
1517
|
+
uiLanguage: () => chrome.i18n.getUILanguage(),
|
|
1518
|
+
bridge
|
|
1519
|
+
});
|
|
1520
|
+
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
1521
|
+
if (!isMessage(msg)) return false;
|
|
1522
|
+
if (!isAllowed(msg.type, sender, chrome.runtime.id, chrome.runtime.getURL(""), false)) {
|
|
1523
|
+
sendResponse({
|
|
1524
|
+
ok: false,
|
|
1525
|
+
error: {
|
|
1526
|
+
name: "Forbidden",
|
|
1527
|
+
message: "Sender not allowed",
|
|
1528
|
+
code: "forbidden"
|
|
1529
|
+
}
|
|
1530
|
+
});
|
|
1531
|
+
return false;
|
|
1532
|
+
}
|
|
1533
|
+
router.handle(msg, { tabId: sender.tab?.id }).then(sendResponse);
|
|
1534
|
+
return true;
|
|
1535
|
+
});
|
|
1536
|
+
var setupMenus = () => registerMenus(chrome.contextMenus, (k) => chrome.i18n.getMessage(k), false).catch((e) => log("LangCoach: context menu setup failed", e));
|
|
1537
|
+
var syncReminder = () => router.handle({ type: "getSettings" }).then((r) => r.ok ? scheduleReminder(r.data, chrome.alarms) : void 0).catch((e) => log("LangCoach: reminder schedule failed", e));
|
|
1538
|
+
var syncRollover = () => scheduleRollover(chrome.alarms).catch((e) => log("LangCoach: rollover schedule failed", e));
|
|
1539
|
+
chrome.runtime.onInstalled.addListener(() => {
|
|
1540
|
+
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
|
|
1541
|
+
setupMenus();
|
|
1542
|
+
syncReminder();
|
|
1543
|
+
syncRollover();
|
|
1544
|
+
router.ensurePlan();
|
|
1545
|
+
});
|
|
1546
|
+
chrome.runtime.onStartup.addListener(() => {
|
|
1547
|
+
setupMenus();
|
|
1548
|
+
syncReminder();
|
|
1549
|
+
syncRollover();
|
|
1550
|
+
router.ensurePlan();
|
|
1551
|
+
});
|
|
1552
|
+
router.rollover();
|
|
1553
|
+
bridge.flush();
|
|
1554
|
+
var onMenuClick = createMenuClickHandler({
|
|
1555
|
+
coach,
|
|
1556
|
+
tabs: chrome.tabs,
|
|
1557
|
+
log
|
|
1558
|
+
});
|
|
1559
|
+
chrome.contextMenus.onClicked.addListener((info, tab) => void onMenuClick(info, tab));
|
|
1560
|
+
var onAlarm = createReminderAlarmHandler({
|
|
1561
|
+
coach,
|
|
1562
|
+
notifications: chrome.notifications,
|
|
1563
|
+
t: (k) => chrome.i18n.getMessage(k),
|
|
1564
|
+
iconUrl: chrome.runtime.getURL("icons/128.png"),
|
|
1565
|
+
ensurePlan: () => router.ensurePlan(),
|
|
1566
|
+
reschedule: () => syncReminder().then(() => void 0)
|
|
1567
|
+
});
|
|
1568
|
+
var onRolloverAlarm = createRolloverAlarmHandler({
|
|
1569
|
+
rollover: () => router.rollover(),
|
|
1570
|
+
reschedule: syncRollover
|
|
1571
|
+
});
|
|
1572
|
+
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
1573
|
+
onAlarm(alarm);
|
|
1574
|
+
onRolloverAlarm(alarm);
|
|
1575
|
+
bridge.onAlarm(alarm);
|
|
1576
|
+
});
|
|
1577
|
+
var onNotificationClick = createReminderClickHandler({
|
|
1578
|
+
sidePanel: chrome.sidePanel,
|
|
1579
|
+
tabs: chrome.tabs,
|
|
1580
|
+
windows: chrome.windows,
|
|
1581
|
+
notifications: chrome.notifications
|
|
1582
|
+
});
|
|
1583
|
+
chrome.notifications.onClicked.addListener((id) => void onNotificationClick(id));
|
|
1584
|
+
//#endregion
|