Merge pull request #11 from outis1one/claude/read-repo-wA3y1
Claude/read repo w a3y1
This commit is contained in:
@@ -244,3 +244,113 @@
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Document scan preview overlay */
|
||||
.ss-doc-preview {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.95);
|
||||
max-width: 480px;
|
||||
width: 90vw;
|
||||
background: #1a1a1a;
|
||||
color: #e5e7eb;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 12px;
|
||||
z-index: 9999999;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ss-doc-preview.visible {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ss-dp-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ss-dp-count {
|
||||
font-size: 11px;
|
||||
color: #f59e0b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ss-dp-note {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ss-dp-items {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ss-dp-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #333;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ss-dp-item:last-child { border-bottom: none; }
|
||||
|
||||
.ss-dp-orig {
|
||||
color: #f87171;
|
||||
background: #1f1f1f;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.ss-dp-repl {
|
||||
color: #4ade80;
|
||||
background: #1f1f1f;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.ss-dp-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ss-dp-btn {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-dp-confirm {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ss-dp-confirm:hover { background: #059669; }
|
||||
|
||||
.ss-dp-cancel {
|
||||
background: #374151;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.ss-dp-cancel:hover { background: #4b5563; }
|
||||
|
||||
+396
-21
@@ -765,35 +765,49 @@
|
||||
const urlStr = typeof url === 'string' ? url : url?.url || '';
|
||||
const method = (options?.method || 'GET').toUpperCase();
|
||||
|
||||
// Only intercept POST/PUT/PATCH with a string body
|
||||
// Only intercept POST/PUT/PATCH with a body
|
||||
if (
|
||||
(method === 'POST' || method === 'PUT' || method === 'PATCH') &&
|
||||
options?.body && typeof options.body === 'string' &&
|
||||
options?.body &&
|
||||
!shouldSkipUrl(urlStr)
|
||||
) {
|
||||
try {
|
||||
// Try JSON
|
||||
const body = JSON.parse(options.body);
|
||||
const { modified, replacements } = processBody(body);
|
||||
|
||||
if (modified) {
|
||||
options = { ...options, body: JSON.stringify(body) };
|
||||
notifySubstitutions(replacements);
|
||||
console.log(
|
||||
`[Silent Send] Substituted ${replacements.length} value(s) in ${urlStr}`
|
||||
);
|
||||
// Handle FormData with file uploads
|
||||
if (options.body instanceof FormData) {
|
||||
try {
|
||||
const newFormData = await processFormData(options.body);
|
||||
if (newFormData) {
|
||||
options = { ...options, body: newFormData };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Silent Send] FormData processing failed:', e);
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON — try raw string substitution (form data, etc.)
|
||||
if (options.body.length > MIN_STRING_LENGTH) {
|
||||
const result = substituteAll(options.body);
|
||||
if (result.modified) {
|
||||
options = { ...options, body: result.text };
|
||||
notifySubstitutions(result.replacements);
|
||||
}
|
||||
// Handle string bodies (JSON, raw text)
|
||||
else if (typeof options.body === 'string') {
|
||||
try {
|
||||
// Try JSON
|
||||
const body = JSON.parse(options.body);
|
||||
const { modified, replacements } = processBody(body);
|
||||
|
||||
if (modified) {
|
||||
options = { ...options, body: JSON.stringify(body) };
|
||||
notifySubstitutions(replacements);
|
||||
console.log(
|
||||
`[Silent Send] Substituted ${result.replacements.length} value(s) in form body`
|
||||
`[Silent Send] Substituted ${replacements.length} value(s) in ${urlStr}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON — try raw string substitution (form data, etc.)
|
||||
if (options.body.length > MIN_STRING_LENGTH) {
|
||||
const result = substituteAll(options.body);
|
||||
if (result.modified) {
|
||||
options = { ...options, body: result.text };
|
||||
notifySubstitutions(result.replacements);
|
||||
console.log(
|
||||
`[Silent Send] Substituted ${result.replacements.length} value(s) in form body`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -801,6 +815,367 @@
|
||||
return originalFetch.call(this, url, options);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Document Upload Processing
|
||||
//
|
||||
// Scans files in FormData uploads for PPI. Supports PDF, DOCX,
|
||||
// XLSX, and text files. Shows preview for binary formats.
|
||||
// ============================================================
|
||||
|
||||
async function processFormData(formData) {
|
||||
let modified = false;
|
||||
const newFormData = new FormData();
|
||||
const allReplacements = [];
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value instanceof File && value.size > 0) {
|
||||
// Process the file through document scanner
|
||||
const usePreview = settings.docScanPreview !== false &&
|
||||
/\.(pdf|docx|xlsx)$/i.test(value.name);
|
||||
|
||||
const result = await documentScan(value, value.name, {
|
||||
previewMode: usePreview,
|
||||
});
|
||||
|
||||
if (result.preview && usePreview && result.replacements.length > 0) {
|
||||
// Show preview and wait for user confirmation
|
||||
const confirmed = await showDocScanPreview(result.preview, value.name);
|
||||
if (!confirmed) {
|
||||
// User cancelled — use original file
|
||||
newFormData.append(key, value);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.replacements.length > 0 && !result.skipped) {
|
||||
let uploadFile = result.file;
|
||||
|
||||
// If preview was confirmed and we have sanitized text, use it
|
||||
if (result._sanitizedText) {
|
||||
uploadFile = new Blob([result._sanitizedText], { type: 'text/plain' });
|
||||
}
|
||||
|
||||
const newFile = new File([uploadFile], result.filename || value.name, {
|
||||
type: uploadFile.type || value.type,
|
||||
});
|
||||
newFormData.append(key, newFile);
|
||||
allReplacements.push(...result.replacements);
|
||||
modified = true;
|
||||
console.log(
|
||||
`[Silent Send] Substituted ${result.replacements.length} value(s) in file: ${value.name}`
|
||||
);
|
||||
} else {
|
||||
newFormData.append(key, value);
|
||||
}
|
||||
} else if (typeof value === 'string') {
|
||||
// String form field — substitute
|
||||
const result = substituteAll(value);
|
||||
if (result.modified) {
|
||||
newFormData.append(key, result.text);
|
||||
allReplacements.push(...result.replacements);
|
||||
modified = true;
|
||||
} else {
|
||||
newFormData.append(key, value);
|
||||
}
|
||||
} else {
|
||||
newFormData.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
notifySubstitutions(allReplacements);
|
||||
return newFormData;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a document file for PPI. Strategy: extract text from any
|
||||
* format, substitute PPI, upload as plaintext. The AI extracts text
|
||||
* from files anyway — no need to preserve formatting in a file
|
||||
* the user never gets back. Original stays untouched on disk.
|
||||
*
|
||||
* Supported: PDF, DOCX, DOC, XLSX, XLS, ODT, ODS, ODP, PPTX, RTF,
|
||||
* and all text/code formats.
|
||||
*/
|
||||
async function documentScan(file, filename, options) {
|
||||
const ext = (filename || '').split('.').pop().toLowerCase();
|
||||
|
||||
// Plain text formats — direct substitution, keep original extension
|
||||
const textExts = new Set(['txt','csv','tsv','json','md','markdown','log',
|
||||
'yaml','yml','toml','ini','cfg','conf','xml','html','htm','css',
|
||||
'js','ts','py','rb','go','rs','java','c','cpp','h','hpp','sh',
|
||||
'bash','zsh','ps1','bat','sql','r','swift','kt','scala','pl','php',
|
||||
'lua','vim','env','gitignore']);
|
||||
|
||||
if (textExts.has(ext)) {
|
||||
const text = await file.text();
|
||||
const result = substituteAll(text);
|
||||
if (result.modified) {
|
||||
return {
|
||||
file: new Blob([result.text], { type: file.type || 'text/plain' }),
|
||||
filename, replacements: result.replacements,
|
||||
};
|
||||
}
|
||||
return { file, filename, replacements: [] };
|
||||
}
|
||||
|
||||
// Binary document formats — extract text, substitute, upload as .txt
|
||||
const docExts = new Set(['pdf','docx','doc','xlsx','xls','odt','ods',
|
||||
'odp','rtf','pptx']);
|
||||
|
||||
if (!docExts.has(ext)) {
|
||||
return { file, filename, replacements: [], skipped: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await extractTextFromDocument(file, ext);
|
||||
|
||||
if (!text || text.trim().length < 5) {
|
||||
return { file, filename, replacements: [], skipped: true,
|
||||
reason: `No extractable text in ${ext.toUpperCase()} (may be scanned/image-only)` };
|
||||
}
|
||||
|
||||
const result = substituteAll(text);
|
||||
const preview = {
|
||||
format: ext,
|
||||
replacementCount: result.replacements.length,
|
||||
replacements: result.replacements.slice(0, 15),
|
||||
note: `${ext.toUpperCase()} text extracted and sanitized for upload`,
|
||||
};
|
||||
|
||||
if (options.previewMode && result.replacements.length > 0) {
|
||||
return { file, filename, replacements: result.replacements, preview,
|
||||
_sanitizedText: result.text };
|
||||
}
|
||||
|
||||
if (result.modified) {
|
||||
return {
|
||||
file: new Blob([result.text], { type: 'text/plain' }),
|
||||
filename: filename.replace(/\.[^.]+$/, '.txt'),
|
||||
replacements: result.replacements, preview,
|
||||
};
|
||||
}
|
||||
return { file, filename, replacements: [] };
|
||||
} catch (e) {
|
||||
console.warn(`[Silent Send] ${ext.toUpperCase()} processing failed:`, e);
|
||||
return { file, filename, replacements: [], skipped: true, reason: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from any supported document format.
|
||||
*/
|
||||
async function extractTextFromDocument(file, ext) {
|
||||
switch (ext) {
|
||||
case 'pdf': return extractPDFText(file);
|
||||
case 'docx': case 'xlsx': case 'pptx':
|
||||
case 'odt': case 'ods': case 'odp':
|
||||
return extractZipXMLText(file, ext);
|
||||
case 'doc': case 'xls':
|
||||
return extractOldBinaryText(file);
|
||||
case 'rtf':
|
||||
return extractRTFText(file);
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** PDF: extract text from content stream operators (Tj, TJ). */
|
||||
async function extractPDFText(file) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const str = new TextDecoder('latin1').decode(new Uint8Array(buffer));
|
||||
const texts = [];
|
||||
const re = /stream\r?\n([\s\S]*?)endstream/g;
|
||||
let m;
|
||||
while ((m = re.exec(str)) !== null) {
|
||||
const content = m[1];
|
||||
const parts = [];
|
||||
const tj = /\(([^)]*)\)\s*Tj/g;
|
||||
let t;
|
||||
while ((t = tj.exec(content)) !== null) {
|
||||
parts.push(t[1].replace(/\\([nrt\\()])/g, (_, c) =>
|
||||
c === 'n' ? '\n' : c === 'r' ? '\r' : c === 't' ? '\t' : c));
|
||||
}
|
||||
const tjArr = /\[(.*?)\]\s*TJ/g;
|
||||
while ((t = tjArr.exec(content)) !== null) {
|
||||
const inner = /\(([^)]*)\)/g;
|
||||
let s;
|
||||
while ((s = inner.exec(t[1])) !== null) parts.push(s[1]);
|
||||
}
|
||||
if (parts.length) texts.push(parts.join(''));
|
||||
}
|
||||
return texts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* DOCX/XLSX/PPTX/ODT/ODS/ODP: extract text from ZIP XML entries.
|
||||
*/
|
||||
async function extractZipXMLText(file, ext) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const texts = [];
|
||||
|
||||
// Scan for ZIP local file headers
|
||||
let pos = 0;
|
||||
while (pos < bytes.length - 30) {
|
||||
if (bytes[pos] !== 0x50 || bytes[pos+1] !== 0x4b ||
|
||||
bytes[pos+2] !== 0x03 || bytes[pos+3] !== 0x04) {
|
||||
pos++; continue;
|
||||
}
|
||||
const view = new DataView(buffer, pos);
|
||||
const compMethod = view.getUint16(8, true);
|
||||
const compSize = view.getUint32(18, true);
|
||||
const nameLen = view.getUint16(26, true);
|
||||
const extraLen = view.getUint16(28, true);
|
||||
const name = new TextDecoder().decode(bytes.slice(pos + 30, pos + 30 + nameLen));
|
||||
const dataStart = pos + 30 + nameLen + extraLen;
|
||||
const rawData = bytes.slice(dataStart, dataStart + compSize);
|
||||
|
||||
if (isTextXML(name, ext) && compSize > 0) {
|
||||
let xmlStr;
|
||||
try {
|
||||
if (compMethod === 8) {
|
||||
const dec = await inflateData(rawData);
|
||||
xmlStr = dec ? new TextDecoder('utf-8').decode(dec) : null;
|
||||
} else if (compMethod === 0) {
|
||||
xmlStr = new TextDecoder('utf-8').decode(rawData);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
if (xmlStr) {
|
||||
const re2 = />([^<]+)</g;
|
||||
let m2;
|
||||
while ((m2 = re2.exec(xmlStr)) !== null) {
|
||||
const t = m2[1].trim();
|
||||
if (t.length >= 2 && !/^[\x00-\x1f]+$/.test(t)) texts.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
pos = dataStart + compSize;
|
||||
}
|
||||
return texts.join(' ');
|
||||
}
|
||||
|
||||
function isTextXML(name, ext) {
|
||||
switch (ext) {
|
||||
case 'docx': return /^word\/(document|header|footer|comments|endnotes|footnotes)/i.test(name);
|
||||
case 'xlsx': return name === 'xl/sharedStrings.xml' || /^xl\/worksheets\/sheet/i.test(name);
|
||||
case 'pptx': return /^ppt\/slides\/slide/i.test(name);
|
||||
case 'odt': case 'odp': return name === 'content.xml' || name === 'styles.xml';
|
||||
case 'ods': return name === 'content.xml';
|
||||
default: return name.endsWith('.xml');
|
||||
}
|
||||
}
|
||||
|
||||
/** Old binary .doc/.xls: extract readable text runs. */
|
||||
async function extractOldBinaryText(file) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const texts = [];
|
||||
// UTF-16LE extraction (Word stores text as UTF-16)
|
||||
let cur = '';
|
||||
for (let i = 0; i < bytes.length - 1; i += 2) {
|
||||
const code = bytes[i] | (bytes[i + 1] << 8);
|
||||
if (code >= 32 && code < 127) { cur += String.fromCharCode(code); }
|
||||
else { if (cur.length >= 3) texts.push(cur); cur = ''; }
|
||||
}
|
||||
if (cur.length >= 3) texts.push(cur);
|
||||
// ASCII fallback
|
||||
cur = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
if (bytes[i] >= 32 && bytes[i] < 127) { cur += String.fromCharCode(bytes[i]); }
|
||||
else { if (cur.length >= 4) texts.push(cur); cur = ''; }
|
||||
}
|
||||
if (cur.length >= 4) texts.push(cur);
|
||||
const seen = new Set();
|
||||
return texts.filter(t => {
|
||||
if (seen.has(t)) return false;
|
||||
seen.add(t);
|
||||
return t.includes(' ') || t.length >= 8;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
/** RTF: strip formatting, extract text. */
|
||||
async function extractRTFText(file) {
|
||||
const text = await file.text();
|
||||
return text
|
||||
.replace(/\{\\[^{}]*\}/g, '')
|
||||
.replace(/\\[a-z]+\d*\s?/gi, '')
|
||||
.replace(/[{}]/g, '')
|
||||
.replace(/\\\\/g, '\\')
|
||||
.replace(/\\\'([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Decompress DEFLATE data using DecompressionStream API. */
|
||||
async function inflateData(data) {
|
||||
if (typeof DecompressionStream === 'undefined') return null;
|
||||
try {
|
||||
const ds = new DecompressionStream('deflate');
|
||||
const writer = ds.writable.getWriter();
|
||||
const reader = ds.readable.getReader();
|
||||
writer.write(data); writer.close();
|
||||
const chunks = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
const total = chunks.reduce((s, c) => s + c.length, 0);
|
||||
const result = new Uint8Array(total);
|
||||
let p = 0;
|
||||
for (const c of chunks) { result.set(c, p); p += c.length; }
|
||||
return result;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Document Scan Preview UI
|
||||
let docPreviewEl = null;
|
||||
|
||||
function showDocScanPreview(preview, filename) {
|
||||
return new Promise((resolve) => {
|
||||
if (!docPreviewEl) {
|
||||
docPreviewEl = document.createElement('div');
|
||||
docPreviewEl.className = 'ss-doc-preview';
|
||||
document.body.appendChild(docPreviewEl);
|
||||
}
|
||||
const items = (preview.replacements || []).map(r =>
|
||||
`<div class="ss-dp-item">
|
||||
<code class="ss-dp-orig">${(r.original || '').length > 25 ? r.original.slice(0, 22) + '...' : r.original}</code>
|
||||
<span>→</span>
|
||||
<code class="ss-dp-repl">${r.replaced}</code>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
docPreviewEl.innerHTML = `
|
||||
<div class="ss-dp-header">
|
||||
<strong>PPI found in ${esc(filename)}</strong>
|
||||
<span class="ss-dp-count">${preview.replacementCount} item(s)</span>
|
||||
</div>
|
||||
<div class="ss-dp-note">${preview.note || ''}</div>
|
||||
<div class="ss-dp-items">${items}</div>
|
||||
<div class="ss-dp-actions">
|
||||
<button class="ss-dp-btn ss-dp-confirm">Substitute & Upload</button>
|
||||
<button class="ss-dp-btn ss-dp-cancel">Upload Original</button>
|
||||
</div>
|
||||
`;
|
||||
docPreviewEl.classList.add('visible');
|
||||
const confirm = docPreviewEl.querySelector('.ss-dp-confirm');
|
||||
const cancel = docPreviewEl.querySelector('.ss-dp-cancel');
|
||||
const cleanup = () => {
|
||||
docPreviewEl.classList.remove('visible');
|
||||
confirm.removeEventListener('click', onConfirm);
|
||||
cancel.removeEventListener('click', onCancel);
|
||||
};
|
||||
const onConfirm = () => { cleanup(); resolve(true); };
|
||||
const onCancel = () => { cleanup(); resolve(false); };
|
||||
confirm.addEventListener('click', onConfirm);
|
||||
cancel.addEventListener('click', onCancel);
|
||||
setTimeout(() => {
|
||||
if (docPreviewEl.classList.contains('visible')) { cleanup(); resolve(false); }
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// XMLHttpRequest Interception — same aggressive approach
|
||||
// ============================================================
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* Silent Send - Document Scanner
|
||||
*
|
||||
* Scans uploaded documents for PPI and substitutes/redacts before
|
||||
* the file reaches the AI service.
|
||||
*
|
||||
* Supported formats:
|
||||
* - PDF: Extract text, scan for PPI, create sanitized plaintext version
|
||||
* (PDFs can't be reliably edited in-place without breaking layout)
|
||||
* - DOCX: Parse XML, find-replace text, repackage ZIP (layout preserved)
|
||||
* - XLSX: Parse cells, find-replace values, repackage (formatting preserved)
|
||||
* - TXT/CSV/JSON/MD: Direct string replacement
|
||||
*
|
||||
* Modes:
|
||||
* - Silent: substitute and upload (default for text files)
|
||||
* - Preview: show PPI findings, let user confirm before upload (default for PDF/DOCX/XLSX)
|
||||
*
|
||||
* Integration:
|
||||
* - The fetch interceptor in content.js detects multipart/form-data uploads
|
||||
* - Calls DocumentScanner.processUpload() which returns the modified file
|
||||
* - Badge count includes document substitutions
|
||||
*/
|
||||
|
||||
const DocumentScanner = {
|
||||
/**
|
||||
* Process a file upload. Detects format and applies appropriate strategy.
|
||||
*
|
||||
* @param {File|Blob} file - the file being uploaded
|
||||
* @param {string} filename - original filename
|
||||
* @param {Function} substituteAll - the substituteAll function from content.js
|
||||
* @param {Object} options - { previewMode: boolean }
|
||||
* @returns {{ file: Blob, filename: string, replacements: Array, preview?: Object, skipped?: boolean }}
|
||||
*/
|
||||
async processUpload(file, filename, substituteAll, options = {}) {
|
||||
const ext = (filename || '').split('.').pop().toLowerCase();
|
||||
const type = this._detectType(ext, file.type);
|
||||
|
||||
switch (type) {
|
||||
case 'text':
|
||||
return this._processText(file, filename, substituteAll);
|
||||
case 'pdf':
|
||||
return this._processPDF(file, filename, substituteAll, options);
|
||||
case 'docx':
|
||||
return this._processDOCX(file, filename, substituteAll, options);
|
||||
case 'xlsx':
|
||||
return this._processXLSX(file, filename, substituteAll, options);
|
||||
default:
|
||||
// Unsupported format — pass through unchanged
|
||||
return { file, filename, replacements: [], skipped: true };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Plain text files: direct string replacement.
|
||||
*/
|
||||
async _processText(file, filename, substituteAll) {
|
||||
const text = await file.text();
|
||||
const result = substituteAll(text);
|
||||
|
||||
if (!result.modified) {
|
||||
return { file, filename, replacements: [] };
|
||||
}
|
||||
|
||||
const newBlob = new Blob([result.text], { type: file.type || 'text/plain' });
|
||||
return {
|
||||
file: newBlob,
|
||||
filename,
|
||||
replacements: result.replacements,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* PDF: extract text, scan for PPI, create sanitized text file.
|
||||
* PDFs can't be reliably edited in-place, so we extract text,
|
||||
* substitute PPI, and send the clean text instead.
|
||||
*/
|
||||
async _processPDF(file, filename, substituteAll, options) {
|
||||
const text = await this._extractPDFText(file);
|
||||
if (!text || text.trim().length === 0) {
|
||||
// Scanned PDF or image-only — can't extract text
|
||||
return {
|
||||
file,
|
||||
filename,
|
||||
replacements: [],
|
||||
skipped: true,
|
||||
reason: 'No extractable text in PDF (may be scanned/image-only)',
|
||||
};
|
||||
}
|
||||
|
||||
const result = substituteAll(text);
|
||||
|
||||
if (!result.modified && !options.previewMode) {
|
||||
return { file, filename, replacements: [] };
|
||||
}
|
||||
|
||||
// Build preview data
|
||||
const preview = {
|
||||
originalLength: text.length,
|
||||
substitutedLength: result.text.length,
|
||||
replacementCount: result.replacements.length,
|
||||
replacements: result.replacements.slice(0, 20), // limit preview
|
||||
format: 'pdf',
|
||||
note: 'PDF will be converted to plain text for upload (layout not preserved)',
|
||||
};
|
||||
|
||||
if (options.previewMode) {
|
||||
return { file, filename, replacements: result.replacements, preview };
|
||||
}
|
||||
|
||||
// Create sanitized text file
|
||||
const sanitizedBlob = new Blob([result.text], { type: 'text/plain' });
|
||||
const sanitizedFilename = filename.replace(/\.pdf$/i, '.txt');
|
||||
|
||||
return {
|
||||
file: sanitizedBlob,
|
||||
filename: sanitizedFilename,
|
||||
replacements: result.replacements,
|
||||
preview,
|
||||
convertedFrom: 'pdf',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* DOCX: parse the ZIP, find-replace text in XML content, repackage.
|
||||
* Layout, formatting, images, and styles are preserved.
|
||||
*/
|
||||
async _processDOCX(file, filename, substituteAll, options) {
|
||||
try {
|
||||
const zip = await this._readZip(file);
|
||||
let totalReplacements = [];
|
||||
let modified = false;
|
||||
|
||||
// DOCX text is in word/document.xml (main body), word/header*.xml,
|
||||
// word/footer*.xml, and word/comments.xml
|
||||
const textFiles = Object.keys(zip.files).filter(name =>
|
||||
/^word\/(document|header\d*|footer\d*|comments|endnotes|footnotes)\.xml$/.test(name)
|
||||
);
|
||||
|
||||
for (const xmlPath of textFiles) {
|
||||
const xmlContent = await zip.files[xmlPath].async('string');
|
||||
|
||||
// Extract text runs from XML, substitute, and rebuild
|
||||
const { xml: newXml, replacements } = this._substituteInXML(xmlContent, substituteAll);
|
||||
|
||||
if (replacements.length > 0) {
|
||||
zip.files[xmlPath] = { data: newXml, isText: true };
|
||||
totalReplacements.push(...replacements);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!modified && !options.previewMode) {
|
||||
return { file, filename, replacements: [] };
|
||||
}
|
||||
|
||||
const preview = {
|
||||
replacementCount: totalReplacements.length,
|
||||
replacements: totalReplacements.slice(0, 20),
|
||||
format: 'docx',
|
||||
note: 'Text in document body, headers, and footers will be substituted. Formatting preserved.',
|
||||
};
|
||||
|
||||
if (options.previewMode) {
|
||||
return { file, filename, replacements: totalReplacements, preview };
|
||||
}
|
||||
|
||||
// Repackage the ZIP
|
||||
const newBlob = await this._writeZip(zip);
|
||||
return {
|
||||
file: newBlob,
|
||||
filename,
|
||||
replacements: totalReplacements,
|
||||
preview,
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[Silent Send] DOCX processing failed:', e);
|
||||
return { file, filename, replacements: [], skipped: true, reason: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* XLSX: parse the ZIP, find-replace text in shared strings and sheet cells.
|
||||
*/
|
||||
async _processXLSX(file, filename, substituteAll, options) {
|
||||
try {
|
||||
const zip = await this._readZip(file);
|
||||
let totalReplacements = [];
|
||||
let modified = false;
|
||||
|
||||
// XLSX stores shared strings in xl/sharedStrings.xml
|
||||
// and inline strings in xl/worksheets/sheet*.xml
|
||||
const targetFiles = Object.keys(zip.files).filter(name =>
|
||||
name === 'xl/sharedStrings.xml' ||
|
||||
/^xl\/worksheets\/sheet\d+\.xml$/.test(name)
|
||||
);
|
||||
|
||||
for (const xmlPath of targetFiles) {
|
||||
const xmlContent = await zip.files[xmlPath].async('string');
|
||||
const { xml: newXml, replacements } = this._substituteInXML(xmlContent, substituteAll);
|
||||
|
||||
if (replacements.length > 0) {
|
||||
zip.files[xmlPath] = { data: newXml, isText: true };
|
||||
totalReplacements.push(...replacements);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!modified && !options.previewMode) {
|
||||
return { file, filename, replacements: [] };
|
||||
}
|
||||
|
||||
const preview = {
|
||||
replacementCount: totalReplacements.length,
|
||||
replacements: totalReplacements.slice(0, 20),
|
||||
format: 'xlsx',
|
||||
note: 'Cell values and shared strings will be substituted. Formatting and formulas preserved.',
|
||||
};
|
||||
|
||||
if (options.previewMode) {
|
||||
return { file, filename, replacements: totalReplacements, preview };
|
||||
}
|
||||
|
||||
const newBlob = await this._writeZip(zip);
|
||||
return {
|
||||
file: newBlob,
|
||||
filename,
|
||||
replacements: totalReplacements,
|
||||
preview,
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[Silent Send] XLSX processing failed:', e);
|
||||
return { file, filename, replacements: [], skipped: true, reason: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// PDF text extraction (no external libraries)
|
||||
//
|
||||
// Parses PDF content streams to extract text. Handles the common
|
||||
// text operators (Tj, TJ, ', "). Doesn't handle every PDF edge
|
||||
// case but works for most text-based PDFs.
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
async _extractPDFText(file) {
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const str = new TextDecoder('latin1').decode(bytes);
|
||||
|
||||
// Find all stream...endstream blocks
|
||||
const texts = [];
|
||||
const streamRegex = /stream\r?\n([\s\S]*?)endstream/g;
|
||||
let match;
|
||||
|
||||
while ((match = streamRegex.exec(str)) !== null) {
|
||||
const streamData = match[1];
|
||||
|
||||
// Try to decompress if FlateDecode
|
||||
let content = streamData;
|
||||
const filterMatch = str.slice(Math.max(0, match.index - 200), match.index)
|
||||
.match(/\/Filter\s*\/FlateDecode/);
|
||||
|
||||
if (filterMatch) {
|
||||
try {
|
||||
const compressed = new Uint8Array(
|
||||
[...streamData].map(c => c.charCodeAt(0))
|
||||
);
|
||||
const decompressed = this._inflateSync(compressed);
|
||||
if (decompressed) {
|
||||
content = new TextDecoder('latin1').decode(decompressed);
|
||||
}
|
||||
} catch { /* use raw */ }
|
||||
}
|
||||
|
||||
// Extract text from PDF operators
|
||||
const extracted = this._extractTextFromOperators(content);
|
||||
if (extracted) texts.push(extracted);
|
||||
}
|
||||
|
||||
return texts.join('\n').trim();
|
||||
} catch (e) {
|
||||
console.warn('[Silent Send] PDF text extraction failed:', e);
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Extract text from PDF content stream operators.
|
||||
* Handles Tj (show string), TJ (show array), ' and " (next line + show).
|
||||
*/
|
||||
_extractTextFromOperators(content) {
|
||||
const parts = [];
|
||||
|
||||
// Match string operands: (text) Tj, [(text) ...] TJ
|
||||
// Tj operator: (string) Tj
|
||||
const tjRegex = /\(([^)]*)\)\s*Tj/g;
|
||||
let m;
|
||||
while ((m = tjRegex.exec(content)) !== null) {
|
||||
parts.push(this._decodePDFString(m[1]));
|
||||
}
|
||||
|
||||
// TJ operator: [(string) num (string) ...] TJ
|
||||
const tjArrayRegex = /\[((?:[^[\]]*?))\]\s*TJ/g;
|
||||
while ((m = tjArrayRegex.exec(content)) !== null) {
|
||||
const inner = m[1];
|
||||
const stringRegex = /\(([^)]*)\)/g;
|
||||
let s;
|
||||
while ((s = stringRegex.exec(inner)) !== null) {
|
||||
parts.push(this._decodePDFString(s[1]));
|
||||
}
|
||||
}
|
||||
|
||||
// ' operator: (string) '
|
||||
const singleQuoteRegex = /\(([^)]*)\)\s*'/g;
|
||||
while ((m = singleQuoteRegex.exec(content)) !== null) {
|
||||
parts.push('\n' + this._decodePDFString(m[1]));
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
},
|
||||
|
||||
/**
|
||||
* Decode PDF string escapes.
|
||||
*/
|
||||
_decodePDFString(str) {
|
||||
return str
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\r/g, '\r')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\\\/g, '\\')
|
||||
.replace(/\\([()])/g, '$1')
|
||||
.replace(/\\(\d{1,3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)));
|
||||
},
|
||||
|
||||
/**
|
||||
* Simple DEFLATE decompression using DecompressionStream API.
|
||||
* Returns null if not available or decompression fails.
|
||||
*/
|
||||
async _inflateSync(data) {
|
||||
if (typeof DecompressionStream === 'undefined') return null;
|
||||
try {
|
||||
const ds = new DecompressionStream('deflate');
|
||||
const writer = ds.writable.getWriter();
|
||||
const reader = ds.readable.getReader();
|
||||
|
||||
writer.write(data);
|
||||
writer.close();
|
||||
|
||||
const chunks = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// ZIP read/write (no external libraries)
|
||||
//
|
||||
// Minimal ZIP parser for DOCX/XLSX. These are standard ZIP files
|
||||
// containing XML. We only need to read/write text entries.
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
async _readZip(file) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const files = {};
|
||||
|
||||
// Find end of central directory record
|
||||
let eocdOffset = -1;
|
||||
for (let i = bytes.length - 22; i >= 0; i--) {
|
||||
if (bytes[i] === 0x50 && bytes[i + 1] === 0x4b &&
|
||||
bytes[i + 2] === 0x05 && bytes[i + 3] === 0x06) {
|
||||
eocdOffset = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (eocdOffset === -1) throw new Error('Not a valid ZIP file');
|
||||
|
||||
const view = new DataView(buffer);
|
||||
const cdOffset = view.getUint32(eocdOffset + 16, true);
|
||||
const cdEntries = view.getUint16(eocdOffset + 10, true);
|
||||
|
||||
// Read central directory entries
|
||||
let offset = cdOffset;
|
||||
for (let i = 0; i < cdEntries; i++) {
|
||||
if (view.getUint32(offset, true) !== 0x02014b50) break;
|
||||
|
||||
const compMethod = view.getUint16(offset + 10, true);
|
||||
const compSize = view.getUint32(offset + 20, true);
|
||||
const uncompSize = view.getUint32(offset + 24, true);
|
||||
const nameLen = view.getUint16(offset + 28, true);
|
||||
const extraLen = view.getUint16(offset + 30, true);
|
||||
const commentLen = view.getUint16(offset + 32, true);
|
||||
const localHeaderOffset = view.getUint32(offset + 42, true);
|
||||
|
||||
const name = new TextDecoder().decode(bytes.slice(offset + 46, offset + 46 + nameLen));
|
||||
|
||||
// Read from local file header
|
||||
const localNameLen = view.getUint16(localHeaderOffset + 26, true);
|
||||
const localExtraLen = view.getUint16(localHeaderOffset + 28, true);
|
||||
const dataOffset = localHeaderOffset + 30 + localNameLen + localExtraLen;
|
||||
const rawData = bytes.slice(dataOffset, dataOffset + compSize);
|
||||
|
||||
files[name] = {
|
||||
compMethod,
|
||||
compSize,
|
||||
uncompSize,
|
||||
rawData,
|
||||
async async(type) {
|
||||
let data = this.rawData;
|
||||
if (this.compMethod === 8) {
|
||||
// Deflate compressed
|
||||
data = await DocumentScanner._inflateSync(data);
|
||||
if (!data) throw new Error('Decompression failed for ' + name);
|
||||
}
|
||||
if (type === 'string') {
|
||||
return new TextDecoder().decode(data);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
offset += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
|
||||
return { files, _originalBuffer: buffer, _originalBytes: bytes };
|
||||
},
|
||||
|
||||
async _writeZip(zip) {
|
||||
// Rebuild ZIP with modified entries
|
||||
// For simplicity: store all modified entries uncompressed,
|
||||
// copy unmodified entries as-is from the original buffer
|
||||
const parts = [];
|
||||
const centralDir = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const [name, entry] of Object.entries(zip.files)) {
|
||||
const nameBytes = new TextEncoder().encode(name);
|
||||
let data;
|
||||
|
||||
if (entry.isText && entry.data) {
|
||||
// Modified entry — store uncompressed
|
||||
data = new TextEncoder().encode(entry.data);
|
||||
} else if (entry.rawData) {
|
||||
// Unmodified — keep original compression
|
||||
data = entry.rawData;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isStored = entry.isText || entry.compMethod === 0;
|
||||
const compMethod = isStored ? 0 : entry.compMethod;
|
||||
const compSize = data.length;
|
||||
const uncompSize = isStored ? data.length : (entry.uncompSize || data.length);
|
||||
|
||||
// Local file header
|
||||
const localHeader = new Uint8Array(30 + nameBytes.length);
|
||||
const lhView = new DataView(localHeader.buffer);
|
||||
lhView.setUint32(0, 0x04034b50, true); // signature
|
||||
lhView.setUint16(4, 20, true); // version needed
|
||||
lhView.setUint16(8, compMethod, true);
|
||||
lhView.setUint32(18, compSize, true);
|
||||
lhView.setUint32(22, uncompSize, true);
|
||||
lhView.setUint16(26, nameBytes.length, true);
|
||||
localHeader.set(nameBytes, 30);
|
||||
|
||||
// Central directory entry
|
||||
const cdEntry = new Uint8Array(46 + nameBytes.length);
|
||||
const cdView = new DataView(cdEntry.buffer);
|
||||
cdView.setUint32(0, 0x02014b50, true);
|
||||
cdView.setUint16(4, 20, true); // version made by
|
||||
cdView.setUint16(6, 20, true); // version needed
|
||||
cdView.setUint16(10, compMethod, true);
|
||||
cdView.setUint32(20, compSize, true);
|
||||
cdView.setUint32(24, uncompSize, true);
|
||||
cdView.setUint16(28, nameBytes.length, true);
|
||||
cdView.setUint32(42, offset, true); // local header offset
|
||||
cdEntry.set(nameBytes, 46);
|
||||
|
||||
centralDir.push(cdEntry);
|
||||
parts.push(localHeader, data);
|
||||
offset += localHeader.length + data.length;
|
||||
}
|
||||
|
||||
// End of central directory
|
||||
const cdStart = offset;
|
||||
let cdSize = 0;
|
||||
for (const cd of centralDir) {
|
||||
parts.push(cd);
|
||||
cdSize += cd.length;
|
||||
}
|
||||
|
||||
const eocd = new Uint8Array(22);
|
||||
const eocdView = new DataView(eocd.buffer);
|
||||
eocdView.setUint32(0, 0x06054b50, true);
|
||||
eocdView.setUint16(8, centralDir.length, true);
|
||||
eocdView.setUint16(10, centralDir.length, true);
|
||||
eocdView.setUint32(12, cdSize, true);
|
||||
eocdView.setUint32(16, cdStart, true);
|
||||
parts.push(eocd);
|
||||
|
||||
return new Blob(parts, { type: 'application/octet-stream' });
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// XML text substitution (for DOCX/XLSX)
|
||||
//
|
||||
// Finds text content within XML elements and applies substitution.
|
||||
// Preserves all XML tags and attributes unchanged.
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
_substituteInXML(xml, substituteAll) {
|
||||
const allReplacements = [];
|
||||
|
||||
// Replace text between XML tags, preserving the tags themselves
|
||||
// This handles <w:t>text</w:t> in DOCX and <t>text</t> in XLSX
|
||||
const newXml = xml.replace(/>([^<]+)</g, (match, textContent) => {
|
||||
// Skip very short or whitespace-only content
|
||||
if (!textContent || textContent.trim().length < 2) return match;
|
||||
|
||||
const result = substituteAll(textContent);
|
||||
if (result.modified) {
|
||||
allReplacements.push(...result.replacements);
|
||||
return '>' + result.text + '<';
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
return { xml: newXml, replacements: allReplacements };
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
_detectType(ext, mimeType) {
|
||||
// By extension
|
||||
const textExts = new Set(['txt', 'csv', 'tsv', 'json', 'md', 'markdown',
|
||||
'log', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'xml', 'html',
|
||||
'htm', 'css', 'js', 'ts', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp',
|
||||
'h', 'hpp', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'sql', 'r', 'swift',
|
||||
'kt', 'scala', 'pl', 'php', 'lua', 'vim', 'env', 'gitignore']);
|
||||
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
if (ext === 'docx') return 'docx';
|
||||
if (ext === 'xlsx') return 'xlsx';
|
||||
if (textExts.has(ext)) return 'text';
|
||||
|
||||
// By MIME type
|
||||
if (mimeType?.includes('pdf')) return 'pdf';
|
||||
if (mimeType?.includes('wordprocessingml')) return 'docx';
|
||||
if (mimeType?.includes('spreadsheetml')) return 'xlsx';
|
||||
if (mimeType?.startsWith('text/')) return 'text';
|
||||
if (mimeType?.includes('json') || mimeType?.includes('xml')) return 'text';
|
||||
|
||||
return 'unknown';
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof globalThis !== 'undefined') {
|
||||
globalThis.DocumentScanner = DocumentScanner;
|
||||
}
|
||||
|
||||
export default DocumentScanner;
|
||||
+1
-1
@@ -40,7 +40,7 @@ const DEFAULT_SETTINGS = {
|
||||
autoAddDetected: true,
|
||||
maxLogEntries: 200,
|
||||
customDomains: [],
|
||||
categories: ['name', 'email', 'phone', 'address', 'ssn', 'dob', 'domain', 'general'],
|
||||
categories: ['name', 'email', 'phone', 'address', 'ssn', 'dob', 'domain', 'password', 'general'],
|
||||
browserSync: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -463,6 +463,7 @@
|
||||
<option value="ssn">SSN</option>
|
||||
<option value="dob">DOB</option>
|
||||
<option value="domain">Domain</option>
|
||||
<option value="password">Password</option>
|
||||
<option value="general">General</option>
|
||||
</select>
|
||||
<label class="checkbox-label">
|
||||
@@ -473,6 +474,47 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Passwords</h2>
|
||||
<p class="section-desc">
|
||||
Imported passwords are automatically redacted when sent to AI services.
|
||||
Password values are hidden by default — enter your vault password to reveal them.
|
||||
</p>
|
||||
|
||||
<div id="passwordsLocked" style="margin-bottom:12px">
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<span style="font-size:12px;color:#6b7280">🔒 Password values are hidden</span>
|
||||
<input type="password" id="passwordRevealKey" placeholder="Vault password to reveal" autocomplete="current-password"
|
||||
style="font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px;width:180px">
|
||||
<button class="btn btn-sm" id="btnRevealPasswords">Reveal</button>
|
||||
</div>
|
||||
<div id="passwordRevealStatus" style="font-size:11px;margin-top:4px;min-height:14px"></div>
|
||||
</div>
|
||||
|
||||
<div id="passwordsUnlocked" style="display:none;margin-bottom:12px">
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<span style="font-size:12px;color:#b45309">🔓 Passwords visible</span>
|
||||
<button class="btn btn-sm" id="btnHidePasswords">Hide</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="mapping-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Password</th>
|
||||
<th>Redacted As</th>
|
||||
<th>Enabled</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="passwordTableBody">
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="noPasswordsMsg" style="text-align:center;color:#9ca3af;padding:16px;font-size:13px;display:none">
|
||||
No passwords imported. Use Transfer Data → Import to add passwords from your password manager.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Custom Domains</h2>
|
||||
<p class="section-desc">Add domains for self-hosted AI services (like OpenWebUI). The extension will activate on these domains in addition to the built-in ones.</p>
|
||||
|
||||
+99
-2
@@ -10,6 +10,7 @@ import api from '../lib/browser-polyfill.js';
|
||||
|
||||
let mappings = [];
|
||||
let settings = {};
|
||||
let passwordsRevealed = false;
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
|
||||
@@ -27,6 +28,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
$('#browserSync').checked = settings.browserSync === true;
|
||||
|
||||
renderMappings();
|
||||
renderPasswords();
|
||||
renderDomains();
|
||||
renderLog();
|
||||
|
||||
@@ -326,6 +328,36 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
renderMappings();
|
||||
});
|
||||
|
||||
// Password reveal/hide
|
||||
$('#btnRevealPasswords').addEventListener('click', async () => {
|
||||
const pw = $('#passwordRevealKey').value;
|
||||
if (!pw) {
|
||||
setPasswordRevealStatus('Enter your vault password.', 'warn');
|
||||
return;
|
||||
}
|
||||
const result = await SilentSendSync.authenticate(pw);
|
||||
if (result.success) {
|
||||
passwordsRevealed = true;
|
||||
$('#passwordsLocked').style.display = 'none';
|
||||
$('#passwordsUnlocked').style.display = 'block';
|
||||
$('#passwordRevealKey').value = '';
|
||||
renderPasswords();
|
||||
} else {
|
||||
setPasswordRevealStatus('Wrong password.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
$('#btnHidePasswords').addEventListener('click', () => {
|
||||
passwordsRevealed = false;
|
||||
$('#passwordsLocked').style.display = 'block';
|
||||
$('#passwordsUnlocked').style.display = 'none';
|
||||
renderPasswords();
|
||||
});
|
||||
|
||||
$('#passwordRevealKey').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') $('#btnRevealPasswords').click();
|
||||
});
|
||||
|
||||
// Clear log
|
||||
$('#btnClearLog').addEventListener('click', async () => {
|
||||
await Storage.clearLog();
|
||||
@@ -369,13 +401,15 @@ async function addMapping() {
|
||||
|
||||
function renderMappings() {
|
||||
const tbody = $('#mappingTableBody');
|
||||
// Exclude password-category mappings — they have their own section
|
||||
const nonPasswordMappings = mappings.filter(m => m.category !== 'password');
|
||||
|
||||
if (mappings.length === 0) {
|
||||
if (nonPasswordMappings.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#9ca3af;padding:24px">No mappings configured</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = mappings
|
||||
tbody.innerHTML = nonPasswordMappings
|
||||
.map(
|
||||
(m) => `
|
||||
<tr data-id="${m.id}">
|
||||
@@ -415,6 +449,69 @@ function renderMappings() {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Passwords Section ---
|
||||
|
||||
function renderPasswords() {
|
||||
const passwordMappings = mappings.filter(m => m.category === 'password');
|
||||
const tbody = $('#passwordTableBody');
|
||||
const noMsg = $('#noPasswordsMsg');
|
||||
|
||||
if (passwordMappings.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
noMsg.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
noMsg.style.display = 'none';
|
||||
|
||||
tbody.innerHTML = passwordMappings.map(m => {
|
||||
const displayReal = passwordsRevealed
|
||||
? escapeHtml(m.real)
|
||||
: '••••••••';
|
||||
|
||||
return `
|
||||
<tr data-id="${m.id}">
|
||||
<td class="real" style="font-family:monospace;font-size:12px">${displayReal}</td>
|
||||
<td class="sub" style="font-size:12px">${escapeHtml(m.substitute)}</td>
|
||||
<td>
|
||||
<label class="toggle" style="width:32px;height:18px">
|
||||
<input type="checkbox" class="toggle-pw-enabled" ${m.enabled ? 'checked' : ''}>
|
||||
<span class="toggle-slider" style="border-radius:18px"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td><button class="btn btn-sm btn-danger btn-delete-pw">×</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// Bind delete
|
||||
tbody.querySelectorAll('.btn-delete-pw').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.closest('tr').dataset.id;
|
||||
await Storage.deleteMapping(id);
|
||||
mappings = mappings.filter(m => m.id !== id);
|
||||
renderPasswords();
|
||||
renderMappings();
|
||||
});
|
||||
});
|
||||
|
||||
// Bind toggle
|
||||
tbody.querySelectorAll('.toggle-pw-enabled').forEach(cb => {
|
||||
cb.addEventListener('change', async () => {
|
||||
const id = cb.closest('tr').dataset.id;
|
||||
await Storage.updateMapping(id, { enabled: cb.checked });
|
||||
const m = mappings.find(m => m.id === id);
|
||||
if (m) m.enabled = cb.checked;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setPasswordRevealStatus(msg, type) {
|
||||
const el = $('#passwordRevealStatus');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
|
||||
}
|
||||
|
||||
async function renderLog() {
|
||||
const log = await Storage.getLog();
|
||||
$('#logCount').textContent = `${log.length} entries`;
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
<option value="ssn">SSN</option>
|
||||
<option value="dob">DOB</option>
|
||||
<option value="domain">Domain</option>
|
||||
<option value="password">Password</option>
|
||||
<option value="general">General</option>
|
||||
</select>
|
||||
<label class="checkbox-label">
|
||||
|
||||
+1
-1
@@ -585,7 +585,7 @@ function renderMappings() {
|
||||
(m) => `
|
||||
<div class="mapping-item" data-id="${m.id}">
|
||||
<div class="mapping-values">
|
||||
<span class="mapping-real">${escapeHtml(m.real)}</span>
|
||||
<span class="mapping-real">${m.category === 'password' ? '••••••••' : escapeHtml(m.real)}</span>
|
||||
→
|
||||
<span class="mapping-sub">${escapeHtml(m.substitute)}</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user