@doccloak/core 0.8.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.md +230 -0
  5. package/dist/doc.d.ts +13 -0
  6. package/dist/doc.js +683 -0
  7. package/dist/docx.d.ts +31 -0
  8. package/dist/docx.js +26 -0
  9. package/dist/dom/docx.d.ts +90 -0
  10. package/dist/dom/docx.js +472 -0
  11. package/dist/dom/index.d.ts +11 -0
  12. package/dist/dom/index.js +11 -0
  13. package/dist/dom/ocr.d.ts +112 -0
  14. package/dist/dom/ocr.js +224 -0
  15. package/dist/engine.d.ts +81 -0
  16. package/dist/engine.js +274 -0
  17. package/dist/env.d.ts +49 -0
  18. package/dist/env.js +53 -0
  19. package/dist/index.d.ts +20 -0
  20. package/dist/index.js +29 -0
  21. package/dist/model-loader.d.ts +55 -0
  22. package/dist/model-loader.js +253 -0
  23. package/dist/pipeline.d.ts +19 -0
  24. package/dist/pipeline.js +128 -0
  25. package/dist/providers/bardsai.d.ts +38 -0
  26. package/dist/providers/bardsai.js +393 -0
  27. package/dist/providers/gliner.d.ts +52 -0
  28. package/dist/providers/gliner.js +360 -0
  29. package/dist/regex/index.d.ts +17 -0
  30. package/dist/regex/index.js +18 -0
  31. package/dist/regex/loader.d.ts +44 -0
  32. package/dist/regex/loader.js +44 -0
  33. package/dist/regex/rules.data.d.ts +2 -0
  34. package/dist/regex/rules.data.js +2671 -0
  35. package/dist/regex/types.d.ts +27 -0
  36. package/dist/regex/types.js +1 -0
  37. package/dist/regex/validators.d.ts +17 -0
  38. package/dist/regex/validators.js +492 -0
  39. package/dist/session.d.ts +59 -0
  40. package/dist/session.js +153 -0
  41. package/dist/types.d.ts +42 -0
  42. package/dist/types.js +28 -0
  43. package/dist/worker-protocol.d.ts +119 -0
  44. package/dist/worker-protocol.js +285 -0
  45. package/package.json +75 -0
  46. package/rules/at.json +70 -0
  47. package/rules/be.json +70 -0
  48. package/rules/ch.json +66 -0
  49. package/rules/cn.json +155 -0
  50. package/rules/de.json +192 -0
  51. package/rules/dk.json +43 -0
  52. package/rules/es.json +158 -0
  53. package/rules/fi.json +43 -0
  54. package/rules/fr.json +178 -0
  55. package/rules/gb.json +194 -0
  56. package/rules/ie.json +48 -0
  57. package/rules/it.json +116 -0
  58. package/rules/jp.json +136 -0
  59. package/rules/nl.json +99 -0
  60. package/rules/no.json +132 -0
  61. package/rules/pl.json +210 -0
  62. package/rules/pt.json +113 -0
  63. package/rules/schema.json +91 -0
  64. package/rules/se.json +149 -0
  65. package/rules/universal.json +279 -0
  66. package/rules/us.json +218 -0
@@ -0,0 +1,153 @@
1
+ export class AnonymizationSession {
2
+ constructor() {
3
+ this.forwardMap = new Map();
4
+ this.reverseMap = new Map();
5
+ this.entityTypeMap = new Map();
6
+ this.counter = 0;
7
+ this.mode = 'labeled';
8
+ }
9
+ setMode(mode) {
10
+ this.mode = mode;
11
+ }
12
+ getMode() {
13
+ return this.mode;
14
+ }
15
+ anonymize(original, entityType) {
16
+ if (this.forwardMap.has(original)) {
17
+ return this.forwardMap.get(original);
18
+ }
19
+ this.counter++;
20
+ const placeholder = this.mode === 'blanked'
21
+ ? '________'
22
+ : `<<REDACTED_${this.counter}>>`;
23
+ this.forwardMap.set(original, placeholder);
24
+ // Blanked placeholders all collide on the same string; they are not
25
+ // reversible, so keep them out of the restore map.
26
+ if (this.mode !== 'blanked') {
27
+ this.reverseMap.set(placeholder, original);
28
+ }
29
+ this.entityTypeMap.set(original, entityType);
30
+ return placeholder;
31
+ }
32
+ deanonymize(text) {
33
+ let result = text;
34
+ // Sort by placeholder length (longest first) to avoid partial replacements
35
+ const entries = [...this.reverseMap.entries()].sort((a, b) => b[0].length - a[0].length);
36
+ for (const [placeholder, original] of entries) {
37
+ // Replacer function keeps '$' sequences in the original value inert
38
+ result = result.replaceAll(placeholder, () => original);
39
+ }
40
+ return result;
41
+ }
42
+ anonymizeText(text, entities) {
43
+ // Sort entities by position (end to start) to preserve indices during replacement
44
+ const sorted = [...entities].sort((a, b) => b.start - a.start);
45
+ let result = text;
46
+ // Clamp overlapping entities (e.g. a manual selection crossing a detected
47
+ // one) so a later replacement never splices into an already-replaced range.
48
+ let minStart = text.length;
49
+ for (const entity of sorted) {
50
+ const effectiveEnd = Math.min(entity.end, minStart);
51
+ if (effectiveEnd <= entity.start)
52
+ continue;
53
+ const placeholder = this.anonymize(entity.value, entity.type);
54
+ result = result.slice(0, entity.start) + placeholder + result.slice(effectiveEnd);
55
+ minStart = entity.start;
56
+ }
57
+ return result;
58
+ }
59
+ getEntries() {
60
+ return [...this.forwardMap.entries()].map(([original, replacement]) => {
61
+ const entityType = this.entityTypeMap.get(original) ?? 'OTHER';
62
+ return { original, replacement, entityType };
63
+ });
64
+ }
65
+ getForward(original) {
66
+ return this.forwardMap.get(original);
67
+ }
68
+ renameLabel(original, newLabel) {
69
+ const oldLabel = this.forwardMap.get(original);
70
+ if (!oldLabel)
71
+ return;
72
+ this.forwardMap.set(original, newLabel);
73
+ this.reverseMap.delete(oldLabel);
74
+ this.reverseMap.set(newLabel, original);
75
+ }
76
+ /**
77
+ * Serialize the session map to JSON.
78
+ *
79
+ * Schema (field-for-field parity with the Python CLI's
80
+ * DocCloak.Cli/doccloak/core/session.py save_map/load_map):
81
+ *
82
+ * [
83
+ * {
84
+ * "original": string, // the original sensitive value
85
+ * "replacement": string, // its placeholder, e.g. "<<REDACTED_1>>" or a renamed label
86
+ * "entity_type": string // EntityType value, e.g. "PERSON" (snake_case key, matching Python)
87
+ * },
88
+ * ...
89
+ * ]
90
+ *
91
+ * - Top level is a JSON array; one object per mapping, in insertion order.
92
+ * - The counter is NOT stored as a field. Like Python's load_map, deserialize
93
+ * derives it from the number of entries.
94
+ * - The mode is NOT stored as a field. Like Python's load_map (which returns
95
+ * `cls()` with the default mode), deserialize yields a 'labeled' session.
96
+ * - Blanked entries are intentionally OMITTED from the output: blanked
97
+ * placeholders ('________') are irreversible by design (this class already
98
+ * keeps them out of the reverse map), so persisting their originals would
99
+ * defeat that. Only reversible entries are serialized.
100
+ * - Output formatting matches Python's json.dumps(entries, indent=2,
101
+ * ensure_ascii=False): 2-space indent, non-ASCII characters kept raw.
102
+ */
103
+ serialize() {
104
+ const entries = this.getEntries()
105
+ .filter(({ replacement, original }) => this.reverseMap.get(replacement) === original)
106
+ .map(({ original, replacement, entityType }) => ({
107
+ original,
108
+ replacement,
109
+ entity_type: entityType,
110
+ }));
111
+ return JSON.stringify(entries, null, 2);
112
+ }
113
+ /**
114
+ * Rebuild a session from JSON produced by serialize() or by the Python
115
+ * CLI's save_map. Mirrors Python's load_map: forward/reverse/type maps are
116
+ * repopulated per entry and the counter is incremented once per entry.
117
+ * Additionally, the counter is raised to the highest N found among
118
+ * '<<REDACTED_N>>' placeholders (a map serialized with gaps, e.g. after
119
+ * blanked entries were omitted, would otherwise reissue a taken number),
120
+ * so new entities anonymized after restore continue numbering without
121
+ * colliding with restored placeholders. Blanked entries ('________') that a
122
+ * Python-produced map may contain are kept in the forward map but excluded
123
+ * from the reverse map, preserving their irreversibility.
124
+ */
125
+ static deserialize(json) {
126
+ const data = JSON.parse(json);
127
+ if (!Array.isArray(data)) {
128
+ throw new Error('Invalid session map JSON: expected a top-level array');
129
+ }
130
+ const session = new AnonymizationSession();
131
+ let maxPlaceholderNumber = 0;
132
+ for (const entry of data) {
133
+ session.forwardMap.set(entry.original, entry.replacement);
134
+ if (entry.replacement !== '________') {
135
+ session.reverseMap.set(entry.replacement, entry.original);
136
+ }
137
+ session.entityTypeMap.set(entry.original, entry.entity_type);
138
+ session.counter++;
139
+ const match = /^<<REDACTED_(\d+)>>$/.exec(entry.replacement);
140
+ if (match) {
141
+ maxPlaceholderNumber = Math.max(maxPlaceholderNumber, Number(match[1]));
142
+ }
143
+ }
144
+ session.counter = Math.max(session.counter, maxPlaceholderNumber);
145
+ return session;
146
+ }
147
+ clear() {
148
+ this.forwardMap.clear();
149
+ this.reverseMap.clear();
150
+ this.entityTypeMap.clear();
151
+ this.counter = 0;
152
+ }
153
+ }
@@ -0,0 +1,42 @@
1
+ export type EntityType = 'PERSON' | 'EMAIL' | 'PHONE' | 'SSN' | 'CREDIT_CARD' | 'DATE' | 'CURRENCY' | 'IP_ADDRESS' | 'IBAN' | 'ADDRESS' | 'COMPANY' | 'OTHER';
2
+ export interface DetectedEntity {
3
+ type: EntityType;
4
+ value: string;
5
+ start: number;
6
+ end: number;
7
+ confidence: number;
8
+ detector: string;
9
+ }
10
+ export type ProgressCallback = (downloaded: number, total: number) => void;
11
+ /**
12
+ * Interface for detection providers.
13
+ * Implement this to add a new model/detection backend.
14
+ * The engine only depends on this interface — swap providers without changing anything else.
15
+ */
16
+ export interface DetectionProvider {
17
+ /** Human-readable name for this provider */
18
+ readonly name: string;
19
+ /** Initialize/download the model. Called once on app load. */
20
+ load(): Promise<void>;
21
+ /** Whether the provider is ready to detect */
22
+ isLoaded(): boolean;
23
+ /** Whether the provider is currently loading */
24
+ isLoading(): boolean;
25
+ /** Register a callback for download progress updates */
26
+ onProgress(callback: ProgressCallback): void;
27
+ /** Run detection on the given text. Optional progress callback (0-1). */
28
+ detect(text: string, onProgress?: (progress: number) => void): Promise<DetectedEntity[]>;
29
+ /** Set detection confidence threshold */
30
+ setThreshold(value: number): void;
31
+ /** Get current detection confidence threshold */
32
+ getThreshold(): number;
33
+ /** Release the ONNX session and free memory. Provider can be re-loaded later. */
34
+ release(): void;
35
+ }
36
+ export interface ReplacementEntry {
37
+ original: string;
38
+ replacement: string;
39
+ entityType: EntityType;
40
+ }
41
+ export declare const ENTITY_COLORS: Record<EntityType, string>;
42
+ export declare const ENTITY_LABELS: Record<EntityType, string>;
package/dist/types.js ADDED
@@ -0,0 +1,28 @@
1
+ export const ENTITY_COLORS = {
2
+ PERSON: '#7B2FF7',
3
+ EMAIL: '#2563EB',
4
+ PHONE: '#0D9488',
5
+ SSN: '#B45309',
6
+ CREDIT_CARD: '#CA8A04',
7
+ DATE: '#6D28D9',
8
+ CURRENCY: '#EA580C',
9
+ IP_ADDRESS: '#0284C7',
10
+ IBAN: '#A16207',
11
+ ADDRESS: '#DC2626',
12
+ COMPANY: '#DB2777',
13
+ OTHER: '#6B7280',
14
+ };
15
+ export const ENTITY_LABELS = {
16
+ PERSON: 'Name',
17
+ EMAIL: 'Email',
18
+ PHONE: 'Phone',
19
+ SSN: 'SSN',
20
+ CREDIT_CARD: 'Credit Card',
21
+ DATE: 'Date',
22
+ CURRENCY: 'Currency',
23
+ IP_ADDRESS: 'IP Address',
24
+ IBAN: 'IBAN',
25
+ ADDRESS: 'Address',
26
+ COMPANY: 'Company',
27
+ OTHER: 'Other',
28
+ };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @doccloak/core - transport-agnostic worker message protocol (T009).
3
+ *
4
+ * Formalizes the message pairs the web app's engine.ts and
5
+ * detection.worker.ts have always exchanged, keeping the exact message
6
+ * names and payload fields on the wire:
7
+ *
8
+ * init / switchProvider -> loaded | loadError (+ downloadProgress events)
9
+ * detect -> detected | detectError (+ detectionProgress events)
10
+ * releaseModel -> released
11
+ * setThreshold / setCustomLabels / setRegex / setRegexRegion (fire and forget)
12
+ *
13
+ * Correlation keeps the existing requestId scheme; requestId is optional on
14
+ * the request side so legacy-style senders (and the regression harness)
15
+ * keep working, while connectEngine always correlates.
16
+ *
17
+ * serveEngine() is the host side: it answers protocol messages with a real
18
+ * engine (web worker entry, extension offscreen document). connectEngine()
19
+ * is the client side: a DocCloakEngine that forwards over the port (web
20
+ * main thread, extension side panel / service worker).
21
+ */
22
+ import type { DetectedEntity } from './types.js';
23
+ import type { DocCloakEngine, EngineSettings, ProviderId } from './engine.js';
24
+ import type { RegexRegionId } from './regex/index.js';
25
+ /** Satisfied by Worker, MessagePort and chrome.runtime.Port wrappers. */
26
+ export interface PortLike {
27
+ postMessage(msg: unknown): void;
28
+ /** Register a message callback; returns an unsubscribe function. */
29
+ onMessage(cb: (msg: unknown) => void | Promise<void>): () => void;
30
+ }
31
+ export type EngineRequest = {
32
+ type: 'init';
33
+ requestId?: number;
34
+ providerId?: ProviderId;
35
+ customLabels?: string[];
36
+ regexEnabled?: boolean;
37
+ regexRegion?: RegexRegionId;
38
+ } | {
39
+ type: 'detect';
40
+ requestId: number;
41
+ text: string;
42
+ } | {
43
+ type: 'switchProvider';
44
+ requestId?: number;
45
+ providerId: ProviderId;
46
+ customLabels?: string[];
47
+ } | {
48
+ type: 'setThreshold';
49
+ value: number;
50
+ } | {
51
+ type: 'setCustomLabels';
52
+ labels: string[];
53
+ } | {
54
+ type: 'setRegex';
55
+ enabled: boolean;
56
+ region?: RegexRegionId;
57
+ } | {
58
+ type: 'setRegexRegion';
59
+ region: RegexRegionId;
60
+ } | {
61
+ type: 'releaseModel';
62
+ requestId?: number;
63
+ };
64
+ export type EngineResponse = {
65
+ type: 'loaded';
66
+ requestId?: number;
67
+ providerId: ProviderId;
68
+ threshold: number;
69
+ customLabels: string[];
70
+ } | {
71
+ type: 'loadError';
72
+ requestId?: number;
73
+ error: string;
74
+ } | {
75
+ type: 'detected';
76
+ requestId: number;
77
+ entities: DetectedEntity[];
78
+ } | {
79
+ type: 'detectError';
80
+ requestId: number;
81
+ error: string;
82
+ } | {
83
+ type: 'detectionProgress';
84
+ requestId: number;
85
+ progress: number;
86
+ } | {
87
+ type: 'downloadProgress';
88
+ downloaded: number;
89
+ total: number;
90
+ } | {
91
+ type: 'released';
92
+ requestId?: number;
93
+ };
94
+ /**
95
+ * Serve a real engine over a port. Returns a stop function that
96
+ * unsubscribes from the port and the engine's progress events.
97
+ */
98
+ export declare function serveEngine(engine: DocCloakEngine, port: PortLike): () => void;
99
+ /** DocCloakEngine proxy returned by connectEngine. */
100
+ export interface EngineClient extends DocCloakEngine {
101
+ /**
102
+ * Switch provider, optionally sending fresh custom labels in the same
103
+ * message (mirrors the pre-extraction switchProvider payload).
104
+ */
105
+ switchProvider(id: ProviderId, customLabels?: string[]): Promise<void>;
106
+ /**
107
+ * Tear the connection down: rejects pending detections/loads (resolves
108
+ * pending releases) and stops listening. Hosts call this when the
109
+ * underlying worker crashes.
110
+ */
111
+ close(reason?: Error): void;
112
+ }
113
+ /**
114
+ * Connect to a served engine over a port. The client keeps a synchronous
115
+ * settings mirror (seeded from `initial`, updated from loaded responses)
116
+ * so getSettings() works without a round trip; persistence stays with
117
+ * whichever side owns the KV store.
118
+ */
119
+ export declare function connectEngine(port: PortLike, initial?: Partial<EngineSettings>): EngineClient;
@@ -0,0 +1,285 @@
1
+ /**
2
+ * @doccloak/core - transport-agnostic worker message protocol (T009).
3
+ *
4
+ * Formalizes the message pairs the web app's engine.ts and
5
+ * detection.worker.ts have always exchanged, keeping the exact message
6
+ * names and payload fields on the wire:
7
+ *
8
+ * init / switchProvider -> loaded | loadError (+ downloadProgress events)
9
+ * detect -> detected | detectError (+ detectionProgress events)
10
+ * releaseModel -> released
11
+ * setThreshold / setCustomLabels / setRegex / setRegexRegion (fire and forget)
12
+ *
13
+ * Correlation keeps the existing requestId scheme; requestId is optional on
14
+ * the request side so legacy-style senders (and the regression harness)
15
+ * keep working, while connectEngine always correlates.
16
+ *
17
+ * serveEngine() is the host side: it answers protocol messages with a real
18
+ * engine (web worker entry, extension offscreen document). connectEngine()
19
+ * is the client side: a DocCloakEngine that forwards over the port (web
20
+ * main thread, extension side panel / service worker).
21
+ */
22
+ import { clampThreshold, defaultThresholdFor } from "./engine.js";
23
+ function errorMessage(err) {
24
+ return err instanceof Error ? err.message : String(err);
25
+ }
26
+ // ── Host side ──────────────────────────────────────────────
27
+ /**
28
+ * Serve a real engine over a port. Returns a stop function that
29
+ * unsubscribes from the port and the engine's progress events.
30
+ */
31
+ export function serveEngine(engine, port) {
32
+ const unsubscribeDownload = engine.onDownloadProgress(({ loaded, total }) => {
33
+ const msg = { type: 'downloadProgress', downloaded: loaded, total };
34
+ port.postMessage(msg);
35
+ });
36
+ function post(msg) {
37
+ port.postMessage(msg);
38
+ }
39
+ async function handleLoad(requestId, run) {
40
+ try {
41
+ await run();
42
+ const s = engine.getSettings();
43
+ post({ type: 'loaded', requestId, providerId: s.providerId, threshold: s.threshold, customLabels: s.customLabels });
44
+ }
45
+ catch (err) {
46
+ post({ type: 'loadError', requestId, error: errorMessage(err) });
47
+ }
48
+ }
49
+ const unsubscribeMessages = port.onMessage(async (raw) => {
50
+ const msg = raw;
51
+ switch (msg.type) {
52
+ case 'init': {
53
+ await handleLoad(msg.requestId, async () => {
54
+ const patch = {};
55
+ if (msg.providerId !== undefined)
56
+ patch.providerId = msg.providerId;
57
+ if (msg.customLabels !== undefined)
58
+ patch.customLabels = msg.customLabels;
59
+ if (msg.regexEnabled !== undefined)
60
+ patch.regexEnabled = msg.regexEnabled;
61
+ if (msg.regexRegion !== undefined)
62
+ patch.regexRegion = msg.regexRegion;
63
+ await engine.updateSettings(patch);
64
+ await engine.preload();
65
+ });
66
+ break;
67
+ }
68
+ case 'switchProvider': {
69
+ await handleLoad(msg.requestId, async () => {
70
+ if (msg.customLabels !== undefined) {
71
+ await engine.updateSettings({ customLabels: msg.customLabels });
72
+ }
73
+ await engine.switchProvider(msg.providerId);
74
+ });
75
+ break;
76
+ }
77
+ case 'detect': {
78
+ try {
79
+ const entities = await engine.detect(msg.text, undefined, (progress) => {
80
+ post({ type: 'detectionProgress', requestId: msg.requestId, progress });
81
+ });
82
+ post({ type: 'detected', requestId: msg.requestId, entities });
83
+ }
84
+ catch (err) {
85
+ post({ type: 'detectError', requestId: msg.requestId, error: errorMessage(err) });
86
+ }
87
+ break;
88
+ }
89
+ case 'setThreshold': {
90
+ await engine.updateSettings({ threshold: msg.value });
91
+ break;
92
+ }
93
+ case 'setCustomLabels': {
94
+ await engine.updateSettings({ customLabels: msg.labels });
95
+ break;
96
+ }
97
+ case 'setRegex': {
98
+ const patch = { regexEnabled: msg.enabled };
99
+ if (msg.region !== undefined)
100
+ patch.regexRegion = msg.region;
101
+ await engine.updateSettings(patch);
102
+ break;
103
+ }
104
+ case 'setRegexRegion': {
105
+ await engine.updateSettings({ regexRegion: msg.region });
106
+ break;
107
+ }
108
+ case 'releaseModel': {
109
+ await engine.release();
110
+ post({ type: 'released', requestId: msg.requestId });
111
+ break;
112
+ }
113
+ }
114
+ });
115
+ return () => {
116
+ unsubscribeMessages();
117
+ unsubscribeDownload();
118
+ };
119
+ }
120
+ /**
121
+ * Connect to a served engine over a port. The client keeps a synchronous
122
+ * settings mirror (seeded from `initial`, updated from loaded responses)
123
+ * so getSettings() works without a round trip; persistence stays with
124
+ * whichever side owns the KV store.
125
+ */
126
+ export function connectEngine(port, initial) {
127
+ const initialProvider = initial?.providerId ?? 'bardsai';
128
+ const settings = {
129
+ providerId: initialProvider,
130
+ threshold: initial?.threshold ?? defaultThresholdFor(initialProvider),
131
+ regexEnabled: initial?.regexEnabled ?? false,
132
+ regexRegion: initial?.regexRegion ?? 'all',
133
+ customLabels: [...(initial?.customLabels ?? [])],
134
+ };
135
+ let nextRequestId = 0;
136
+ let closed = false;
137
+ const pending = new Map();
138
+ const downloadListeners = new Set();
139
+ const detectionListeners = new Set();
140
+ function takePending(requestId) {
141
+ if (requestId === undefined)
142
+ return undefined;
143
+ const entry = pending.get(requestId);
144
+ if (entry)
145
+ pending.delete(requestId);
146
+ return entry;
147
+ }
148
+ const unsubscribe = port.onMessage((raw) => {
149
+ const msg = raw;
150
+ switch (msg.type) {
151
+ case 'downloadProgress': {
152
+ for (const cb of downloadListeners)
153
+ cb({ loaded: msg.downloaded, total: msg.total });
154
+ break;
155
+ }
156
+ case 'detectionProgress': {
157
+ pending.get(msg.requestId)?.onProgress?.(msg.progress);
158
+ for (const cb of detectionListeners)
159
+ cb(msg.progress);
160
+ break;
161
+ }
162
+ case 'loaded': {
163
+ settings.providerId = msg.providerId;
164
+ settings.threshold = msg.threshold;
165
+ settings.customLabels = [...(msg.customLabels ?? [])];
166
+ takePending(msg.requestId)?.resolve(undefined);
167
+ break;
168
+ }
169
+ case 'loadError': {
170
+ takePending(msg.requestId)?.reject(new Error(msg.error));
171
+ break;
172
+ }
173
+ case 'detected': {
174
+ takePending(msg.requestId)?.resolve(msg.entities ?? []);
175
+ break;
176
+ }
177
+ case 'detectError': {
178
+ takePending(msg.requestId)?.reject(new Error(msg.error));
179
+ break;
180
+ }
181
+ case 'released': {
182
+ takePending(msg.requestId)?.resolve(undefined);
183
+ break;
184
+ }
185
+ }
186
+ });
187
+ function request(kind, message, onProgress) {
188
+ if (closed)
189
+ return Promise.reject(new Error('Engine connection closed'));
190
+ const requestId = ++nextRequestId;
191
+ return new Promise((resolve, reject) => {
192
+ pending.set(requestId, { kind, resolve, reject, onProgress });
193
+ port.postMessage({ ...message, requestId });
194
+ });
195
+ }
196
+ async function preload() {
197
+ await request('load', {
198
+ type: 'init',
199
+ providerId: settings.providerId,
200
+ customLabels: settings.customLabels,
201
+ regexEnabled: settings.regexEnabled,
202
+ regexRegion: settings.regexRegion,
203
+ });
204
+ }
205
+ async function switchProvider(id, customLabels) {
206
+ const labels = customLabels ?? settings.customLabels;
207
+ settings.customLabels = [...labels];
208
+ await request('load', { type: 'switchProvider', providerId: id, customLabels: labels });
209
+ }
210
+ async function updateSettings(patch) {
211
+ if (patch.regexRegion !== undefined && patch.regexEnabled === undefined) {
212
+ settings.regexRegion = patch.regexRegion;
213
+ port.postMessage({ type: 'setRegexRegion', region: patch.regexRegion });
214
+ }
215
+ if (patch.regexEnabled !== undefined) {
216
+ settings.regexEnabled = patch.regexEnabled;
217
+ if (patch.regexRegion !== undefined)
218
+ settings.regexRegion = patch.regexRegion;
219
+ port.postMessage({
220
+ type: 'setRegex',
221
+ enabled: patch.regexEnabled,
222
+ region: settings.regexRegion,
223
+ });
224
+ }
225
+ if (patch.threshold !== undefined) {
226
+ settings.threshold = clampThreshold(patch.threshold);
227
+ port.postMessage({ type: 'setThreshold', value: settings.threshold });
228
+ }
229
+ if (patch.customLabels !== undefined) {
230
+ settings.customLabels = patch.customLabels.filter((l) => l.trim().length > 0);
231
+ port.postMessage({ type: 'setCustomLabels', labels: settings.customLabels });
232
+ }
233
+ if (patch.providerId !== undefined && patch.providerId !== settings.providerId) {
234
+ await switchProvider(patch.providerId);
235
+ }
236
+ }
237
+ function detect(text, signal, onProgress) {
238
+ if (signal?.aborted)
239
+ return Promise.reject(new Error('Detection aborted'));
240
+ const promise = request('detect', { type: 'detect', text }, onProgress);
241
+ if (signal) {
242
+ const requestId = nextRequestId;
243
+ signal.addEventListener('abort', () => {
244
+ takePending(requestId)?.reject(new Error('Detection aborted'));
245
+ }, { once: true });
246
+ }
247
+ return promise;
248
+ }
249
+ function close(reason) {
250
+ if (closed)
251
+ return;
252
+ closed = true;
253
+ unsubscribe();
254
+ const err = reason ?? new Error('Engine connection closed');
255
+ for (const [, entry] of pending) {
256
+ if (entry.kind === 'release')
257
+ entry.resolve(undefined);
258
+ else
259
+ entry.reject(err);
260
+ }
261
+ pending.clear();
262
+ }
263
+ return {
264
+ ready: Promise.resolve(),
265
+ detect,
266
+ preload,
267
+ switchProvider,
268
+ getSettings() {
269
+ return { ...settings, customLabels: [...settings.customLabels] };
270
+ },
271
+ updateSettings,
272
+ onDownloadProgress(cb) {
273
+ downloadListeners.add(cb);
274
+ return () => downloadListeners.delete(cb);
275
+ },
276
+ onDetectionProgress(cb) {
277
+ detectionListeners.add(cb);
278
+ return () => detectionListeners.delete(cb);
279
+ },
280
+ async release() {
281
+ await request('release', { type: 'releaseModel' });
282
+ },
283
+ close,
284
+ };
285
+ }