import _ from 'lodash'; import { clientContext } from '../../context/client'; import { translateTextsClient } from './translate-texts.client'; import { isLocal } from '../../context'; const rxEmail = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()\.,;\s@\"]+\.{0,1})+([^<>()\.,;:\s@\"]{2,}|[\d\.]+))$/i; interface IItem { node: Node; key: string; orig: string; hash: string; knowns: WeakMap; } function getAncestors(node: Node):Node[] { if (!node) return []; const ancestors = []; while (node) { ancestors.push(node); node = node.parentElement; } return ancestors; } export class TranslationWidget { knownTitles = new WeakMap(); knownPlaceholders = new WeakMap(); knownContent = new WeakMap(); items:IItem[] = []; checkSoon: () => Promise; langCode: string; constructor() { this.langCode = clientContext.user?.langCode; if (!this.langCode || ['en', 'en-us'].includes(this.langCode.toLowerCase())) return; this.checkSoon = _.throttle( () => this.check(), 200, { leading: true, trailing: true }, ); this.scan(document); this.checkSoon().then(); const observer = new MutationObserver(this.onMutate.bind(this)); observer.observe(document, { characterData: true, subtree: true, childList: true, attributes: true, attributeFilter: ['placeholder', 'title'], }); } async check ():Promise { if (!this.items.length) return; const items = this.items; this.items = []; await this.translateItems(items); } async translateItems(items:IItem[]):Promise { const cleanItems = items.map(item => { const prefix = item.orig.match(/^\s*/)?.[0] || ''; const suffix = item.orig.match(/\s*$/)?.[0] || ''; const main = item.orig.substring(prefix.length, item.orig.length - suffix.length); return { ...item, prefix, suffix, main }; }); const translations = await translateTextsClient({ langCode: this.langCode, texts: cleanItems.map(i => i.main), }); for (const [i, translation] of translations.entries()) { const item = cleanItems[i]; const fullTranslation = [item.prefix, translation, item.suffix].filter(x => x).join(''); const newHash = fasthash(fullTranslation); item.knowns.set(item.node, newHash); item.node[item.key] = fullTranslation; } } onMutate (mutations: MutationRecord[]):void { for (const mutation of mutations) { if (mutation.target) this.scan(mutation.target); for (const node of mutation.addedNodes || []) { const ancestors = getAncestors(node); const ignore = !!ancestors.find(n => n.nodeName === 'HEAD' || (n instanceof Element && n.classList.contains('notranslate'))); if (ignore) continue; this.scan(node); } } this.checkSoon().then(); } private checkItem ({ knowns, node, key }: { knowns: WeakMap, node:Node, key: string }) { const orig = node[key] as string; if (!orig || typeof orig !== 'string' || !/[a-z]/i.test(orig)) return; if (rxEmail.test(orig)) return; const knownHash = knowns.get(node); const hash = fasthash(orig); if (hash === knownHash) return; this.items.push({ knowns, node, key, orig, hash }); } scan (node:Node):void { if (!node) return; if (['SCRIPT', 'STYLE', 'HEAD', 'LINK', 'TEMPLATE'].includes(node.nodeName)) { return; } if (node instanceof Element) { if (node.classList.contains('notranslate')) { return; } if (node.getAttribute('placeholder')) { this.checkItem({ knowns: this.knownPlaceholders, node, key: 'placeholder' }); } if (node.getAttribute('title')) { this.checkItem({ knowns: this.knownTitles, node, key: 'title' }); } } if (node.nodeType === Node.TEXT_NODE) { this.checkItem({ knowns: this.knownContent, node, key: 'textContent' }); } for (const child of node.childNodes) { this.scan(child); } } } function fasthash(s:string):string { return [...s].reduce((hash, c) => (Math.imul(31, hash) + c.charCodeAt(0)) | 0, 0 ).toString(); }