@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.
- package/CHANGELOG.md +35 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +230 -0
- package/dist/doc.d.ts +13 -0
- package/dist/doc.js +683 -0
- package/dist/docx.d.ts +31 -0
- package/dist/docx.js +26 -0
- package/dist/dom/docx.d.ts +90 -0
- package/dist/dom/docx.js +472 -0
- package/dist/dom/index.d.ts +11 -0
- package/dist/dom/index.js +11 -0
- package/dist/dom/ocr.d.ts +112 -0
- package/dist/dom/ocr.js +224 -0
- package/dist/engine.d.ts +81 -0
- package/dist/engine.js +274 -0
- package/dist/env.d.ts +49 -0
- package/dist/env.js +53 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +29 -0
- package/dist/model-loader.d.ts +55 -0
- package/dist/model-loader.js +253 -0
- package/dist/pipeline.d.ts +19 -0
- package/dist/pipeline.js +128 -0
- package/dist/providers/bardsai.d.ts +38 -0
- package/dist/providers/bardsai.js +393 -0
- package/dist/providers/gliner.d.ts +52 -0
- package/dist/providers/gliner.js +360 -0
- package/dist/regex/index.d.ts +17 -0
- package/dist/regex/index.js +18 -0
- package/dist/regex/loader.d.ts +44 -0
- package/dist/regex/loader.js +44 -0
- package/dist/regex/rules.data.d.ts +2 -0
- package/dist/regex/rules.data.js +2671 -0
- package/dist/regex/types.d.ts +27 -0
- package/dist/regex/types.js +1 -0
- package/dist/regex/validators.d.ts +17 -0
- package/dist/regex/validators.js +492 -0
- package/dist/session.d.ts +59 -0
- package/dist/session.js +153 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +28 -0
- package/dist/worker-protocol.d.ts +119 -0
- package/dist/worker-protocol.js +285 -0
- package/package.json +75 -0
- package/rules/at.json +70 -0
- package/rules/be.json +70 -0
- package/rules/ch.json +66 -0
- package/rules/cn.json +155 -0
- package/rules/de.json +192 -0
- package/rules/dk.json +43 -0
- package/rules/es.json +158 -0
- package/rules/fi.json +43 -0
- package/rules/fr.json +178 -0
- package/rules/gb.json +194 -0
- package/rules/ie.json +48 -0
- package/rules/it.json +116 -0
- package/rules/jp.json +136 -0
- package/rules/nl.json +99 -0
- package/rules/no.json +132 -0
- package/rules/pl.json +210 -0
- package/rules/pt.json +113 -0
- package/rules/schema.json +91 -0
- package/rules/se.json +149 -0
- package/rules/universal.json +279 -0
- package/rules/us.json +218 -0
package/dist/session.js
ADDED
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|