@privacyscrubber/sdk 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +258 -0
- package/index.d.ts +217 -0
- package/index.js +581 -0
- package/package.json +51 -0
- package/polyfill.js +33 -0
- package/ps-license-manager.js +257 -0
- package/ps-pii-engine.cjs +1297 -0
- package/ps-pii-engine.js +1297 -0
- package/scrubber-core.cjs +1711 -0
- package/shared-ui.js +533 -0
- package/ui-modals.js +819 -0
|
@@ -0,0 +1,1711 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PrivacyScrubber Core Engine — Shared Module
|
|
3
|
+
* Pure JavaScript, zero DOM dependencies.
|
|
4
|
+
* Used by both the Chrome Extension and (via extraction) the main website.
|
|
5
|
+
*
|
|
6
|
+
* Zero-server rule: No fetch(), no XMLHttpRequest, no external calls.
|
|
7
|
+
* Airplane Mode Verified: works with no network after load.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ─── Regex Rules ─────────────────────────────────────────────────────────────
|
|
11
|
+
(function() {
|
|
12
|
+
if (typeof window !== 'undefined' && window.PrivacyScrubberCore && window.PrivacyScrubberCore.isInitialized) return;
|
|
13
|
+
|
|
14
|
+
function getEngine() {
|
|
15
|
+
if (typeof window !== 'undefined' && window.PrivacyScrubberEngine) return window.PrivacyScrubberEngine;
|
|
16
|
+
if (typeof global !== 'undefined' && global.PrivacyScrubberEngine) return global.PrivacyScrubberEngine;
|
|
17
|
+
if (typeof require === 'function') {
|
|
18
|
+
try { return require('./ps-pii-engine.cjs'); } catch(e) {}
|
|
19
|
+
try { return require('./ps-pii-engine.js'); } catch(e) {}
|
|
20
|
+
try { return require('../chrome-extension/ps-pii-engine.js'); } catch(e) {}
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Runtime Initialization for dynamic rule synchronization.
|
|
27
|
+
* @param {Object} config - { regexes, profiles, names }
|
|
28
|
+
*/
|
|
29
|
+
function init(config) {
|
|
30
|
+
if (!config) return;
|
|
31
|
+
const engine = getEngine();
|
|
32
|
+
if (engine) engine.init(config);
|
|
33
|
+
if (typeof window !== 'undefined' && window.PrivacyScrubberCore) {
|
|
34
|
+
window.PrivacyScrubberCore.isInitialized = true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stitchOrphanedNameLines(text, profile) {
|
|
39
|
+
if (!text || !text.includes('\n')) return text;
|
|
40
|
+
const prof = (profile || 'general').toLowerCase();
|
|
41
|
+
if (prof === 'medical') {
|
|
42
|
+
text = text.replace(/Patient Name:\s*\n+([A-Z][a-zA-Z]+\s[A-Z][a-zA-Z]+)/g, 'Patient Name: $1');
|
|
43
|
+
} else if (prof === 'legal') {
|
|
44
|
+
text = text.replace(/Defendant:\s*\n+([A-Z][a-zA-Z]+\s[A-Z][a-zA-Z]+)/g, 'Defendant: $1');
|
|
45
|
+
}
|
|
46
|
+
return text;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Protect PII from plain text.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} text - Raw input text
|
|
53
|
+
* @param {Array<{label: string, pattern: string}>} [customRules=[]] - Optional PRO regex rules or exact text
|
|
54
|
+
* @returns {{ scrubbedText: string, tokenMap: Object, count: number }}
|
|
55
|
+
* scrubbedText: text with PII replaced by tokens like [NAME_1]
|
|
56
|
+
* tokenMap: { "[NAME_1]": "John Doe", ... } — for data reveal
|
|
57
|
+
* count: total number of items protected
|
|
58
|
+
* uniqueUnmasked: Set of unmasked values
|
|
59
|
+
*/
|
|
60
|
+
function scrubText(text, customRules = [], tokenLabelMap = {}, profile = 'General', existingSessionMap = {}, isPro = false, ignoreList = null) {
|
|
61
|
+
if (!text) return { scrubbedText: "", tokenMap: {}, count: 0, uniqueUnmasked: new Set(), trialMeta: null, executionMs: 0 };
|
|
62
|
+
|
|
63
|
+
const executionStartMs = (typeof performance !== 'undefined' && typeof performance.now === 'function') ? performance.now() : Date.now();
|
|
64
|
+
// Stitch PDF-split name fragments across line breaks (all profiles)
|
|
65
|
+
text = stitchOrphanedNameLines(text, profile);
|
|
66
|
+
|
|
67
|
+
// Protect system prompt from being scrubbed or counted
|
|
68
|
+
let extractedSystemPrompt = "";
|
|
69
|
+
let textToProcess = text.replace(/[\u200b\u200c\u200d\ufeff]/g, '');
|
|
70
|
+
|
|
71
|
+
const isSpecialized = profile && profile.toLowerCase() !== 'general';
|
|
72
|
+
|
|
73
|
+
// SDK / Core Engine Limits Enforcer (Free Tier)
|
|
74
|
+
if (!isPro) {
|
|
75
|
+
const charLimit = isSpecialized ? 5000 : 15000;
|
|
76
|
+
if (textToProcess.length > charLimit) {
|
|
77
|
+
throw new Error(`PrivacyScrubber Free Tier limit exceeded (${charLimit.toLocaleString()} chars). Please upgrade to PRO or TEAMS.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let trialMeta = null;
|
|
82
|
+
|
|
83
|
+
// Split attached table labels/headers (e.g. Sarah MitchellEmail: -> Sarah Mitchell Email:)
|
|
84
|
+
// Specifically matches a letter followed directly by field names and a colon
|
|
85
|
+
textToProcess = textToProcess.replace(/([a-zA-Z])(Email|Phone|Mobile|Tel|Address|IP|ID|URL|SSN|Date):/g, '$1 $2:');
|
|
86
|
+
|
|
87
|
+
const sysMarker = "[Privacy Scrubber Mode]";
|
|
88
|
+
const oldMarker = "[SYSTEM INSTRUCTION: DATA PRIVACY MODE]";
|
|
89
|
+
const ctxMarker = "[Context: identifiers";
|
|
90
|
+
const newMarker = "[Privacy Note:";
|
|
91
|
+
if (textToProcess.includes(sysMarker) || textToProcess.includes(oldMarker) || textToProcess.includes(ctxMarker) || textToProcess.includes(newMarker)) {
|
|
92
|
+
const sysPromptRegex = /(?:\n*----------------------\s*|\n*---\s*)?(?:\[SYSTEM INSTRUCTION: DATA PRIVACY MODE\]|\[Privacy Scrubber Mode\]|\[Context: identifiers|\[Privacy Note:)[\s\S]*/;
|
|
93
|
+
const match = textToProcess.match(sysPromptRegex);
|
|
94
|
+
if (match) {
|
|
95
|
+
extractedSystemPrompt = match[0].trim();
|
|
96
|
+
textToProcess = textToProcess.replace(sysPromptRegex, '').trimEnd();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const sessionMap = {};
|
|
100
|
+
const counters = { NAME: 0, EMAIL: 0, PHONE: 0, ID: 0, FINANCIAL: 0, SECRET: 0, ADDRESS: 0, CUSTOM: 0, SSN: 0, DATE: 0, PHI: 0 };
|
|
101
|
+
const customCounters = {};
|
|
102
|
+
const plaintextToTokenAtlas = {};
|
|
103
|
+
|
|
104
|
+
// Seed counters and atlas from existing session map to prevent overwrites
|
|
105
|
+
if (existingSessionMap && typeof existingSessionMap === 'object') {
|
|
106
|
+
Object.entries(existingSessionMap).forEach(([token, value]) => {
|
|
107
|
+
const match = token.match(/^\[([A-Z_a-z0-9]+)_(\d+)\]$/);
|
|
108
|
+
if (match) {
|
|
109
|
+
const type = match[1];
|
|
110
|
+
const idx = parseInt(match[2], 10);
|
|
111
|
+
if (counters[type] !== undefined) {
|
|
112
|
+
counters[type] = Math.max(counters[type], idx);
|
|
113
|
+
} else {
|
|
114
|
+
customCounters[type] = Math.max(customCounters[type] || 0, idx);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
plaintextToTokenAtlas[value.toLowerCase()] = token;
|
|
118
|
+
plaintextToTokenAtlas[value] = token;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
// Default labels
|
|
124
|
+
const labels = {
|
|
125
|
+
NAME: 'NAME', EMAIL: 'EMAIL', PHONE: 'PHONE', ID: 'ID',
|
|
126
|
+
FINANCIAL: 'FINANCIAL', SECRET: 'SECRET', ADDRESS: 'ADDRESS', CUSTOM: 'CUSTOM',
|
|
127
|
+
SSN: 'SSN', DATE: 'DATE', PHI: 'PHI',
|
|
128
|
+
...tokenLabelMap
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
const PROFILE_ALIAS_MAP = (getEngine() && getEngine().PROFILE_ALIAS_MAP) ? getEngine().PROFILE_ALIAS_MAP : {};
|
|
133
|
+
|
|
134
|
+
// Node.js local license key validation enforcement (ZTDS Compliance)
|
|
135
|
+
if (!isPro && typeof process !== 'undefined' && process.env && typeof require === 'function' && profile && profile.toLowerCase() !== 'general') {
|
|
136
|
+
try {
|
|
137
|
+
const key = (process.env.PRIVACYSCRUBBER_KEY || "").trim();
|
|
138
|
+
let isKeyPro = false;
|
|
139
|
+
if (key) {
|
|
140
|
+
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw3f37srO402PU4++Baf8\nFG8LY4l/IA3NKLlBnYmNHRTjfI/O/w5PDZn1xPcUQevojA1J+A5moKcjXsJ5b21X\nhJoYSkE4vLpcVYOt1FhRwEHs1APDSyss0HixboLz2eW2XQf2NbwajWtNlyxvgczO\nKE6ClnLomtsaKywwqB4alzdYnnnFJttFPjwmgPSO7D9AgN9sYaVkXOaOFrIZ90Ng\nTRhSHUeL7ReltWlCHwz9xf5m2FrKtxr2VBlEoyPjsFzalHMey1EX+yXe81zM7IIi\nt1Z8agLzo7WIfNBAIWmRlerTplaFFZrQgdF5g/Y0n8IIMZOtadgoY8E855psDNZV\n7wIDAQAB\n-----END PUBLIC KEY-----`;
|
|
141
|
+
const crypto = require('crypto');
|
|
142
|
+
const [payloadBase64, signatureBase64] = key.split('.');
|
|
143
|
+
if (payloadBase64 && signatureBase64) {
|
|
144
|
+
const verifier = crypto.createVerify('SHA256');
|
|
145
|
+
verifier.update(payloadBase64);
|
|
146
|
+
const isVerified = verifier.verify(PUBLIC_KEY, signatureBase64, 'base64');
|
|
147
|
+
if (isVerified) {
|
|
148
|
+
const payload = JSON.parse(Buffer.from(payloadBase64, 'base64').toString('utf8'));
|
|
149
|
+
if (!payload.expires || payload.expires > Math.floor(Date.now() / 1000)) {
|
|
150
|
+
isKeyPro = true;
|
|
151
|
+
isPro = true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!isKeyPro && !isPro) {
|
|
157
|
+
profile = 'General';
|
|
158
|
+
}
|
|
159
|
+
} catch (e) {
|
|
160
|
+
if (!isPro) profile = 'General';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const engine = getEngine();
|
|
165
|
+
if (!engine) {
|
|
166
|
+
return { scrubbedText: textToProcess, count: 0, tokenMap: {}, piiBreakdown: {}, uniqueUnmasked: new Set(), trialMeta: null, executionMs: 0 };
|
|
167
|
+
}
|
|
168
|
+
const detectResult = engine.detectMatches(textToProcess, profile, customRules, [], null);
|
|
169
|
+
let filtered = detectResult.filteredMatches;
|
|
170
|
+
textToProcess = detectResult.processedText;
|
|
171
|
+
|
|
172
|
+
// Filter out items in ignoreList (False Positives restored by user)
|
|
173
|
+
if (ignoreList && (ignoreList instanceof Set || Array.isArray(ignoreList))) {
|
|
174
|
+
const ignoreSet = ignoreList instanceof Set ? ignoreList : new Set(ignoreList);
|
|
175
|
+
filtered = filtered.filter(m => {
|
|
176
|
+
if (!m || !m.value) return false;
|
|
177
|
+
const val = m.value;
|
|
178
|
+
const valLower = val.toLowerCase();
|
|
179
|
+
const valTrim = val.trim();
|
|
180
|
+
const valTrimLower = valTrim.toLowerCase();
|
|
181
|
+
return !ignoreSet.has(val) &&
|
|
182
|
+
!ignoreSet.has(valLower) &&
|
|
183
|
+
!ignoreSet.has(valTrim) &&
|
|
184
|
+
!ignoreSet.has(valTrimLower);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Assign tokens in left-to-right order
|
|
189
|
+
|
|
190
|
+
const uniqueUnmasked = new Set();
|
|
191
|
+
|
|
192
|
+
filtered.forEach(m => {
|
|
193
|
+
// Deduplication Check: Reuse tokens for identical values (case-insensitive for core types)
|
|
194
|
+
const matchKey = m.type === 'CUSTOM' || m.type === 'ID' || m.type === 'SECRET' ? m.value : m.value.toLowerCase();
|
|
195
|
+
|
|
196
|
+
uniqueUnmasked.add(matchKey);
|
|
197
|
+
|
|
198
|
+
if (plaintextToTokenAtlas[matchKey]) {
|
|
199
|
+
m.token = plaintextToTokenAtlas[matchKey];
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Generate unique token using custom labels if provided
|
|
204
|
+
const label = m.customLabel || labels[m.type] || m.type;
|
|
205
|
+
const isBuiltIn = counters[m.type] !== undefined;
|
|
206
|
+
|
|
207
|
+
if (isBuiltIn && !m.customLabel) {
|
|
208
|
+
// Built-in type (NAME, EMAIL, PHONE, etc.) — use seeded counters[] for collision prevention
|
|
209
|
+
counters[m.type]++;
|
|
210
|
+
m.token = `[${label}_${counters[m.type]}]`;
|
|
211
|
+
} else {
|
|
212
|
+
// Custom label (PRO rule) — use customCounters[]
|
|
213
|
+
customCounters[label] = (customCounters[label] || 0) + 1;
|
|
214
|
+
m.token = `[${label}_${customCounters[label]}]`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
plaintextToTokenAtlas[matchKey] = m.token;
|
|
218
|
+
sessionMap[m.token] = m.value;
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Replace from start → end using string builder (preserves O(N) linear performance on large payloads)
|
|
222
|
+
const leftToRight = [...filtered].sort((a, b) => a.start - b.start);
|
|
223
|
+
let lastIdx = 0;
|
|
224
|
+
const pieces = [];
|
|
225
|
+
for (let i = 0; i < leftToRight.length; i++) {
|
|
226
|
+
const m = leftToRight[i];
|
|
227
|
+
if (m.start > lastIdx) {
|
|
228
|
+
pieces.push(textToProcess.substring(lastIdx, m.start));
|
|
229
|
+
}
|
|
230
|
+
pieces.push(m.token);
|
|
231
|
+
lastIdx = m.end;
|
|
232
|
+
}
|
|
233
|
+
if (lastIdx < textToProcess.length) {
|
|
234
|
+
pieces.push(textToProcess.substring(lastIdx));
|
|
235
|
+
}
|
|
236
|
+
const result = pieces.join('');
|
|
237
|
+
|
|
238
|
+
const piiBreakdown = {};
|
|
239
|
+
Object.keys(sessionMap).forEach(k => {
|
|
240
|
+
let t = k.split('_')[0].replace('[', '');
|
|
241
|
+
piiBreakdown[t] = (piiBreakdown[t] || 0) + 1;
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
let finalScrubbed = result;
|
|
245
|
+
if (extractedSystemPrompt) {
|
|
246
|
+
if (extractedSystemPrompt.startsWith('----------------------')) {
|
|
247
|
+
finalScrubbed = result + '\n\n' + extractedSystemPrompt;
|
|
248
|
+
} else {
|
|
249
|
+
finalScrubbed = result + '\n\n----------------------\n' + extractedSystemPrompt;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const executionEndMs = (typeof performance !== 'undefined' && typeof performance.now === 'function') ? performance.now() : Date.now();
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
scrubbedText: finalScrubbed,
|
|
257
|
+
tokenMap: sessionMap,
|
|
258
|
+
count: Object.keys(sessionMap).length,
|
|
259
|
+
uniqueUnmasked: uniqueUnmasked,
|
|
260
|
+
piiBreakdown: piiBreakdown,
|
|
261
|
+
trialMeta: trialMeta,
|
|
262
|
+
executionMs: Math.max(0, executionEndMs - executionStartMs)
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const LABEL_ALIASES = {
|
|
267
|
+
NAME: ['NAME', 'NAMES', 'USERNAME', 'USER_NAME', 'CLIENTNAME', 'CLIENT_NAME', 'CANDIDATE_NAME', 'FULL_NAME', 'FIRSTNAME', 'FIRST_NAME', 'LASTNAME', 'LAST_NAME', 'SURNAME', 'ИМЯ', 'ИМЕНА', 'ПОЛЬЗОВАТЕЛЬ', 'ФИО', 'КЛИЕНТ', 'NOMBRE', 'NOMBRES', 'USUARIO', 'CLIENTE', 'NOM', 'NOMS', 'UTILISATEUR', 'NAME', 'NAMEN', 'BENUTZER', 'KUNDE', 'NOME', 'COGNOME', 'UTENTE', 'NAAM', 'GEBRUIKER', 'KLANT'],
|
|
268
|
+
EMAIL: ['EMAIL', 'EMAILS', 'EMAILADDR', 'EMAIL_ADDR', 'EMAILADDRESS', 'EMAIL_ADDRESS', 'EMAIL_ADR', 'MAIL', 'MAILS', 'ПОЧТА', 'ЭЛ_ПОЧТА', 'АДРЕС_ПОЧТЫ', 'МЕЙЛ', 'МАЙЛ', 'CORREO', 'COURRIEL', 'CORREO_ELECTRONICO', 'MEL'],
|
|
269
|
+
PHONE: ['PHONE', 'PHONES', 'PHONENUM', 'PHONE_NUM', 'PHONENUMBER', 'PHONE_NUMBER', 'TEL', 'TELS', 'TELEPHONE', 'TELEPHONES', 'MOBILE', 'CELL', 'ТЕЛЕФОН', 'ТЕЛЕФОНЫ', 'НОМЕР_ТЕЛЕФОНА', 'НОМЕР', 'MOVIL', 'PORTABLE', 'HANDY', 'TELEFONI', 'CELLULARE'],
|
|
270
|
+
ID: ['ID', 'IDS', 'IDNUM', 'ID_NUM', 'IDNUMBER', 'ID_NUMBER', 'IDENTIFIER', 'IDENTIFIERS', 'PASSPORT', 'SSN', 'EIN', 'TAXID', 'TAX_ID', 'LICENSE', 'LICENSE_PLATE', 'ИД', 'ИДЕНТИФИКАТОР', 'ПАСПОРТ', 'СНИЛС', 'ИНН', 'IDENTIFICADOR', 'PASAPORTE', 'IDENTIFIANT', 'PASSEPORT', 'IDENTIFIKATOR', 'PASS', 'IDENTIFICATORE', 'PASSAPORTO'],
|
|
271
|
+
FINANCIAL: ['FINANCIAL', 'FINANCIALS', 'MONEY', 'AMOUNT', 'PRICE', 'COST', 'CARD', 'CREDITCARD', 'DEBITCARD', 'ACCOUNT', 'IBAN', 'BIC', 'ДЕНЬГИ', 'СУММА', 'КАРТА', 'СЧЕТ', 'БАНК', 'DINERO', 'CANTIDAD', 'TARJETA', 'CUENTA', 'ARGENT', 'MONTANT', 'COMPTE', 'GELD', 'BETRAG', 'KONTO'],
|
|
272
|
+
ADDRESS: ['ADDRESS', 'ADDRESSES', 'STREET', 'STREET_ADDRESS', 'CITY', 'STATE', 'ZIP', 'ZIPCODE', 'ZIP_CODE', 'COUNTRY', 'LOCATION', 'АДРЕС', 'АДРЕСА', 'УЛИЦА', 'ГОРОД', 'СТРАНА', 'DIRECCION', 'DIRECCIONES', 'CALLE', 'CIUDAD', 'PAIS', 'ADRESSE', 'ADRESSES', 'RUE', 'VILLE', 'STRASSE', 'STADT', 'LAND'],
|
|
273
|
+
DATE: ['DATE', 'DATES', 'BIRTHDAY', 'DOB', 'ДАТА', 'ДАТЫ', 'ДЕНЬ_РОЖДЕНИЯ', 'FECHA', 'FECHAS', 'CUMPLEANOS', 'ANNIVERSAIRE', 'DATUM', 'DATEN', 'GEBURTSTAG'],
|
|
274
|
+
PHI: ['PHI', 'MRN', 'NHS', 'HEALTH', 'MEDICAL', 'PATIENT', 'МЕД', 'ПАЦИЕНТ', 'PACIENTE'],
|
|
275
|
+
SECRET: ['SECRET', 'SECRETS', 'KEY', 'KEYS', 'TOKEN', 'TOKENS', 'PASSWORD', 'PASSWORDS', 'AUTH', 'APIKEY', 'API_KEY', 'КЛЮЧ', 'КЛЮЧИ', 'ПАРОЛЬ', 'ПАРОЛИ', 'ТОКЕН', 'CLAVE', 'CONTRASENA', 'CLE', 'MOT_DE_PASSE', 'SCHLUESSEL', 'PASSWORT'],
|
|
276
|
+
CUSTOM: ['CUSTOM', 'CUSTOMS', 'RULE', 'RULES', 'КАСТОМ', 'ПРАВИЛО']
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
function getLabelAliases(label) {
|
|
280
|
+
const upper = label.toUpperCase();
|
|
281
|
+
if (LABEL_ALIASES[upper]) {
|
|
282
|
+
return LABEL_ALIASES[upper];
|
|
283
|
+
}
|
|
284
|
+
const aliases = new Set([label, upper, label.toLowerCase()]);
|
|
285
|
+
aliases.add(label.replace(/_/g, ' '));
|
|
286
|
+
aliases.add(label.replace(/_/g, '-'));
|
|
287
|
+
aliases.add(label.replace(/ /g, '_'));
|
|
288
|
+
aliases.add(label.replace(/-/g, '_'));
|
|
289
|
+
return Array.from(aliases);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function formatToken(label, index, format = 'brackets') {
|
|
293
|
+
const cleanLabel = String(label || 'PII').replace(/[^A-Za-z0-9_]/g, '_').toUpperCase();
|
|
294
|
+
switch(format) {
|
|
295
|
+
case 'xml': return `<${cleanLabel}_${index}>`;
|
|
296
|
+
case 'mustache': return `{{${cleanLabel}_${index}}}`;
|
|
297
|
+
case 'underscores': return `__${cleanLabel}_${index}__`;
|
|
298
|
+
case 'brackets':
|
|
299
|
+
default: return `[${cleanLabel}_${index}]`;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function buildRestorationRegexAndRules(tokenMap) {
|
|
304
|
+
const keys = Object.keys(tokenMap || {});
|
|
305
|
+
if (keys.length === 0) {
|
|
306
|
+
return { compositeRegex: null, looseRules: [] };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const sortedKeys = [...keys].sort((a, b) => {
|
|
310
|
+
const innerA = a.replace(/^\[|<|\{\{|__|\]|>|\}\}|__/g, '');
|
|
311
|
+
const innerB = b.replace(/^\[|<|\{\{|__|\]|>|\}\}|__/g, '');
|
|
312
|
+
const matchA = innerA.match(/^([A-Za-z_0-9]+?)[-_]?(\d+)$/);
|
|
313
|
+
const matchB = innerB.match(/^([A-Za-z_0-9]+?)[-_]?(\d+)$/);
|
|
314
|
+
|
|
315
|
+
if (matchA && matchB) {
|
|
316
|
+
const idxA = parseInt(matchA[2], 10);
|
|
317
|
+
const idxB = parseInt(matchB[2], 10);
|
|
318
|
+
const labelA = matchA[1];
|
|
319
|
+
const labelB = matchB[1];
|
|
320
|
+
|
|
321
|
+
if (idxA !== idxB) {
|
|
322
|
+
return idxB - idxA;
|
|
323
|
+
}
|
|
324
|
+
if (labelA.length !== labelB.length) {
|
|
325
|
+
return labelB.length - labelA.length;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return b.length - a.length;
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const looseRules = [];
|
|
332
|
+
const regexParts = [];
|
|
333
|
+
|
|
334
|
+
sortedKeys.forEach(k => {
|
|
335
|
+
const inner = k.replace(/^\[|<|\{\{|__|\]|>|\}\}|__/g, '');
|
|
336
|
+
const match = inner.match(/^([A-Za-z_0-9]+?)[-_]?(\d+)$/);
|
|
337
|
+
if (match) {
|
|
338
|
+
const label = match[1];
|
|
339
|
+
const baseIndex = parseInt(match[2], 10);
|
|
340
|
+
const aliases = getLabelAliases(label);
|
|
341
|
+
|
|
342
|
+
const escapedAliases = aliases.map(a => a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
343
|
+
const aliasesGroup = `(?:${escapedAliases.join('|')})`;
|
|
344
|
+
|
|
345
|
+
const looseRegex = '(?:\\[?\\s*' + aliasesGroup + '[-_\\s]*0*' + baseIndex + '\\s*\\]?|<\\s*' + aliasesGroup + '[-_\\s]*0*' + baseIndex + '\\s*>|\\{\\{\\s*' + aliasesGroup + '[-_\\s]*0*' + baseIndex + '\\s*\\}\\}|__\\s*' + aliasesGroup + '[-_\\s]*0*' + baseIndex + '\\s*__)(?:\'s|’s|s|[а-яёА-ЯЁ]{1,3})?';
|
|
346
|
+
looseRules.push({ patternStr: looseRegex, regex: new RegExp('^' + looseRegex + '$', 'i'), originalKey: k });
|
|
347
|
+
regexParts.push(looseRegex);
|
|
348
|
+
} else {
|
|
349
|
+
const safe = k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
350
|
+
looseRules.push({ patternStr: safe, regex: new RegExp('^' + safe + '$', 'i'), originalKey: k });
|
|
351
|
+
regexParts.push(safe);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const compositeRegex = new RegExp('(?<=^|[^a-zA-Z0-9_А-Яа-яЁё])(' + regexParts.join('|') + ')(?=$|[^a-zA-Z0-9_А-Яа-яЁё])', 'gi');
|
|
356
|
+
|
|
357
|
+
return { compositeRegex, looseRules };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Clean AI prompt prefix (e.g., "Claude responded:", "ChatGPT:") from the text.
|
|
362
|
+
*
|
|
363
|
+
* @param {string} text
|
|
364
|
+
* @returns {string}
|
|
365
|
+
*/
|
|
366
|
+
function isJsonPayload(str) {
|
|
367
|
+
if (!str || typeof str !== "string") return false;
|
|
368
|
+
const trimmed = str.trim();
|
|
369
|
+
if (!((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]")))) {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
JSON.parse(trimmed);
|
|
374
|
+
return true;
|
|
375
|
+
} catch (_) {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function cleanAIPromptPrefix(text) {
|
|
381
|
+
if (!text) return "";
|
|
382
|
+
let cleaned = text;
|
|
383
|
+
// If text is a full valid JSON object or array, preserve structure
|
|
384
|
+
if (!isJsonPayload(cleaned)) {
|
|
385
|
+
// 1. Strip raw CSS / style blocks leaked from ChatGPT Canvas, web components or stylesheets
|
|
386
|
+
cleaned = cleaned.replace(/^\s*(?:[.#][a-zA-Z0-9_-]+|\[[a-zA-Z0-9_#.:\-*>=,'"\s]+\]|:is\([^)]+\)|[a-zA-Z0-9_-]+)?\s*\{[^}]*?(?:\}\s*|\n\n+|$)/gi, "");
|
|
387
|
+
cleaned = cleaned.replace(/^[;{} \t\r\n]+/, "");
|
|
388
|
+
cleaned = cleaned.replace(/(?:^|\n)[a-zA-Z0-9_#.:\-*>[\]=\s,'"]+\{[^}]*(--[a-zA-Z0-9_-]+:|color-mix\(|var\()[^}]*\}/g, "");
|
|
389
|
+
}
|
|
390
|
+
// 2. Strip AI author prefixes and platform artifacts
|
|
391
|
+
cleaned = cleaned.replace(/^\s*(?:Claude responded|Claude|ChatGPT|Gemini|Grok|DeepSeek|Kimi|Copilot|Assistant|User)\s*(?::|\bsaid\b|\bresponded\b|(?=\s))\s*/i, "");
|
|
392
|
+
cleaned = cleaned.replace(/^(?:Here (?:is|are) (?:the )?(?:redacted|scrubbed|sanitized|processed|clean|updated|modified) (?:text|output|version|data).*?[:\n]+|\*\*Scrubbed Text\*\*[:\n]+|### Scrubbed Text[:\n]+)/i, '');
|
|
393
|
+
cleaned = cleaned.replace(/^\s*Edit\s*\n+/i, "");
|
|
394
|
+
cleaned = cleaned.replace(/\s*\bEdit\s+in\s+a\s+page\b\s*$/i, "");
|
|
395
|
+
// 3. Strip stray leading colons, semicolons, or separators left by stripped icons/artifact headers
|
|
396
|
+
cleaned = cleaned.replace(/^[:;|\-\—\–]+(?=\n|$)/, "");
|
|
397
|
+
cleaned = cleaned.replace(/^[:;]+\s*/, "");
|
|
398
|
+
return cleaned.trim();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function buildFastTokenLookup(tokenMap) {
|
|
402
|
+
const lookup = new Map();
|
|
403
|
+
const customRegexParts = [];
|
|
404
|
+
const keys = Object.keys(tokenMap || {});
|
|
405
|
+
|
|
406
|
+
for (let i = 0; i < keys.length; i++) {
|
|
407
|
+
const k = keys[i];
|
|
408
|
+
const v = tokenMap[k];
|
|
409
|
+
lookup.set(k, v);
|
|
410
|
+
lookup.set(k.toUpperCase(), v);
|
|
411
|
+
|
|
412
|
+
const inner = k.replace(/^\[|<|\{\{|__|\]|>|\}\}|__/g, '');
|
|
413
|
+
const match = inner.match(/^([A-Za-z_0-9]+?)[-_]?(\d+)$/);
|
|
414
|
+
if (match) {
|
|
415
|
+
const label = match[1];
|
|
416
|
+
const baseIndex = parseInt(match[2], 10);
|
|
417
|
+
const aliases = getLabelAliases(label);
|
|
418
|
+
for (let a = 0; a < aliases.length; a++) {
|
|
419
|
+
const u = aliases[a].toUpperCase();
|
|
420
|
+
lookup.set(u + '_' + baseIndex, v);
|
|
421
|
+
lookup.set(u + '-' + baseIndex, v);
|
|
422
|
+
lookup.set(u + ' ' + baseIndex, v);
|
|
423
|
+
lookup.set(u + baseIndex, v);
|
|
424
|
+
lookup.set('[' + u + '_' + baseIndex + ']', v);
|
|
425
|
+
lookup.set('<' + u + '_' + baseIndex + '>', v);
|
|
426
|
+
lookup.set('{{' + u + '_' + baseIndex + '}}', v);
|
|
427
|
+
lookup.set('__' + u + '_' + baseIndex + '__', v);
|
|
428
|
+
lookup.set('[' + u + ' ' + baseIndex + ']', v);
|
|
429
|
+
lookup.set('[' + u + '-' + baseIndex + ']', v);
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
customRegexParts.push(k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
let regexStr = '(?:\\[\\s*[A-Za-z0-9_\\-А-Яа-яЁё ]+\\s*\\]|<\\s*[A-Za-z0-9_\\-А-Яа-яЁё ]+\\s*>|\\{\\{\\s*[A-Za-z0-9_\\-А-Яа-яЁё ]+\\s*\\}\\}|__\\s*[A-Za-z0-9_\\-А-Яа-яЁё ]+\\s*__|(?<=^|[^a-zA-Z0-9_А-Яа-яЁё])[A-Za-z_А-Яа-яЁё]+[-_\\s]*\\d+)';
|
|
437
|
+
if (customRegexParts.length > 0) {
|
|
438
|
+
regexStr = '(?:' + regexStr + '|' + customRegexParts.join('|') + ')';
|
|
439
|
+
}
|
|
440
|
+
const tokenRegex = new RegExp(regexStr + '(?:\'s|’s|s|[а-яёА-ЯЁ]{1,3})?', 'gi');
|
|
441
|
+
|
|
442
|
+
return { lookup, tokenRegex };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Reverse-protect: replace tokens in AI response with originals from tokenMap.
|
|
447
|
+
*
|
|
448
|
+
* @param {string} aiResponse - Text containing tokens like [NAME_1]
|
|
449
|
+
* @param {Object} tokenMap - { "[NAME_1]": "John Doe", ... }
|
|
450
|
+
* @returns {{ restoredText: string, restoredCount: number }}
|
|
451
|
+
*/
|
|
452
|
+
function unscrubText(aiResponse, tokenMap) {
|
|
453
|
+
let text = cleanAIPromptPrefix(aiResponse);
|
|
454
|
+
let restoredCount = 0;
|
|
455
|
+
|
|
456
|
+
if (!tokenMap || Object.keys(tokenMap).length === 0) {
|
|
457
|
+
return { restoredText: text, restoredCount: 0 };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const keyCount = Object.keys(tokenMap).length;
|
|
461
|
+
if (keyCount > 50) {
|
|
462
|
+
const { lookup, tokenRegex } = buildFastTokenLookup(tokenMap);
|
|
463
|
+
text = text.replace(tokenRegex, (match) => {
|
|
464
|
+
if (lookup.has(match)) {
|
|
465
|
+
restoredCount++;
|
|
466
|
+
return lookup.get(match);
|
|
467
|
+
}
|
|
468
|
+
const upper = match.toUpperCase();
|
|
469
|
+
if (lookup.has(upper)) {
|
|
470
|
+
restoredCount++;
|
|
471
|
+
return lookup.get(upper);
|
|
472
|
+
}
|
|
473
|
+
const possMatch = match.match(/^([\s\S]+?)('s|’s|s|[а-яёА-ЯЁ]{1,3})$/);
|
|
474
|
+
if (possMatch) {
|
|
475
|
+
const base = possMatch[1];
|
|
476
|
+
const suffix = possMatch[2];
|
|
477
|
+
if (lookup.has(base)) {
|
|
478
|
+
restoredCount++;
|
|
479
|
+
return lookup.get(base) + suffix;
|
|
480
|
+
}
|
|
481
|
+
if (lookup.has(base.toUpperCase())) {
|
|
482
|
+
restoredCount++;
|
|
483
|
+
return lookup.get(base.toUpperCase()) + suffix;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return match;
|
|
487
|
+
});
|
|
488
|
+
return { restoredText: text, restoredCount };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const { compositeRegex, looseRules } = buildRestorationRegexAndRules(tokenMap);
|
|
492
|
+
if (compositeRegex) {
|
|
493
|
+
text = text.replace(compositeRegex, (match) => {
|
|
494
|
+
restoredCount++;
|
|
495
|
+
if (tokenMap[match]) {
|
|
496
|
+
return tokenMap[match];
|
|
497
|
+
}
|
|
498
|
+
let origKey = match;
|
|
499
|
+
for (let i = 0; i < looseRules.length; i++) {
|
|
500
|
+
const rule = looseRules[i];
|
|
501
|
+
if (rule.regex && rule.regex.test(match)) {
|
|
502
|
+
origKey = rule.originalKey;
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return tokenMap[origKey] || match;
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return { restoredText: text, restoredCount };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Reverse-protect with HTML highlighting: replace tokens in AI response with originals wrapped in span.
|
|
515
|
+
*
|
|
516
|
+
* @param {string} aiResponse - Text containing tokens like [NAME_1]
|
|
517
|
+
* @param {Object} tokenMap - { "[NAME_1]": "John Doe", ... }
|
|
518
|
+
* @returns {{ restoredHTML: string, restoredCount: number }}
|
|
519
|
+
*/
|
|
520
|
+
function unscrubTextAsHTML(aiResponse, tokenMap) {
|
|
521
|
+
let restoredCount = 0;
|
|
522
|
+
|
|
523
|
+
const cleanResponse = cleanAIPromptPrefix(aiResponse);
|
|
524
|
+
// ALWAYS escape HTML first — even with empty tokenMap — to prevent XSS from AI-generated content
|
|
525
|
+
let text = cleanResponse.replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c] || c));
|
|
526
|
+
|
|
527
|
+
if (!tokenMap || Object.keys(tokenMap).length === 0) {
|
|
528
|
+
return { restoredHTML: text, restoredCount: 0 };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const keyCount = Object.keys(tokenMap).length;
|
|
532
|
+
if (keyCount > 50) {
|
|
533
|
+
const { lookup, tokenRegex } = buildFastTokenLookup(tokenMap);
|
|
534
|
+
text = text.replace(tokenRegex, (match) => {
|
|
535
|
+
let rawVal = null;
|
|
536
|
+
if (lookup.has(match)) {
|
|
537
|
+
rawVal = lookup.get(match);
|
|
538
|
+
} else if (lookup.has(match.toUpperCase())) {
|
|
539
|
+
rawVal = lookup.get(match.toUpperCase());
|
|
540
|
+
} else {
|
|
541
|
+
const possMatch = match.match(/^([\s\S]+?)('s|’s|s|[а-яёА-ЯЁ]{1,3})$/);
|
|
542
|
+
if (possMatch) {
|
|
543
|
+
const base = possMatch[1];
|
|
544
|
+
const suffix = possMatch[2];
|
|
545
|
+
if (lookup.has(base)) rawVal = lookup.get(base) + suffix;
|
|
546
|
+
else if (lookup.has(base.toUpperCase())) rawVal = lookup.get(base.toUpperCase()) + suffix;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (rawVal !== null) {
|
|
550
|
+
restoredCount++;
|
|
551
|
+
const safeVal = rawVal.replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c] || c));
|
|
552
|
+
return `<span class="ps-restored-data" title="✓ Restored locally in-browser RAM (Never sent to AI)" style="border-bottom: 2px dashed #10b981; color: #10b981; background-color: rgba(16, 185, 129, 0.15); border-radius: 4px; padding: 1px 5px; margin: 0 1px; cursor: help; font-weight: 600; text-shadow: 0 0 5px rgba(16, 185, 129, 0.3);">${safeVal}</span>`;
|
|
553
|
+
}
|
|
554
|
+
return match;
|
|
555
|
+
});
|
|
556
|
+
return { restoredHTML: text, restoredCount };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const { compositeRegex, looseRules } = buildRestorationRegexAndRules(tokenMap);
|
|
560
|
+
if (compositeRegex) {
|
|
561
|
+
text = text.replace(compositeRegex, (match) => {
|
|
562
|
+
restoredCount++;
|
|
563
|
+
let origKey = match;
|
|
564
|
+
for (let i = 0; i < looseRules.length; i++) {
|
|
565
|
+
const rule = looseRules[i];
|
|
566
|
+
if (rule.regex && rule.regex.test(match)) {
|
|
567
|
+
origKey = rule.originalKey;
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const rawVal = tokenMap[origKey] || match;
|
|
572
|
+
const safeVal = rawVal.replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c] || c));
|
|
573
|
+
return `<span class="ps-restored-data" title="✓ Restored locally in-browser RAM (Never sent to AI)" style="border-bottom: 2px dashed #10b981; color: #10b981; background-color: rgba(16, 185, 129, 0.15); border-radius: 4px; padding: 1px 5px; margin: 0 1px; cursor: help; font-weight: 600; text-shadow: 0 0 5px rgba(16, 185, 129, 0.3);">${safeVal}</span>`;
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return { restoredHTML: text, restoredCount };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Expose standalone hydrateRegex for unit testing
|
|
581
|
+
function hydrateRegex(rule) {
|
|
582
|
+
if (rule && typeof rule.regex === 'string' && rule.regex.startsWith('/')) {
|
|
583
|
+
try {
|
|
584
|
+
const lastSlash = rule.regex.lastIndexOf('/');
|
|
585
|
+
const pattern = rule.regex.substring(1, lastSlash);
|
|
586
|
+
const flags = rule.regex.substring(lastSlash + 1);
|
|
587
|
+
return { ...rule, regex: new RegExp(pattern, flags) };
|
|
588
|
+
} catch (e) {
|
|
589
|
+
console.error('PS: Failed to hydrate regex:', rule.regex, e);
|
|
590
|
+
return rule;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return rule;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function showTeamsPassphraseModal(onSaveCallback) {
|
|
597
|
+
const existing = document.getElementById('ps-teams-modal');
|
|
598
|
+
if (existing) existing.remove();
|
|
599
|
+
|
|
600
|
+
const overlay = document.createElement('div');
|
|
601
|
+
overlay.id = 'ps-teams-modal';
|
|
602
|
+
Object.assign(overlay.style, {
|
|
603
|
+
position: 'fixed', top: '0', left: '0', width: '100vw', height: '100vh',
|
|
604
|
+
background: 'rgba(2,6,23,0.8)', backdropFilter: 'blur(10px)',
|
|
605
|
+
zIndex: '2147483647', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
606
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
const modal = document.createElement('div');
|
|
610
|
+
Object.assign(modal.style, {
|
|
611
|
+
background: 'rgba(15,23,42,0.9)', border: '1px solid rgba(59,130,246,0.3)',
|
|
612
|
+
borderRadius: '16px', padding: '24px', width: '380px', maxWidth: '90%',
|
|
613
|
+
boxShadow: '0 25px 50px -12px rgba(0,0,0,0.5), 0 0 20px rgba(59,130,246,0.1)',
|
|
614
|
+
color: '#f8fafc', display: 'flex', flexDirection: 'column', gap: '16px',
|
|
615
|
+
animation: 'ps-slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1)'
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
if (!document.getElementById('ps-modal-styles')) {
|
|
619
|
+
const style = document.createElement('style');
|
|
620
|
+
style.id = 'ps-modal-styles';
|
|
621
|
+
style.textContent = `
|
|
622
|
+
@keyframes ps-slide-up { from { opacity: 0; transform: translateY(20px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
|
623
|
+
.ps-modal-input { width: 100%; box-sizing: border-box; background: #020617; border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 12px; border-radius: 8px; font-size: 14px; outline: none; transition: all 0.2s; }
|
|
624
|
+
.ps-modal-input:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
|
|
625
|
+
.ps-modal-btn { flex: 1; padding: 10px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; border: none; transition: all 0.2s; }
|
|
626
|
+
.ps-modal-btn.primary { background: linear-gradient(135deg, #06b6d4, #3b82f6); color: white; box-shadow: 0 4px 12px rgba(59,130,246,0.3); }
|
|
627
|
+
.ps-modal-btn.primary:hover { filter: brightness(1.1); transform: translateY(-1px); }
|
|
628
|
+
.ps-modal-btn.primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; filter: none; }
|
|
629
|
+
.ps-modal-btn.secondary { background: rgba(255,255,255,0.05); color: #cbd5e1; border: 1px solid rgba(255,255,255,0.1); }
|
|
630
|
+
.ps-modal-btn.secondary:hover { background: rgba(255,255,255,0.1); }
|
|
631
|
+
`;
|
|
632
|
+
document.head.appendChild(style);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const header = document.createElement('div');
|
|
636
|
+
Object.assign(header.style, { display: 'flex', alignItems: 'center', gap: '12px' });
|
|
637
|
+
|
|
638
|
+
const headerIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
639
|
+
headerIcon.setAttribute("width", "24"); headerIcon.setAttribute("height", "24"); headerIcon.setAttribute("viewBox", "0 0 24 24");
|
|
640
|
+
headerIcon.setAttribute("fill", "none"); headerIcon.setAttribute("stroke", "#06b6d4"); headerIcon.setAttribute("stroke-width", "2");
|
|
641
|
+
const hp1 = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
642
|
+
hp1.setAttribute("d", "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2");
|
|
643
|
+
headerIcon.appendChild(hp1);
|
|
644
|
+
const hc1 = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
|
645
|
+
hc1.setAttribute("cx", "9"); hc1.setAttribute("cy", "7"); hc1.setAttribute("r", "4");
|
|
646
|
+
headerIcon.appendChild(hc1);
|
|
647
|
+
|
|
648
|
+
const title = document.createElement('h3');
|
|
649
|
+
title.textContent = 'TEAMS Passphrase';
|
|
650
|
+
Object.assign(title.style, { margin: '0', fontSize: '18px', fontWeight: '600' });
|
|
651
|
+
|
|
652
|
+
header.appendChild(headerIcon);
|
|
653
|
+
header.appendChild(title);
|
|
654
|
+
|
|
655
|
+
const desc = document.createElement('p');
|
|
656
|
+
desc.textContent = 'Secure Session Sharing requires a shared key. Set your team\'s passphrase to encrypt the data locally before sharing.';
|
|
657
|
+
Object.assign(desc.style, { margin: '0', fontSize: '13px', color: '#94a3b8', lineHeight: '1.5' });
|
|
658
|
+
|
|
659
|
+
const inputContainer = document.createElement('div');
|
|
660
|
+
const passInput = document.createElement('input');
|
|
661
|
+
passInput.type = 'password';
|
|
662
|
+
passInput.id = 'ps-teams-pass-input';
|
|
663
|
+
passInput.className = 'ps-modal-input';
|
|
664
|
+
passInput.placeholder = 'Configure Passphrase (min 8 chars)';
|
|
665
|
+
|
|
666
|
+
const strengthBar = document.createElement('div');
|
|
667
|
+
strengthBar.id = 'ps-teams-strength-bar';
|
|
668
|
+
Object.assign(strengthBar.style, { height: '4px', borderRadius: '2px', background: 'rgba(255,255,255,0.05)', marginTop: '8px', overflow: 'hidden' });
|
|
669
|
+
const strengthFill = document.createElement('div');
|
|
670
|
+
strengthFill.id = 'ps-teams-strength-fill';
|
|
671
|
+
Object.assign(strengthFill.style, { height: '100%', width: '0%', transition: 'all 0.3s' });
|
|
672
|
+
strengthBar.appendChild(strengthFill);
|
|
673
|
+
|
|
674
|
+
const strengthLabel = document.createElement('div');
|
|
675
|
+
strengthLabel.id = 'ps-teams-strength-label';
|
|
676
|
+
Object.assign(strengthLabel.style, { fontSize: '10px', textTransform: 'uppercase', fontWeight: 'bold', marginTop: '4px', textAlign: 'center' });
|
|
677
|
+
strengthLabel.textContent = '\u00A0';
|
|
678
|
+
|
|
679
|
+
const errorMsg = document.createElement('div');
|
|
680
|
+
errorMsg.id = 'ps-teams-error';
|
|
681
|
+
Object.assign(errorMsg.style, { color: '#ef4444', fontSize: '12px', marginTop: '4px', display: 'none', textAlign: 'center' });
|
|
682
|
+
errorMsg.textContent = 'Passphrase is too weak. Mix characters & letters.';
|
|
683
|
+
|
|
684
|
+
inputContainer.appendChild(passInput);
|
|
685
|
+
inputContainer.appendChild(strengthBar);
|
|
686
|
+
inputContainer.appendChild(strengthLabel);
|
|
687
|
+
inputContainer.appendChild(errorMsg);
|
|
688
|
+
|
|
689
|
+
const btnGroup = document.createElement('div');
|
|
690
|
+
Object.assign(btnGroup.style, { display: 'flex', gap: '10px', marginTop: '4px' });
|
|
691
|
+
const cancelBtn = document.createElement('button');
|
|
692
|
+
cancelBtn.id = 'ps-teams-cancel';
|
|
693
|
+
cancelBtn.className = 'ps-modal-btn secondary';
|
|
694
|
+
cancelBtn.textContent = 'Cancel';
|
|
695
|
+
const saveBtn = document.createElement('button');
|
|
696
|
+
saveBtn.id = 'ps-teams-save';
|
|
697
|
+
saveBtn.className = 'ps-modal-btn primary';
|
|
698
|
+
saveBtn.disabled = true;
|
|
699
|
+
saveBtn.textContent = 'Save & Encrypt';
|
|
700
|
+
btnGroup.appendChild(cancelBtn);
|
|
701
|
+
btnGroup.appendChild(saveBtn);
|
|
702
|
+
|
|
703
|
+
// SECURITY: Opt-in persistence removed to strictly enforce ZTDS (no local storage for plaintext keys).
|
|
704
|
+
|
|
705
|
+
const footer = document.createElement('div');
|
|
706
|
+
Object.assign(footer.style, { textAlign: 'center', marginTop: '4px' });
|
|
707
|
+
const learnLink = document.createElement('a');
|
|
708
|
+
learnLink.href = 'https://privacyscrubber.com/teams';
|
|
709
|
+
learnLink.target = '_blank';
|
|
710
|
+
Object.assign(learnLink.style, { color: '#60a5fa', fontSize: '12px', textDecoration: 'none' });
|
|
711
|
+
learnLink.textContent = 'Learn about TEAMS Cryptography →';
|
|
712
|
+
footer.appendChild(learnLink);
|
|
713
|
+
|
|
714
|
+
modal.appendChild(header);
|
|
715
|
+
modal.appendChild(desc);
|
|
716
|
+
modal.appendChild(inputContainer);
|
|
717
|
+
modal.appendChild(btnGroup);
|
|
718
|
+
modal.appendChild(footer);
|
|
719
|
+
|
|
720
|
+
overlay.appendChild(modal);
|
|
721
|
+
document.body.appendChild(overlay);
|
|
722
|
+
|
|
723
|
+
passInput.focus();
|
|
724
|
+
|
|
725
|
+
passInput.addEventListener('input', () => {
|
|
726
|
+
const p = passInput.value;
|
|
727
|
+
let score = 0;
|
|
728
|
+
if (p.length >= 8) score++;
|
|
729
|
+
if (p.length >= 14) score++;
|
|
730
|
+
if (/[A-Z]/.test(p) && /[a-z]/.test(p)) score++;
|
|
731
|
+
if (/[0-9]/.test(p)) score++;
|
|
732
|
+
if (/[^A-Za-z0-9]/.test(p)) score++;
|
|
733
|
+
score = Math.min(score, 4);
|
|
734
|
+
|
|
735
|
+
const levels = [
|
|
736
|
+
{ pct: '0%', color: 'transparent', text: '' },
|
|
737
|
+
{ pct: '20%', color: '#ef4444', text: 'Weak' },
|
|
738
|
+
{ pct: '40%', color: '#fbbf24', text: 'Fair' },
|
|
739
|
+
{ pct: '60%', color: '#facc15', text: 'Good' },
|
|
740
|
+
{ pct: '80%', color: '#4ade80', text: 'Strong' },
|
|
741
|
+
{ pct: '100%', color: '#10b981', text: 'Great' },
|
|
742
|
+
];
|
|
743
|
+
|
|
744
|
+
const level = (p.length === 0) ? levels[0] : levels[score];
|
|
745
|
+
strengthFill.style.width = level.pct;
|
|
746
|
+
strengthFill.style.background = level.color;
|
|
747
|
+
strengthLabel.textContent = level.text || '\u00A0';
|
|
748
|
+
strengthLabel.style.color = level.color;
|
|
749
|
+
|
|
750
|
+
if (score >= 2) {
|
|
751
|
+
saveBtn.disabled = false;
|
|
752
|
+
errorMsg.style.display = 'none';
|
|
753
|
+
} else {
|
|
754
|
+
saveBtn.disabled = true;
|
|
755
|
+
if (p.length > 0) errorMsg.style.display = 'block';
|
|
756
|
+
else errorMsg.style.display = 'none';
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
const close = () => {
|
|
761
|
+
overlay.style.opacity = '0';
|
|
762
|
+
setTimeout(() => overlay.remove(), 200);
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
cancelBtn.addEventListener('click', close);
|
|
766
|
+
saveBtn.addEventListener('click', () => {
|
|
767
|
+
if (!saveBtn.disabled) {
|
|
768
|
+
const val = passInput.value.trim();
|
|
769
|
+
close();
|
|
770
|
+
if (onSaveCallback) onSaveCallback({
|
|
771
|
+
passphrase: val,
|
|
772
|
+
shouldClearSession: false,
|
|
773
|
+
shouldSaveLocal: false
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
passInput.addEventListener('keydown', (e) => {
|
|
778
|
+
if (e.key === 'Enter' && !saveBtn.disabled) {
|
|
779
|
+
saveBtn.click();
|
|
780
|
+
} else if (e.key === 'Escape') {
|
|
781
|
+
close();
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
overlay.addEventListener('click', (e) => {
|
|
785
|
+
if (e.target === overlay) close();
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
function showInPageToast(text, variant = 'success') {
|
|
789
|
+
const existing = document.getElementById('ps-ext-toast');
|
|
790
|
+
if (existing) existing.remove();
|
|
791
|
+
|
|
792
|
+
const toast = document.createElement('div');
|
|
793
|
+
toast.id = 'ps-ext-toast';
|
|
794
|
+
toast.textContent = text;
|
|
795
|
+
|
|
796
|
+
const colors = {
|
|
797
|
+
success: { bg: 'rgba(16,185,129,0.12)', border: 'rgba(16,185,129,0.35)', color: '#10b981' },
|
|
798
|
+
info: { bg: 'rgba(59,130,246,0.12)', border: 'rgba(59,130,246,0.35)', color: '#60a5fa' },
|
|
799
|
+
warning: { bg: 'rgba(251,191,36,0.1)', border: 'rgba(251,191,36,0.3)', color: '#fbbf24' },
|
|
800
|
+
error: { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.35)', color: '#ef4444' },
|
|
801
|
+
};
|
|
802
|
+
const { bg, border, color } = colors[variant] || colors.info;
|
|
803
|
+
|
|
804
|
+
Object.assign(toast.style, {
|
|
805
|
+
position: 'fixed', top: '24px', left: '50%', transform: 'translateX(-50%)', zIndex: '2147483647',
|
|
806
|
+
background: bg, border: `1px solid ${border}`, color,
|
|
807
|
+
padding: '9px 18px', borderRadius: '12px', fontSize: '13px',
|
|
808
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
|
809
|
+
fontWeight: '500', boxShadow: '0 8px 30px rgba(0,0,0,0.3)',
|
|
810
|
+
backdropFilter: 'blur(8px)', transition: 'opacity 0.3s ease', opacity: '1',
|
|
811
|
+
textAlign: 'center'
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
document.body.appendChild(toast);
|
|
815
|
+
setTimeout(() => {
|
|
816
|
+
toast.style.opacity = '0';
|
|
817
|
+
setTimeout(() => toast.remove(), 350);
|
|
818
|
+
}, 2500);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Natively walks DOM to replace Tokens with Original text natively inside SPAs.
|
|
823
|
+
* Guaranteed safe for React/Angular since we only manipulate target TextNodes.
|
|
824
|
+
*/
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Natively walks DOM to replace Original Text with Tokens natively inside SPAs.
|
|
828
|
+
* Guaranteed safe for React/Angular input fields via MutationObserver syncs.
|
|
829
|
+
*/
|
|
830
|
+
function scrubDataInDOM(rootElement, customRules, globalTokenLabels, activeProfile, existingSessionMap, detectOnly = false, isPro = false) {
|
|
831
|
+
if (!rootElement) return null;
|
|
832
|
+
|
|
833
|
+
// ── TEXTAREA / INPUT fast path ────────────────────────────────────────
|
|
834
|
+
// Native <textarea> and <input> store their content in .value — it is
|
|
835
|
+
// NOT a child text node, so createTreeWalker finds nothing and always
|
|
836
|
+
// returns count=0. Handle them directly here.
|
|
837
|
+
if (rootElement.tagName === 'TEXTAREA' || rootElement.tagName === 'INPUT') {
|
|
838
|
+
const text = rootElement.value || '';
|
|
839
|
+
if (!text.trim()) return { count: 0, tokenMap: existingSessionMap || {} };
|
|
840
|
+
const result = scrubText(text, customRules, globalTokenLabels, activeProfile, existingSessionMap || {}, isPro);
|
|
841
|
+
if (result.uniqueUnmasked.size > 0 && !detectOnly) {
|
|
842
|
+
rootElement.value = result.scrubbedText;
|
|
843
|
+
rootElement.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
|
|
844
|
+
}
|
|
845
|
+
return { count: result.uniqueUnmasked.size, tokenMap: { ...(existingSessionMap || {}), ...result.tokenMap } };
|
|
846
|
+
}
|
|
847
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
848
|
+
|
|
849
|
+
// ── DETECT ONLY fast path ─────────────────────────────────────────────
|
|
850
|
+
// If we only need to update the badge counter (no DOM mutation), evaluate
|
|
851
|
+
// the full text string at once. This bypasses text node splitting issues
|
|
852
|
+
// in ProseMirror/contenteditable and guarantees a 100% accurate count.
|
|
853
|
+
if (detectOnly) {
|
|
854
|
+
const fullText = rootElement.innerText || rootElement.textContent || '';
|
|
855
|
+
if (!fullText.trim()) return { count: 0, tokenMap: existingSessionMap || {} };
|
|
856
|
+
const fullResult = scrubText(fullText, customRules, globalTokenLabels, activeProfile, existingSessionMap || {}, isPro);
|
|
857
|
+
return { count: fullResult.uniqueUnmasked.size, tokenMap: existingSessionMap || {} };
|
|
858
|
+
}
|
|
859
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
860
|
+
|
|
861
|
+
// ── ACTUAL DOM MUTATION (TreeWalker for AutoScrub) ────────────────────
|
|
862
|
+
|
|
863
|
+
const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_TEXT, null, false);
|
|
864
|
+
const nodesToProcess = [];
|
|
865
|
+
let node;
|
|
866
|
+
while(node = walker.nextNode()) {
|
|
867
|
+
if (!node.nodeValue.trim()) continue;
|
|
868
|
+
nodesToProcess.push(node);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
let globalTokenMap = { ...existingSessionMap };
|
|
872
|
+
let allUniqueUnmasked = new Set();
|
|
873
|
+
|
|
874
|
+
nodesToProcess.forEach(node => {
|
|
875
|
+
const originalText = node.nodeValue;
|
|
876
|
+
const result = scrubText(originalText, customRules, globalTokenLabels, activeProfile, globalTokenMap, isPro);
|
|
877
|
+
|
|
878
|
+
if (result.uniqueUnmasked.size > 0) {
|
|
879
|
+
if (!detectOnly && result.scrubbedText !== originalText) {
|
|
880
|
+
node.nodeValue = result.scrubbedText;
|
|
881
|
+
}
|
|
882
|
+
if (result.count > 0) {
|
|
883
|
+
globalTokenMap = { ...globalTokenMap, ...result.tokenMap };
|
|
884
|
+
}
|
|
885
|
+
result.uniqueUnmasked.forEach(k => allUniqueUnmasked.add(k));
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// Force React/ProseMirror to notice the change
|
|
890
|
+
if (allUniqueUnmasked.size > 0 && !detectOnly) {
|
|
891
|
+
rootElement.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return {
|
|
895
|
+
count: allUniqueUnmasked.size,
|
|
896
|
+
tokenMap: globalTokenMap
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const GLOBAL_ASSISTANT_SELECTORS = [
|
|
901
|
+
// ChatGPT
|
|
902
|
+
'[data-message-author-role="assistant"]',
|
|
903
|
+
'[data-message-author-role="model"]',
|
|
904
|
+
'[data-testid="chat-message-text"]',
|
|
905
|
+
'[data-testid="assistant-message"]',
|
|
906
|
+
'[data-is-streaming]',
|
|
907
|
+
'.agent-turn',
|
|
908
|
+
'div[role="article"].agent-turn',
|
|
909
|
+
// Claude
|
|
910
|
+
'model-response',
|
|
911
|
+
'.font-claude-message',
|
|
912
|
+
'.claude-artifact',
|
|
913
|
+
'[data-testid="artifact-renderer"]',
|
|
914
|
+
// Gemini
|
|
915
|
+
'.model-response-text__content',
|
|
916
|
+
'.response-content',
|
|
917
|
+
'message-content[is-response]',
|
|
918
|
+
// Grok (x.com)
|
|
919
|
+
'[data-testid="messageblock"]',
|
|
920
|
+
'[class*="GrokResponseMessage"]',
|
|
921
|
+
// DeepSeek
|
|
922
|
+
'.ds-markdown',
|
|
923
|
+
// Copilot — NEW React UI (copilot.microsoft.com as of 2025)
|
|
924
|
+
'[data-testid="ai-message"]',
|
|
925
|
+
'[class*="AIMessageContent"]',
|
|
926
|
+
'[class*="ResponseMessage"]',
|
|
927
|
+
'[class*="CopilotMessage"]',
|
|
928
|
+
'[class*="BotMessage"]',
|
|
929
|
+
// Copilot — legacy cib-serp Shadow DOM + Adaptive Cards
|
|
930
|
+
'.ac-textBlock',
|
|
931
|
+
'.cib-message-text',
|
|
932
|
+
'.cib-message',
|
|
933
|
+
'[class*="bot-message"]',
|
|
934
|
+
'[class*="copilot-message"]',
|
|
935
|
+
// Qwen / Tongyi
|
|
936
|
+
'.output-area',
|
|
937
|
+
// Perplexity
|
|
938
|
+
'[data-testid="answer"]',
|
|
939
|
+
// Generic article-based message wrappers (used by many platforms)
|
|
940
|
+
// Excluded from sidebar via nav/aside filter in gatherUniversalAIContext()
|
|
941
|
+
'div[role="article"]',
|
|
942
|
+
// Conservative generic fallbacks — nav/aside exclusion filter prevents sidebar matches
|
|
943
|
+
'.prose',
|
|
944
|
+
'.whitespace-pre-wrap',
|
|
945
|
+
'.message-content:not([contenteditable])',
|
|
946
|
+
'[class*="message-row"]',
|
|
947
|
+
'[class*="assistant"]',
|
|
948
|
+
'.markdown'
|
|
949
|
+
].join(', ');
|
|
950
|
+
|
|
951
|
+
function revealDataInDOM(tokenMap) {
|
|
952
|
+
if (!tokenMap || Object.keys(tokenMap).length === 0) {
|
|
953
|
+
if (typeof showInPageToast === 'function') showInPageToast("No protected session data to reveal.", "info");
|
|
954
|
+
return 0;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const existingRestoredSpans = document.querySelectorAll('.ps-restored-data');
|
|
958
|
+
|
|
959
|
+
const { compositeRegex, looseRules } = buildRestorationRegexAndRules(tokenMap);
|
|
960
|
+
if (!compositeRegex) {
|
|
961
|
+
if (typeof showInPageToast === 'function') showInPageToast("No protected session data to reveal.", "info");
|
|
962
|
+
return 0;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
let restoredCount = 0;
|
|
966
|
+
const nodesToReplace = [];
|
|
967
|
+
|
|
968
|
+
const ASSISTANT_SELECTORS = GLOBAL_ASSISTANT_SELECTORS;
|
|
969
|
+
|
|
970
|
+
function isPromptInput(el) {
|
|
971
|
+
if (!el) return false;
|
|
972
|
+
if (el.closest) {
|
|
973
|
+
return !!el.closest('[data-ps-attached="true"]');
|
|
974
|
+
}
|
|
975
|
+
let parent = el;
|
|
976
|
+
while (parent) {
|
|
977
|
+
if (parent.dataset && parent.dataset.psAttached === 'true') {
|
|
978
|
+
return true;
|
|
979
|
+
}
|
|
980
|
+
parent = parent.parentNode || parent.host;
|
|
981
|
+
}
|
|
982
|
+
return false;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Shadow-DOM-aware assistant container detection:
|
|
986
|
+
// .closest() cannot cross shadow boundaries, so if it returns null
|
|
987
|
+
// we check whether the node lives inside a ShadowRoot whose host
|
|
988
|
+
// (or host ancestor) matches an AI response selector.
|
|
989
|
+
function isInsideAssistant(el) {
|
|
990
|
+
if (!el || !el.closest) return false;
|
|
991
|
+
if (el.closest(ASSISTANT_SELECTORS)) return true;
|
|
992
|
+
// Shadow DOM fallback
|
|
993
|
+
const root = el.getRootNode ? el.getRootNode() : null;
|
|
994
|
+
if (typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot && root.host) {
|
|
995
|
+
// Check if the shadow host itself matches, or has an ancestor that does
|
|
996
|
+
if (root.host.matches && root.host.matches(ASSISTANT_SELECTORS)) return true;
|
|
997
|
+
if (root.host.closest && root.host.closest(ASSISTANT_SELECTORS)) return true;
|
|
998
|
+
}
|
|
999
|
+
return false;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function walkTextNodes(root) {
|
|
1003
|
+
if (!root) return;
|
|
1004
|
+
|
|
1005
|
+
// Handle elements with shadow roots if the root itself has one
|
|
1006
|
+
if (root.shadowRoot) {
|
|
1007
|
+
walkTextNodes(root.shadowRoot);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Standard node iteration
|
|
1011
|
+
let child = root.firstChild;
|
|
1012
|
+
while (child) {
|
|
1013
|
+
const next = child.nextSibling;
|
|
1014
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
1015
|
+
if (child.parentElement) {
|
|
1016
|
+
const tag = child.parentElement.tagName;
|
|
1017
|
+
if (!['SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'NOSCRIPT'].includes(tag)) {
|
|
1018
|
+
// Determine if node is inside an AI assistant response container.
|
|
1019
|
+
// If so, ALWAYS process it — skip both inPromptInput and isInstructionNode checks.
|
|
1020
|
+
const isAssistant = isInsideAssistant(child.parentElement);
|
|
1021
|
+
|
|
1022
|
+
let shouldSkip = false;
|
|
1023
|
+
if (!isAssistant) {
|
|
1024
|
+
// Ignore user input areas where user types prompts actively.
|
|
1025
|
+
// Precise check via isPromptInput helper (looks for data-ps-attached attribute).
|
|
1026
|
+
const inPromptInput = isPromptInput(child.parentElement);
|
|
1027
|
+
if (inPromptInput) {
|
|
1028
|
+
shouldSkip = true;
|
|
1029
|
+
} else {
|
|
1030
|
+
// Check if inside the instruction block itself.
|
|
1031
|
+
const parentTc = child.parentElement.textContent;
|
|
1032
|
+
if (parentTc && parentTc.length < 8000 && (parentTc.includes('[Privacy Scrubber Mode]') || parentTc.includes('[SYSTEM INSTRUCTION: DATA PRIVACY MODE]'))) {
|
|
1033
|
+
shouldSkip = true;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
if (!shouldSkip) {
|
|
1040
|
+
compositeRegex.lastIndex = 0;
|
|
1041
|
+
const hasMatch = compositeRegex.test(child.nodeValue);
|
|
1042
|
+
if (hasMatch) {
|
|
1043
|
+
if (['PRE', 'CODE'].includes(tag) || (child.parentElement.closest && child.parentElement.closest('pre, code'))) {
|
|
1044
|
+
const { restoredText, restoredCount: count } = unscrubText(child.nodeValue, tokenMap);
|
|
1045
|
+
if (count > 0) {
|
|
1046
|
+
child.nodeValue = restoredText;
|
|
1047
|
+
restoredCount += count;
|
|
1048
|
+
}
|
|
1049
|
+
} else {
|
|
1050
|
+
nodesToReplace.push(child);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
1057
|
+
const tag = child.tagName;
|
|
1058
|
+
if (!['SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'NOSCRIPT'].includes(tag)) {
|
|
1059
|
+
if (tag === 'IFRAME') {
|
|
1060
|
+
try {
|
|
1061
|
+
const iframeDoc = child.contentDocument || child.contentWindow?.document;
|
|
1062
|
+
if (iframeDoc && iframeDoc.body) {
|
|
1063
|
+
walkTextNodes(iframeDoc.body);
|
|
1064
|
+
}
|
|
1065
|
+
} catch (e) {
|
|
1066
|
+
// ignore cross-origin
|
|
1067
|
+
}
|
|
1068
|
+
} else {
|
|
1069
|
+
// Check if inside an AI assistant response — always walk those.
|
|
1070
|
+
const isAssistantEl = isInsideAssistant(child);
|
|
1071
|
+
|
|
1072
|
+
let shouldSkipEl = false;
|
|
1073
|
+
if (!isAssistantEl) {
|
|
1074
|
+
const inPromptInput = isPromptInput(child);
|
|
1075
|
+
if (inPromptInput) {
|
|
1076
|
+
shouldSkipEl = true;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
if (!shouldSkipEl) {
|
|
1081
|
+
walkTextNodes(child);
|
|
1082
|
+
if (child.shadowRoot) {
|
|
1083
|
+
walkTextNodes(child.shadowRoot);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
child = next;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
walkTextNodes(document.documentElement);
|
|
1094
|
+
|
|
1095
|
+
// TOGGLE RE-MASK: If no unmasked tokens were found on screen, but there are already revealed .ps-restored-data spans, re-mask them back to tokens
|
|
1096
|
+
if (nodesToReplace.length === 0 && existingRestoredSpans.length > 0) {
|
|
1097
|
+
let remaskedCount = 0;
|
|
1098
|
+
existingRestoredSpans.forEach(span => {
|
|
1099
|
+
const origToken = span.getAttribute('data-original-token') || span.getAttribute('data-token');
|
|
1100
|
+
let tokenToRestore = origToken;
|
|
1101
|
+
if (!tokenToRestore) {
|
|
1102
|
+
const currentVal = span.textContent;
|
|
1103
|
+
for (const [t, v] of Object.entries(tokenMap)) {
|
|
1104
|
+
if (v === currentVal) {
|
|
1105
|
+
tokenToRestore = t;
|
|
1106
|
+
break;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
if (tokenToRestore && span.parentElement) {
|
|
1111
|
+
const textNode = document.createTextNode(tokenToRestore);
|
|
1112
|
+
try {
|
|
1113
|
+
span.parentElement.replaceChild(textNode, span);
|
|
1114
|
+
remaskedCount++;
|
|
1115
|
+
} catch (_) {}
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
if (remaskedCount > 0) {
|
|
1119
|
+
if (typeof showInPageToast === 'function') showInPageToast(`🔒 Re-masked in DOM. Original tokens restored.`, "info");
|
|
1120
|
+
document.querySelectorAll('.ps-toolbar-reveal, [id$="-reveal"]').forEach(b => {
|
|
1121
|
+
b.setAttribute('title', 'Reveal Original Data (Decrypted Locally)');
|
|
1122
|
+
b.classList.remove('ps-revealed-active');
|
|
1123
|
+
});
|
|
1124
|
+
return remaskedCount;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
nodesToReplace.forEach(node => {
|
|
1129
|
+
const text = node.nodeValue;
|
|
1130
|
+
const fragment = document.createDocumentFragment();
|
|
1131
|
+
let lastIndex = 0;
|
|
1132
|
+
let match;
|
|
1133
|
+
|
|
1134
|
+
compositeRegex.lastIndex = 0;
|
|
1135
|
+
while ((match = compositeRegex.exec(text)) !== null) {
|
|
1136
|
+
if (match.index > lastIndex) {
|
|
1137
|
+
fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index)));
|
|
1138
|
+
}
|
|
1139
|
+
let origKey = match[0];
|
|
1140
|
+
for (const rule of looseRules) {
|
|
1141
|
+
if (new RegExp('^' + rule.patternStr + '$', 'i').test(match[0])) {
|
|
1142
|
+
origKey = rule.originalKey;
|
|
1143
|
+
break;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
const rawVal = tokenMap[origKey] || match[0];
|
|
1147
|
+
|
|
1148
|
+
const span = document.createElement('span');
|
|
1149
|
+
span.className = 'ps-restored-data';
|
|
1150
|
+
span.setAttribute('data-original-token', origKey);
|
|
1151
|
+
span.title = `✓ Restored locally in-browser RAM (Never sent to AI) | Original: ${origKey}`;
|
|
1152
|
+
Object.assign(span.style, {
|
|
1153
|
+
borderBottom: '2px dashed #10b981',
|
|
1154
|
+
color: '#10b981',
|
|
1155
|
+
backgroundColor: 'rgba(16, 185, 129, 0.15)',
|
|
1156
|
+
borderRadius: '4px',
|
|
1157
|
+
padding: '1px 5px',
|
|
1158
|
+
margin: '0 1px',
|
|
1159
|
+
cursor: 'help',
|
|
1160
|
+
fontWeight: '600',
|
|
1161
|
+
position: 'relative',
|
|
1162
|
+
display: 'inline-block',
|
|
1163
|
+
zIndex: '1',
|
|
1164
|
+
boxShadow: '0 0 8px rgba(16, 185, 129, 0.25)',
|
|
1165
|
+
textShadow: '0 0 4px rgba(16, 185, 129, 0.4)'
|
|
1166
|
+
});
|
|
1167
|
+
span.textContent = rawVal;
|
|
1168
|
+
|
|
1169
|
+
fragment.appendChild(span);
|
|
1170
|
+
restoredCount++;
|
|
1171
|
+
lastIndex = compositeRegex.lastIndex;
|
|
1172
|
+
}
|
|
1173
|
+
if (lastIndex < text.length) {
|
|
1174
|
+
fragment.appendChild(document.createTextNode(text.substring(lastIndex)));
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
if (node.parentElement) {
|
|
1178
|
+
try {
|
|
1179
|
+
node.parentElement.replaceChild(fragment, node);
|
|
1180
|
+
} catch (e) {
|
|
1181
|
+
// Fail silently
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
if (restoredCount > 0) {
|
|
1187
|
+
if (typeof showInPageToast === 'function') showInPageToast(`✅ Decrypted Locally! Your data is now safe to copy.`, "success");
|
|
1188
|
+
document.querySelectorAll('.ps-toolbar-reveal, [id$="-reveal"]').forEach(b => {
|
|
1189
|
+
b.setAttribute('title', 'Hide / Re-mask Protected Data in DOM');
|
|
1190
|
+
b.classList.add('ps-revealed-active');
|
|
1191
|
+
});
|
|
1192
|
+
} else {
|
|
1193
|
+
if (typeof showInPageToast === 'function') showInPageToast("No active tokens found on the screen.", "info");
|
|
1194
|
+
}
|
|
1195
|
+
return restoredCount;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function gatherUniversalAIContext() {
|
|
1199
|
+
try {
|
|
1200
|
+
// Helper function to recursively find elements matching selectors across shadow DOMs and same-origin iframes
|
|
1201
|
+
function querySelectorAllRecursive(root, selector) {
|
|
1202
|
+
// Single depth-first traversal that crosses shadow DOM boundaries.
|
|
1203
|
+
// Does NOT use root.querySelectorAll() to avoid duplicating shadow DOM results.
|
|
1204
|
+
const results = [];
|
|
1205
|
+
if (!root) return results;
|
|
1206
|
+
|
|
1207
|
+
function walk(node) {
|
|
1208
|
+
if (!node) return;
|
|
1209
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1210
|
+
try {
|
|
1211
|
+
if (node.matches && node.matches(selector)) results.push(node);
|
|
1212
|
+
} catch (e) {}
|
|
1213
|
+
// Recurse into shadow DOM (depth-first before light children)
|
|
1214
|
+
if (node.shadowRoot) walk(node.shadowRoot);
|
|
1215
|
+
// Recurse into same-origin iframes
|
|
1216
|
+
if (node.tagName === 'IFRAME') {
|
|
1217
|
+
try {
|
|
1218
|
+
const iframeDoc = node.contentDocument || node.contentWindow?.document;
|
|
1219
|
+
if (iframeDoc) walk(iframeDoc);
|
|
1220
|
+
} catch (e) {}
|
|
1221
|
+
return; // children handled above
|
|
1222
|
+
}
|
|
1223
|
+
} else if (node.nodeType !== Node.DOCUMENT_NODE && node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
|
|
1224
|
+
return; // text node, comment, etc
|
|
1225
|
+
}
|
|
1226
|
+
// Walk light DOM / document children
|
|
1227
|
+
let child = node.firstChild;
|
|
1228
|
+
while (child) { walk(child); child = child.nextSibling; }
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
walk(root);
|
|
1232
|
+
return results;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
// 1. Gather Input Text
|
|
1237
|
+
const inputs = querySelectorAllRecursive(document, 'textarea, div[contenteditable="true"], div.ProseMirror');
|
|
1238
|
+
// Find the active visible input, ignoring our own injected toolbar
|
|
1239
|
+
const activeInput = inputs.find(el => ((el.offsetParent !== null && el.offsetHeight > 0) || (typeof window !== 'undefined' && !window.chrome?.runtime)) && !el.closest('.ps-toolbar-container'));
|
|
1240
|
+
const inputText = activeInput ? (activeInput.value || activeInput.innerText || activeInput.textContent || "") : "";
|
|
1241
|
+
|
|
1242
|
+
// 2. Gather Output Text (Last AI Response)
|
|
1243
|
+
let outputText = "";
|
|
1244
|
+
|
|
1245
|
+
// Universal selectors covering major AI models + Claude Artifacts
|
|
1246
|
+
const knownSelectors = GLOBAL_ASSISTANT_SELECTORS;
|
|
1247
|
+
|
|
1248
|
+
const candidateNodes = querySelectorAllRecursive(document, knownSelectors);
|
|
1249
|
+
|
|
1250
|
+
// Filter out nodes that obviously belong to the user or sidebar/navigation
|
|
1251
|
+
const validResponses = Array.from(candidateNodes).filter(el => {
|
|
1252
|
+
// If it is inside a shadow DOM, offsetParent might be null, check offsetHeight/getBoundingClientRect
|
|
1253
|
+
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
|
1254
|
+
const isJSDOM = typeof window !== 'undefined' && !window.chrome?.runtime && el.offsetParent === null && el.offsetHeight === 0;
|
|
1255
|
+
const isVisible = isJSDOM || (el.offsetParent !== null && el.offsetHeight > 0) || (rect && rect.height > 0 && rect.width > 0);
|
|
1256
|
+
if (!isVisible) return false;
|
|
1257
|
+
|
|
1258
|
+
// SIDEBAR/NAV EXCLUSION: exclude elements inside navigation or sidebar areas.
|
|
1259
|
+
// This prevents Kimi sidebar, Grok left panel, and similar from being captured.
|
|
1260
|
+
if (el.closest) {
|
|
1261
|
+
if (el.closest('nav, aside, header')) return false;
|
|
1262
|
+
if (el.closest('[role="navigation"], [role="complementary"], [role="banner"]')) return false;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// QWEN THINKING FILTER: exclude Qwen/DeepSeek "Thinking completed" reasoning blocks.
|
|
1266
|
+
// These are collapsible sections shown before the actual response.
|
|
1267
|
+
if (el.closest) {
|
|
1268
|
+
if (el.closest('[class*="thinking"], [class*="reasoning"], [class*="Thinking"], [class*="chain-of-thought"]')) return false;
|
|
1269
|
+
if (el.classList.contains('thinking') || el.classList.contains('reasoning')) return false;
|
|
1270
|
+
}
|
|
1271
|
+
// Also exclude by text heuristic: element whose ONLY content is the thinking header
|
|
1272
|
+
{
|
|
1273
|
+
const rawText = (el.innerText || el.textContent || '').trim();
|
|
1274
|
+
if (rawText.toLowerCase() === 'thinking completed' || rawText.toLowerCase() === 'thinking...') return false;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// KEY EXCLUSION: elements that contain the active textarea are page/chat wrappers,
|
|
1278
|
+
// never AI responses. This is the definitive fix for Kimi (and similar platforms)
|
|
1279
|
+
// where a large wrapper div matches a generic selector and includes the entire
|
|
1280
|
+
// sidebar + chat area + user input field.
|
|
1281
|
+
if (activeInput && el.contains(activeInput)) return false;
|
|
1282
|
+
|
|
1283
|
+
// Exclude user messages that share generic classes like .message-content
|
|
1284
|
+
const role = el.getAttribute('data-message-author-role');
|
|
1285
|
+
if (role === 'user') return false;
|
|
1286
|
+
|
|
1287
|
+
// Exclude explicit user classes
|
|
1288
|
+
if (el.closest) {
|
|
1289
|
+
if (el.closest('.user-message') || el.classList.contains('user-message')) return false;
|
|
1290
|
+
if (el.closest('[data-testid="user-message"]')) return false;
|
|
1291
|
+
if (el.closest('[data-message-author-role="user"]')) return false;
|
|
1292
|
+
} else {
|
|
1293
|
+
let parent = el.parentNode;
|
|
1294
|
+
while (parent) {
|
|
1295
|
+
if (parent.classList && (parent.classList.contains('user-message') || parent.getAttribute('data-testid') === 'user-message')) {
|
|
1296
|
+
return false;
|
|
1297
|
+
}
|
|
1298
|
+
parent = parent.parentNode || parent.host;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
return true;
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
function getCleanElementText(el) {
|
|
1306
|
+
if (!el) return "";
|
|
1307
|
+
try {
|
|
1308
|
+
const clone = el.cloneNode(true);
|
|
1309
|
+
clone.querySelectorAll('style, script, noscript, svg, link, template, [hidden]').forEach(s => s.remove());
|
|
1310
|
+
clone.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
|
|
1311
|
+
clone.querySelectorAll('p, div, li, tr, h1, h2, h3, h4, h5, h6, pre, blockquote').forEach(b => {
|
|
1312
|
+
b.insertAdjacentText('afterend', '\n');
|
|
1313
|
+
});
|
|
1314
|
+
const text = (clone.textContent || "").replace(/\n{3,}/g, '\n\n').trim();
|
|
1315
|
+
if (/^\[data-conversation-component=[^\]]+\]\{/.test(text) || /^\{[ \t\r\n]*--[a-zA-Z0-9_-]+:/.test(text)) return "";
|
|
1316
|
+
return text;
|
|
1317
|
+
} catch (_) {
|
|
1318
|
+
return (el.textContent || "").trim();
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function extractCleanAssistantMessageText(turnEl) {
|
|
1323
|
+
if (!turnEl) return "";
|
|
1324
|
+
try {
|
|
1325
|
+
const clone = turnEl.cloneNode(true);
|
|
1326
|
+
// 1. Remove style, script, noscript, svg, link, template elements (prevents CSS leaks)
|
|
1327
|
+
clone.querySelectorAll('style, script, noscript, svg, link, template, [hidden]').forEach(el => el.remove());
|
|
1328
|
+
|
|
1329
|
+
// 2. Remove reasoning / thought accordions
|
|
1330
|
+
clone.querySelectorAll(
|
|
1331
|
+
'[class*="thinking"], [class*="reasoning"], [class*="thought"], [data-testid*="thought"], [data-testid*="reasoning"], details, .thinking, .reasoning'
|
|
1332
|
+
).forEach(el => el.remove());
|
|
1333
|
+
|
|
1334
|
+
// 3. Remove bottom toolbar action buttons and canvas headers
|
|
1335
|
+
clone.querySelectorAll(
|
|
1336
|
+
'button, [role="button"], [class*="toolbar"], [class*="actions"], [class*="feedback"], [data-testid*="action"], [class*="copy-button"], [class*="read-aloud"], .ps-token-chips-bar, [class*="canvas-header"], [data-testid*="artifact-header"]'
|
|
1337
|
+
).forEach(el => el.remove());
|
|
1338
|
+
|
|
1339
|
+
// 4. Find top-level markdown / content blocks
|
|
1340
|
+
const contentNodes = clone.querySelectorAll(
|
|
1341
|
+
'.markdown, .prose, [class*="markdown"], [class*="prose"], .text-message, .ds-markdown, .font-claude-message, message-content, pre, table'
|
|
1342
|
+
);
|
|
1343
|
+
if (contentNodes.length > 0) {
|
|
1344
|
+
const topNodes = Array.from(contentNodes).filter((node, _, list) =>
|
|
1345
|
+
!list.some(parent => parent !== node && parent.contains(node))
|
|
1346
|
+
);
|
|
1347
|
+
const joined = topNodes.map(n => getCleanElementText(n)).filter(Boolean).join('\n\n');
|
|
1348
|
+
if (joined) return cleanAIPromptPrefix(joined);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
const fullCleaned = getCleanElementText(clone);
|
|
1352
|
+
if (fullCleaned) return cleanAIPromptPrefix(fullCleaned);
|
|
1353
|
+
} catch (_) {}
|
|
1354
|
+
return cleanAIPromptPrefix(turnEl.innerText || turnEl.textContent || "").trim();
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
if (validResponses.length > 0) {
|
|
1358
|
+
// Return the text of the very last valid response on the page.
|
|
1359
|
+
// De-ancestor: if element A contains element B (both matched), prefer B (more specific).
|
|
1360
|
+
const leafResponses = validResponses.filter((el, _, arr) =>
|
|
1361
|
+
!arr.some(other => other !== el && el.contains(other))
|
|
1362
|
+
);
|
|
1363
|
+
const candidates = leafResponses.length > 0 ? leafResponses : validResponses;
|
|
1364
|
+
const lastLeaf = candidates[candidates.length - 1];
|
|
1365
|
+
|
|
1366
|
+
const fullTurn = resolveFullAssistantTurnElement(lastLeaf, activeInput);
|
|
1367
|
+
outputText = extractCleanAssistantMessageText(fullTurn || lastLeaf);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
if (!outputText.trim()) {
|
|
1372
|
+
// Fallback: look for any element containing tokens, excluding prompt inputs and nav/sidebar areas
|
|
1373
|
+
const allElements = querySelectorAllRecursive(document, 'div, p, span, li, td, input, textarea, [role="textbox"]');
|
|
1374
|
+
const tokenContainingElements = allElements.filter(el => {
|
|
1375
|
+
// Exclude navigation/sidebar areas (same as primary filter)
|
|
1376
|
+
if (el.closest && el.closest('nav, aside, header, [role="navigation"], [role="complementary"], [role="banner"]')) return false;
|
|
1377
|
+
// Exclude page/chat wrappers that contain the active input
|
|
1378
|
+
if (activeInput && el.contains(activeInput)) return false;
|
|
1379
|
+
// Exclude prompt inputs (active ones with data-ps-attached attribute)
|
|
1380
|
+
let parent = el;
|
|
1381
|
+
while (parent) {
|
|
1382
|
+
if (parent.dataset && parent.dataset.psAttached === 'true') return false;
|
|
1383
|
+
parent = parent.parentNode || parent.host;
|
|
1384
|
+
}
|
|
1385
|
+
if (['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(el.tagName)) return false;
|
|
1386
|
+
const text = (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') ? (el.value || "") : (el.innerText || el.textContent || "");
|
|
1387
|
+
return /\[[A-Z]+_[0-9]+\]/.test(text);
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
let bestEl = null;
|
|
1391
|
+
let maxTokens = 0;
|
|
1392
|
+
let bestTextLength = 0;
|
|
1393
|
+
|
|
1394
|
+
for (const el of tokenContainingElements) {
|
|
1395
|
+
const text = (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') ? (el.value || "") : (el.innerText || el.textContent || "");
|
|
1396
|
+
const matches = text.match(/\[[A-Z]+_[0-9]+\]/g) || [];
|
|
1397
|
+
const uniqueTokens = new Set(matches).size;
|
|
1398
|
+
if (uniqueTokens > maxTokens || (uniqueTokens === maxTokens && uniqueTokens > 0 && text.length > bestTextLength)) {
|
|
1399
|
+
maxTokens = uniqueTokens;
|
|
1400
|
+
bestTextLength = text.length;
|
|
1401
|
+
bestEl = el;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
if (bestEl) {
|
|
1405
|
+
outputText = (bestEl.tagName === 'INPUT' || bestEl.tagName === 'TEXTAREA') ? (bestEl.value || "") : (bestEl.innerText || bestEl.textContent || "");
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
return {
|
|
1410
|
+
inputText: inputText.trim(),
|
|
1411
|
+
outputText: cleanAIPromptPrefix(outputText).trim()
|
|
1412
|
+
};
|
|
1413
|
+
} catch (e) {
|
|
1414
|
+
console.error("[PrivacyScrubber] Universal Context gather failed:", e);
|
|
1415
|
+
return { inputText: "", outputText: "" };
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Export to window for content scripts
|
|
1420
|
+
// Expose for OCR and advanced usage
|
|
1421
|
+
function getRules(profile) {
|
|
1422
|
+
const engine = getEngine();
|
|
1423
|
+
if (engine) {
|
|
1424
|
+
return engine.getActiveRules(profile);
|
|
1425
|
+
}
|
|
1426
|
+
return [];
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Show a right-click context menu for Profile Switching
|
|
1431
|
+
*/
|
|
1432
|
+
function showProfileMenu(e, anchorElement, onProfileChange) {
|
|
1433
|
+
// Remove existing menu if any
|
|
1434
|
+
let existing = document.getElementById('ps-profile-menu');
|
|
1435
|
+
if (existing) {
|
|
1436
|
+
existing.remove();
|
|
1437
|
+
return; // Act as a toggle
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
const PROFILES = [
|
|
1441
|
+
{ id: 'general', label: 'General' },
|
|
1442
|
+
{ id: 'engineering', label: 'Engineering' },
|
|
1443
|
+
{ id: 'finance', label: 'Finance' },
|
|
1444
|
+
{ id: 'legal', label: 'Legal' },
|
|
1445
|
+
{ id: 'medical', label: 'Healthcare' },
|
|
1446
|
+
{ id: 'hr', label: 'HR' }
|
|
1447
|
+
];
|
|
1448
|
+
|
|
1449
|
+
chrome.storage.local.get(['ps_active_profile', 'ps_is_pro', 'ps_is_teams'], (data) => {
|
|
1450
|
+
const activeProfile = (data.ps_active_profile || 'general').toLowerCase();
|
|
1451
|
+
const isPro = data.ps_is_pro || data.ps_is_teams || false;
|
|
1452
|
+
|
|
1453
|
+
const menu = document.createElement('div');
|
|
1454
|
+
menu.id = 'ps-profile-menu';
|
|
1455
|
+
menu.className = 'ps-profile-menu';
|
|
1456
|
+
|
|
1457
|
+
// Positioning
|
|
1458
|
+
const rect = anchorElement.getBoundingClientRect();
|
|
1459
|
+
// Try to position it below and to the left of the button
|
|
1460
|
+
menu.style.top = (rect.bottom + window.scrollY + 5) + 'px';
|
|
1461
|
+
menu.style.left = (rect.right + window.scrollX - 150) + 'px';
|
|
1462
|
+
|
|
1463
|
+
const header = document.createElement('div');
|
|
1464
|
+
header.className = 'ps-profile-menu-header';
|
|
1465
|
+
header.innerText = 'Detection Profile';
|
|
1466
|
+
menu.appendChild(header);
|
|
1467
|
+
|
|
1468
|
+
PROFILES.forEach(p => {
|
|
1469
|
+
const item = document.createElement('div');
|
|
1470
|
+
item.className = 'ps-profile-menu-item';
|
|
1471
|
+
const isCurrentActive = p.id.toLowerCase() === activeProfile;
|
|
1472
|
+
if (isCurrentActive) item.classList.add('active');
|
|
1473
|
+
|
|
1474
|
+
let labelText = p.label;
|
|
1475
|
+
item.innerText = labelText;
|
|
1476
|
+
|
|
1477
|
+
if (!isPro && p.id !== 'general') {
|
|
1478
|
+
item.style.opacity = '0.5';
|
|
1479
|
+
item.style.cursor = 'not-allowed';
|
|
1480
|
+
item.title = 'PRO Feature';
|
|
1481
|
+
item.innerText = labelText + ' 🔒';
|
|
1482
|
+
} else if (isCurrentActive) {
|
|
1483
|
+
item.innerText = labelText + ' ✓';
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
item.addEventListener('click', (ev) => {
|
|
1487
|
+
ev.stopPropagation();
|
|
1488
|
+
if (!isPro && p.id !== 'general') {
|
|
1489
|
+
if (typeof showInPageToast === 'function') {
|
|
1490
|
+
showInPageToast('Specialized Profiles require PRO upgrade.', 'warning');
|
|
1491
|
+
}
|
|
1492
|
+
menu.remove();
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
chrome.storage.local.set({ ps_active_profile: p.id }, () => {
|
|
1497
|
+
if (typeof showInPageToast === 'function') {
|
|
1498
|
+
showInPageToast(`Profile Switched to ${p.label}`, 'success');
|
|
1499
|
+
}
|
|
1500
|
+
menu.remove();
|
|
1501
|
+
|
|
1502
|
+
// Trigger immediate re-scrub if callback provided
|
|
1503
|
+
if (typeof onProfileChange === 'function') {
|
|
1504
|
+
onProfileChange(p.id);
|
|
1505
|
+
}
|
|
1506
|
+
});
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
menu.appendChild(item);
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
document.body.appendChild(menu);
|
|
1513
|
+
|
|
1514
|
+
// Close on click outside
|
|
1515
|
+
const closeMenu = (ev) => {
|
|
1516
|
+
if (!menu.contains(ev.target) && ev.target !== anchorElement) {
|
|
1517
|
+
menu.remove();
|
|
1518
|
+
document.removeEventListener('click', closeMenu);
|
|
1519
|
+
document.removeEventListener('contextmenu', closeMenu);
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
setTimeout(() => {
|
|
1524
|
+
document.addEventListener('click', closeMenu);
|
|
1525
|
+
document.addEventListener('contextmenu', closeMenu);
|
|
1526
|
+
}, 50);
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* exportSessionToFile — Downloads the current session map as a JSON file
|
|
1532
|
+
*/
|
|
1533
|
+
function exportSessionToFile(sessionMap, filename = 'ps-session', extraContext = null) {
|
|
1534
|
+
if (!sessionMap || Object.keys(sessionMap).length === 0) {
|
|
1535
|
+
if (window.showInPageToast) window.showInPageToast("No active session data to export.", "info");
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
try {
|
|
1539
|
+
const data = {
|
|
1540
|
+
version: "1.6.4",
|
|
1541
|
+
timestamp: new Date().toISOString(),
|
|
1542
|
+
sessionMap: sessionMap,
|
|
1543
|
+
context: extraContext
|
|
1544
|
+
};
|
|
1545
|
+
const jsonStr = JSON.stringify(data, null, 2);
|
|
1546
|
+
if (typeof downloadFile === 'function') {
|
|
1547
|
+
downloadFile(jsonStr, `${filename}-${Date.now()}.json`, 'application/json');
|
|
1548
|
+
} else {
|
|
1549
|
+
const blob = new Blob([jsonStr], { type: 'application/json' });
|
|
1550
|
+
const url = URL.createObjectURL(blob);
|
|
1551
|
+
if (typeof chrome !== 'undefined' && chrome.downloads) {
|
|
1552
|
+
chrome.downloads.download({ url: url, filename: `${filename}-${Date.now()}.json` });
|
|
1553
|
+
} else {
|
|
1554
|
+
const a = document.createElement('a');
|
|
1555
|
+
a.href = url;
|
|
1556
|
+
a.download = `${filename}-${Date.now()}.json`;
|
|
1557
|
+
document.body.appendChild(a);
|
|
1558
|
+
a.click();
|
|
1559
|
+
document.body.removeChild(a);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
if (window.showInPageToast) window.showInPageToast("✓ Session exported successfully", "success");
|
|
1563
|
+
} catch (e) {
|
|
1564
|
+
console.error("PrivacyScrubber Export Error:", e);
|
|
1565
|
+
if (window.showInPageToast) window.showInPageToast("❌ Export failed.", "error");
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function extractCleanAssistantMessageText(turnEl) {
|
|
1570
|
+
if (!turnEl) return "";
|
|
1571
|
+
try {
|
|
1572
|
+
const clone = turnEl.cloneNode(true);
|
|
1573
|
+
// 1. Remove style, script, noscript, svg, link, template elements (prevents CSS leaks)
|
|
1574
|
+
clone.querySelectorAll('style, script, noscript, svg, link, template, [hidden]').forEach(el => el.remove());
|
|
1575
|
+
|
|
1576
|
+
// 2. Remove reasoning / thought accordions
|
|
1577
|
+
clone.querySelectorAll(
|
|
1578
|
+
'[class*="thinking"], [class*="reasoning"], [class*="thought"], [data-testid*="thought"], [data-testid*="reasoning"], details, .thinking, .reasoning'
|
|
1579
|
+
).forEach(el => el.remove());
|
|
1580
|
+
|
|
1581
|
+
// 3. Remove bottom toolbar action buttons and canvas headers
|
|
1582
|
+
clone.querySelectorAll(
|
|
1583
|
+
'button, [role="button"], [class*="toolbar"], [class*="actions"], [class*="feedback"], [data-testid*="action"], [class*="copy-button"], [class*="read-aloud"], .ps-token-chips-bar, [class*="canvas-header"], [data-testid*="artifact-header"]'
|
|
1584
|
+
).forEach(el => el.remove());
|
|
1585
|
+
|
|
1586
|
+
// 4. Find top-level markdown / content blocks
|
|
1587
|
+
const contentNodes = clone.querySelectorAll(
|
|
1588
|
+
'.markdown, .prose, [class*="markdown"], [class*="prose"], .text-message, .ds-markdown, .font-claude-message, message-content, pre, table'
|
|
1589
|
+
);
|
|
1590
|
+
function getClean(el) {
|
|
1591
|
+
if (!el) return "";
|
|
1592
|
+
try {
|
|
1593
|
+
const c = el.cloneNode(true);
|
|
1594
|
+
c.querySelectorAll('style, script, noscript, svg, link, template, [hidden]').forEach(s => s.remove());
|
|
1595
|
+
c.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
|
|
1596
|
+
c.querySelectorAll('p, div, li, tr, h1, h2, h3, h4, h5, h6, pre, blockquote').forEach(b => {
|
|
1597
|
+
b.insertAdjacentText('afterend', '\n');
|
|
1598
|
+
});
|
|
1599
|
+
const text = (c.textContent || "").replace(/\n{3,}/g, '\n\n').trim();
|
|
1600
|
+
if (/^\[data-conversation-component=[^\]]+\]\{/.test(text) || /^\{[ \t\r\n]*--[a-zA-Z0-9_-]+:/.test(text)) return "";
|
|
1601
|
+
return text;
|
|
1602
|
+
} catch (_) {
|
|
1603
|
+
return (el.textContent || "").trim();
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
if (contentNodes.length > 0) {
|
|
1607
|
+
const topNodes = Array.from(contentNodes).filter((node, _, list) =>
|
|
1608
|
+
!list.some(parent => parent !== node && parent.contains(node))
|
|
1609
|
+
);
|
|
1610
|
+
const joined = topNodes.map(n => getClean(n)).filter(Boolean).join('\n\n');
|
|
1611
|
+
if (joined) return cleanAIPromptPrefix(joined);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
const fullCleaned = getClean(clone);
|
|
1615
|
+
if (fullCleaned) return cleanAIPromptPrefix(fullCleaned);
|
|
1616
|
+
} catch (_) {}
|
|
1617
|
+
return cleanAIPromptPrefix(turnEl.innerText || turnEl.textContent || "").trim();
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
function resolveFullAssistantTurnElement(leafOrContainer, textarea) {
|
|
1621
|
+
if (!leafOrContainer) return null;
|
|
1622
|
+
try {
|
|
1623
|
+
const turnContainer = leafOrContainer.closest ? leafOrContainer.closest(
|
|
1624
|
+
'article, [data-message-author-role="assistant"], [data-message-author-role="model"], [data-testid*="assistant-message"], [data-testid*="conversation-turn"], [class*="chat-message"], [class*="ds-message"], [class*="message-item"], [class*="response-container"], [class*="chat-turn"], [class*="agent-turn"], [class*="talk-bubble"], [class*="bot-message"], [class*="output-block"]'
|
|
1625
|
+
) : null;
|
|
1626
|
+
|
|
1627
|
+
if (turnContainer && (!textarea || !turnContainer.contains(textarea))) {
|
|
1628
|
+
const userMsgCount = turnContainer.querySelectorAll ? turnContainer.querySelectorAll('.user-message, [data-message-author-role="user"], [data-testid="user-message"]').length : 0;
|
|
1629
|
+
if (userMsgCount === 0) {
|
|
1630
|
+
return turnContainer;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
if (leafOrContainer.parentElement) {
|
|
1635
|
+
const parent = leafOrContainer.parentElement;
|
|
1636
|
+
if (!parent.closest('nav, aside, header, [role="navigation"]') && (!textarea || !parent.contains(textarea))) {
|
|
1637
|
+
const siblingContentNodes = parent.querySelectorAll ? parent.querySelectorAll('.ds-markdown, [class*="ds-markdown"], .markdown, .prose, pre, p, blockquote, ol, ul, div') : [];
|
|
1638
|
+
if (siblingContentNodes.length > 1) {
|
|
1639
|
+
return parent;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
} catch (_) {}
|
|
1644
|
+
return leafOrContainer;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
const engine = getEngine();
|
|
1648
|
+
|
|
1649
|
+
if (typeof window !== 'undefined') {
|
|
1650
|
+
window.PrivacyScrubberCore = {
|
|
1651
|
+
init,
|
|
1652
|
+
scrubText,
|
|
1653
|
+
unscrubText: (text, sessionMap, opts) => {
|
|
1654
|
+
if (!engine) return null;
|
|
1655
|
+
const res = engine.unscrubText(text, sessionMap, opts);
|
|
1656
|
+
return res ? { restoredText: res.text, text: res.text, restoredCount: res.count, count: res.count } : null;
|
|
1657
|
+
},
|
|
1658
|
+
unscrubTextAsHTML: (text, sessionMap, opts) => {
|
|
1659
|
+
if (!engine) return null;
|
|
1660
|
+
const res = engine.unscrubTextAsHTML(text, sessionMap, opts);
|
|
1661
|
+
return res ? { restoredHTML: res.text, html: res.text, text: res.text, restoredCount: res.count, count: res.count } : null;
|
|
1662
|
+
},
|
|
1663
|
+
showTeamsPassphraseModal,
|
|
1664
|
+
scrubDataInDOM,
|
|
1665
|
+
revealDataInDOM,
|
|
1666
|
+
gatherUniversalAIContext,
|
|
1667
|
+
resolveFullAssistantTurnElement,
|
|
1668
|
+
extractCleanAssistantMessageText,
|
|
1669
|
+
extractLLMAssistantText: extractCleanAssistantMessageText,
|
|
1670
|
+
exportSessionToFile,
|
|
1671
|
+
getRules,
|
|
1672
|
+
showProfileMenu,
|
|
1673
|
+
cleanAIPromptPrefix: (text) => engine ? engine.cleanAIPromptPrefix(text) : text,
|
|
1674
|
+
hydrateRegex: (rule) => engine ? engine.hydrateRegex(rule) : rule,
|
|
1675
|
+
isInitialized: false
|
|
1676
|
+
};
|
|
1677
|
+
window.showInPageToast = showInPageToast;
|
|
1678
|
+
|
|
1679
|
+
// v1.4.4: Automatic re-hydration on boot from local storage cache
|
|
1680
|
+
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local) {
|
|
1681
|
+
chrome.storage.local.get(['ps_rules_cache'], (data) => {
|
|
1682
|
+
if (data.ps_rules_cache) {
|
|
1683
|
+
init(data.ps_rules_cache);
|
|
1684
|
+
}
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
1689
|
+
module.exports = {
|
|
1690
|
+
init,
|
|
1691
|
+
scrubText,
|
|
1692
|
+
unscrubText: (text, sessionMap, opts) => {
|
|
1693
|
+
if (!engine) return null;
|
|
1694
|
+
const res = engine.unscrubText(text, sessionMap, opts);
|
|
1695
|
+
return res ? { restoredText: res.text, text: res.text, restoredCount: res.count, count: res.count } : null;
|
|
1696
|
+
},
|
|
1697
|
+
unscrubTextAsHTML: (text, sessionMap, opts) => {
|
|
1698
|
+
if (!engine) return null;
|
|
1699
|
+
const res = engine.unscrubTextAsHTML(text, sessionMap, opts);
|
|
1700
|
+
return res ? { restoredHTML: res.text, html: res.text, text: res.text, restoredCount: res.count, count: res.count } : null;
|
|
1701
|
+
},
|
|
1702
|
+
resolveFullAssistantTurnElement,
|
|
1703
|
+
extractCleanAssistantMessageText,
|
|
1704
|
+
extractLLMAssistantText: extractCleanAssistantMessageText,
|
|
1705
|
+
cleanAIPromptPrefix: (text) => engine ? engine.cleanAIPromptPrefix(text) : text,
|
|
1706
|
+
hydrateRegex: (rule) => engine ? engine.hydrateRegex(rule) : rule,
|
|
1707
|
+
getRules
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
})();
|
|
1711
|
+
|