@doccloak/core 0.10.0 → 0.11.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 +41 -0
- package/dist/dom/docx.js +3 -17
- package/dist/dom/opc.d.ts +6 -0
- package/dist/dom/opc.js +18 -5
- package/dist/session.d.ts +36 -1
- package/dist/session.js +205 -30
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,47 @@ All notable changes to `@doccloak/core` are documented here. The format is
|
|
|
4
4
|
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
|
|
5
5
|
project follows [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.11.0] - 2026-09-23
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Variant-faithful restore (T171):** person variants still share one
|
|
12
|
+
identity, but every distinct variant now gets its own token, so
|
|
13
|
+
`deanonymize` writes back exactly what was there instead of the longest
|
|
14
|
+
known form. In labeled mode the token keeps the group's number and adds
|
|
15
|
+
a positional suffix: `[PERSON_2]` for "John Smith", `[PERSON_2_LAST]`
|
|
16
|
+
for "Smith", `[PERSON_2_FIRST]` for "John", `_MIDDLE` / `_SHORT` /
|
|
17
|
+
`_FULL` / `_ALT` for the other shapes, with a counter on collisions
|
|
18
|
+
(`[PERSON_2_LAST_2]` for "smith"). In surrogate mode a variant that is a
|
|
19
|
+
strict part of the canonical name takes the matching words of the
|
|
20
|
+
surrogate with the variant's casing ("Smith" -> "Nowak", "smith" ->
|
|
21
|
+
"nowak"); fuller forms and reshaped surrogates fall back to the group
|
|
22
|
+
surrogate as before. Blanked mode is unchanged. The tolerant restore
|
|
23
|
+
pass resolves mangled variant tokens like any other.
|
|
24
|
+
- **PERSON pre-pass in `anonymizeText`:** people are mapped before other
|
|
25
|
+
entities, fullest name first and then in reading order, so a variant
|
|
26
|
+
group always forms around the full name and several people are numbered
|
|
27
|
+
in the order they appear (`[PERSON_1]` is now the first person in the
|
|
28
|
+
text; other entity types keep their historical numbering order).
|
|
29
|
+
- **`renameLabel` returns the applied `[oldLabel, newLabel]` pairs** (was
|
|
30
|
+
`void`). Renaming a group's base token re-derives its variant tokens
|
|
31
|
+
(`[PERSON_1]` -> `[CLIENT]` takes `[PERSON_1_LAST]` to `[CLIENT_LAST]`);
|
|
32
|
+
renaming a variant token renames only that token and detaches it from
|
|
33
|
+
the group.
|
|
34
|
+
- `deserialize` / `importEntries` re-link variant tokens to their base and
|
|
35
|
+
keep legacy many-to-one maps restoring the longest variant.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **Office value scrubbing applies longer values first and sees URL-encoded
|
|
40
|
+
forms:** hyperlink targets, field instructions and formula literals in
|
|
41
|
+
.docx / .xlsx exports are scrubbed with the longest sensitive value first,
|
|
42
|
+
so a name variant listed before the full name ("Kowalski" before
|
|
43
|
+
"Jan Kowalski") no longer leaves "Jan" behind. External relationship
|
|
44
|
+
targets also match the percent-encoded form of each value, so
|
|
45
|
+
"Jan%20Kowalski" in a link is removed as a whole. `.docx` and `.xlsx`
|
|
46
|
+
writers now share one `replaceSensitiveValues` implementation.
|
|
47
|
+
|
|
7
48
|
## [0.10.0] - 2026-09-23
|
|
8
49
|
|
|
9
50
|
### Added
|
package/dist/dom/docx.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import JSZip from 'jszip';
|
|
10
10
|
import { normalizeReplacements } from "../docx.js";
|
|
11
|
+
import { replaceSensitiveValues } from "./opc.js";
|
|
11
12
|
export { normalizeReplacements };
|
|
12
13
|
const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
13
14
|
/**
|
|
@@ -255,7 +256,7 @@ function sanitizeContentPartAttributes(xmlDoc, valueReplacements) {
|
|
|
255
256
|
attr.value = 'R';
|
|
256
257
|
}
|
|
257
258
|
else if (attr.localName === 'instr') {
|
|
258
|
-
attr.value =
|
|
259
|
+
attr.value = replaceSensitiveValues(attr.value, valueReplacements, false);
|
|
259
260
|
}
|
|
260
261
|
}
|
|
261
262
|
if (el.localName === 'docPr' || el.localName === 'cNvPr') {
|
|
@@ -268,21 +269,6 @@ function sanitizeContentPartAttributes(xmlDoc, valueReplacements) {
|
|
|
268
269
|
}
|
|
269
270
|
}
|
|
270
271
|
}
|
|
271
|
-
/**
|
|
272
|
-
* Replace every occurrence of each sensitive value (case-insensitive) in a string.
|
|
273
|
-
* Uses a replacer function so '$' sequences in values or placeholders are inert.
|
|
274
|
-
*/
|
|
275
|
-
function replaceValues(input, valueReplacements, urlEncode) {
|
|
276
|
-
let result = input;
|
|
277
|
-
for (const { value, replacement } of valueReplacements) {
|
|
278
|
-
if (!value)
|
|
279
|
-
continue;
|
|
280
|
-
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
281
|
-
const substitute = urlEncode ? encodeURIComponent(replacement) : replacement;
|
|
282
|
-
result = result.replace(new RegExp(escaped, 'gi'), () => substitute);
|
|
283
|
-
}
|
|
284
|
-
return result;
|
|
285
|
-
}
|
|
286
272
|
/**
|
|
287
273
|
* Scrub metadata parts of the package that survive text replacement untouched:
|
|
288
274
|
* document properties, comment author registry, revision fingerprints,
|
|
@@ -383,7 +369,7 @@ async function sanitizeDocxMetadata(zip, valueReplacements) {
|
|
|
383
369
|
}
|
|
384
370
|
if (rel.getAttribute('TargetMode') === 'External') {
|
|
385
371
|
const target = rel.getAttribute('Target') ?? '';
|
|
386
|
-
const scrubbed =
|
|
372
|
+
const scrubbed = replaceSensitiveValues(target, valueReplacements, true);
|
|
387
373
|
if (scrubbed !== target)
|
|
388
374
|
rel.setAttribute('Target', scrubbed);
|
|
389
375
|
}
|
package/dist/dom/opc.d.ts
CHANGED
|
@@ -19,6 +19,12 @@ export declare function blankElementsByLocalName(doc: Document, names: Set<strin
|
|
|
19
19
|
/**
|
|
20
20
|
* Replace every occurrence of each sensitive value (case-insensitive) in a
|
|
21
21
|
* string. A replacer function keeps '$' sequences in values/placeholders inert.
|
|
22
|
+
*
|
|
23
|
+
* Longer values are applied first so a name variant ("Kowalski") can never
|
|
24
|
+
* pre-empt the fuller form it is part of ("Jan Kowalski") and leave the rest
|
|
25
|
+
* of it behind. When the input is a URL (urlEncode), the percent-encoded form
|
|
26
|
+
* of each value is matched too, so "Jan%20Kowalski" in a hyperlink target is
|
|
27
|
+
* scrubbed as a whole.
|
|
22
28
|
*/
|
|
23
29
|
export declare function replaceSensitiveValues(input: string, valueReplacements: ValueReplacement[], urlEncode: boolean): string;
|
|
24
30
|
/**
|
package/dist/dom/opc.js
CHANGED
|
@@ -34,15 +34,28 @@ export function blankElementsByLocalName(doc, names) {
|
|
|
34
34
|
/**
|
|
35
35
|
* Replace every occurrence of each sensitive value (case-insensitive) in a
|
|
36
36
|
* string. A replacer function keeps '$' sequences in values/placeholders inert.
|
|
37
|
+
*
|
|
38
|
+
* Longer values are applied first so a name variant ("Kowalski") can never
|
|
39
|
+
* pre-empt the fuller form it is part of ("Jan Kowalski") and leave the rest
|
|
40
|
+
* of it behind. When the input is a URL (urlEncode), the percent-encoded form
|
|
41
|
+
* of each value is matched too, so "Jan%20Kowalski" in a hyperlink target is
|
|
42
|
+
* scrubbed as a whole.
|
|
37
43
|
*/
|
|
38
44
|
export function replaceSensitiveValues(input, valueReplacements, urlEncode) {
|
|
39
45
|
let result = input;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
const ordered = [...valueReplacements]
|
|
47
|
+
.filter(({ value }) => value)
|
|
48
|
+
.sort((a, b) => b.value.length - a.value.length);
|
|
49
|
+
for (const { value, replacement } of ordered) {
|
|
50
|
+
const forms = [value];
|
|
51
|
+
if (urlEncode) {
|
|
52
|
+
const encoded = encodeURIComponent(value);
|
|
53
|
+
if (encoded !== value)
|
|
54
|
+
forms.push(encoded);
|
|
55
|
+
}
|
|
56
|
+
const pattern = forms.map((form) => form.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
|
44
57
|
const substitute = urlEncode ? encodeURIComponent(replacement) : replacement;
|
|
45
|
-
result = result.replace(new RegExp(
|
|
58
|
+
result = result.replace(new RegExp(pattern, 'gi'), () => substitute);
|
|
46
59
|
}
|
|
47
60
|
return result;
|
|
48
61
|
}
|
package/dist/session.d.ts
CHANGED
|
@@ -16,6 +16,11 @@ export declare class AnonymizationSession {
|
|
|
16
16
|
private reverseMap;
|
|
17
17
|
private entityTypeMap;
|
|
18
18
|
private typeCounters;
|
|
19
|
+
/**
|
|
20
|
+
* Variant token -> its group's base token and suffix (T171), so renaming
|
|
21
|
+
* the base re-derives its variants and deserialized maps re-link.
|
|
22
|
+
*/
|
|
23
|
+
private variantOf;
|
|
19
24
|
private mode;
|
|
20
25
|
/** Surrogate-mode salt (T043); constant for the session's lifetime. */
|
|
21
26
|
private salt;
|
|
@@ -46,6 +51,27 @@ export declare class AnonymizationSession {
|
|
|
46
51
|
*/
|
|
47
52
|
private findPersonVariant;
|
|
48
53
|
anonymize(original: string, entityType: EntityType): string;
|
|
54
|
+
/**
|
|
55
|
+
* The group a mapped value belongs to (T171): its base token and the
|
|
56
|
+
* canonical original that token restores to.
|
|
57
|
+
*/
|
|
58
|
+
private variantGroup;
|
|
59
|
+
/**
|
|
60
|
+
* Labeled-mode variant token (T171): the group's base token plus a
|
|
61
|
+
* positional suffix, computed against the group's canonical name when
|
|
62
|
+
* the variant is part of it, else against the value it matched. Distinct
|
|
63
|
+
* originals never share a token: a suffix already in use gets a counter
|
|
64
|
+
* ("Smith" -> [PERSON_2_LAST], "smith" -> [PERSON_2_LAST_2]).
|
|
65
|
+
*/
|
|
66
|
+
private variantPlaceholder;
|
|
67
|
+
/**
|
|
68
|
+
* Surrogate-mode variant (T171): when the variant is a strict part of the
|
|
69
|
+
* group's canonical name and the surrogate has the same word count, take
|
|
70
|
+
* the surrogate words at the same positions with the variant's casing.
|
|
71
|
+
* Anything else (fuller forms, reshaped surrogates, a collision with an
|
|
72
|
+
* existing value) falls back to the group's surrogate as before.
|
|
73
|
+
*/
|
|
74
|
+
private variantSurrogate;
|
|
49
75
|
deanonymize(text: string): string;
|
|
50
76
|
/**
|
|
51
77
|
* Tolerant pass (T098) over text the exact pass already handled: scan
|
|
@@ -63,7 +89,16 @@ export declare class AnonymizationSession {
|
|
|
63
89
|
anonymizeText(text: string, entities: DetectedEntity[]): string;
|
|
64
90
|
getEntries(): ReplacementEntry[];
|
|
65
91
|
getForward(original: string): string | undefined;
|
|
66
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Rename the label of a mapped value. Renaming a group's base token
|
|
94
|
+
* (T171) re-derives every variant token from the new label
|
|
95
|
+
* ([PERSON_1] -> [CLIENT] takes [PERSON_1_LAST] to [CLIENT_LAST]);
|
|
96
|
+
* renaming a variant token renames only that token and detaches it from
|
|
97
|
+
* the group, so a later base rename does not overwrite the user's choice.
|
|
98
|
+
* Returns every [oldLabel, newLabel] pair applied, so hosts can update
|
|
99
|
+
* already-rendered text without re-anonymizing.
|
|
100
|
+
*/
|
|
101
|
+
renameLabel(original: string, newLabel: string): Array<[string, string]>;
|
|
67
102
|
/**
|
|
68
103
|
* Serialize the session map to JSON.
|
|
69
104
|
*
|
package/dist/session.js
CHANGED
|
@@ -34,6 +34,59 @@ function isPersonVariant(a, b) {
|
|
|
34
34
|
const [small, big] = ta.length <= tb.length ? [ta, tb] : [tb, ta];
|
|
35
35
|
return small.every((t) => big.includes(t)) && small.some((t) => t.length >= 3);
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Variant suffix (T171): where a person variant sits inside the reference
|
|
39
|
+
* name. A single token at the reference's first/last position is a first
|
|
40
|
+
* name / surname; a single token elsewhere is a middle name; several tokens
|
|
41
|
+
* are a shortened form; a variant with MORE tokens than the reference is the
|
|
42
|
+
* fuller form. Suffixes are stable uppercase ASCII so the resulting token
|
|
43
|
+
* ([PERSON_2_LAST]) stays inside the tolerant-restore candidate shape.
|
|
44
|
+
*/
|
|
45
|
+
function variantSuffix(variant, reference) {
|
|
46
|
+
const vt = personTokens(variant);
|
|
47
|
+
const rt = personTokens(reference);
|
|
48
|
+
if (vt.length > rt.length)
|
|
49
|
+
return 'FULL';
|
|
50
|
+
if (vt.length === rt.length)
|
|
51
|
+
return 'ALT';
|
|
52
|
+
if (vt.length === 1) {
|
|
53
|
+
const idx = rt.indexOf(vt[0]);
|
|
54
|
+
if (idx === 0)
|
|
55
|
+
return 'FIRST';
|
|
56
|
+
if (idx === rt.length - 1)
|
|
57
|
+
return 'LAST';
|
|
58
|
+
return 'MIDDLE';
|
|
59
|
+
}
|
|
60
|
+
return 'SHORT';
|
|
61
|
+
}
|
|
62
|
+
/** Whether every person token of `a` occurs in `b`. */
|
|
63
|
+
function isTokenSubset(a, b) {
|
|
64
|
+
const tb = personTokens(b);
|
|
65
|
+
const ta = personTokens(a);
|
|
66
|
+
return ta.length > 0 && ta.every((t) => tb.includes(t));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A variant token derived from its base token: the suffix goes inside the
|
|
70
|
+
* closing bracket ("[PERSON_2]" -> "[PERSON_2_LAST]"); a renamed label
|
|
71
|
+
* without brackets gets a plain underscore suffix.
|
|
72
|
+
*/
|
|
73
|
+
function withVariantSuffix(base, suffix) {
|
|
74
|
+
return base.endsWith(']') ? `${base.slice(0, -1)}_${suffix}]` : `${base}_${suffix}`;
|
|
75
|
+
}
|
|
76
|
+
/** Variant token shape, for re-linking deserialized maps (T171). */
|
|
77
|
+
const VARIANT_TOKEN_RE = /^(\[.+?)_(FIRST|LAST|MIDDLE|SHORT|FULL|ALT)(?:_(\d+))?\]$/;
|
|
78
|
+
/**
|
|
79
|
+
* Apply the case pattern of `sample` to `word` (T171 surrogate variants):
|
|
80
|
+
* an all-lowercase "smith" maps to "nowak", an all-uppercase "SMITH" to
|
|
81
|
+
* "NOWAK"; anything else keeps the surrogate's own casing.
|
|
82
|
+
*/
|
|
83
|
+
function matchCase(word, sample) {
|
|
84
|
+
if (sample === sample.toLowerCase() && sample !== sample.toUpperCase())
|
|
85
|
+
return word.toLowerCase();
|
|
86
|
+
if (sample === sample.toUpperCase() && sample !== sample.toLowerCase())
|
|
87
|
+
return word.toUpperCase();
|
|
88
|
+
return word;
|
|
89
|
+
}
|
|
37
90
|
/**
|
|
38
91
|
* Typed placeholder token, e.g. "[PERSON_1]" or "[CREDIT_CARD_2]".
|
|
39
92
|
* EntityType ids are stable uppercase ASCII ([A-Z_]), so generated tokens
|
|
@@ -52,6 +105,11 @@ export class AnonymizationSession {
|
|
|
52
105
|
this.reverseMap = new Map();
|
|
53
106
|
this.entityTypeMap = new Map();
|
|
54
107
|
this.typeCounters = new Map();
|
|
108
|
+
/**
|
|
109
|
+
* Variant token -> its group's base token and suffix (T171), so renaming
|
|
110
|
+
* the base re-derives its variants and deserialized maps re-link.
|
|
111
|
+
*/
|
|
112
|
+
this.variantOf = new Map();
|
|
55
113
|
this.mode = options?.mode ?? 'labeled';
|
|
56
114
|
this.salt = options?.salt ?? generateSessionSalt();
|
|
57
115
|
}
|
|
@@ -120,23 +178,31 @@ export class AnonymizationSession {
|
|
|
120
178
|
return this.forwardMap.get(original);
|
|
121
179
|
}
|
|
122
180
|
// T057 person-variant unification: "John Smith", "John" and "Smith"
|
|
123
|
-
// in one session are the same person and share one
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
181
|
+
// in one session are the same person and share one identity. T171
|
|
182
|
+
// makes restore exact: every distinct variant gets its OWN token that
|
|
183
|
+
// keeps the group's number ([PERSON_2] / [PERSON_2_LAST]) or, in
|
|
184
|
+
// surrogate mode, the matching part of the group's surrogate ("Nowak"
|
|
185
|
+
// for "Smith" when "John Smith" became "Adam Nowak"), so the reverse map
|
|
186
|
+
// stays one-to-one and restore writes back exactly what was there.
|
|
127
187
|
if (entityType === 'PERSON') {
|
|
128
188
|
const match = this.findPersonVariant(original);
|
|
129
189
|
if (match) {
|
|
130
|
-
const
|
|
131
|
-
|
|
190
|
+
const token = this.mode === 'blanked'
|
|
191
|
+
? '________'
|
|
192
|
+
: this.mode === 'surrogate'
|
|
193
|
+
? this.variantSurrogate(original, match)
|
|
194
|
+
: this.variantPlaceholder(original, match);
|
|
195
|
+
this.forwardMap.set(original, token);
|
|
132
196
|
this.entityTypeMap.set(original, entityType);
|
|
133
197
|
if (this.mode !== 'blanked') {
|
|
134
|
-
|
|
198
|
+
// Token-mapping can legitimately fall back to reusing the group's
|
|
199
|
+
// surrogate; then the longest original stays canonical (T057).
|
|
200
|
+
const canonical = this.reverseMap.get(token);
|
|
135
201
|
if (canonical === undefined || original.length > canonical.length) {
|
|
136
|
-
this.reverseMap.set(
|
|
202
|
+
this.reverseMap.set(token, original);
|
|
137
203
|
}
|
|
138
204
|
}
|
|
139
|
-
return
|
|
205
|
+
return token;
|
|
140
206
|
}
|
|
141
207
|
}
|
|
142
208
|
const placeholder = this.mode === 'blanked'
|
|
@@ -153,6 +219,65 @@ export class AnonymizationSession {
|
|
|
153
219
|
this.entityTypeMap.set(original, entityType);
|
|
154
220
|
return placeholder;
|
|
155
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* The group a mapped value belongs to (T171): its base token and the
|
|
224
|
+
* canonical original that token restores to.
|
|
225
|
+
*/
|
|
226
|
+
variantGroup(match) {
|
|
227
|
+
const matchedToken = this.forwardMap.get(match);
|
|
228
|
+
const base = this.variantOf.get(matchedToken)?.base ?? matchedToken;
|
|
229
|
+
return { base, canonical: this.reverseMap.get(base) ?? match };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Labeled-mode variant token (T171): the group's base token plus a
|
|
233
|
+
* positional suffix, computed against the group's canonical name when
|
|
234
|
+
* the variant is part of it, else against the value it matched. Distinct
|
|
235
|
+
* originals never share a token: a suffix already in use gets a counter
|
|
236
|
+
* ("Smith" -> [PERSON_2_LAST], "smith" -> [PERSON_2_LAST_2]).
|
|
237
|
+
*/
|
|
238
|
+
variantPlaceholder(original, match) {
|
|
239
|
+
const { base, canonical } = this.variantGroup(match);
|
|
240
|
+
const reference = isTokenSubset(original, canonical) ? canonical : match;
|
|
241
|
+
const suffix = variantSuffix(original, reference);
|
|
242
|
+
let token = withVariantSuffix(base, suffix);
|
|
243
|
+
for (let n = 2; this.reverseMap.has(token); n++) {
|
|
244
|
+
token = withVariantSuffix(base, `${suffix}_${n}`);
|
|
245
|
+
}
|
|
246
|
+
this.variantOf.set(token, { base, suffix });
|
|
247
|
+
return token;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Surrogate-mode variant (T171): when the variant is a strict part of the
|
|
251
|
+
* group's canonical name and the surrogate has the same word count, take
|
|
252
|
+
* the surrogate words at the same positions with the variant's casing.
|
|
253
|
+
* Anything else (fuller forms, reshaped surrogates, a collision with an
|
|
254
|
+
* existing value) falls back to the group's surrogate as before.
|
|
255
|
+
*/
|
|
256
|
+
variantSurrogate(original, match) {
|
|
257
|
+
const { base, canonical } = this.variantGroup(match);
|
|
258
|
+
const canonicalWords = canonical.split(/\s+/).filter(Boolean);
|
|
259
|
+
const surrogateWords = base.split(/\s+/).filter(Boolean);
|
|
260
|
+
const canonicalTokens = personTokens(canonical);
|
|
261
|
+
const variantWords = original.split(/\s+/).filter(Boolean);
|
|
262
|
+
const variantTokens = personTokens(original);
|
|
263
|
+
const shapesAlign = canonicalWords.length === surrogateWords.length
|
|
264
|
+
&& canonicalTokens.length === canonicalWords.length
|
|
265
|
+
&& variantTokens.length === variantWords.length
|
|
266
|
+
&& variantTokens.length < canonicalTokens.length
|
|
267
|
+
&& variantTokens.every((t) => canonicalTokens.includes(t));
|
|
268
|
+
if (!shapesAlign)
|
|
269
|
+
return base;
|
|
270
|
+
const mapped = variantWords
|
|
271
|
+
.map((word, i) => matchCase(surrogateWords[canonicalTokens.indexOf(variantTokens[i])], word))
|
|
272
|
+
.join(' ');
|
|
273
|
+
const taken = mapped === original
|
|
274
|
+
|| this.forwardMap.has(mapped)
|
|
275
|
+
|| (this.reverseMap.has(mapped) && this.reverseMap.get(mapped) !== original);
|
|
276
|
+
if (taken)
|
|
277
|
+
return base;
|
|
278
|
+
this.variantOf.set(mapped, { base, suffix: '' });
|
|
279
|
+
return mapped;
|
|
280
|
+
}
|
|
156
281
|
deanonymize(text) {
|
|
157
282
|
let result = text;
|
|
158
283
|
// Sort by placeholder length (longest first) to avoid partial replacements
|
|
@@ -201,17 +326,21 @@ export class AnonymizationSession {
|
|
|
201
326
|
return out + text.slice(last);
|
|
202
327
|
}
|
|
203
328
|
anonymizeText(text, entities) {
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
329
|
+
// PERSON pre-pass: map people before anything else so that (T171) each
|
|
330
|
+
// variant group forms around its FULLEST name - "John Smith" is the
|
|
331
|
+
// base and "Smith" / "smith" hang off it, whatever order they appear in
|
|
332
|
+
// - and so that (T043) an email appearing before/after its owner can
|
|
333
|
+
// derive its local part from the person's surrogate (jan.kowalski maps
|
|
334
|
+
// to adam.nowak style). Fullest name first, then reading order.
|
|
335
|
+
// anonymize() is idempotent per value, so the replacement pass below
|
|
336
|
+
// reuses these mappings; other types keep the historical end-to-start
|
|
337
|
+
// issuing order (placeholder numbering).
|
|
338
|
+
if (this.mode !== 'blanked') {
|
|
339
|
+
const people = entities
|
|
340
|
+
.filter((e) => e.type === 'PERSON')
|
|
341
|
+
.sort((a, b) => personTokens(b.value).length - personTokens(a.value).length || a.start - b.start);
|
|
342
|
+
for (const entity of people)
|
|
343
|
+
this.anonymize(entity.value, entity.type);
|
|
215
344
|
}
|
|
216
345
|
// Sort entities by position (end to start) to preserve indices during replacement
|
|
217
346
|
const sorted = [...entities].sort((a, b) => b.start - a.start);
|
|
@@ -238,19 +367,51 @@ export class AnonymizationSession {
|
|
|
238
367
|
getForward(original) {
|
|
239
368
|
return this.forwardMap.get(original);
|
|
240
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* Rename the label of a mapped value. Renaming a group's base token
|
|
372
|
+
* (T171) re-derives every variant token from the new label
|
|
373
|
+
* ([PERSON_1] -> [CLIENT] takes [PERSON_1_LAST] to [CLIENT_LAST]);
|
|
374
|
+
* renaming a variant token renames only that token and detaches it from
|
|
375
|
+
* the group, so a later base rename does not overwrite the user's choice.
|
|
376
|
+
* Returns every [oldLabel, newLabel] pair applied, so hosts can update
|
|
377
|
+
* already-rendered text without re-anonymizing.
|
|
378
|
+
*/
|
|
241
379
|
renameLabel(original, newLabel) {
|
|
242
380
|
const oldLabel = this.forwardMap.get(original);
|
|
243
|
-
if (!oldLabel)
|
|
244
|
-
return;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
381
|
+
if (!oldLabel || oldLabel === newLabel)
|
|
382
|
+
return [];
|
|
383
|
+
const pairs = [];
|
|
384
|
+
const rename = (from, to) => {
|
|
385
|
+
// Every original sharing the label (legacy many-to-one maps) moves
|
|
386
|
+
// with it, and the canonical restore value carries over.
|
|
387
|
+
const canonical = this.reverseMap.get(from);
|
|
388
|
+
for (const [orig, label] of this.forwardMap) {
|
|
389
|
+
if (label === from)
|
|
390
|
+
this.forwardMap.set(orig, to);
|
|
391
|
+
}
|
|
392
|
+
this.reverseMap.delete(from);
|
|
393
|
+
if (canonical !== undefined)
|
|
394
|
+
this.reverseMap.set(to, canonical);
|
|
395
|
+
pairs.push([from, to]);
|
|
396
|
+
};
|
|
397
|
+
if (this.variantOf.has(oldLabel)) {
|
|
398
|
+
this.variantOf.delete(oldLabel);
|
|
399
|
+
rename(oldLabel, newLabel);
|
|
400
|
+
return pairs;
|
|
251
401
|
}
|
|
252
|
-
|
|
253
|
-
|
|
402
|
+
rename(oldLabel, newLabel);
|
|
403
|
+
for (const [token, info] of [...this.variantOf]) {
|
|
404
|
+
if (info.base !== oldLabel)
|
|
405
|
+
continue;
|
|
406
|
+
let next = withVariantSuffix(newLabel, info.suffix);
|
|
407
|
+
for (let n = 2; this.reverseMap.has(next) || next === newLabel; n++) {
|
|
408
|
+
next = withVariantSuffix(newLabel, `${info.suffix}_${n}`);
|
|
409
|
+
}
|
|
410
|
+
this.variantOf.delete(token);
|
|
411
|
+
this.variantOf.set(next, { base: newLabel, suffix: info.suffix });
|
|
412
|
+
rename(token, next);
|
|
413
|
+
}
|
|
414
|
+
return pairs;
|
|
254
415
|
}
|
|
255
416
|
/**
|
|
256
417
|
* Serialize the session map to JSON.
|
|
@@ -332,6 +493,10 @@ export class AnonymizationSession {
|
|
|
332
493
|
}
|
|
333
494
|
}
|
|
334
495
|
this.entityTypeMap.set(entry.original, entry.entityType);
|
|
496
|
+
// T171 variant tokens ([PERSON_1_LAST], [PERSON_1_LAST_2]) carry no
|
|
497
|
+
// counter of their own; they re-link to their base below.
|
|
498
|
+
if (VARIANT_TOKEN_RE.test(entry.replacement))
|
|
499
|
+
continue;
|
|
335
500
|
const match = TYPED_PLACEHOLDER_RE.exec(entry.replacement);
|
|
336
501
|
if (match) {
|
|
337
502
|
const [, type, num] = match;
|
|
@@ -339,6 +504,15 @@ export class AnonymizationSession {
|
|
|
339
504
|
this.typeCounters.set(type, Math.max(current, Number(num)));
|
|
340
505
|
}
|
|
341
506
|
}
|
|
507
|
+
for (const entry of entries) {
|
|
508
|
+
const variant = VARIANT_TOKEN_RE.exec(entry.replacement);
|
|
509
|
+
if (!variant)
|
|
510
|
+
continue;
|
|
511
|
+
const base = `${variant[1]}]`;
|
|
512
|
+
if (this.reverseMap.has(base)) {
|
|
513
|
+
this.variantOf.set(entry.replacement, { base, suffix: variant[2] });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
342
516
|
}
|
|
343
517
|
/**
|
|
344
518
|
* Rebuild a session from JSON produced by serialize() or by the Python
|
|
@@ -382,5 +556,6 @@ export class AnonymizationSession {
|
|
|
382
556
|
this.reverseMap.clear();
|
|
383
557
|
this.entityTypeMap.clear();
|
|
384
558
|
this.typeCounters.clear();
|
|
559
|
+
this.variantOf.clear();
|
|
385
560
|
}
|
|
386
561
|
}
|