Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 46 additions & 13 deletions src/content/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
let sidebarVisible = false;
let originalTexts = new Map();
let translatedTexts = new Map();
const MAP_SIZE_CAP = 5000;
let pendingActions = [];
let gtTranslateQueue = [];
let gtProcessing = false;
Expand Down Expand Up @@ -598,7 +599,7 @@

gtProcessing = false;
hideTranslationProgress();
pruneOriginalTexts();
pruneDetachedEntries();

for (const { el, targetLang } of geminiQueue) {
if (el && el.parentNode) queueGeminiBlockTranslation(el, targetLang);
Expand All @@ -610,15 +611,31 @@
translatedTexts.get(originalText).push({ el });
}

/**
* Prune originalTexts Map by removing entries where the element
* is no longer attached to the DOM (el.parentNode is null).
* Called after each GT batch processing completes.
*/
function pruneOriginalTexts() {
function pruneDetachedEntries() {
for (const [el] of originalTexts) {
if (!el.parentNode) originalTexts.delete(el);
}
for (const [text, entries] of translatedTexts) {
const live = entries.filter(e => e.el?.parentNode);
if (live.length === 0) translatedTexts.delete(text);
else if (live.length < entries.length) translatedTexts.set(text, live);
}
if (originalTexts.size > MAP_SIZE_CAP) {
const excess = originalTexts.size - MAP_SIZE_CAP;
const iter = originalTexts.keys();
for (let i = 0; i < excess; i++) {
const key = iter.next().value;
originalTexts.delete(key);
}
}
if (translatedTexts.size > MAP_SIZE_CAP) {
const excess = translatedTexts.size - MAP_SIZE_CAP;
const iter = translatedTexts.keys();
for (let i = 0; i < excess; i++) {
const key = iter.next().value;
translatedTexts.delete(key);
}
}
}

function addVerifySpinner(el) {
Expand Down Expand Up @@ -952,12 +969,14 @@ RULES:
const trimmed = result.trim();
if (trimmed.length > xml.length * 3 || trimmed.includes('SOURCE') || trimmed.includes('RULES:')) return;

el.innerHTML = xmlToHtml(trimmed, tagInfo);
el.classList.remove('si18n-verifying');
if (el?.parentNode) {
el.innerHTML = xmlToHtml(trimmed, tagInfo);
el.classList.remove('si18n-verifying');
}
translator._cacheTranslation(pureText, el.textContent.trim(), targetLang);
}).catch(err => {
console.warn('[SkillBridge] Gemini block translation failed:', err.message);
el.classList.remove('si18n-verifying');
if (el?.parentNode) el.classList.remove('si18n-verifying');
});

el.classList.add('si18n-verifying');
Expand Down Expand Up @@ -987,12 +1006,23 @@ RULES:
// DOM OBSERVER (with cleanup)
// ============================================================

let _pruneScheduled = false;
function schedulePrune() {
if (_pruneScheduled) return;
_pruneScheduled = true;
requestAnimationFrame(() => {
_pruneScheduled = false;
pruneDetachedEntries();
});
}

function observeDOM() {
domObserver = new MutationObserver((mutations) => {
if (currentLang === 'en') return;
if (!translator || !isReady) return;

let hasRemovals = false;
for (const mutation of mutations) {
if (mutation.removedNodes.length > 0) hasRemovals = true;

if (currentLang === 'en' || !translator || !isReady) continue;
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE &&
!node.closest('.skillbridge-sidebar') &&
Expand All @@ -1001,6 +1031,9 @@ RULES:
}
}
}
if (hasRemovals && (originalTexts.size > 0 || translatedTexts.size > 0)) {
schedulePrune();
}
});

domObserver.observe(document.body, { childList: true, subtree: true });
Expand Down
59 changes: 53 additions & 6 deletions src/content/sidebar-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -330,9 +330,56 @@

function applyInline(text) {
return text
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code>$1</code>');
.replace(/\*\*(.*?)\*\*/g, (_, g) => '<strong>' + sb.escapeHtml(g) + '</strong>')
.replace(/\*(.*?)\*/g, (_, g) => '<em>' + sb.escapeHtml(g) + '</em>')
.replace(/`(.*?)`/g, (_, g) => '<code>' + sb.escapeHtml(g) + '</code>');
}

// ============================================================
// SIMPLE HTML SANITIZER (no external dependency)
// ============================================================

/**
* Strip dangerous tags and attributes from trusted-structure HTML.
* Keeps only the tags used by our own formatResponse / history rendering.
*/
function sanitizeHtml(html) {
const ALLOWED_TAGS = new Set([
'div', 'span', 'p', 'h3', 'ul', 'ol', 'li', 'strong', 'em', 'code',
'br', 'button', 'svg', 'polyline', 'path', 'circle',
]);
const ALLOWED_ATTRS = new Set([
'class', 'id', 'data-id', 'data-question', 'style', 'title',
'aria-label', 'role',
// SVG presentational attributes
'width', 'height', 'viewBox', 'fill', 'stroke', 'stroke-width',
'stroke-linecap', 'stroke-linejoin', 'cx', 'cy', 'r', 'd', 'points',
]);
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');

function walk(node) {
const children = Array.from(node.childNodes);
for (const child of children) {
if (child.nodeType === Node.ELEMENT_NODE) {
const tag = child.tagName.toLowerCase();
if (!ALLOWED_TAGS.has(tag)) {
child.remove();
continue;
}
// Strip disallowed attributes (including event handlers)
for (const attr of Array.from(child.attributes)) {
if (!ALLOWED_ATTRS.has(attr.name) || attr.name.startsWith('on')) {
child.removeAttribute(attr.name);
}
}
walk(child);
}
}
}

walk(doc.body);
return doc.body.innerHTML;
}

// ============================================================
Expand Down Expand Up @@ -487,7 +534,7 @@
`;
}
}
listEl.innerHTML = html;
listEl.innerHTML = sanitizeHtml(html);

// Event delegation instead of per-item listeners
listEl.addEventListener('click', (e) => {
Expand Down Expand Up @@ -515,7 +562,7 @@
if (time) metaHtml += `<span class="si18n-detail-time">${time}</span>`;
metaHtml += `</div>`;
}
listEl.innerHTML = `
listEl.innerHTML = sanitizeHtml(`
<div class="si18n-history-detail">
${metaHtml}
<div class="si18n-chat-msg si18n-chat-user">
Expand All @@ -525,7 +572,7 @@
<div class="si18n-chat-bubble">${formatResponse(conv.answer)}</div>
</div>
</div>
`;
`);
};
} catch (e) {
console.warn('[SkillBridge] Failed to load conversation:', e);
Expand Down
21 changes: 11 additions & 10 deletions src/lib/page-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
id: data.id,
success: true,
result: result || data.text,
}, '*');
}, window.location.origin);
} catch (err) {
const errMsg = err?.error || err?.message || String(err);
log('Translate error:', errMsg);
Expand All @@ -117,7 +117,7 @@
success: false,
error: errMsg,
result: data.text,
}, '*');
}, window.location.origin);
}
}

Expand All @@ -135,7 +135,7 @@
id: data.id,
success: true,
result: result || '',
}, '*');
}, window.location.origin);
} catch (err) {
const errMsg = err?.error || err?.message || String(err);
log('Verify error:', errMsg);
Expand All @@ -147,7 +147,7 @@
success: false,
error: errMsg,
result: '',
}, '*');
}, window.location.origin);
}
}

Expand All @@ -173,7 +173,7 @@
type: 'CHAT_STREAM_CHUNK',
id: data.id,
text,
}, '*');
}, window.location.origin);
}
}
window.postMessage({
Expand All @@ -182,7 +182,7 @@
type: 'CHAT_STREAM_END',
id: data.id,
success: true,
}, '*');
}, window.location.origin);
} else {
// Non-streaming fallback
const result = await callAI(prompt, data.model);
Expand All @@ -193,27 +193,28 @@
id: data.id,
success: true,
result: result || 'No response',
}, '*');
}, window.location.origin);
}
} catch (err) {
const errMsg = err?.error || err?.message || String(err);
log('Chat error:', errMsg);
window.postMessage({
__skillbridge__: true,
__nonce__: _bridgeNonce,
type: 'CHAT_RESPONSE',
id: data.id,
success: false,
error: errMsg,
result: 'Error: ' + errMsg,
}, '*');
}, window.location.origin);
}
}
});

loadPuter().then(() => {
window.postMessage({ __skillbridge__: true, __nonce__: _bridgeNonce, type: 'BRIDGE_READY' }, '*');
window.postMessage({ __skillbridge__: true, __nonce__: _bridgeNonce, type: 'BRIDGE_READY' }, window.location.origin);
}).catch((err) => {
log('Auto-load failed:', err.message);
window.postMessage({ __skillbridge__: true, type: 'BRIDGE_ERROR', error: err.message }, '*');
window.postMessage({ __skillbridge__: true, __nonce__: _bridgeNonce, type: 'BRIDGE_ERROR', error: err.message }, window.location.origin);
});
})();
67 changes: 40 additions & 27 deletions src/lib/translator.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class SkilljarTranslator {
this.pendingCallbacks = new Map();
this._db = null; // IndexedDB for verified translation cache
this._verifyQueue = []; // Queue of texts awaiting Gemini verification
this._isVerifying = false;
this._verifyLock = null; // Promise-based lock for verify queue processing
this._onUpdateCallbacks = []; // Callbacks when Gemini improves a translation
// Premium languages have static dictionaries; others use Google Translate only
// Use shared constants from constants.js
Expand Down Expand Up @@ -50,18 +50,23 @@ class SkilljarTranslator {
const store = tx.objectStore('translations');
const req = store.openCursor();
const now = Date.now();
req.onsuccess = (e) => {
const cursor = e.target.result;
if (!cursor) return;
const entry = cursor.value;
if (entry.timestamp && now - entry.timestamp > SKILLBRIDGE_THRESHOLDS.CACHE_TTL_MS) {
cursor.delete();
}
cursor.continue();
};
req.onerror = () => {
console.warn('[SkillBridge] Cache cleanup cursor failed');
};

return new Promise((resolve, reject) => {
req.onsuccess = (e) => {
const cursor = e.target.result;
if (!cursor) return;
const entry = cursor.value;
if (entry.timestamp && now - entry.timestamp > SKILLBRIDGE_THRESHOLDS.CACHE_TTL_MS) {
cursor.delete();
}
cursor.continue();
};
req.onerror = () => {
console.warn('[SkillBridge] Cache cleanup cursor failed');
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch (err) {
console.warn('[SkillBridge] Cache cleanup failed:', err);
}
Expand Down Expand Up @@ -331,33 +336,32 @@ class SkilljarTranslator {
targetLang,
});

if (!this._isVerifying) {
setTimeout(() => this._processVerifyQueue(), SKILLBRIDGE_DELAYS.VERIFY_QUEUE);
if (!this._verifyLock) {
this._verifyLock = new Promise(resolve => {
setTimeout(() => {
this._runVerifyQueue().finally(() => {
this._verifyLock = null;
resolve();
});
}, SKILLBRIDGE_DELAYS.VERIFY_QUEUE);
});
}
return true;
}

async _processVerifyQueue() {
if (this._isVerifying || this._verifyQueue.length === 0) return;
async _runVerifyQueue() {
if (!this.isReady) {
// Retry later when bridge is ready
setTimeout(() => this._processVerifyQueue(), SKILLBRIDGE_DELAYS.VERIFY_QUEUE_RETRY);
return;
await new Promise(r => setTimeout(r, SKILLBRIDGE_DELAYS.VERIFY_QUEUE_RETRY));
if (!this.isReady) return;
}

this._isVerifying = true;

// Process in small batches (3 at a time to avoid overwhelming Gemini)
while (this._verifyQueue.length > 0) {
const batch = this._verifyQueue.splice(0, SKILLBRIDGE_THRESHOLDS.GEMINI_BATCH_SIZE);
await Promise.all(batch.map(item => this._verifySingle(item)));
// Small delay between batches
if (this._verifyQueue.length > 0) {
await new Promise(r => setTimeout(r, SKILLBRIDGE_DELAYS.GEMINI_BATCH));
}
}

this._isVerifying = false;
}

async _verifySingle({ original, googleTranslation, targetLang }) {
Expand Down Expand Up @@ -548,7 +552,16 @@ RULES:
this.isReady = true;
// Process any pending verify queue now that bridge is ready
if (this._verifyQueue.length > 0) {
setTimeout(() => this._processVerifyQueue(), SKILLBRIDGE_DELAYS.BRIDGE_READY_VERIFY);
if (!this._verifyLock) {
this._verifyLock = new Promise(resolve => {
setTimeout(() => {
this._runVerifyQueue().finally(() => {
this._verifyLock = null;
resolve();
});
}, SKILLBRIDGE_DELAYS.BRIDGE_READY_VERIFY);
});
}
}
}

Expand Down
Loading
Loading