@privacyscrubber/mcp-server 1.0.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.
@@ -0,0 +1,1532 @@
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
+
15
+ let REGEX_RULES = JSON.parse('[{"type":"EMAIL","regex":"/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}/g"},{"type":"FINANCIAL","regex":"/\\\\$[0-9,.]+\\\\b/g"},{"type":"FINANCIAL","regex":"/\\\\b[A-Z]{4}(?:AD|AE|AF|AG|AI|AL|AM|AO|AQ|AR|AS|AT|AU|AW|AX|AZ|BA|BB|BD|BE|BF|BG|BH|BI|BJ|BL|BM|BN|BO|BQ|BR|BS|BT|BV|BW|BY|BZ|CA|CC|CD|CF|CG|CH|CI|CK|CL|CM|CN|CO|CR|CU|CV|CW|CX|CY|CZ|DE|DJ|DK|DM|DO|DZ|EC|EE|EG|EH|ER|ES|ET|FI|FJ|FK|FM|FO|FR|GA|GB|GD|GE|GF|GG|GH|GI|GL|GM|GN|GP|GQ|GR|GS|GT|GU|GW|GY|HK|HM|HN|HR|HT|HU|ID|IE|IL|IM|IN|IO|IQ|IR|IS|IT|JE|JM|JO|JP|KE|KG|KH|KI|KM|KN|KP|KR|KW|KY|KZ|LA|LB|LC|LI|LK|LR|LS|LT|LU|LV|LY|MA|MC|MD|ME|MF|MG|MH|MK|ML|MM|MN|MO|MP|MQ|MR|MS|MT|MU|MV|MW|MX|MY|MZ|NA|NC|NE|NF|NG|NI|NL|NO|NP|NR|NU|NZ|OM|PA|PE|PF|PG|PH|PK|PL|PM|PN|PR|PS|PT|PW|PY|QA|RE|RO|RS|RU|RW|SA|SB|SC|SD|SE|SG|SH|SI|SJ|SK|SL|SM|SN|SO|SR|SS|ST|SV|SX|SY|SZ|TC|TD|TF|TG|TJ|TK|TL|TM|TN|TO|TR|TT|TV|TW|TZ|UA|UG|UM|US|UY|UZ|VA|VC|VE|VG|VI|VN|VU|WF|WS|YE|YT|ZA|ZM|ZW)[A-Z2-9][A-NP-Z0-9]([A-Z0-9]{3})?\\\\b/g"},{"type":"FINANCIAL","regex":"/\\\\b\\\\d{9}\\\\b/g"},{"type":"FINANCIAL","regex":"/\\\\b[A-Z]{2}[0-9]{2}[a-zA-Z0-9]{4}[0-9]{7}[a-zA-Z0-9]{0,16}\\\\b/g"},{"type":"FINANCIAL","regex":"/\\\\bPORTFOLIO[-_][A-Z0-9]{5,}\\\\b/gi"},{"type":"FINANCIAL","regex":"/\\\\b(?:\\\\d[ -]?){13,19}\\\\b/g"},{"type":"FINANCIAL","regex":"/\\\\b(?:1|3|bc1)[a-zA-HJ-NP-Z0-9]{25,39}\\\\b/g"},{"type":"FINANCIAL","regex":"/\\\\b0x[a-fA-F0-9]{40}\\\\b/g"},{"type":"LEGAL","regex":"/\\\\bCASE[-_][A-Z0-9]{4,}\\\\b/gi"},{"type":"LEGAL","regex":"/\\\\bMATTER[-_][A-Z0-9]{4,}\\\\b/gi"},{"type":"LEGAL","regex":"/\\\\b[A-Z]{2,4}[- ]?\\\\d{2}[- ]?\\\\d{4,}\\\\b/g"},{"type":"PRIVILEGE","regex":"/ATTORNEY[- ]CLIENT[- ]PRIVILEGE/gi"},{"type":"ID","regex":"/\\\\bEEID[ -]?\\\\d{4,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bRESUME[-_]?[A-Z0-9]{4,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bLEAD[-_][A-Z0-9]{5,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bCAMPAIGN[-_][A-Z0-9]{4,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bDEAL[-_][A-Z0-9]{4,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bENTITY[-_][0-9]{4,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bOPPORTUNITY[-_][A-Z0-9]{5,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bPROSPECT[-_][A-Z0-9]{5,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bTICKET[-_][A-Z0-9]{5,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bZENDESK[-_][0-9]{4,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bEMP[-_]\\\\d{3,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bLIS[-]?\\\\d{6,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bPARCEL[-]?\\\\d{5,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bAGENT[-_][A-Z0-9]{4,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bTASK[-_][A-Z0-9]{5,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bSTUDENT[-_][0-9]{5,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bCOURSE[-_][A-Z0-9]{4,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bINSTANCE[-_]ID[-_][a-z0-9-]{10,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bENV[-_][A-Z0-9]{3,}\\\\b/gi"},{"type":"PRIVACY","regex":"/\\\\bTENANT[-_]ID[-_][0-9]{4,}\\\\b/gi"},{"type":"ADDRESS","regex":"/\\\\b\\\\d{1,6}\\\\s+[A-Z][a-zA-Z0-9.-]*\\\\s+(?:St|Street|Ave|Avenue|Blvd|Boulevard|Rd|Road|Ln|Lane|Dr|Drive|Way|Ct|Court|Pl|Place|Terrace|Pkwy|Parkway|Sq|Square)\\\\b/gi"},{"type":"LOCATION","regex":"/\\\\b[A-Z][a-zA-Z\\\\s.-]{2,25},\\\\s*[A-Z]{2}\\\\b/g"},{"type":"PHI","regex":"/\\\\bMRN[ -]?\\\\d{6,}\\\\b/gi"},{"type":"PHI","regex":"/\\\\b[A-TV-Z]\\\\d{2}[. ]?\\\\d[A-Z0-9]?\\\\b/g"},{"type":"PHI","regex":"/\\\\b[A-Z]{2,3}\\\\d{6,8}\\\\b/g"},{"type":"PHI","regex":"/\\\\bNHS[ -]?\\\\d{3}[ -]?\\\\d{3}[ -]?\\\\d{4}\\\\b/gi"},{"type":"SECRET","regex":"/\\\\b(?:AKIA|ASIA|AGPA|AIDA|AROA|AIPA)[A-Z0-9]{16}\\\\b/g"},{"type":"SECRET","regex":"/\\\\beyJ[a-zA-Z0-9_-]+\\\\.[a-zA-Z0-9_-]+\\\\.[a-zA-Z0-9_-]+\\\\b/g"},{"type":"SECRET","regex":"/\\\\b(?:ghp|gho|ghu|ghs|ghr|glpat|npm|xox[baprs])[-_][A-Za-z0-9_]{10,}\\\\b/g"},{"type":"SECRET","regex":"/\\\\b(?:[rs]k)_(?:test|live)_[a-zA-Z0-9]{24,}\\\\b/g"},{"type":"SECRET","regex":"/\\\\b(sk|pk|secret|key|token|auth)(?:[-_][a-zA-Z0-9_-]{5,}|(?=[a-zA-Z0-9_-]{5,}\\\\b)(?=[a-zA-Z_-]*[0-9])[a-zA-Z0-9_-]{5,})\\\\b/gi"},{"type":"SECRET","regex":"/\\\\b[a-fA-F0-9]{32,64}\\\\b/g"},{"type":"SECRET","regex":"/\\\\bCVE-\\\\d{4}-\\\\d{4,}\\\\b/gi"},{"type":"SECRET","regex":"/\\\\b(MD5|SHA1|SHA256)[:\\\\s][a-f0-9]{32,64}\\\\b/gi"},{"type":"SECRET","regex":"/\\\\b(DB|POSTGRES|REDIS|MYSQL|AWS|SECRET|PASSWORD|TOKEN|API|KEY)[A-Z0-9_]*\\\\s*[:=]\\\\s*[^\\\\s\\"\']+\\\\b/gi"},{"type":"COPYRIGHT","regex":"/\\\\bPROJECT[-_][A-Z0-9]{5,}\\\\b/gi"},{"type":"COPYRIGHT","regex":"/\\\\b(DRAFT|ASSET|SCRIPT)[-_][0-9]{4,}\\\\b/gi"},{"type":"PRIVACY","regex":"/\\\\b(GDPR|HIPAA|CCPA|SOC2)[-_]AUDIT[-_]\\\\d{4}\\\\b/gi"},{"type":"PRIVACY","regex":"/\\\\bPOLICY[-_][A-Z0-9]{5,}\\\\b/gi"},{"type":"PRIVACY","regex":"/\\\\bGRADE[S]?[:\\\\s]*[A-DF][+-]?\\\\b/gi"},{"type":"PRIVACY","regex":"/\\\\b(DOB|BIRTHDAY)[:\\\\s]*[0-9./-]{6,10}\\\\b/gi"},{"type":"PRIVACY","regex":"/\\\\b(PASSWORD|PWD|SECRET)[:\\\\s]*[\\\\S]{4,}\\\\b/gi"},{"type":"ID","regex":"/\\\\b\\\\d{3}-\\\\d{2}-\\\\d{4}\\\\b/g"},{"type":"ID","regex":"/(?:\\\\/|[A-Za-z]:\\\\\\\\)[\\\\w\\\\-. ]+(?:[\\\\/\\\\\\\\][\\\\w\\\\-. ]+)+/g"},{"type":"ID","regex":"/\\\\b[A-CEGHJ-PR-TW-Z]{1}[A-CEGHJ-NPR-TW-Z]{1}[0-9]{6}[A-DFM]{1}\\\\b/gi"},{"type":"ID","regex":"/\\\\b[A-Z]{2}[0-9]{6,12}\\\\b/gi"},{"type":"ID","regex":"/[A-Z0-9<]{30,44}/gi"},{"type":"IP","regex":"/\\\\b(?:\\\\d{1,3}\\\\.){3}\\\\d{1,3}\\\\b/g"},{"type":"IP","regex":"/\\\\b(?:[a-fA-F0-9]{1,4}:){7}[a-fA-F0-9]{1,4}\\\\b/g"},{"type":"ID","regex":"/\\\\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\\\\b/g"},{"type":"ID","regex":"/(?:\\\\B\\\\/|\\\\b[a-zA-Z]:\\\\\\\\)(?:[\\\\w.-]+[\\\\/\\\\\\\\]?)+\\\\b/g"},{"type":"ID","regex":"/\\\\b\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}(?:\\\\.\\\\d+)?(?:Z|[+-]\\\\d{2}(?::\\\\d{2})?)?\\\\b/g"},{"type":"ID","regex":"/\\\\b\\\\d{4}-\\\\d{2}-\\\\d{2}\\\\b/g"},{"type":"ID","regex":"/\\\\b\\\\d{2}\\\\/\\\\d{2}\\\\/\\\\d{4}\\\\b/g"},{"type":"PHONE","regex":"/\\\\b(?:\\\\+?\\\\d{1,3}[-.\\\\s]?)?\\\\(?\\\\d{3}\\\\)?[-.\\\\s]?\\\\d{3}[-.\\\\s]?\\\\d{4}\\\\b/g"},{"type":"PHONE","regex":"/(?:\\\\+?1[-.\\\\s]?)?\\\\(?\\\\d{3}\\\\)?[-.\\\\s]\\\\d{3}[-.\\\\s]\\\\d{4}/g"},{"type":"PHONE","regex":"/\\\\+?[1-9]\\\\d{1,3}[\\\\s.-]\\\\(?\\\\d{1,4}\\\\)?[\\\\s.-]\\\\d{2,4}[\\\\s.-]\\\\d{4}/g"},{"type":"PHONE","regex":"/(?:\\\\+44\\\\s?7\\\\d{3}|\\\\(?07\\\\d{3}\\\\)?)\\\\s?\\\\d{3}\\\\s?\\\\d{3}\\\\b/g"},{"type":"PHONE","regex":"/\\\\b(?:\\\\d{3}[-.\\\\s]??\\\\d{4}|\\\\(\\\\d{3}\\\\)\\\\s??\\\\d{3}[-.\\\\s]??\\\\d{4}|\\\\d{3}[-.\\\\s]??\\\\d{3}[-.\\\\s]??\\\\d{4})\\\\b/g"},{"type":"LOCATION","regex":"/\\\\b-?\\\\d{1,3}\\\\.\\\\d{4,6}[° ]?[NSns],\\\\s*-?\\\\d{1,3}\\\\.\\\\d{4,6}[° ]?[EWew]\\\\b/g"},{"type":"LOCATION","regex":"/\\\\b(LATITUDE|LONGITUDE)[:\\\\s]-?\\\\d{1,3}\\\\.\\\\d{4,10}\\\\b/gi"},{"type":"ID","regex":"/\\\\bDL[ -]?\\\\d{6,12}\\\\b/gi"},{"type":"ID","regex":"/\\\\bDRIVER[S]?\\\\s+LICENSE[ -]?\\\\d{6,15}\\\\b/gi"},{"type":"NAME","regex":"/(?<=^|[^\\\\p{L}\\\\p{N}_])\\\\p{Lu}[\\\\p{Ll}\'-]*(?:\\\\p{Lu}[\\\\p{Ll}\'-]*)?\\\\p{L}(?:[ \\\\t\\\\xA0]+(?:\\\\p{Lu}[\\\\p{Ll}\'-]{1,}(?:\\\\p{Lu}[\\\\p{Ll}\'-]*)?\\\\p{L}|\\\\p{Lu}\\\\.?|van|von|de|di|da|la|le|del|du|der|van[ \\\\t\\\\xA0]+de)){0,1}[ \\\\t\\\\xA0]+\\\\p{Lu}[\\\\p{Ll}\'-]*(?:\\\\p{Lu}[\\\\p{Ll}\'-]*)?\\\\p{L}(?:\'s)?(?=[^\\\\p{L}\\\\p{N}_]|$)/gu"},{"type":"NAME","regex":"/(?<=^|[^\\\\p{L}\\\\p{N}_])\\\\p{Lu}{3,}(?:[\\\\p{Lu}\'-]*\\\\p{Lu})?(?:[ \\\\t\\\\xA0]+\\\\p{Lu}{3,}(?:[\\\\p{Lu}\'-]*\\\\p{Lu})?){1,2}(?:\'s)?(?=[^\\\\p{L}\\\\p{N}_]|$)/gu"},{"type":"NAME","regex":"/(?<=^|[^\\\\p{L}\\\\p{N}_])\\\\p{Lu}{2,}(?:[\\\\p{Lu}\'-]*\\\\p{Lu})?(?:[ \\\\t\\\\xA0]+\\\\p{Lu}\\\\.)+[ \\\\t\\\\xA0]+\\\\p{Lu}{2,}(?:[\\\\p{Lu}\'-]*\\\\p{Lu})?(?:\'s)?(?=[^\\\\p{L}\\\\p{N}_]|$)/gu"},{"type":"NAME","regex":"/(?<=^|[^\\\\p{L}\\\\p{N}_])(?:Mr|Mrs|Ms|Dr|Prof|Hon|Mr\\\\.|Mrs\\\\.|Ms\\\\.|Dr\\\\.|Prof\\\\.|Hon\\\\.)[ \\\\t\\\\xA0]+\\\\p{Lu}[\\\\p{Ll}\'-]*(?:\\\\p{Lu}[\\\\p{Ll}\'-]*)?\\\\p{L}(?=[^\\\\p{L}\\\\p{N}_]|$)/gu"}]').map(r => { const ls=r.regex.lastIndexOf('/'); return {...r, regex: new RegExp(r.regex.substring(1,ls), r.regex.substring(ls+1)) }; });
16
+ let PROFILE_RULES = (function(){ const p = JSON.parse('{"general":[],"legal":[{"type":"LEGAL","regex":"/\\\\bCASE[-_][A-Z0-9]{4,}\\\\b/gi"},{"type":"LEGAL","regex":"/\\\\bMATTER[-_][A-Z0-9]{4,}\\\\b/gi"},{"type":"LEGAL","regex":"/\\\\b[A-Z]{2,4}[- ]?\\\\d{2}[- ]?\\\\d{4,}\\\\b/g"},{"type":"PRIVILEGE","regex":"/ATTORNEY[- ]CLIENT[- ]PRIVILEGE/gi"}],"hr":[{"type":"ID","regex":"/\\\\bEEID[ -]?\\\\d{4,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bEMP[-_]\\\\d{3,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bRESUME[-_]?[A-Z0-9]{4,}\\\\b/gi"},{"type":"PRIVACY","regex":"/\\\\b(DOB|BIRTHDAY)[:\\\\s]*[0-9./-]{6,10}\\\\b/gi"},{"type":"ADDRESS","regex":"/\\\\b\\\\d{1,6}\\\\s+[A-Z][a-zA-Z0-9.-]*\\\\s+(?:St|Street|Ave|Avenue|Blvd|Boulevard|Rd|Road|Ln|Lane|Dr|Drive|Way|Ct|Court|Pl|Place|Terrace|Pkwy|Parkway|Sq|Square)\\\\b/gi"}],"finance":[{"type":"FINANCIAL","regex":"/\\\\b[A-Z]{4}(?:AD|AE|AF|AG|AI|AL|AM|AO|AQ|AR|AS|AT|AU|AW|AX|AZ|BA|BB|BD|BE|BF|BG|BH|BI|BJ|BL|BM|BN|BO|BQ|BR|BS|BT|BV|BW|BY|BZ|CA|CC|CD|CF|CG|CH|CI|CK|CL|CM|CN|CO|CR|CU|CV|CW|CX|CY|CZ|DE|DJ|DK|DM|DO|DZ|EC|EE|EG|EH|ER|ES|ET|FI|FJ|FK|FM|FO|FR|GA|GB|GD|GE|GF|GG|GH|GI|GL|GM|GN|GP|GQ|GR|GS|GT|GU|GW|GY|HK|HM|HN|HR|HT|HU|ID|IE|IL|IM|IN|IO|IQ|IR|IS|IT|JE|JM|JO|JP|KE|KG|KH|KI|KM|KN|KP|KR|KW|KY|KZ|LA|LB|LC|LI|LK|LR|LS|LT|LU|LV|LY|MA|MC|MD|ME|MF|MG|MH|MK|ML|MM|MN|MO|MP|MQ|MR|MS|MT|MU|MV|MW|MX|MY|MZ|NA|NC|NE|NF|NG|NI|NL|NO|NP|NR|NU|NZ|OM|PA|PE|PF|PG|PH|PK|PL|PM|PN|PR|PS|PT|PW|PY|QA|RE|RO|RS|RU|RW|SA|SB|SC|SD|SE|SG|SH|SI|SJ|SK|SL|SM|SN|SO|SR|SS|ST|SV|SX|SY|SZ|TC|TD|TF|TG|TJ|TK|TL|TM|TN|TO|TR|TT|TV|TW|TZ|UA|UG|UM|US|UY|UZ|VA|VC|VE|VG|VI|VN|VU|WF|WS|YE|YT|ZA|ZM|ZW)[A-Z2-9][A-NP-Z0-9]([A-Z0-9]{3})?\\\\b/g"},{"type":"FINANCIAL","regex":"/\\\\b[A-Z]{2}[0-9]{2}[a-zA-Z0-9]{4}[0-9]{7}[a-zA-Z0-9]{0,16}\\\\b/g"},{"type":"FINANCIAL","regex":"/\\\\bPORTFOLIO[-_][A-Z0-9]{5,}\\\\b/gi"}],"medical":[{"type":"PHI","regex":"/\\\\bMRN[ -]?\\\\d{6,}\\\\b/gi"},{"type":"PHI","regex":"/\\\\b[A-TV-Z]\\\\d{2}[. ]?\\\\d[A-Z0-9]?\\\\b/g"},{"type":"PHI","regex":"/\\\\b[A-Z]{2,3}\\\\d{6,8}\\\\b/g"},{"type":"PHI","regex":"/\\\\bNHS[ -]?\\\\d{3}[ -]?\\\\d{3}[ -]?\\\\d{4}\\\\b/gi"}],"security":[{"type":"SECRET","regex":"/\\\\bCVE-\\\\d{4}-\\\\d{4,}\\\\b/gi"},{"type":"SECRET","regex":"/\\\\b(MD5|SHA1|SHA256)[:\\\\s][a-f0-9]{32,64}\\\\b/gi"},{"type":"SECRET","regex":"/\\\\b(?:INCIDENT|BREACH)[-_: ]?ID[-_: ]?[0-9]{4,10}\\\\b/gi"}],"marketing":[{"type":"ID","regex":"/\\\\b(?:LEAD|PROSPECT)[-_: ]*[A-Z0-9_-]*[0-9][A-Z0-9_-]*\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:CAMPAIGN|CLID|GCLID|FBCLID)[-_:=]*[A-Za-z0-9_-]{10,}\\\\b/gi"},{"type":"FINANCIAL","regex":"/(?<=\\\\b(?:LTV|CAC)[\\\\s:]*)\\\\$[0-9,.]+\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:SEGMENT|COHORT)[-_: ]*[0-9]{4,8}\\\\b/gi"}],"bizops":[{"type":"ID","regex":"/\\\\b(?:DEAL|KPI|METRIC)[-_: ]?[A-Z0-9]{4,}\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:ENTITY|VENDOR|PARTNER)[-_: ]?[0-9]{4,10}\\\\b/gi"},{"type":"FINANCIAL","regex":"/\\\\b(?:REVENUE|EBITDA|PROFIT|MARGIN)[-_: ]?\\\\$?[0-9,.]+[KM]?\\\\b/gi"},{"type":"SECRET","regex":"/\\\\b(?:NDA|M&A|MERGER)[-_: ]?[A-Z0-9]{4,}\\\\b/gi"}],"sales":[{"type":"ID","regex":"/\\\\bOPPORTUNITY[-_: ]?[A-Z0-9]{5,}\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:DOCUSIGN|CONTRACT)[-_: ]?[0-9A-F]{8,32}\\\\b/gi"},{"type":"FINANCIAL","regex":"/\\\\b(?:ARR|MRR|QUOTA)[\\\\s:]?\\\\$?[0-9,.]+[KM]?\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:SFDC|HUBSPOT)[-_: ]?[0-9A-Z]{15,18}\\\\b/gi"}],"support":[{"type":"ID","regex":"/\\\\bTICKET[-_:# ]?[A-Z0-9]{5,10}\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:ZENDESK|INTERCOM|JIRA)[-_:# ]?[0-9]{4,10}\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:REFUND|RETURN|RMA)[-_:# ]?[A-Z0-9]{6,12}\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:LOYALTY|REWARDS)[-_:# ]?\\\\d{8,12}\\\\b/gi"}],"realestate":[{"type":"ID","regex":"/\\\\b(?:MLS|LIS)[- ]?\\\\d{6,10}\\\\b/gi"},{"type":"ID","regex":"/\\\\bPARCEL[- ]?\\\\d{5,15}\\\\b/gi"},{"type":"ID","regex":"/\\\\bTENANT[-_]ID[-_][0-9]{4,}\\\\b/gi"},{"type":"FINANCIAL","regex":"/\\\\b(?:RENT|LEASE|ESCROW)[\\\\s:]?\\\\$[0-9,]{3,}\\\\b/gi"},{"type":"SECRET","regex":"/\\\\b(?:GATE|DOOR|LOBBY)[-_ ](?:CODE|PIN)[\\\\s:]?\\\\d{4,6}\\\\b/gi"}],"compliance":[{"type":"SECRET","regex":"/\\\\b(?:GDPR|HIPAA|CCPA|SOC2|ISO27001)[-_: ]?AUDIT[-_: ]?\\\\d{4}\\\\b/gi"},{"type":"SECRET","regex":"/\\\\b(?:DPA|POLICY)[-_: ]?[A-Z0-9]{5,15}\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:SAR|DSAR)[-_: ]?\\\\d{4,10}\\\\b/gi"}],"ccpa":[{"type":"ID","regex":"/\\\\bDL[ -]?\\\\d{6,12}\\\\b/gi"},{"type":"LOCATION","regex":"/\\\\b-?\\\\d{1,3}\\\\.\\\\d{4,6}[° ]?[NSns],\\\\s*-?\\\\d{1,3}\\\\.\\\\d{4,6}[° ]?[EWew]\\\\b/g"},{"type":"PRIVACY","regex":"/\\\\b(?:CCPA|CPRA)[-_: ]?OPT[-_ ]OUT\\\\b/gi"},{"type":"ID","regex":"/\\\\bACCOUNT[ -]?(?:ID|NUM|NUMBER)[:\\\\s][A-Z0-9]{6,20}\\\\b/gi"}],"engineering":[{"type":"SECRET","regex":"/\\\\b(DB|POSTGRES|REDIS|MYSQL|AWS|SECRET|PASSWORD|TOKEN|API|KEY)[A-Z0-9_]*\\\\s*[:=]\\\\s*[\\"\']?[A-Za-z0-9_-]{10,}[\\"\']?\\\\b/gi"},{"type":"ID","regex":"/\\\\b[a-z0-9._-]+\\\\/[a-z0-9._-]+:[a-z0-9._-]+\\\\b/g"},{"type":"ID","regex":"/\\\\b[a-fA-F0-9]{40}\\\\b/g"},{"type":"ID","regex":"/\\\\b[a-z0-9](?:[-a-z0-9]*[a-z0-9])?\\\\.svc\\\\.cluster\\\\.local\\\\b/g"}],"agents":[{"type":"ID","regex":"/\\\\b(?:AGENT|VECTOR|EMBEDDING)[-_: ]?[A-Z0-9]{8,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bTASK[-_: ]?[A-Z0-9]{5,15}\\\\b/gi"},{"type":"SECRET","regex":"/\\\\b(?:SYS_PROMPT|SYSTEM_PROMPT|OPENAI_API_KEY)[-_: ]?[A-Za-z0-9_-]{10,}\\\\b/gi"}],"academic":[{"type":"ID","regex":"/\\\\b(?:STUDENT|ALUMNI)[-_:# ]?[0-9]{5,10}\\\\b/gi"},{"type":"ID","regex":"/\\\\bCOURSE[-_:# ]?[A-Z]{3,4}[ ]?[0-9]{3,4}\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:FERPA|IRB)[-_:# ]?[A-Z0-9]{5,10}\\\\b/gi"},{"type":"ID","regex":"/\\\\bGRADE[S]?[\\\\s:][A-DF][+-]?\\\\b/gi"}],"creative":[{"type":"SECRET","regex":"/\\\\b(?:PROJECT|DRAFT|ASSET|SCRIPT|IP)[-_: ]?[0-9]{4,10}\\\\b/gi"},{"type":"SECRET","regex":"/\\\\b(?:SPOILER|UNRELEASED|EMBARGOED)\\\\b/gi"},{"type":"NAME","regex":"/\\\\b(?:GHOSTWRITER|SOURCE)[:\\\\s][A-Z][a-z]+ [A-Z][a-z]+\\\\b/g"}],"tech":[{"type":"ID","regex":"/\\\\b(?:INSTANCE|NODE|CLUSTER)[-_]ID[-_][a-z0-9-]{10,}\\\\b/gi"},{"type":"ID","regex":"/\\\\bENV[-_: ]?(?:PROD|STAGING|DEV|TEST|QA)\\\\b/gi"},{"type":"SECRET","regex":"/\\\\b(?:CONFIG|KUBECONFIG|TFSTATE)[-_: ]?[A-Z0-9]{6,15}\\\\b/gi"}],"personal":[{"type":"ID","regex":"/\\\\b(?:DOB|BIRTHDAY)[\\\\s:]*[0-9./-]{6,10}\\\\b/gi"},{"type":"SECRET","regex":"/\\\\b(?:PASSWORD|PWD|SECRET|PIN)[\\\\s:]*[\\\\S]{4,20}\\\\b/gi"},{"type":"PHONE","regex":"/\\\\b(?:WIFE|HUSBAND|PARTNER|MOM|DAD)[\\\\s:]+(?:\\\\+?1[-.\\\\s]?)?\\\\(?\\\\d{3}\\\\)?[-.\\\\s]\\\\d{3}[-.\\\\s]\\\\d{4}\\\\b/gi"}],"wealthmgmt":[{"type":"LEGAL","regex":"/\\\\b(?:THE\\\\s+)?[A-Z][A-Z\\\\s.\'-]{4,}\\\\s+(?:(?:REVOCABLE|IRREVOCABLE|LIVING|FAMILY|TESTAMENTARY|CHARITABLE|GENERATION-SKIPPING)\\\\s+)*TRUST\\\\b/g"},{"type":"NAME","regex":"/(?<=^|[^\\\\p{L}\\\\p{N}_])\\\\p{Lu}{3,}(?:[\\\\p{Lu}\'-]*\\\\p{Lu})?(?:[ \\\\t\\\\xA0]+\\\\p{Lu}\\\\.)*[ \\\\t\\\\xA0]+\\\\p{Lu}\\\\p{Ll}[\\\\p{Ll}\'-]*(?:\\\\p{Lu}[\\\\p{Ll}\'-]*)?\\\\p{L}(?:\'s)?(?=[^\\\\p{L}\\\\p{N}_]|$)/gu"},{"type":"FINANCIAL","regex":"/\\\\b(?:ABA|Routing(?:\\\\s+No)?|RTN)[:\\\\s#]*\\\\d{9}\\\\b/gi"},{"type":"FINANCIAL","regex":"/\\\\b(?:Account|Acct\\\\.?)[:\\\\s#]*\\\\d{4}[-\\\\s]?\\\\d{4}[-\\\\s]?\\\\d{2,6}\\\\b/gi"},{"type":"FINANCIAL","regex":"/\\\\b(?:Policy|Contract)\\\\s*(?:No\\\\.?|Number|#)[:\\\\s]+[A-Z0-9][A-Z0-9\\\\-]{3,14}\\\\b/gi"},{"type":"ID","regex":"/\\\\bCRD\\\\s*#?\\\\s*\\\\d{4,8}\\\\b/gi"},{"type":"FINANCIAL","regex":"/\\\\b(?:Annual\\\\s+(?:Distribution|Withdrawal)|RMD|Required\\\\s+Minimum\\\\s+Distribution)[:\\\\s]+[$][\\\\d,]+(?:[.][\\\\d]{2})?\\\\b/gi"},{"type":"FINANCIAL","regex":"/\\\\b(?:Portfolio|Market\\\\s+Value|Net\\\\s+Worth|AUM|Total\\\\s+Assets)[:\\\\s]+[$][\\\\d,.]+[KMB]?\\\\b/gi"},{"type":"FINANCIAL","regex":"/\\\\b(?:Roth\\\\s+)?(?:IRA|401k|401\\\\(k\\\\)|403b|403\\\\(b\\\\)|SEP|SIMPLE)\\\\s*(?:Account|Acct|Plan)?\\\\s*(?:No|Number|#)?[:\\\\s#]*[A-Z0-9]{4,15}\\\\b/gi"}],"insurance":[{"type":"ID","regex":"/\\\\b(?:Claim|CLM)[:\\\\s#-]*[A-Z0-9]{6,15}\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:Policy|POL)[:\\\\s#-]*[A-Z]{0,4}[-]?\\\\d{6,12}\\\\b/gi"},{"type":"FINANCIAL","regex":"/\\\\b(?:Loss|Claim\\\\s+Amount|Settlement|Reserve|Indemnity)[:\\\\s]+[$][\\\\d,]+(?:[.]\\\\d{2})?\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:Adjuster\\\\s+(?:ID|No)|Claim\\\\s+Rep(?:\\\\.|resentative)?)[:\\\\s#]*[A-Z0-9]{4,12}\\\\b/gi"},{"type":"ID","regex":"/\\\\bNAIC[:\\\\s#]*\\\\d{5}\\\\b/gi"},{"type":"FINANCIAL","regex":"/\\\\b(?:Deductible|Premium|Coverage\\\\s+Amount)[:\\\\s]+[$][\\\\d,]+(?:[.]\\\\d{2})?\\\\b/gi"},{"type":"ID","regex":"/\\\\b[A-HJ-NPR-Z0-9]{17}\\\\b/g"},{"type":"ID","regex":"/\\\\b(?:Insured|Named\\\\s+Insured)[:\\\\s]+[A-Z][a-z]+(?:\\\\s+[A-Z][a-z]+)+/g"},{"type":"ID","regex":"/\\\\b(?:Agent|Producer)\\\\s*(?:Code|No|ID)[:\\\\s#]*[A-Z0-9]{4,12}\\\\b/gi"}],"accounting":[{"type":"ID","regex":"/\\\\b(?:EIN|FEIN|Tax\\\\s+ID)[:\\\\s#]*\\\\d{2}-\\\\d{7}\\\\b/gi"},{"type":"FINANCIAL","regex":"/\\\\b(?:AGI|Adjusted\\\\s+Gross\\\\s+Income|Taxable\\\\s+Income|Total\\\\s+Income)[:\\\\s]+[$][\\\\d,]+(?:[.]\\\\d{2})?\\\\b/gi"},{"type":"FINANCIAL","regex":"/\\\\bBox\\\\s+\\\\d{1,2}[a-z]?[:\\\\s]+[$][\\\\d,]+(?:[.]\\\\d{2})?\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:Form|Schedule)\\\\s+(?:1040|1040-SR|W-2|W-4|1099-[A-Z]{1,4}|K-1|941|990|4562)\\\\b/gi"},{"type":"FINANCIAL","regex":"/\\\\b(?:Federal|State)\\\\s+(?:Tax\\\\s+)?(?:Refund|Amount\\\\s+Owed|Balance\\\\s+Due)[:\\\\s]+[$][\\\\d,]+(?:[.]\\\\d{2})?\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:State\\\\s+Tax\\\\s+ID|SUI|UI\\\\s+Account\\\\s+No)[:\\\\s#]*[A-Z]{0,3}[-]?\\\\d{4,15}\\\\b/gi"},{"type":"FINANCIAL","regex":"/\\\\b(?:Net\\\\s+Pay|Gross\\\\s+Pay|Taxable\\\\s+Wages)[:\\\\s]+[$][\\\\d,]+(?:[.]\\\\d{2})?\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:CAF|Practitioner\\\\s+(?:PIN|ID)|PTIN)[:\\\\s#]*[A-Z]?\\\\d{6,9}\\\\b/gi"}],"pharma":[{"type":"ID","regex":"/\\\\b(?:Subject|Patient|Participant)\\\\s+(?:ID|No)[:\\\\s#]*[A-Z0-9]{3,12}\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:Protocol|Study)\\\\s*(?:No|Number|ID)[:\\\\s#]*[A-Z0-9][-A-Z0-9]{3,15}\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:IND|NDA|BLA|ANDA)\\\\s*(?:No|Number|#)?[:\\\\s]*\\\\d{3,6}\\\\b/gi"},{"type":"ID","regex":"/\\\\bIRB[:\\\\s#]*[A-Z0-9]{4,12}\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:Site\\\\s+(?:No|ID)|Investigator\\\\s+Site)[:\\\\s#]*\\\\d{3,6}\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:Batch|Lot|Serial)\\\\s*(?:No[.]?|Number|#)?[:\\\\s#]*[A-Z0-9][A-Z0-9\\\\-]{3,14}\\\\b/gi"},{"type":"PHI","regex":"/\\\\b(?:Dose|Dosage)[:\\\\s]+\\\\d+(?:\\\\.\\\\d+)?\\\\s*(?:mg|mcg|mL|IU|units?)\\\\b/gi"},{"type":"ID","regex":"/\\\\b(?:CRF|eCRF|Case\\\\s+Report\\\\s+Form)\\\\s*(?:No|Page|ID)?[:\\\\s#]*[A-Z0-9]{2,10}\\\\b/gi"}]}'); for(let k in p){ p[k] = p[k].map(r => { const ls=r.regex.lastIndexOf('/'); return {...r, regex: new RegExp(r.regex.substring(1,ls), r.regex.substring(ls+1)) }; }); } return p; })();
17
+ let NAME_STOP_LIST = new Set(JSON.parse('["case no","account no","client no","ref no","matter no","affected user","incident date","incident type","incident report","review period","review date","salary band","salary range","personal email","work email","business email","lead source","account exec","account executive","next appt","next appointment","last appt","chief complaint","portfolio value","portfolio manager","tax id","account number","employee id","social security","date of","date of birth","place of birth","hiring manager","direct report","team lead","team leader","job title","job function","pay grade","review type","performance review","annual review","invoice number","purchase order","order number","oak street","main street","high street","park avenue","first street","second street","third street","north avenue","south avenue","east side","west side","soc team","hr team","it team","qa team","ux team","new york","los angeles","san francisco","las vegas","united states","united kingdom","north america","south america","senior analyst","senior engineer","senior manager","senior consultant","junior analyst","junior engineer","junior developer","vice president","chief executive","chief officer","account manager","project manager","product manager","security officer","compliance officer","general hospital","medical center","urgent care","primary care","read more","learn more","click here","sign up","log in","privacy policy","terms of","terms of service","true positive","false positive","open source","private identifiers","data privacy","system instruction","system instructions","critical","instruction","user data","protected user","privacy scrubber","end of","thank you","best regards","kind regards","warm regards","yours truly","sincerely yours","good morning","good afternoon","good evening","hello world","hello there","supreme court","high court","district court","civil court","non disclosure","data protection","intellectual property","trade secret","force majeure","habeas corpus","amicus curiae","board member","board meeting","general assembly","first name","last name","middle name","full name","email address","phone number","cell phone","home phone","zip code","postal code","page number","section one","table contents","table of","figure one","marketing department","sales department","engineering team","product team","customer support","human resources","public relations","artificial intelligence","machine learning","deep learning","large language","operating system","source code","user interface","web browser","pull request","merge request","commit message","code review","cloud computing","database schema","blood pressure","heart rate","chief physician","treating physician","health care","healthcare provider","medical record","grade a","grade b","grade c","grade d","grade f","version 1","version 2","version 3","version 4","version 5","step 1","step 2","step 3","step 4","step 5","page 1","page 2","page 3","page 4","page 5","cs101","course cs101","january","february","march","april","may","june","july","august","september","october","november","december","monday","tuesday","wednesday","thursday","friday","saturday","sunday","yesterday","tomorrow","today","last week","next month","early morning","late night","microsoft office","google workspace","slack channel","zoom meeting","teams call","amazon prime","netflix show","youtube video","twitter post","linkedin profile","central park","white house","grand canyon","mount everest","pacific ocean","atlantic ocean","silicon valley","wall street","fifth avenue","times square","golden gate","empire state","statue liberty","tower bridge","big ben","eiffel tower","great wall","london bridge","san jose","las vegas","mexico city","hong kong","san diego","software development","user experience","front end","back end","full stack","web application","mobile app","desktop app","cloud services","data analytics","cyber security","network security","information security","incident response","disaster recovery","business continuity","risk management","quality assurance","user acceptance","beta test","release candidate","version control","continuous integration","continuous deployment","agile scrum","kanban board","sprint planning","daily standup","product backlog","user story","acceptance criteria","technical debt","codebase","repository","branch name","tag version","hot fix","patch note","legal counsel","general counsel","human capital","talent acquisition","employee benefits","payroll services","stock options","health insurance","retirement plan","vacation time","sick leave","performance bonus","commission structure","sales target","market share","brand identity","marketing campaign","ad spend","click rate","conversion rate","customer lifetime","churn rate","user engagement","social media","content strategy","search engine","local search","organic traffic","paid search","email marketing","press release","case study","white paper","user guide","help center","frequently asked","support ticket","service level","response time","resolution time","customer satisfaction","net promoter","user feedback","product feature","roadmap item","beta program","early access","invite only","project status","meeting notes","call transcript","action items","follow up","best practices","success stories","class name","function name","variable name","database table","schema name","index name","query result","error message","warning message","log entry","debug log","stack trace","staff member","team member","board member","board meeting","committee member","executive board","email us","contact us","about us","sign in","sign out","quarterly results","strategic planning","market research","customer base","privacy settings","account settings","security settings","download now","free trial","limited time","copyright protected","all rights","rights reserved","credit score","monthly rent","lease application","property address","reference number","additional identifier","lease agreement","hiring review","candidate name","lighting","keyboard","creating","building","training","planning","starting","painting","printing","returned","released","required","accepted","imported","services","products","accounts","settings","partners","keywords","keystone","keyspace","keynotes","keychain"]'));
18
+ let JARGON_WORDS = new Set(JSON.parse('["step","page","grade","version","course","class","follow","chapter","lesson","unit","marketing","manager","specialist","science","administration","university","skills","leadership","communication","working","proficiency","decision","driven","experience","summary","bachelor","ads","solutions","positioning","acquisition","strategy","research","database","forecast","interest","prepared","merchant","document","feedback","template","campaign","partners","settings","keystone"]'));
19
+ let NOT_NAME_WORDS = new Set(JSON.parse('["the","a","an","this","that","these","those","my","your","his","her","their","our","its","it","he","she","they","we","i","you","who","whom","which","what","whose","why","how","when","where","with","for","from","by","to","at","in","on","of","about","as","into","through","during","before","after","above","below","and","but","or","so","yet","hello","hi","hey","dear","greetings","summary","experience","education","skills","languages","project","history","background","objective","profile","awards","honors","certifications","publications","interests","references","manager","director","specialist","analyst","engineer","developer","consultant","officer","representative","agent","lead","leader","president","coordinator","admin","administrator","executive","founder","partner","intern","trainee","advisor","head","vp","chief","marketing","sales","engineering","finance","accounting","legal","operations","support","recruiting","talent","acquisition","compliance","security","technical","development","product","design","creative","strategy","planning","analytics","science","business","administration","google","ads","analytics","meta","hubspot","crm","salesforce","wordpress","mailchimp","adobe","figma","canva","slack","zoom","teams","microsoft","office","excel","word","powerpoint","notion","jira","confluence","github","gitlab","aws","azure","cloud","database","sql","python","java","javascript","html","css","react","node","api","saas","b2b","b2c","url","domain","website","app","application","software","email","phone","contact","address","bachelor","master","doctor","associate","degree","university","college","school","institute","academy","graduated","major","minor","gpa","cum","laude","honors","deans","list","scholarship","results","driven","oriented","expert","professional","proven","track","record","creative","excellent","communication","verbal","written","native","fluent","bilingual","working","proficiency","strategic","interpersonal","teamwork","organizational","detail","analytical","results-driven","data-driven","customer-centric","detail-oriented","cross-functional","self-motivated","time-management","problem-solving","fast-paced","year-over-year","trust","trustee","co-trustee","settlor","grantor","beneficiary","agreement","will","estate","witness","declaration","signatory","testator","notary","commission","county","state","court","article","section","paragraph","schedule","exhibit","amendment","addendum","power","attorney","guardian","executor","administrator","survivor","predecessor","successor","whereof","hereby","thereby","herein","therein","witnesseth","whereas","therefore","now","dated","effective","california","texas","florida","york","illinois","pennsylvania","ohio","georgia","michigan","carolina","virginia","washington","arizona","massachusetts","tennessee","indiana","maryland","missouri","wisconsin","colorado","minnesota","alabama","louisiana","kentucky","oregon","oklahoma","connecticut","utah","iowa","nevada","arkansas","mississippi","kansas","new mexico","nebraska","idaho","hawaii","maine","new hampshire","rhode island","montana","delaware","south dakota","north dakota","alaska","vermont","wyoming"]'));
20
+
21
+
22
+ /**
23
+ * Runtime Initialization for dynamic rule synchronization.
24
+ * @param {Object} config - { regexes, profiles, names }
25
+ */
26
+ function init(config) {
27
+ if (!config) return;
28
+
29
+ // Re-hydrate regex objects if they came from JSON storage as strings
30
+ const hydrateRegex = (rule) => {
31
+ if (rule && typeof rule.regex === 'string' && rule.regex.startsWith('/')) {
32
+ try {
33
+ const lastSlash = rule.regex.lastIndexOf('/');
34
+ const pattern = rule.regex.substring(1, lastSlash);
35
+ const flags = rule.regex.substring(lastSlash + 1);
36
+ return { ...rule, regex: new RegExp(pattern, flags) };
37
+ } catch (e) {
38
+ console.error('PS: Failed to hydrate regex:', rule.regex, e);
39
+ return rule;
40
+ }
41
+ }
42
+ return rule;
43
+ };
44
+
45
+ if (config.regexes) {
46
+ REGEX_RULES = config.regexes.map(hydrateRegex);
47
+ }
48
+
49
+ if (config.profiles) {
50
+ PROFILE_RULES = {};
51
+ for (const [profile, rules] of Object.entries(config.profiles)) {
52
+ PROFILE_RULES[profile] = rules.map(hydrateRegex);
53
+ }
54
+ }
55
+
56
+ if (config.names) NAME_STOP_LIST = new Set(config.names);
57
+
58
+ if (typeof window !== 'undefined' && window.PrivacyScrubberCore) {
59
+ window.PrivacyScrubberCore.isInitialized = true;
60
+ }
61
+ }
62
+
63
+ // Stitcher runs for ALL profiles — LEGAL_STARTERS guard prevents false positives.
64
+
65
+ // Signal A: line ends with an ALL-CAPS name fragment (caps word or initial+dot)
66
+ // e.g. "KKKKK I." or "JOHNSON" or "KKKKK"
67
+ const RE_LINE_ENDS_AS_NAME = /(?:^|[ ,])[A-Z]{2,}(?:[A-Z'-]*[A-Z])?(?:\s+[A-Z]\.)*\s*$/m;
68
+
69
+ // Signal B: next line starts with a surname — either:
70
+ // Mixed-case: e.g. "Mmmmmmm, of Branford" (PDF mixed-caps rendering)
71
+ // ALL-CAPS: e.g. "MMMMMM, of Branford" (PDF all-caps rendering)
72
+ const RE_LINE_STARTS_AS_SURNAME = /^(?:[A-Z][a-z]{2,}(?:[A-Za-z'-]*)?|[A-Z]{3,}(?:[A-Z'-]*)?)(?:[,\s]|$)/;
73
+
74
+ // Guard: if next line starts with one of these words, it is a legal/structural paragraph
75
+ // starter — NOT a surname. Skip stitching to avoid false positives.
76
+ const LEGAL_STARTERS = new Set([
77
+ // Mixed-case legal clause starters
78
+ 'Wherein','That','This','The','Said','Dated','Whereas','Now','Therefore',
79
+ 'Hereby','Herein','Hereto','Herewith','Hereafter','Upon','Pursuant',
80
+ 'Between','Among','Under','Within','Without','During','After','Before',
81
+ 'Each','Any','All','Such','Both','Either','Neither','No','Not','If','When',
82
+ // ALL-CAPS equivalents (trust/deed documents often use all-caps paragraphs)
83
+ 'WHEREIN','THAT','THIS','WHEREAS','NOW','THEREFORE','HEREBY','HEREIN',
84
+ 'PURSUANT','BETWEEN','AMONG','UNDER','DURING','AFTER','BEFORE',
85
+ 'TRUST','AGREEMENT','DEED','SCHEDULE','EXHIBIT','ARTICLE','SECTION'
86
+ ]);
87
+
88
+ function stitchOrphanedNameLines(text, profile) {
89
+ // Only run on multi-line text (single-line paste has nothing to stitch)
90
+ if (!text.includes('\n')) return text;
91
+ const lines = text.split('\n');
92
+ if (lines.length < 2) return text;
93
+ const result = [];
94
+ let i = 0;
95
+ while (i < lines.length) {
96
+ const line = lines[i];
97
+ const next = lines[i + 1];
98
+ if (next !== undefined && RE_LINE_ENDS_AS_NAME.test(line) && RE_LINE_STARTS_AS_SURNAME.test(next.trimStart())) {
99
+ // Guard: do not stitch if next line starts with a legal clause word
100
+ const firstWord = (next.trimStart().match(/^[A-Za-z]+/) || [''])[0];
101
+ if (LEGAL_STARTERS.has(firstWord)) {
102
+ result.push(line);
103
+ i++;
104
+ } else {
105
+ // Stitch the two lines — replace \n with a space
106
+ result.push(line.trimEnd() + ' ' + next.trimStart());
107
+ i += 2;
108
+ }
109
+ } else {
110
+ result.push(line);
111
+ i++;
112
+ }
113
+ }
114
+ return result.join('\n');
115
+ }
116
+
117
+ /**
118
+ * Protect PII from plain text.
119
+ *
120
+ * @param {string} text - Raw input text
121
+ * @param {Array<{label: string, pattern: string}>} [customRules=[]] - Optional PRO regex rules or exact text
122
+ * @returns {{ scrubbedText: string, tokenMap: Object, count: number }}
123
+ * scrubbedText: text with PII replaced by tokens like [NAME_1]
124
+ * tokenMap: { "[NAME_1]": "John Doe", ... } — for data reveal
125
+ * count: total number of items protected
126
+ * uniqueUnmasked: Set of unmasked values
127
+ */
128
+ function scrubText(text, customRules = [], tokenLabelMap = {}, profile = 'General', existingSessionMap = {}) {
129
+ if (!text) return { scrubbedText: "", tokenMap: {}, count: 0, uniqueUnmasked: new Set() };
130
+
131
+ // Stitch PDF-split name fragments across line breaks (all profiles)
132
+ text = stitchOrphanedNameLines(text, profile);
133
+
134
+ // Protect system prompt from being scrubbed or counted
135
+ let extractedSystemPrompt = "";
136
+ let textToProcess = text.replace(/[\u200b\u200c\u200d\ufeff]/g, '');
137
+
138
+ // Split attached table labels/headers (e.g. Sarah MitchellEmail: -> Sarah Mitchell Email:)
139
+ // Specifically matches a letter followed directly by field names and a colon
140
+ textToProcess = textToProcess.replace(/([a-zA-Z])(Email|Phone|Mobile|Tel|Address|IP|ID|URL|SSN|Date):/g, '$1 $2:');
141
+
142
+ const sysMarker = "[Privacy Scrubber Mode]";
143
+ const oldMarker = "[SYSTEM INSTRUCTION: DATA PRIVACY MODE]";
144
+ const newMarker = "[Context: identifiers";
145
+ if (textToProcess.includes(sysMarker) || textToProcess.includes(oldMarker) || textToProcess.includes(newMarker)) {
146
+ const sysPromptRegex = /(?:----------------------\s*)?(?:\[SYSTEM INSTRUCTION: DATA PRIVACY MODE\]|\[Privacy Scrubber Mode\]|\[Context: identifiers)[\s\S]*/;
147
+ const match = textToProcess.match(sysPromptRegex);
148
+ if (match) {
149
+ extractedSystemPrompt = match[0];
150
+ textToProcess = textToProcess.replace(sysPromptRegex, '');
151
+ }
152
+ }
153
+ const sessionMap = {};
154
+ const counters = { NAME: 0, EMAIL: 0, PHONE: 0, ID: 0, FINANCIAL: 0, SECRET: 0, ADDRESS: 0, CUSTOM: 0, SSN: 0, DATE: 0, PHI: 0 };
155
+ const customCounters = {};
156
+ const plaintextToTokenAtlas = {};
157
+
158
+ // Seed counters and atlas from existing session map to prevent overwrites
159
+ if (existingSessionMap && typeof existingSessionMap === 'object') {
160
+ Object.entries(existingSessionMap).forEach(([token, value]) => {
161
+ const match = token.match(/^\[([A-Z_a-z0-9]+)_(\d+)\]$/);
162
+ if (match) {
163
+ const type = match[1];
164
+ const idx = parseInt(match[2], 10);
165
+ if (counters[type] !== undefined) {
166
+ counters[type] = Math.max(counters[type], idx);
167
+ } else {
168
+ customCounters[type] = Math.max(customCounters[type] || 0, idx);
169
+ }
170
+ }
171
+ plaintextToTokenAtlas[value.toLowerCase()] = token;
172
+ plaintextToTokenAtlas[value] = token;
173
+ });
174
+ }
175
+
176
+
177
+ // Default labels
178
+ const labels = {
179
+ NAME: 'NAME', EMAIL: 'EMAIL', PHONE: 'PHONE', ID: 'ID',
180
+ FINANCIAL: 'FINANCIAL', SECRET: 'SECRET', ADDRESS: 'ADDRESS', CUSTOM: 'CUSTOM',
181
+ SSN: 'SSN', DATE: 'DATE', PHI: 'PHI',
182
+ ...tokenLabelMap
183
+ };
184
+ let matches = [];
185
+
186
+ // Custom rules (PRO) — sorted longest pattern first to prevent partial matches
187
+ if (customRules && customRules.length > 0) {
188
+ const sorted = [...customRules].sort((a, b) => {
189
+ const patternA = typeof a === 'string' ? a : a.pattern;
190
+ const patternB = typeof b === 'string' ? b : b.pattern;
191
+ return patternB.length - patternA.length;
192
+ });
193
+
194
+ sorted.forEach(cr => {
195
+ const pattern = typeof cr === 'string' ? cr : cr.pattern;
196
+ const label = typeof cr === 'string' ? 'CUSTOM' : (cr.label || 'CUSTOM');
197
+
198
+ let rx;
199
+ try {
200
+ // If it looks like exact text (no regex boundaries/quantifiers), escape it
201
+ const isExact = !/(\^|\$|\\[bBdDwWsS]|\[|\(|\{|\*|\+|\|)/.test(pattern);
202
+ if (isExact) {
203
+ const safe = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
204
+ rx = new RegExp(`(?:^|\\b)${safe}(?:\\b|$)`, 'gi');
205
+ } else {
206
+ rx = new RegExp(pattern, 'gi');
207
+ }
208
+ } catch (e) {
209
+ console.warn('Invalid custom regex pattern:', pattern);
210
+ return;
211
+ }
212
+
213
+ let m;
214
+ // Prevent infinite loops from 0-length regex matches
215
+ if (rx.test('')) return;
216
+ rx.lastIndex = 0;
217
+
218
+ while ((m = rx.exec(textToProcess)) !== null) {
219
+ matches.push({ start: m.index, end: m.index + m[0].length, value: m[0], type: 'CUSTOM', customLabel: label });
220
+ }
221
+ });
222
+ }
223
+
224
+ // Combine built-in regex rules with profile rules.
225
+ // PROFILE_ALIAS_MAP: normalizes popup.html dropdown values to PROFILE_RULES keys.
226
+ // Popup has display names like 'Medical', 'Engineering', 'Bizops' etc. — these must
227
+ // map to the canonical keys used in PROFILE_RULES (loaded from ps_rules_cache).
228
+ // Any unrecognized profile silently falls back to General (base REGEX_RULES only).
229
+ const PROFILE_ALIAS_MAP = {
230
+ // Healthcare
231
+ 'medical': 'medical',
232
+ 'healthcare': 'medical',
233
+ 'health': 'medical',
234
+ 'pharma': 'pharma',
235
+ // Engineering / Dev
236
+ 'engineering': 'engineering',
237
+ 'dev': 'engineering',
238
+ 'tech': 'tech',
239
+ // Finance
240
+ 'finance': 'finance',
241
+ 'bizops': 'bizops',
242
+ 'sales': 'sales',
243
+ 'wealthmgmt': 'wealthmgmt',
244
+ 'insurance': 'insurance',
245
+ 'accounting': 'accounting',
246
+ // Legal
247
+ 'legal': 'legal',
248
+ 'compliance': 'compliance',
249
+ 'ccpa': 'ccpa',
250
+ // HR
251
+ 'hr': 'hr',
252
+ // Security
253
+ 'security': 'security',
254
+ // Marketing / Support
255
+ 'marketing': 'marketing',
256
+ 'support': 'support',
257
+ };
258
+ let activeRules = [...REGEX_RULES];
259
+ if (profile && profile.toLowerCase() !== 'general') {
260
+ const rawKey = profile.toLowerCase();
261
+ // Resolve alias (undefined = key not in map at all → treat as General)
262
+ const resolvedKey = PROFILE_ALIAS_MAP.hasOwnProperty(rawKey)
263
+ ? PROFILE_ALIAS_MAP[rawKey]
264
+ : rawKey; // pass-through for direct keys like 'health', 'finance', 'hr', 'legal', 'dev'
265
+ if (resolvedKey && PROFILE_RULES[resolvedKey]) {
266
+ activeRules = [...PROFILE_RULES[resolvedKey], ...activeRules];
267
+ }
268
+ }
269
+
270
+
271
+ activeRules.forEach(rule => {
272
+ // Apply Detection Profiles Logic
273
+ const isDevProfile = (profile === 'Dev' || profile.toLowerCase() === 'engineering' || profile.toLowerCase() === 'security');
274
+ if (isDevProfile && rule.type === 'NAME') return; // Dev/Security profiles don't protect function names
275
+
276
+ rule.regex.lastIndex = 0;
277
+ let m;
278
+
279
+ while ((m = rule.regex.exec(textToProcess)) !== null) {
280
+ let matchedText = m[0];
281
+ let start = m.index;
282
+ let end = m.index + matchedText.length;
283
+
284
+ if (rule.type === 'NAME') {
285
+ let words = matchedText.split(/[ \t\xA0]+/);
286
+
287
+ // Trim leading stop words/jargon words
288
+ while (words.length >= 2 && (NOT_NAME_WORDS.has(words[0].toLowerCase()) || JARGON_WORDS.has(words[0].toLowerCase()))) {
289
+ const removedWord = words.shift();
290
+ const prefixLength = removedWord.length + (matchedText.slice(removedWord.length).match(/^[ \t\xA0]+/)?.[0]?.length || 0);
291
+ start += prefixLength;
292
+ matchedText = matchedText.slice(prefixLength);
293
+ }
294
+
295
+ // Trim trailing stop words/jargon words
296
+ while (words.length >= 2 && (NOT_NAME_WORDS.has(words[words.length - 1].toLowerCase()) || JARGON_WORDS.has(words[words.length - 1].toLowerCase()))) {
297
+ const removedWord = words.pop();
298
+ const suffixLength = removedWord.length + (matchedText.slice(0, matchedText.length - removedWord.length).match(/[ \t\xA0]+$/)?.[0]?.length || 0);
299
+ end -= suffixLength;
300
+ matchedText = matchedText.slice(0, matchedText.length - suffixLength);
301
+ }
302
+
303
+ if (words.length < 2) {
304
+ continue;
305
+ }
306
+
307
+ // Final validation: check if any remaining words are stop/jargon words
308
+ if (words.some(w => NOT_NAME_WORDS.has(w.toLowerCase()) || JARGON_WORDS.has(w.toLowerCase()))) {
309
+ continue;
310
+ }
311
+ const val = matchedText.toLowerCase().trim();
312
+ if (NAME_STOP_LIST.has(val)) {
313
+ continue;
314
+ }
315
+
316
+ matches.push({ start, end, value: matchedText, type: rule.type });
317
+ } else {
318
+ const val = matchedText.toLowerCase().trim();
319
+ if (NAME_STOP_LIST.has(val)) {
320
+ continue;
321
+ }
322
+ const words = val.split(/\s+/);
323
+ if (words.some(w => JARGON_WORDS.has(w))) {
324
+ continue;
325
+ }
326
+ matches.push({ start, end, value: matchedText, type: rule.type });
327
+ }
328
+ }
329
+ });
330
+
331
+ /* --- Dynamic Name Learning Phase --- */
332
+ const learnedNames = new Set();
333
+ matches.forEach(m => {
334
+ if (m.type === 'NAME') {
335
+ const nameWords = m.value.split(/[^\p{L}'-]+/u);
336
+ nameWords.forEach(w => {
337
+ if (w && w.length >= 2 && /^\p{Lu}/u.test(w)) {
338
+ const wl = w.toLowerCase();
339
+ if (!NOT_NAME_WORDS.has(wl) && !JARGON_WORDS.has(wl) && !NAME_STOP_LIST.has(wl)) {
340
+ learnedNames.add(w);
341
+ }
342
+ }
343
+ });
344
+ }
345
+ });
346
+
347
+ if (learnedNames.size > 0) {
348
+ learnedNames.forEach(name => {
349
+ const safeName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
350
+ const rx = new RegExp(`(?<=^|[^\\p{L}\\p{N}_])${safeName}(?=[^\\p{L}\\p{N}_]|$)`, 'gu');
351
+ let m;
352
+ while ((m = rx.exec(text)) !== null) {
353
+ const exists = matches.some(existing => existing.start <= m.index && existing.end >= m.index + m[0].length);
354
+ if (!exists) {
355
+ matches.push({ start: m.index, end: m.index + m[0].length, value: m[0], type: 'NAME' });
356
+ }
357
+ }
358
+ });
359
+ }
360
+
361
+ // Sort by start, prefer longer matches, remove overlaps
362
+
363
+ matches.sort((a, b) => a.start - b.start || b.end - a.end);
364
+ let filtered = [];
365
+ let lastEnd = 0;
366
+ matches.forEach(m => {
367
+ if (m.start >= lastEnd) { filtered.push(m); lastEnd = m.end; }
368
+ });
369
+
370
+ // Assign tokens in left-to-right order
371
+
372
+ const uniqueUnmasked = new Set();
373
+
374
+ filtered.forEach(m => {
375
+ // Deduplication Check: Reuse tokens for identical values (case-insensitive for core types)
376
+ const matchKey = m.type === 'CUSTOM' || m.type === 'ID' || m.type === 'SECRET' ? m.value : m.value.toLowerCase();
377
+
378
+ uniqueUnmasked.add(matchKey);
379
+
380
+ if (plaintextToTokenAtlas[matchKey]) {
381
+ m.token = plaintextToTokenAtlas[matchKey];
382
+ return;
383
+ }
384
+
385
+ // Generate unique token using custom labels if provided
386
+ const label = m.customLabel || labels[m.type] || m.type;
387
+ const isBuiltIn = counters[m.type] !== undefined;
388
+
389
+ if (isBuiltIn && !m.customLabel) {
390
+ // Built-in type (NAME, EMAIL, PHONE, etc.) — use seeded counters[] for collision prevention
391
+ counters[m.type]++;
392
+ m.token = `[${label}_${counters[m.type]}]`;
393
+ } else {
394
+ // Custom label (PRO rule) — use customCounters[]
395
+ customCounters[label] = (customCounters[label] || 0) + 1;
396
+ m.token = `[${label}_${customCounters[label]}]`;
397
+ }
398
+
399
+ plaintextToTokenAtlas[matchKey] = m.token;
400
+ sessionMap[m.token] = m.value;
401
+ });
402
+
403
+ // Replace from end → start (preserves string indices while mutating result)
404
+ const rightToLeft = [...filtered].sort((a, b) => b.start - a.start);
405
+ let result = textToProcess;
406
+ rightToLeft.forEach(m => {
407
+ result = result.substring(0, m.start) + m.token + result.substring(m.end);
408
+ });
409
+
410
+ return {
411
+ scrubbedText: extractedSystemPrompt + result,
412
+ tokenMap: sessionMap,
413
+ count: Object.keys(sessionMap).length,
414
+ uniqueUnmasked: uniqueUnmasked
415
+ };
416
+ }
417
+
418
+ const LABEL_ALIASES = {
419
+ 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'],
420
+ EMAIL: ['EMAIL', 'EMAILS', 'EMAILADDR', 'EMAIL_ADDR', 'EMAILADDRESS', 'EMAIL_ADDRESS', 'EMAIL_ADR', 'MAIL', 'MAILS', 'ПОЧТА', 'ЭЛ_ПОЧТА', 'АДРЕС_ПОЧТЫ', 'МЕЙЛ', 'МАЙЛ', 'CORREO', 'COURRIEL', 'CORREO_ELECTRONICO', 'MEL'],
421
+ PHONE: ['PHONE', 'PHONES', 'PHONENUM', 'PHONE_NUM', 'PHONENUMBER', 'PHONE_NUMBER', 'TEL', 'TELS', 'TELEPHONE', 'TELEPHONES', 'MOBILE', 'CELL', 'ТЕЛЕФОН', 'ТЕЛЕФОНЫ', 'НОМЕР_ТЕЛЕФОНА', 'НОМЕР', 'MOVIL', 'PORTABLE', 'HANDY', 'TELEFONI', 'CELLULARE'],
422
+ 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'],
423
+ FINANCIAL: ['FINANCIAL', 'FINANCIALS', 'MONEY', 'AMOUNT', 'PRICE', 'COST', 'CARD', 'CREDITCARD', 'DEBITCARD', 'ACCOUNT', 'IBAN', 'BIC', 'ДЕНЬГИ', 'СУММА', 'КАРТА', 'СЧЕТ', 'БАНК', 'DINERO', 'CANTIDAD', 'TARJETA', 'CUENTA', 'ARGENT', 'MONTANT', 'COMPTE', 'GELD', 'BETRAG', 'KONTO'],
424
+ 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'],
425
+ DATE: ['DATE', 'DATES', 'BIRTHDAY', 'DOB', 'ДАТА', 'ДАТЫ', 'ДЕНЬ_РОЖДЕНИЯ', 'FECHA', 'FECHAS', 'CUMPLEANOS', 'ANNIVERSAIRE', 'DATUM', 'DATEN', 'GEBURTSTAG'],
426
+ PHI: ['PHI', 'MRN', 'NHS', 'HEALTH', 'MEDICAL', 'PATIENT', 'МЕД', 'ПАЦИЕНТ', 'PACIENTE'],
427
+ SECRET: ['SECRET', 'SECRETS', 'KEY', 'KEYS', 'TOKEN', 'TOKENS', 'PASSWORD', 'PASSWORDS', 'AUTH', 'APIKEY', 'API_KEY', 'КЛЮЧ', 'КЛЮЧИ', 'ПАРОЛЬ', 'ПАРОЛИ', 'ТОКЕН', 'CLAVE', 'CONTRASENA', 'CLE', 'MOT_DE_PASSE', 'SCHLUESSEL', 'PASSWORT'],
428
+ CUSTOM: ['CUSTOM', 'CUSTOMS', 'RULE', 'RULES', 'КАСТОМ', 'ПРАВИЛО']
429
+ };
430
+
431
+ function getLabelAliases(label) {
432
+ const upper = label.toUpperCase();
433
+ if (LABEL_ALIASES[upper]) {
434
+ return LABEL_ALIASES[upper];
435
+ }
436
+ const aliases = new Set([label, upper, label.toLowerCase()]);
437
+ aliases.add(label.replace(/_/g, ' '));
438
+ aliases.add(label.replace(/_/g, '-'));
439
+ aliases.add(label.replace(/ /g, '_'));
440
+ aliases.add(label.replace(/-/g, '_'));
441
+ return Array.from(aliases);
442
+ }
443
+
444
+ function buildRestorationRegexAndRules(tokenMap) {
445
+ const keys = Object.keys(tokenMap);
446
+ if (keys.length === 0) {
447
+ return { compositeRegex: null, looseRules: [] };
448
+ }
449
+
450
+ const sortedKeys = [...keys].sort((a, b) => {
451
+ const innerA = a.replace(/^\[|\]$/g, '');
452
+ const innerB = b.replace(/^\[|\]$/g, '');
453
+ const matchA = innerA.match(/^([A-Za-z_0-9]+?)[-_]?(\d+)$/);
454
+ const matchB = innerB.match(/^([A-Za-z_0-9]+?)[-_]?(\d+)$/);
455
+
456
+ if (matchA && matchB) {
457
+ const idxA = parseInt(matchA[2], 10);
458
+ const idxB = parseInt(matchB[2], 10);
459
+ const labelA = matchA[1];
460
+ const labelB = matchB[1];
461
+
462
+ if (idxA !== idxB) {
463
+ return idxB - idxA;
464
+ }
465
+ if (labelA.length !== labelB.length) {
466
+ return labelB.length - labelA.length;
467
+ }
468
+ }
469
+ return b.length - a.length;
470
+ });
471
+
472
+ const looseRules = [];
473
+ const regexParts = [];
474
+
475
+ sortedKeys.forEach(k => {
476
+ const inner = k.replace(/^\[|\]$/g, '');
477
+ const match = inner.match(/^([A-Za-z_0-9]+?)[-_]?(\d+)$/);
478
+ if (match) {
479
+ const label = match[1];
480
+ const baseIndex = parseInt(match[2], 10);
481
+ const aliases = getLabelAliases(label);
482
+
483
+ const escapedAliases = aliases.map(a => a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
484
+ const aliasesGroup = `(?:${escapedAliases.join('|')})`;
485
+
486
+ const looseRegex = '\\[?\\s*' + aliasesGroup + '[-_\\s]*0*' + baseIndex + '\\s*\\]?';
487
+ looseRules.push({ patternStr: looseRegex, originalKey: k });
488
+ regexParts.push(looseRegex);
489
+ } else {
490
+ const safe = k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
491
+ looseRules.push({ patternStr: safe, originalKey: k });
492
+ regexParts.push(safe);
493
+ }
494
+ });
495
+
496
+ const compositeRegex = new RegExp('(?<=^|[^a-zA-Z0-9_А-Яа-яЁё])(' + regexParts.join('|') + ')(?=$|[^a-zA-Z0-9_А-Яа-яЁё])', 'gi');
497
+
498
+ return { compositeRegex, looseRules };
499
+ }
500
+
501
+ /**
502
+ * Clean AI prompt prefix (e.g., "Claude responded:", "ChatGPT:") from the text.
503
+ *
504
+ * @param {string} text
505
+ * @returns {string}
506
+ */
507
+ function cleanAIPromptPrefix(text) {
508
+ if (!text) return "";
509
+ let cleaned = text.replace(/^\s*(Claude responded|Claude|ChatGPT|Gemini|Grok|DeepSeek|Kimi|Copilot|Assistant|User)\s*(?::|\bsaid\b|\bresponded\b)\s*/i, "");
510
+ cleaned = cleaned.replace(/^\s*Edit\s*\n+/i, "");
511
+ cleaned = cleaned.replace(/\s*\bEdit\s+in\s+a\s+page\b\s*$/i, "");
512
+ return cleaned;
513
+ }
514
+
515
+ /**
516
+ * Reverse-protect: replace tokens in AI response with originals from tokenMap.
517
+ *
518
+ * @param {string} aiResponse - Text containing tokens like [NAME_1]
519
+ * @param {Object} tokenMap - { "[NAME_1]": "John Doe", ... }
520
+ * @returns {{ restoredText: string, restoredCount: number }}
521
+ */
522
+ function unscrubText(aiResponse, tokenMap) {
523
+ let text = cleanAIPromptPrefix(aiResponse);
524
+ let restoredCount = 0;
525
+
526
+ const { compositeRegex, looseRules } = buildRestorationRegexAndRules(tokenMap);
527
+ if (compositeRegex) {
528
+ text = text.replace(compositeRegex, (match) => {
529
+ restoredCount++;
530
+ let origKey = match;
531
+ for (const rule of looseRules) {
532
+ if (new RegExp('^' + rule.patternStr + '$', 'i').test(match)) {
533
+ origKey = rule.originalKey;
534
+ break;
535
+ }
536
+ }
537
+ return tokenMap[origKey] || match;
538
+ });
539
+ }
540
+
541
+ return { restoredText: text, restoredCount };
542
+ }
543
+
544
+ /**
545
+ * Reverse-protect with HTML highlighting: replace tokens in AI response with originals wrapped in span.
546
+ *
547
+ * @param {string} aiResponse - Text containing tokens like [NAME_1]
548
+ * @param {Object} tokenMap - { "[NAME_1]": "John Doe", ... }
549
+ * @returns {{ restoredHTML: string, restoredCount: number }}
550
+ */
551
+ function unscrubTextAsHTML(aiResponse, tokenMap) {
552
+ let restoredCount = 0;
553
+
554
+ const cleanResponse = cleanAIPromptPrefix(aiResponse);
555
+ // ALWAYS escape HTML first — even with empty tokenMap — to prevent XSS from AI-generated content
556
+ let text = cleanResponse.replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c] || c));
557
+
558
+ const { compositeRegex, looseRules } = buildRestorationRegexAndRules(tokenMap);
559
+ if (compositeRegex) {
560
+ text = text.replace(compositeRegex, (match) => {
561
+ restoredCount++;
562
+ let origKey = match;
563
+ for (const rule of looseRules) {
564
+ if (new RegExp('^' + rule.patternStr + '$', 'i').test(match)) {
565
+ origKey = rule.originalKey;
566
+ break;
567
+ }
568
+ }
569
+ const rawVal = tokenMap[origKey] || match;
570
+ const safeVal = rawVal.replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c] || c));
571
+ return `<span class="ps-restored-data" title="Restored by PrivacyScrubber" style="border-bottom: 2px dashed #10b981; color: inherit; cursor: help; padding-bottom: 1px; font-weight: 500; text-shadow: 0 0 5px rgba(16, 185, 129, 0.2);">${safeVal}</span>`;
572
+ });
573
+ }
574
+
575
+ return { restoredHTML: text, restoredCount };
576
+ }
577
+
578
+ // Expose standalone hydrateRegex for unit testing
579
+ function hydrateRegex(rule) {
580
+ if (rule && typeof rule.regex === 'string' && rule.regex.startsWith('/')) {
581
+ try {
582
+ const lastSlash = rule.regex.lastIndexOf('/');
583
+ const pattern = rule.regex.substring(1, lastSlash);
584
+ const flags = rule.regex.substring(lastSlash + 1);
585
+ return { ...rule, regex: new RegExp(pattern, flags) };
586
+ } catch (e) {
587
+ console.error('PS: Failed to hydrate regex:', rule.regex, e);
588
+ return rule;
589
+ }
590
+ }
591
+ return rule;
592
+ }
593
+
594
+ function showTeamsPassphraseModal(onSaveCallback) {
595
+ const existing = document.getElementById('ps-teams-modal');
596
+ if (existing) existing.remove();
597
+
598
+ const overlay = document.createElement('div');
599
+ overlay.id = 'ps-teams-modal';
600
+ Object.assign(overlay.style, {
601
+ position: 'fixed', top: '0', left: '0', width: '100vw', height: '100vh',
602
+ background: 'rgba(2,6,23,0.8)', backdropFilter: 'blur(10px)',
603
+ zIndex: '2147483647', display: 'flex', alignItems: 'center', justifyContent: 'center',
604
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
605
+ });
606
+
607
+ const modal = document.createElement('div');
608
+ Object.assign(modal.style, {
609
+ background: 'rgba(15,23,42,0.9)', border: '1px solid rgba(59,130,246,0.3)',
610
+ borderRadius: '16px', padding: '24px', width: '380px', maxWidth: '90%',
611
+ boxShadow: '0 25px 50px -12px rgba(0,0,0,0.5), 0 0 20px rgba(59,130,246,0.1)',
612
+ color: '#f8fafc', display: 'flex', flexDirection: 'column', gap: '16px',
613
+ animation: 'ps-slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1)'
614
+ });
615
+
616
+ if (!document.getElementById('ps-modal-styles')) {
617
+ const style = document.createElement('style');
618
+ style.id = 'ps-modal-styles';
619
+ style.textContent = `
620
+ @keyframes ps-slide-up { from { opacity: 0; transform: translateY(20px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }
621
+ .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; }
622
+ .ps-modal-input:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
623
+ .ps-modal-btn { flex: 1; padding: 10px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; border: none; transition: all 0.2s; }
624
+ .ps-modal-btn.primary { background: linear-gradient(135deg, #06b6d4, #3b82f6); color: white; box-shadow: 0 4px 12px rgba(59,130,246,0.3); }
625
+ .ps-modal-btn.primary:hover { filter: brightness(1.1); transform: translateY(-1px); }
626
+ .ps-modal-btn.primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; filter: none; }
627
+ .ps-modal-btn.secondary { background: rgba(255,255,255,0.05); color: #cbd5e1; border: 1px solid rgba(255,255,255,0.1); }
628
+ .ps-modal-btn.secondary:hover { background: rgba(255,255,255,0.1); }
629
+ `;
630
+ document.head.appendChild(style);
631
+ }
632
+
633
+ const header = document.createElement('div');
634
+ Object.assign(header.style, { display: 'flex', alignItems: 'center', gap: '12px' });
635
+
636
+ const headerIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
637
+ headerIcon.setAttribute("width", "24"); headerIcon.setAttribute("height", "24"); headerIcon.setAttribute("viewBox", "0 0 24 24");
638
+ headerIcon.setAttribute("fill", "none"); headerIcon.setAttribute("stroke", "#06b6d4"); headerIcon.setAttribute("stroke-width", "2");
639
+ const hp1 = document.createElementNS("http://www.w3.org/2000/svg", "path");
640
+ hp1.setAttribute("d", "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2");
641
+ headerIcon.appendChild(hp1);
642
+ const hc1 = document.createElementNS("http://www.w3.org/2000/svg", "circle");
643
+ hc1.setAttribute("cx", "9"); hc1.setAttribute("cy", "7"); hc1.setAttribute("r", "4");
644
+ headerIcon.appendChild(hc1);
645
+
646
+ const title = document.createElement('h3');
647
+ title.textContent = 'TEAMS Passphrase';
648
+ Object.assign(title.style, { margin: '0', fontSize: '18px', fontWeight: '600' });
649
+
650
+ header.appendChild(headerIcon);
651
+ header.appendChild(title);
652
+
653
+ const desc = document.createElement('p');
654
+ desc.textContent = 'Secure Session Sharing requires a shared key. Set your team\'s passphrase to encrypt the data locally before sharing.';
655
+ Object.assign(desc.style, { margin: '0', fontSize: '13px', color: '#94a3b8', lineHeight: '1.5' });
656
+
657
+ const inputContainer = document.createElement('div');
658
+ const passInput = document.createElement('input');
659
+ passInput.type = 'password';
660
+ passInput.id = 'ps-teams-pass-input';
661
+ passInput.className = 'ps-modal-input';
662
+ passInput.placeholder = 'Configure Passphrase (min 8 chars)';
663
+
664
+ const strengthBar = document.createElement('div');
665
+ strengthBar.id = 'ps-teams-strength-bar';
666
+ Object.assign(strengthBar.style, { height: '4px', borderRadius: '2px', background: 'rgba(255,255,255,0.05)', marginTop: '8px', overflow: 'hidden' });
667
+ const strengthFill = document.createElement('div');
668
+ strengthFill.id = 'ps-teams-strength-fill';
669
+ Object.assign(strengthFill.style, { height: '100%', width: '0%', transition: 'all 0.3s' });
670
+ strengthBar.appendChild(strengthFill);
671
+
672
+ const strengthLabel = document.createElement('div');
673
+ strengthLabel.id = 'ps-teams-strength-label';
674
+ Object.assign(strengthLabel.style, { fontSize: '10px', textTransform: 'uppercase', fontWeight: 'bold', marginTop: '4px', textAlign: 'center' });
675
+ strengthLabel.textContent = '\u00A0';
676
+
677
+ const errorMsg = document.createElement('div');
678
+ errorMsg.id = 'ps-teams-error';
679
+ Object.assign(errorMsg.style, { color: '#ef4444', fontSize: '12px', marginTop: '4px', display: 'none', textAlign: 'center' });
680
+ errorMsg.textContent = 'Passphrase is too weak. Mix characters & letters.';
681
+
682
+ inputContainer.appendChild(passInput);
683
+ inputContainer.appendChild(strengthBar);
684
+ inputContainer.appendChild(strengthLabel);
685
+ inputContainer.appendChild(errorMsg);
686
+
687
+ const btnGroup = document.createElement('div');
688
+ Object.assign(btnGroup.style, { display: 'flex', gap: '10px', marginTop: '4px' });
689
+ const cancelBtn = document.createElement('button');
690
+ cancelBtn.id = 'ps-teams-cancel';
691
+ cancelBtn.className = 'ps-modal-btn secondary';
692
+ cancelBtn.textContent = 'Cancel';
693
+ const saveBtn = document.createElement('button');
694
+ saveBtn.id = 'ps-teams-save';
695
+ saveBtn.className = 'ps-modal-btn primary';
696
+ saveBtn.disabled = true;
697
+ saveBtn.textContent = 'Save & Encrypt';
698
+ btnGroup.appendChild(cancelBtn);
699
+ btnGroup.appendChild(saveBtn);
700
+
701
+ // SECURITY: Opt-in persistence removed to strictly enforce ZTDS (no local storage for plaintext keys).
702
+
703
+ const footer = document.createElement('div');
704
+ Object.assign(footer.style, { textAlign: 'center', marginTop: '4px' });
705
+ const learnLink = document.createElement('a');
706
+ learnLink.href = 'https://privacyscrubber.com/teams';
707
+ learnLink.target = '_blank';
708
+ Object.assign(learnLink.style, { color: '#60a5fa', fontSize: '12px', textDecoration: 'none' });
709
+ learnLink.textContent = 'Learn about TEAMS Cryptography →';
710
+ footer.appendChild(learnLink);
711
+
712
+ modal.appendChild(header);
713
+ modal.appendChild(desc);
714
+ modal.appendChild(inputContainer);
715
+ modal.appendChild(btnGroup);
716
+ modal.appendChild(footer);
717
+
718
+ overlay.appendChild(modal);
719
+ document.body.appendChild(overlay);
720
+
721
+ passInput.focus();
722
+
723
+ passInput.addEventListener('input', () => {
724
+ const p = passInput.value;
725
+ let score = 0;
726
+ if (p.length >= 8) score++;
727
+ if (p.length >= 14) score++;
728
+ if (/[A-Z]/.test(p) && /[a-z]/.test(p)) score++;
729
+ if (/[0-9]/.test(p)) score++;
730
+ if (/[^A-Za-z0-9]/.test(p)) score++;
731
+ score = Math.min(score, 4);
732
+
733
+ const levels = [
734
+ { pct: '0%', color: 'transparent', text: '' },
735
+ { pct: '20%', color: '#ef4444', text: 'Weak' },
736
+ { pct: '40%', color: '#fbbf24', text: 'Fair' },
737
+ { pct: '60%', color: '#facc15', text: 'Good' },
738
+ { pct: '80%', color: '#4ade80', text: 'Strong' },
739
+ { pct: '100%', color: '#10b981', text: 'Great' },
740
+ ];
741
+
742
+ const level = (p.length === 0) ? levels[0] : levels[score];
743
+ strengthFill.style.width = level.pct;
744
+ strengthFill.style.background = level.color;
745
+ strengthLabel.textContent = level.text || '\u00A0';
746
+ strengthLabel.style.color = level.color;
747
+
748
+ if (score >= 2) {
749
+ saveBtn.disabled = false;
750
+ errorMsg.style.display = 'none';
751
+ } else {
752
+ saveBtn.disabled = true;
753
+ if (p.length > 0) errorMsg.style.display = 'block';
754
+ else errorMsg.style.display = 'none';
755
+ }
756
+ });
757
+
758
+ const close = () => {
759
+ overlay.style.opacity = '0';
760
+ setTimeout(() => overlay.remove(), 200);
761
+ };
762
+
763
+ cancelBtn.addEventListener('click', close);
764
+ saveBtn.addEventListener('click', () => {
765
+ if (!saveBtn.disabled) {
766
+ const val = passInput.value.trim();
767
+ close();
768
+ if (onSaveCallback) onSaveCallback({
769
+ passphrase: val,
770
+ shouldClearSession: false,
771
+ shouldSaveLocal: false
772
+ });
773
+ }
774
+ });
775
+ passInput.addEventListener('keydown', (e) => {
776
+ if (e.key === 'Enter' && !saveBtn.disabled) {
777
+ saveBtn.click();
778
+ } else if (e.key === 'Escape') {
779
+ close();
780
+ }
781
+ });
782
+ overlay.addEventListener('click', (e) => {
783
+ if (e.target === overlay) close();
784
+ });
785
+ }
786
+ function showInPageToast(text, variant = 'success') {
787
+ const existing = document.getElementById('ps-ext-toast');
788
+ if (existing) existing.remove();
789
+
790
+ const toast = document.createElement('div');
791
+ toast.id = 'ps-ext-toast';
792
+ toast.textContent = text;
793
+
794
+ const colors = {
795
+ success: { bg: 'rgba(16,185,129,0.12)', border: 'rgba(16,185,129,0.35)', color: '#10b981' },
796
+ info: { bg: 'rgba(59,130,246,0.12)', border: 'rgba(59,130,246,0.35)', color: '#60a5fa' },
797
+ warning: { bg: 'rgba(251,191,36,0.1)', border: 'rgba(251,191,36,0.3)', color: '#fbbf24' },
798
+ error: { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.35)', color: '#ef4444' },
799
+ };
800
+ const { bg, border, color } = colors[variant] || colors.info;
801
+
802
+ Object.assign(toast.style, {
803
+ position: 'fixed', top: '24px', left: '50%', transform: 'translateX(-50%)', zIndex: '2147483647',
804
+ background: bg, border: `1px solid ${border}`, color,
805
+ padding: '9px 18px', borderRadius: '12px', fontSize: '13px',
806
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
807
+ fontWeight: '500', boxShadow: '0 8px 30px rgba(0,0,0,0.3)',
808
+ backdropFilter: 'blur(8px)', transition: 'opacity 0.3s ease', opacity: '1',
809
+ textAlign: 'center'
810
+ });
811
+
812
+ document.body.appendChild(toast);
813
+ setTimeout(() => {
814
+ toast.style.opacity = '0';
815
+ setTimeout(() => toast.remove(), 350);
816
+ }, 2500);
817
+ }
818
+
819
+ /**
820
+ * Natively walks DOM to replace Tokens with Original text natively inside SPAs.
821
+ * Guaranteed safe for React/Angular since we only manipulate target TextNodes.
822
+ */
823
+
824
+ /**
825
+ * Natively walks DOM to replace Original Text with Tokens natively inside SPAs.
826
+ * Guaranteed safe for React/Angular input fields via MutationObserver syncs.
827
+ */
828
+ function scrubDataInDOM(rootElement, customRules, globalTokenLabels, activeProfile, existingSessionMap, detectOnly = false) {
829
+ if (!rootElement) return null;
830
+
831
+ // ── TEXTAREA / INPUT fast path ────────────────────────────────────────
832
+ // Native <textarea> and <input> store their content in .value — it is
833
+ // NOT a child text node, so createTreeWalker finds nothing and always
834
+ // returns count=0. Handle them directly here.
835
+ if (rootElement.tagName === 'TEXTAREA' || rootElement.tagName === 'INPUT') {
836
+ const text = rootElement.value || '';
837
+ if (!text.trim()) return { count: 0, tokenMap: existingSessionMap || {} };
838
+ const result = scrubText(text, customRules, globalTokenLabels, activeProfile, existingSessionMap || {});
839
+ if (result.uniqueUnmasked.size > 0 && !detectOnly) {
840
+ rootElement.value = result.scrubbedText;
841
+ rootElement.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
842
+ }
843
+ return { count: result.uniqueUnmasked.size, tokenMap: { ...(existingSessionMap || {}), ...result.tokenMap } };
844
+ }
845
+ // ─────────────────────────────────────────────────────────────────────
846
+
847
+ // ── DETECT ONLY fast path ─────────────────────────────────────────────
848
+ // If we only need to update the badge counter (no DOM mutation), evaluate
849
+ // the full text string at once. This bypasses text node splitting issues
850
+ // in ProseMirror/contenteditable and guarantees a 100% accurate count.
851
+ if (detectOnly) {
852
+ const fullText = rootElement.innerText || rootElement.textContent || '';
853
+ if (!fullText.trim()) return { count: 0, tokenMap: existingSessionMap || {} };
854
+ const fullResult = scrubText(fullText, customRules, globalTokenLabels, activeProfile, existingSessionMap || {});
855
+ return { count: fullResult.uniqueUnmasked.size, tokenMap: existingSessionMap || {} };
856
+ }
857
+ // ─────────────────────────────────────────────────────────────────────
858
+
859
+ // ── ACTUAL DOM MUTATION (TreeWalker for AutoScrub) ────────────────────
860
+
861
+ const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_TEXT, null, false);
862
+ const nodesToProcess = [];
863
+ let node;
864
+ while(node = walker.nextNode()) {
865
+ if (!node.nodeValue.trim()) continue;
866
+ nodesToProcess.push(node);
867
+ }
868
+
869
+ let globalTokenMap = { ...existingSessionMap };
870
+ let allUniqueUnmasked = new Set();
871
+
872
+ nodesToProcess.forEach(node => {
873
+ const originalText = node.nodeValue;
874
+ const result = scrubText(originalText, customRules, globalTokenLabels, activeProfile, globalTokenMap);
875
+
876
+ if (result.uniqueUnmasked.size > 0) {
877
+ if (!detectOnly && result.scrubbedText !== originalText) {
878
+ node.nodeValue = result.scrubbedText;
879
+ }
880
+ if (result.count > 0) {
881
+ globalTokenMap = { ...globalTokenMap, ...result.tokenMap };
882
+ }
883
+ result.uniqueUnmasked.forEach(k => allUniqueUnmasked.add(k));
884
+ }
885
+ });
886
+
887
+ // Force React/ProseMirror to notice the change
888
+ if (allUniqueUnmasked.size > 0 && !detectOnly) {
889
+ rootElement.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
890
+ }
891
+
892
+ return {
893
+ count: allUniqueUnmasked.size,
894
+ tokenMap: globalTokenMap
895
+ };
896
+ }
897
+
898
+ const GLOBAL_ASSISTANT_SELECTORS = [
899
+ // ChatGPT
900
+ '[data-message-author-role="assistant"]',
901
+ '[data-message-author-role="model"]',
902
+ '[data-testid="chat-message-text"]',
903
+ '[data-testid="assistant-message"]',
904
+ '[data-is-streaming]',
905
+ '.agent-turn',
906
+ 'div[role="article"].agent-turn',
907
+ // Claude
908
+ 'model-response',
909
+ '.font-claude-message',
910
+ '.claude-artifact',
911
+ '[data-testid="artifact-renderer"]',
912
+ // Gemini
913
+ '.model-response-text__content',
914
+ '.response-content',
915
+ 'message-content[is-response]',
916
+ // Grok (x.com)
917
+ '[data-testid="messageblock"]',
918
+ '[class*="GrokResponseMessage"]',
919
+ // DeepSeek
920
+ '.ds-markdown',
921
+ // Copilot — NEW React UI (copilot.microsoft.com as of 2025)
922
+ '[data-testid="ai-message"]',
923
+ '[class*="AIMessageContent"]',
924
+ '[class*="ResponseMessage"]',
925
+ '[class*="CopilotMessage"]',
926
+ '[class*="BotMessage"]',
927
+ // Copilot — legacy cib-serp Shadow DOM + Adaptive Cards
928
+ '.ac-textBlock',
929
+ '.cib-message-text',
930
+ '.cib-message',
931
+ '[class*="bot-message"]',
932
+ '[class*="copilot-message"]',
933
+ // Qwen / Tongyi
934
+ '.output-area',
935
+ // Perplexity
936
+ '[data-testid="answer"]',
937
+ // Generic article-based message wrappers (used by many platforms)
938
+ // Excluded from sidebar via nav/aside filter in gatherUniversalAIContext()
939
+ 'div[role="article"]',
940
+ // Conservative generic fallbacks — nav/aside exclusion filter prevents sidebar matches
941
+ '.prose',
942
+ '.whitespace-pre-wrap',
943
+ '.message-content:not([contenteditable])',
944
+ '[class*="message-row"]',
945
+ '[class*="assistant"]',
946
+ '.markdown'
947
+ ].join(', ');
948
+
949
+ function revealDataInDOM(tokenMap) {
950
+ const { compositeRegex, looseRules } = buildRestorationRegexAndRules(tokenMap);
951
+ if (!compositeRegex) {
952
+ if (typeof showInPageToast === 'function') showInPageToast("No protected session data to reveal.", "info");
953
+ return 0;
954
+ }
955
+
956
+ let restoredCount = 0;
957
+ const nodesToReplace = [];
958
+
959
+ const ASSISTANT_SELECTORS = GLOBAL_ASSISTANT_SELECTORS;
960
+
961
+ function isPromptInput(el) {
962
+ if (!el) return false;
963
+ if (el.closest) {
964
+ return !!el.closest('[data-ps-attached="true"]');
965
+ }
966
+ let parent = el;
967
+ while (parent) {
968
+ if (parent.dataset && parent.dataset.psAttached === 'true') {
969
+ return true;
970
+ }
971
+ parent = parent.parentNode || parent.host;
972
+ }
973
+ return false;
974
+ }
975
+
976
+ // Shadow-DOM-aware assistant container detection:
977
+ // .closest() cannot cross shadow boundaries, so if it returns null
978
+ // we check whether the node lives inside a ShadowRoot whose host
979
+ // (or host ancestor) matches an AI response selector.
980
+ function isInsideAssistant(el) {
981
+ if (!el || !el.closest) return false;
982
+ if (el.closest(ASSISTANT_SELECTORS)) return true;
983
+ // Shadow DOM fallback
984
+ const root = el.getRootNode();
985
+ if (root instanceof ShadowRoot && root.host) {
986
+ // Check if the shadow host itself matches, or has an ancestor that does
987
+ if (root.host.matches && root.host.matches(ASSISTANT_SELECTORS)) return true;
988
+ if (root.host.closest && root.host.closest(ASSISTANT_SELECTORS)) return true;
989
+ }
990
+ return false;
991
+ }
992
+
993
+ function walkTextNodes(root) {
994
+ if (!root) return;
995
+
996
+ // Handle elements with shadow roots if the root itself has one
997
+ if (root.shadowRoot) {
998
+ walkTextNodes(root.shadowRoot);
999
+ }
1000
+
1001
+ // Standard node iteration
1002
+ let child = root.firstChild;
1003
+ while (child) {
1004
+ const next = child.nextSibling;
1005
+ if (child.nodeType === Node.TEXT_NODE) {
1006
+ if (child.parentElement) {
1007
+ const tag = child.parentElement.tagName;
1008
+ if (!['SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'NOSCRIPT'].includes(tag)) {
1009
+ // Determine if node is inside an AI assistant response container.
1010
+ // If so, ALWAYS process it — skip both inPromptInput and isInstructionNode checks.
1011
+ const isAssistant = isInsideAssistant(child.parentElement);
1012
+
1013
+ let shouldSkip = false;
1014
+ if (!isAssistant) {
1015
+ // Ignore user input areas where user types prompts actively.
1016
+ // Precise check via isPromptInput helper (looks for data-ps-attached attribute).
1017
+ const inPromptInput = isPromptInput(child.parentElement);
1018
+ if (inPromptInput) {
1019
+ shouldSkip = true;
1020
+ } else {
1021
+ // Check if inside the instruction block itself.
1022
+ const parentTc = child.parentElement.textContent;
1023
+ if (parentTc && parentTc.length < 8000 && (parentTc.includes('[Privacy Scrubber Mode]') || parentTc.includes('[SYSTEM INSTRUCTION: DATA PRIVACY MODE]'))) {
1024
+ shouldSkip = true;
1025
+ }
1026
+ }
1027
+ }
1028
+
1029
+
1030
+ if (!shouldSkip) {
1031
+ compositeRegex.lastIndex = 0;
1032
+ const hasMatch = compositeRegex.test(child.nodeValue);
1033
+ if (hasMatch) {
1034
+ if (['PRE', 'CODE'].includes(tag) || (child.parentElement.closest && child.parentElement.closest('pre, code'))) {
1035
+ const { restoredText, restoredCount: count } = unscrubText(child.nodeValue, tokenMap);
1036
+ if (count > 0) {
1037
+ child.nodeValue = restoredText;
1038
+ restoredCount += count;
1039
+ }
1040
+ } else {
1041
+ nodesToReplace.push(child);
1042
+ }
1043
+ }
1044
+ }
1045
+ }
1046
+ }
1047
+ } else if (child.nodeType === Node.ELEMENT_NODE) {
1048
+ const tag = child.tagName;
1049
+ if (!['SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'NOSCRIPT'].includes(tag)) {
1050
+ if (tag === 'IFRAME') {
1051
+ try {
1052
+ const iframeDoc = child.contentDocument || child.contentWindow?.document;
1053
+ if (iframeDoc && iframeDoc.body) {
1054
+ walkTextNodes(iframeDoc.body);
1055
+ }
1056
+ } catch (e) {
1057
+ // ignore cross-origin
1058
+ }
1059
+ } else {
1060
+ // Check if inside an AI assistant response — always walk those.
1061
+ const isAssistantEl = isInsideAssistant(child);
1062
+
1063
+ let shouldSkipEl = false;
1064
+ if (!isAssistantEl) {
1065
+ const inPromptInput = isPromptInput(child);
1066
+ if (inPromptInput) {
1067
+ shouldSkipEl = true;
1068
+ }
1069
+ // NOTE: We intentionally do NOT check textContent for
1070
+ // '[Privacy Scrubber Mode]' at the element level because
1071
+ // textContent aggregates ALL descendant text. A parent
1072
+ // container (like <main>) would match the user prompt's
1073
+ // instruction block and skip the entire subtree including
1074
+ // AI responses. The instruction check is only safe at the
1075
+ // TEXT_NODE level where parentElement scope is narrow.
1076
+ }
1077
+
1078
+ if (!shouldSkipEl) {
1079
+ walkTextNodes(child);
1080
+ if (child.shadowRoot) {
1081
+ walkTextNodes(child.shadowRoot);
1082
+ }
1083
+ }
1084
+ }
1085
+ }
1086
+ }
1087
+ child = next;
1088
+ }
1089
+ }
1090
+
1091
+ walkTextNodes(document.documentElement);
1092
+
1093
+ nodesToReplace.forEach(node => {
1094
+ const text = node.nodeValue;
1095
+ const fragment = document.createDocumentFragment();
1096
+ let lastIndex = 0;
1097
+ let match;
1098
+
1099
+ compositeRegex.lastIndex = 0;
1100
+ while ((match = compositeRegex.exec(text)) !== null) {
1101
+ if (match.index > lastIndex) {
1102
+ fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index)));
1103
+ }
1104
+ let origKey = match[0];
1105
+ for (const rule of looseRules) {
1106
+ if (new RegExp('^' + rule.patternStr + '$', 'i').test(match[0])) {
1107
+ origKey = rule.originalKey;
1108
+ break;
1109
+ }
1110
+ }
1111
+ const rawVal = tokenMap[origKey] || match[0];
1112
+
1113
+ const span = document.createElement('span');
1114
+ span.className = 'ps-restored-data';
1115
+ span.title = 'Decrypted Locally. LLMs cannot see this.';
1116
+ Object.assign(span.style, {
1117
+ borderBottom: '2px dashed #10b981',
1118
+ color: '#10b981',
1119
+ cursor: 'help',
1120
+ paddingBottom: '1px',
1121
+ fontWeight: '500',
1122
+ position: 'relative',
1123
+ zIndex: '1',
1124
+ textShadow: '0 0 5px rgba(16, 185, 129, 0.2)'
1125
+ });
1126
+ span.textContent = rawVal;
1127
+
1128
+ fragment.appendChild(span);
1129
+ restoredCount++;
1130
+ lastIndex = compositeRegex.lastIndex;
1131
+ }
1132
+ if (lastIndex < text.length) {
1133
+ fragment.appendChild(document.createTextNode(text.substring(lastIndex)));
1134
+ }
1135
+
1136
+ if (node.parentElement) {
1137
+ try {
1138
+ node.parentElement.replaceChild(fragment, node);
1139
+ } catch (e) {
1140
+ // Fail silently
1141
+ }
1142
+ }
1143
+ });
1144
+
1145
+ if (restoredCount > 0) {
1146
+ if (typeof showInPageToast === 'function') showInPageToast(`✅ Decrypted Locally! Your data is now safe to copy.`, "success");
1147
+ } else {
1148
+ if (typeof showInPageToast === 'function') showInPageToast("No active tokens found on the screen.", "info");
1149
+ }
1150
+ return restoredCount;
1151
+ }
1152
+
1153
+ function gatherUniversalAIContext() {
1154
+ try {
1155
+ // Helper function to recursively find elements matching selectors across shadow DOMs and same-origin iframes
1156
+ function querySelectorAllRecursive(root, selector) {
1157
+ // Single depth-first traversal that crosses shadow DOM boundaries.
1158
+ // Does NOT use root.querySelectorAll() to avoid duplicating shadow DOM results.
1159
+ const results = [];
1160
+ if (!root) return results;
1161
+
1162
+ function walk(node) {
1163
+ if (!node) return;
1164
+ if (node.nodeType === Node.ELEMENT_NODE) {
1165
+ try {
1166
+ if (node.matches && node.matches(selector)) results.push(node);
1167
+ } catch (e) {}
1168
+ // Recurse into shadow DOM (depth-first before light children)
1169
+ if (node.shadowRoot) walk(node.shadowRoot);
1170
+ // Recurse into same-origin iframes
1171
+ if (node.tagName === 'IFRAME') {
1172
+ try {
1173
+ const iframeDoc = node.contentDocument || node.contentWindow?.document;
1174
+ if (iframeDoc) walk(iframeDoc);
1175
+ } catch (e) {}
1176
+ return; // children handled above
1177
+ }
1178
+ } else if (node.nodeType !== Node.DOCUMENT_NODE && node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
1179
+ return; // text node, comment, etc
1180
+ }
1181
+ // Walk light DOM / document children
1182
+ let child = node.firstChild;
1183
+ while (child) { walk(child); child = child.nextSibling; }
1184
+ }
1185
+
1186
+ walk(root);
1187
+ return results;
1188
+ }
1189
+
1190
+
1191
+ // 1. Gather Input Text
1192
+ const inputs = querySelectorAllRecursive(document, 'textarea, div[contenteditable="true"], div.ProseMirror');
1193
+ // Find the active visible input, ignoring our own injected toolbar
1194
+ const activeInput = inputs.find(el => el.offsetParent !== null && el.offsetHeight > 0 && !el.closest('.ps-toolbar-container'));
1195
+ const inputText = activeInput ? (activeInput.value || activeInput.innerText || activeInput.textContent || "") : "";
1196
+
1197
+ // 2. Gather Output Text (Last AI Response)
1198
+ let outputText = "";
1199
+
1200
+ // Universal selectors covering major AI models + Claude Artifacts
1201
+ const knownSelectors = GLOBAL_ASSISTANT_SELECTORS;
1202
+
1203
+ const candidateNodes = querySelectorAllRecursive(document, knownSelectors);
1204
+
1205
+ // Filter out nodes that obviously belong to the user or sidebar/navigation
1206
+ const validResponses = Array.from(candidateNodes).filter(el => {
1207
+ // If it is inside a shadow DOM, offsetParent might be null, check offsetHeight/getBoundingClientRect
1208
+ const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
1209
+ const isVisible = (el.offsetParent !== null && el.offsetHeight > 0) || (rect && rect.height > 0 && rect.width > 0);
1210
+ if (!isVisible) return false;
1211
+
1212
+ // SIDEBAR/NAV EXCLUSION: exclude elements inside navigation or sidebar areas.
1213
+ // This prevents Kimi sidebar, Grok left panel, and similar from being captured.
1214
+ if (el.closest) {
1215
+ if (el.closest('nav, aside, header')) return false;
1216
+ if (el.closest('[role="navigation"], [role="complementary"], [role="banner"]')) return false;
1217
+ }
1218
+
1219
+ // QWEN THINKING FILTER: exclude Qwen/DeepSeek "Thinking completed" reasoning blocks.
1220
+ // These are collapsible sections shown before the actual response.
1221
+ if (el.closest) {
1222
+ if (el.closest('[class*="thinking"], [class*="reasoning"], [class*="Thinking"], [class*="chain-of-thought"]')) return false;
1223
+ if (el.classList.contains('thinking') || el.classList.contains('reasoning')) return false;
1224
+ }
1225
+ // Also exclude by text heuristic: element whose ONLY content is the thinking header
1226
+ {
1227
+ const rawText = (el.innerText || el.textContent || '').trim();
1228
+ if (rawText.toLowerCase() === 'thinking completed' || rawText.toLowerCase() === 'thinking...') return false;
1229
+ }
1230
+
1231
+ // KEY EXCLUSION: elements that contain the active textarea are page/chat wrappers,
1232
+ // never AI responses. This is the definitive fix for Kimi (and similar platforms)
1233
+ // where a large wrapper div matches a generic selector and includes the entire
1234
+ // sidebar + chat area + user input field.
1235
+ if (activeInput && el.contains(activeInput)) return false;
1236
+
1237
+ // Exclude user messages that share generic classes like .message-content
1238
+ const role = el.getAttribute('data-message-author-role');
1239
+ if (role === 'user') return false;
1240
+
1241
+ // Exclude explicit user classes
1242
+ if (el.closest) {
1243
+ if (el.closest('.user-message') || el.classList.contains('user-message')) return false;
1244
+ if (el.closest('[data-testid="user-message"]')) return false;
1245
+ if (el.closest('[data-message-author-role="user"]')) return false;
1246
+ } else {
1247
+ let parent = el.parentNode;
1248
+ while (parent) {
1249
+ if (parent.classList && (parent.classList.contains('user-message') || parent.getAttribute('data-testid') === 'user-message')) {
1250
+ return false;
1251
+ }
1252
+ parent = parent.parentNode || parent.host;
1253
+ }
1254
+ }
1255
+
1256
+ return true;
1257
+ });
1258
+
1259
+ if (validResponses.length > 0) {
1260
+ // Return the text of the very last valid response on the page.
1261
+ // De-ancestor: if element A contains element B (both matched), prefer B (more specific).
1262
+ const leafResponses = validResponses.filter((el, _, arr) =>
1263
+ !arr.some(other => other !== el && el.contains(other))
1264
+ );
1265
+ const candidates = leafResponses.length > 0 ? leafResponses : validResponses;
1266
+ const lastLeaf = candidates[candidates.length - 1];
1267
+
1268
+ // MULTI-LEAF AGGREGATION (fixes Copilot partial response):
1269
+ // When multiple leaf elements exist, check if they share a close ancestor
1270
+ // within 2 DOM levels of the last leaf. If yes, use the ancestor's full text
1271
+ // (groups same-message paragraphs). Depth limit of 2 prevents merging Kimi's
1272
+ // separate user + AI message elements whose common ancestor is 4+ levels up.
1273
+ let found = false;
1274
+ if (candidates.length > 1) {
1275
+ let probe = lastLeaf.parentElement;
1276
+ for (let depth = 0; depth < 2 && probe; depth++) {
1277
+ if (activeInput && probe.contains(activeInput)) break;
1278
+ if (probe.tagName === 'BODY') break;
1279
+ const contained = candidates.filter(el => probe.contains(el));
1280
+ if (contained.length >= 2) {
1281
+ outputText = probe.innerText || probe.textContent || '';
1282
+ found = true;
1283
+ break;
1284
+ }
1285
+ probe = probe.parentElement;
1286
+ }
1287
+ }
1288
+ if (!found) {
1289
+ outputText = lastLeaf.innerText || lastLeaf.textContent || '';
1290
+ }
1291
+ }
1292
+
1293
+
1294
+ if (!outputText.trim()) {
1295
+ // Fallback: look for any element containing tokens, excluding prompt inputs and nav/sidebar areas
1296
+ const allElements = querySelectorAllRecursive(document, 'div, p, span, li, td, input, textarea, [role="textbox"]');
1297
+ const tokenContainingElements = allElements.filter(el => {
1298
+ // Exclude navigation/sidebar areas (same as primary filter)
1299
+ if (el.closest && el.closest('nav, aside, header, [role="navigation"], [role="complementary"], [role="banner"]')) return false;
1300
+ // Exclude page/chat wrappers that contain the active input
1301
+ if (activeInput && el.contains(activeInput)) return false;
1302
+ // Exclude prompt inputs (active ones with data-ps-attached attribute)
1303
+ let parent = el;
1304
+ while (parent) {
1305
+ if (parent.dataset && parent.dataset.psAttached === 'true') return false;
1306
+ parent = parent.parentNode || parent.host;
1307
+ }
1308
+ if (['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(el.tagName)) return false;
1309
+ const text = (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') ? (el.value || "") : (el.innerText || el.textContent || "");
1310
+ return /\[[A-Z]+_[0-9]+\]/.test(text);
1311
+ });
1312
+
1313
+ let bestEl = null;
1314
+ let maxTokens = 0;
1315
+ let bestTextLength = 0;
1316
+
1317
+ for (const el of tokenContainingElements) {
1318
+ const text = (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') ? (el.value || "") : (el.innerText || el.textContent || "");
1319
+ const matches = text.match(/\[[A-Z]+_[0-9]+\]/g) || [];
1320
+ const uniqueTokens = new Set(matches).size;
1321
+ if (uniqueTokens > maxTokens || (uniqueTokens === maxTokens && uniqueTokens > 0 && text.length > bestTextLength)) {
1322
+ maxTokens = uniqueTokens;
1323
+ bestTextLength = text.length;
1324
+ bestEl = el;
1325
+ }
1326
+ }
1327
+ if (bestEl) {
1328
+ outputText = (bestEl.tagName === 'INPUT' || bestEl.tagName === 'TEXTAREA') ? (bestEl.value || "") : (bestEl.innerText || bestEl.textContent || "");
1329
+ }
1330
+ }
1331
+
1332
+ return {
1333
+ inputText: inputText.trim(),
1334
+ outputText: cleanAIPromptPrefix(outputText).trim()
1335
+ };
1336
+ } catch (e) {
1337
+ console.error("[PrivacyScrubber] Universal Context gather failed:", e);
1338
+ return { inputText: "", outputText: "" };
1339
+ }
1340
+ }
1341
+
1342
+ // Export to window for content scripts
1343
+ // Expose for OCR and advanced usage
1344
+ function getRules(profile = 'General') {
1345
+ let activeRules = [...REGEX_RULES];
1346
+ if (profile && profile.toLowerCase() !== 'general') {
1347
+ // Apply same alias map as scrubText() for consistency
1348
+ const ALIAS = {
1349
+ 'medical': 'medical', 'healthcare': 'medical',
1350
+ 'engineering': 'engineering', 'security': 'security',
1351
+ 'bizops': 'finance', 'sales': 'finance',
1352
+ 'compliance': 'legal',
1353
+ };
1354
+ const rawKey = profile.toLowerCase();
1355
+ const resolvedKey = ALIAS[rawKey] || rawKey;
1356
+ if (resolvedKey && PROFILE_RULES[resolvedKey]) {
1357
+ activeRules = [...PROFILE_RULES[resolvedKey], ...activeRules];
1358
+ }
1359
+ }
1360
+ return activeRules;
1361
+ }
1362
+
1363
+ /**
1364
+ * Show a right-click context menu for Profile Switching
1365
+ */
1366
+ function showProfileMenu(e, anchorElement, onProfileChange) {
1367
+ // Remove existing menu if any
1368
+ let existing = document.getElementById('ps-profile-menu');
1369
+ if (existing) {
1370
+ existing.remove();
1371
+ return; // Act as a toggle
1372
+ }
1373
+
1374
+ const PROFILES = [
1375
+ { id: 'General', label: 'General' },
1376
+ { id: 'Engineering', label: 'Engineering' },
1377
+ { id: 'Finance', label: 'Finance' },
1378
+ { id: 'Legal', label: 'Legal' },
1379
+ { id: 'Medical', label: 'Healthcare' },
1380
+ { id: 'HR', label: 'HR' }
1381
+ ];
1382
+
1383
+ chrome.storage.local.get(['ps_active_profile', 'ps_is_pro', 'ps_is_teams'], (data) => {
1384
+ const activeProfile = data.ps_active_profile || 'General';
1385
+ const isPro = data.ps_is_pro || data.ps_is_teams || false;
1386
+
1387
+ const menu = document.createElement('div');
1388
+ menu.id = 'ps-profile-menu';
1389
+ menu.className = 'ps-profile-menu';
1390
+
1391
+ // Positioning
1392
+ const rect = anchorElement.getBoundingClientRect();
1393
+ // Try to position it below and to the left of the button
1394
+ menu.style.top = (rect.bottom + window.scrollY + 5) + 'px';
1395
+ menu.style.left = (rect.right + window.scrollX - 150) + 'px';
1396
+
1397
+ const header = document.createElement('div');
1398
+ header.className = 'ps-profile-menu-header';
1399
+ header.innerText = 'Detection Profile';
1400
+ menu.appendChild(header);
1401
+
1402
+ PROFILES.forEach(p => {
1403
+ const item = document.createElement('div');
1404
+ item.className = 'ps-profile-menu-item';
1405
+ if (p.id === activeProfile) item.classList.add('active');
1406
+
1407
+ let labelText = p.label;
1408
+ item.innerText = labelText;
1409
+
1410
+ if (!isPro && p.id !== 'General') {
1411
+ item.style.opacity = '0.5';
1412
+ item.style.cursor = 'not-allowed';
1413
+ item.title = 'PRO Feature';
1414
+ item.innerText = labelText + ' 🔒';
1415
+ } else if (p.id === activeProfile) {
1416
+ item.innerText = labelText + ' ✓';
1417
+ }
1418
+
1419
+ item.addEventListener('click', (ev) => {
1420
+ ev.stopPropagation();
1421
+ if (!isPro && p.id !== 'General') {
1422
+ if (typeof showInPageToast === 'function') {
1423
+ showInPageToast('Specialized Profiles require PRO upgrade.', 'warning');
1424
+ }
1425
+ menu.remove();
1426
+ return;
1427
+ }
1428
+
1429
+ chrome.storage.local.set({ ps_active_profile: p.id }, () => {
1430
+ if (typeof showInPageToast === 'function') {
1431
+ showInPageToast(`Profile Switched to ${p.label}`, 'success');
1432
+ }
1433
+ menu.remove();
1434
+
1435
+ // Trigger immediate re-scrub if callback provided
1436
+ if (typeof onProfileChange === 'function') {
1437
+ onProfileChange(p.id);
1438
+ }
1439
+ });
1440
+ });
1441
+
1442
+ menu.appendChild(item);
1443
+ });
1444
+
1445
+ document.body.appendChild(menu);
1446
+
1447
+ // Close on click outside
1448
+ const closeMenu = (ev) => {
1449
+ if (!menu.contains(ev.target) && ev.target !== anchorElement) {
1450
+ menu.remove();
1451
+ document.removeEventListener('click', closeMenu);
1452
+ document.removeEventListener('contextmenu', closeMenu);
1453
+ }
1454
+ };
1455
+
1456
+ setTimeout(() => {
1457
+ document.addEventListener('click', closeMenu);
1458
+ document.addEventListener('contextmenu', closeMenu);
1459
+ }, 50);
1460
+ });
1461
+ }
1462
+
1463
+ /**
1464
+ * exportSessionToFile — Downloads the current session map as a JSON file
1465
+ */
1466
+ function exportSessionToFile(sessionMap, filename = 'ps-session', extraContext = null) {
1467
+ if (!sessionMap || Object.keys(sessionMap).length === 0) {
1468
+ if (window.showInPageToast) window.showInPageToast("No active session data to export.", "info");
1469
+ return;
1470
+ }
1471
+ try {
1472
+ const data = {
1473
+ version: "1.5.2",
1474
+ timestamp: new Date().toISOString(),
1475
+ sessionMap: sessionMap,
1476
+ context: extraContext
1477
+ };
1478
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
1479
+ const url = URL.createObjectURL(blob);
1480
+ const a = document.createElement('a');
1481
+ a.href = url;
1482
+ a.download = `${filename}-${Date.now()}.json`;
1483
+ document.body.appendChild(a);
1484
+ a.click();
1485
+ document.body.removeChild(a);
1486
+ setTimeout(() => URL.revokeObjectURL(url), 5000);
1487
+ if (window.showInPageToast) window.showInPageToast("✓ Session exported successfully", "success");
1488
+ } catch (e) {
1489
+ console.error("PrivacyScrubber Export Error:", e);
1490
+ if (window.showInPageToast) window.showInPageToast("❌ Export failed.", "error");
1491
+ }
1492
+ }
1493
+
1494
+ if (typeof window !== 'undefined') {
1495
+ window.PrivacyScrubberCore = {
1496
+ init,
1497
+ scrubText,
1498
+ unscrubText,
1499
+ unscrubTextAsHTML,
1500
+ showTeamsPassphraseModal,
1501
+ scrubDataInDOM,
1502
+ revealDataInDOM,
1503
+ gatherUniversalAIContext,
1504
+ exportSessionToFile,
1505
+ getRules,
1506
+ showProfileMenu,
1507
+ cleanAIPromptPrefix,
1508
+ isInitialized: false
1509
+ };
1510
+ window.showInPageToast = showInPageToast;
1511
+
1512
+ // v1.4.4: Automatic re-hydration on boot from local storage cache
1513
+ if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local) {
1514
+ chrome.storage.local.get(['ps_rules_cache'], (data) => {
1515
+ if (data.ps_rules_cache) {
1516
+ init(data.ps_rules_cache);
1517
+ }
1518
+ });
1519
+ }
1520
+ } else if (typeof module !== 'undefined' && module.exports) {
1521
+ module.exports = {
1522
+ init,
1523
+ scrubText,
1524
+ unscrubText,
1525
+ getRules,
1526
+ unscrubTextAsHTML,
1527
+ hydrateRegex,
1528
+ cleanAIPromptPrefix
1529
+ };
1530
+ }
1531
+ })();
1532
+