(function($) { 'use strict'; class Options { constructor() { // Default options this.lang = null this.collectMissing = true this.showMissing = false this.test = false this.dynamicDataPlaceholders = true // Whether to detect content that looks like dynamic and replace it with placeholders this.wordCharRegex = 'a-zà-öø-ÿ0-9' this.groupNodeNames = ['A', 'IMG'] // Variable containing the default group node names: ['A', 'IMG']. this.mandatoryTranslateRegex = [] /** * Element selectors that require attribute translation * Key value pairs: { selector: [ attributeName1, attributeName2, ... ] } * Attribute `attributeName*` will be translated for elementes with `selector` */ this.translateAttributes = { 'input[type="button"]': [ 'value' ], 'input[type="submit"]': [ 'value' ], 'input[placeholder]': [ 'placeholder' ], 'img': [ 'src', 'alt' ], 'a': [ 'href' ] } } get(propertyName) { return this[propertyName]; } only(keys) { const result = {}; keys.forEach(key => result[key] = this.get(key)); return result; } // Regex to match non-word chars (not for translation) // Include digits to allow sentences such as "1st time" to be translated including number getWordChRegexStr() { return '[' + this.wordCharRegex + ']' } getNonWordChRegexStr() { return '[^' + this.wordCharRegex + ']' } // Regex to detect word start (word may start with opening parenthesis) getWordStartRegexStr() { const wordChRegexStr = this.getWordChRegexStr() return '(?:' + '(?:' + '[\\[({<]+' + '\\s*' + ')*' + wordChRegexStr + ')'; } // Regex to detect word end getWordEndRegexStr() { const wordChRegexStr = this.getWordChRegexStr() return '(?:' + wordChRegexStr + '(?:' + '\\s*' + '[\\])}>]+' + ')*' + ')'; } // Regex to ignore numbers, dates, emails, etc. getIgnoreRegexStr() { return '(?:' + '(?:' + '\\d+' + // Starts with a digit '(?:' + '[()\\[\\]:.,\\s=*/+-]+' + // Delimeters used in dates, time, numbers, math expressions '\\d+' + // Followed by a number ')*' + // May be repeated one or more times '(?:\\s*(?:am|pm))?' + // Ignore am/pm time ')|(?:' + '\\S+@\\S+\\.\\S+' + // Email match ')' + ')'; } // Ignored if there is full sentence match of ignored chars getIgnoreRegexFullStr() { const nonWordChRegexStr = this.getNonWordChRegexStr() return '(?:' + nonWordChRegexStr + '+' + ')'; } // Regex to detect parts that should not be translated getIgnoreRegex() { const ignoreRegexStr = this.getIgnoreRegexStr() return new RegExp('\\b(' + ignoreRegexStr + ')\\b', 'gi') } getIgnoreRegexFull() { const ignoreRegexStr = this.getIgnoreRegexStr() const ignoreRegexFullStr = this.getIgnoreRegexFullStr() return new RegExp('^(?:' + ignoreRegexStr + '|' + ignoreRegexFullStr + ')$', 'i'); } // Regex used in trim() getTrimStartRegex() { const nonWordChRegexStr = this.getNonWordChRegexStr() const wordStartRegexStr = this.getWordStartRegexStr() return new RegExp('^(' + nonWordChRegexStr + '*?)(' + wordStartRegexStr + ')', 'i'); } getTrimEndRegex() { const wordEndRegexStr = this.getWordEndRegexStr() const nonWordChRegexStr = this.getNonWordChRegexStr() return new RegExp('(' + wordEndRegexStr + ')(' + nonWordChRegexStr + '*?)$', 'i'); } instance(props) { Object.assign(this, props); return this } } /** * TranslationNode holds jQuery element to be translated. In case all child elements * are inline elements, the whole html will be sent to translation as a single piece. * If at least 1 child element is a block-level element, child nodes will be translated * one-by-one. * @param {jQuery} elem jQuery element to be translated * @param {TranslationNode} parentNode parent TranslationNode in DOM */ function TranslationNode(elem, parentNode) { if (!(this instanceof TranslationNode)) { throw new Error('TranslationNode must be created with the new keyword'); } if (elem.length != 1) { throw new Error('TranslationNode() - only single elements should be added for translation'); } this._elem = elem; this._parentNode = parentNode || null; // See isSinglePiece() this._isSinglePiece = this.isTextNode() || !this.isBreakTranslate(); this._childTranslationNodes = []; } TranslationNode.prototype.getUuid = function () { if (this._uuid === undefined) { this._uuid = this.generateUuid(); } return this._uuid } TranslationNode.prototype.generateUuid = function () { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } TranslationNode.prototype.getAllParentNodes = function () { if (this._parentNodes === undefined) { this._parentNodes = []; if (this._parentNode) { this._parentNodes = this._parentNodes.concat(this._parentNode, this._parentNode.getAllParentNodes()); } } return this._parentNodes; } /** * Recursievely translate current node */ TranslationNode.prototype.translate = function(options) { var singlePieceNodes = this.getSinglePieceNodes(); for (var i = 0; i < singlePieceNodes.length; i++) { var translationSentence = new TranslationSentence(singlePieceNodes[i], options); translationSentence.translate(); } }; /** * Recursievely translate current node */ TranslationNode.prototype.getTranslationTexts = function(options) { return this.getSinglePieceNodes().map(function(singlePieceNode) { var translationSentence = new TranslationSentence(singlePieceNode, options); return translationSentence.getText(); }); }; /** * Get single-piece translatable nodes only * * @return {array} of single-piece TranslationNodes */ TranslationNode.prototype.getSinglePieceNodes = function() { if (this.isSinglePiece() && !this.isNoTranslate()) { return [ this ]; } return this._childTranslationNodes.reduce(function(nodes, translationNode) { return nodes.concat(translationNode.getSinglePieceNodes()); }, []); } TranslationNode.prototype.getNodeNameGroupNodes = function () { if (this.isSinglePiece() && !this.isNoTranslate()) { return [ this ]; } if (this.isNodeNameGroup()) { return [ this ] } return this._childTranslationNodes.reduce(function(nodes, translationNode) { return nodes.concat(translationNode.getNodeNameGroupNodes()); }, []); } /** * Get all non-empty child text nodes of current single-piece TranslationNode * * @return {array} of TranslationNode text-nodes */ TranslationNode.prototype.getTextNodes = function() { if (this.isIgnored() || !this.isSinglePiece()) { return []; } if (this.isTextNode()) { return [ this ]; } if (this.isAttributeNode()) { return [ this ]; } return this._childTranslationNodes.reduce(function(nodes, translationNode) { return nodes.concat(translationNode.getTextNodes()); }, []); } TranslationNode.prototype.revert = function(setText) { if (this._updatedText && setText) { this.setText(this.getText()); } this._updatedText = null; this._updatedTextLen = null; for (var i = 0; i < this._childTranslationNodes.length; i++) { this._childTranslationNodes[i].revert(setText); } } /** * Get node text * * @returns {string} */ TranslationNode.prototype.getText = function() { if (this._text === undefined) { this._text = this.isAttributeNode() ? this._elem.attr(this._translateAttribute) : this._elem.text(); } return this._text; } /** * Change node text. May provide start and end points if only portion of the text * should be updated. start and end points are relative to original text and sometimes * we need to update the text multiple times. To correctly replace the text, we keep * track of text length updates * * @param {string} text new text * @param {integer} start start point (may be undefined if whole text should be replaced) * @param {integer} end end point (may be undefined if whole text should be replaced) */ TranslationNode.prototype.setText = function(text, start, end) { this._updatedText = this._updatedText || this.getText(); this._updatedTextLen = this._updatedTextLen || []; var oldText = this._updatedText; if (start !== undefined && end !== undefined) { var lenChanged = 0; for (var i = 0; i < this._updatedTextLen.length; i++) { var textLenUpdate = this._updatedTextLen[i]; if (start >= textLenUpdate.start) { lenChanged += textLenUpdate.len; } } text = oldText.substr(0, start + lenChanged) + text + oldText.substr(end + lenChanged); } else { start = 0; end = oldText.length; } if (oldText == text) { return; } // Keep track of text len updates if (oldText.length != text.length) { this._updatedTextLen.push({ start: start, end: end, len: text.length - oldText.length }); } this._updatedText = text; if (this.isTextNode()) { this.getElem().get(0).textContent = text; } else if (this.isAttributeNode()) { this.getElem().attr(this._translateAttribute, text); } else { this.getElem().text(text); } } TranslationNode.prototype.highlightText = function() { if (this.isTextNode()) { this.getElem().wrap(''); } else { this.getElem().css('color', 'red'); } } TranslationNode.prototype.trim = function (string) { if (this.isMandatoryTranslate() && string) return string return trim(string) } /** * If current node is text node */ TranslationNode.prototype.isTextNode = function() { if (this._isTextNode === undefined) { this._isTextNode = this._elem.get(0).nodeType == 3; } return this._isTextNode; } /** * If current node is comment node */ TranslationNode.prototype.isCommentNode = function() { if (this._isCommentNode === undefined) { this._isCommentNode = this._elem.get(0).nodeType == 8; } return this._isCommentNode; } /** * If current node is a white space or no-content element * * @return {boolean} */ TranslationNode.prototype.isEmpty = function() { if (this._isEmpty === undefined) { // For performance reasons test for space only inline elements this._isEmpty = this.isInline() && this.trim(this.getText()) == '' && this.getTranslationAttributes().length == 0; } return this._isEmpty; } /** * If current node is ignored for translation * In this case all child nodes are ignored as well * * @return {boolean} */ TranslationNode.prototype.isIgnored = function() { if (this._isIgnored === undefined) { this._isIgnored = this._elem.is('style,script,noscript,iframe,textarea') || this.isEmpty(); } return this._isIgnored; } /** * If current node should not be translated (because it's a number, date, or has no-translate class) * If parent has no-translate class, child nodes are not translated as well * unless overriden by do-translate class. * * @return {boolean} */ TranslationNode.prototype.isNoTranslate = function() { if (this._isNoTranslate === undefined) { this._isNoTranslate = this.isIgnored() || this._elem.hasClass('no-translate') || ( !this._elem.hasClass('do-translate') && this._parentNode && this._parentNode.isNoTranslate() ); } return this._isNoTranslate; } /** * Check if break translate was requested (translate each text node individualy and not combine into single-piece) * Requested by using br-translate class on parent node * * @return {boolean} */ TranslationNode.prototype.isBreakTranslate = function() { if (this._isBreakTranslate === undefined) { this._isBreakTranslate = this._elem.hasClass('br-translate') || ( !this._elem.hasClass('sg-translate') && this._parentNode && this._parentNode.isBreakTranslate() ); } return this._isBreakTranslate; } /** * Check if single translate was requested by using sg-translate class * * @return {boolean} */ TranslationNode.prototype.isSingleTranslate = function() { if (this._isSingleTranslate === undefined) { this._isSingleTranslate = this._elem.hasClass('sg-translate') || ( !this.isBreakTranslate() && this._parentNode && this._parentNode.isSingleTranslate() ); } return this._isSingleTranslate; } /** * If current node is inline, which means it's allowed to be a child inline * element of a larger text to be translated as a single piece * * @return {boolean} */ TranslationNode.prototype.isInline = function() { if (this._isInline === undefined) { this._isInline = this.isTextNode() || this.isCommentNode() || this.isSingleTranslate() || ( !this.isBreakTranslate() && this._elem.is('a,b,br,cite,em,i,img,q,s,small,span,strong,sub,sup,u') && this._elem.css('display') != 'block' ) } return this._isInline; } /** * If all child nodes are inline, current node should be sent for translation as a single piece * * @return {boolean} */ TranslationNode.prototype.isSinglePiece = function() { return this._isSinglePiece; } /** * If the current node is a group * * @returns {boolean} */ TranslationNode.prototype.isNodeNameGroup = function() { return optionsClass.get('groupNodeNames').includes(this._elem.prop('nodeName')); } TranslationNode.prototype.isMandatoryTranslate = function() { let text = this.getText() ?? '' for (let regex of optionsClass.get('mandatoryTranslateRegex')) { if (regex.test(text)) { return true } } return false } /** * Returns corresponsing jQuery element * * @return {jQuery} */ TranslationNode.prototype.getElem = function() { return this._elem; } /** * If current node is an Attribute Node that should be translated */ TranslationNode.prototype.isAttributeNode = function() { return this._translateAttribute !== undefined; } /** * Get non-empty attributes defined for translation using translateAttributes * * @return {array} of attribute strings */ TranslationNode.prototype.getTranslationAttributes = function() { if (this._translationAttributes === undefined) { this._translationAttributes = []; let translateAttributes = optionsClass.get('translateAttributes') for (var selector in translateAttributes) { if ($(this._elem).is(selector)) { var attrNames = translateAttributes[selector]; for(var i in attrNames) { var attrName = attrNames[i]; if (this._elem.attr(attrName)) { this._translationAttributes.push(attrName); } } break; } } } return this._translationAttributes; } /** * TranslationSentence builds a sentence for translation. It adds all required * placeholders for no-translate elements * * @param {TranslationNode} translationNode single-piece TranslationNode to translate */ function TranslationSentence(translationNode, options) { if (!(this instanceof TranslationSentence)) { throw new Error('TranslationSentence must be created with the new keyword'); } if (!translationNode.isSinglePiece() || translationNode.isNoTranslate()) { throw new Error('TranslationSentence() - single-piece translatable node is expected'); } this._translationNode = translationNode; this._options = options; } /** * Translate current sentence */ TranslationSentence.prototype.translate = function() { var text = this.getText(); if (!text) { return; } var translatedText = dictionary.getTranslation(text, this._options.lang); if (this._options.test) { translatedText = text; } else if (!translatedText || translatedText == text) { return; } // Revert previous translation this._translationNode.revert(); var placeholders = this.getPlaceholders(); if (placeholders.length == 1) { if (this._options.test) { placeholders[0].highlightText(); placeholders[0].setText('<-' + translatedText + '->'); } else { placeholders[0].setText(translatedText); } return; } for (var i = 0; i < placeholders.length; i++) { var placeholder = placeholders[i]; var placeholderRegExp = placeholder.getRegExp(); var matches = placeholderRegExp.exec(translatedText); if (matches) { if (this._options.test) { placeholder.highlightText(); placeholder.setText('<-' + matches[1] + '->'); } else { placeholder.setText(matches[1]); } } } } /** * Get text for translation with all required placeholders */ TranslationSentence.prototype.getText = function() { if (this._text === undefined) { var placeholders = this.getPlaceholders(); var wrap = placeholders.length > 1; // Add placeholders only if we have more than 1 elements this._text = placeholders.map(function(placeholder) { return placeholder.getText(wrap); }).join(' '); } return this._text; } /** * Get array of TextPiece objects to be concatenated into a single sentence for translation * * @return {array} of TextPiece objects */ TranslationSentence.prototype.getTextPieces = function() { if (this._textPieces === undefined) { this._textPieces = []; var textNodes = this._translationNode.getTextNodes(); for (var i = 0; i < textNodes.length; i++) { var textNode = textNodes[i]; var text = textNode.getText(); // Match text against ignoreRegex to find any ignore patterns var match, start = 0; // If singleTranslate was requested - don't check for regex if (!textNode.isSingleTranslate() && this._options.dynamicDataPlaceholders) { while(match = optionsClass.getIgnoreRegex().exec(text)) { if (match.index > start) { // Get translatable piece before ignored match this._textPieces.push(new TextPiece( textNode, { start: start, end: match.index }) ); } start = match.index + match[0].length; // Add ignored match this._textPieces.push(new TextPiece( textNode, { start: match.index, end: start, noTranslate: true }) ); } } if (start == 0) { // No match - add whole element this._textPieces.push(new TextPiece(textNode)); } else if (start < text.length) { // Add remaining text piece this._textPieces.push(new TextPiece( textNode, { start: start, end: text.length, }) ); } } } return this._textPieces; } /** * Get array of Placeholder objects required to build current sentence * * @return {array} of Placeholder objects */ TranslationSentence.prototype.getPlaceholders = function() { if (this._placeHolders === undefined) { this._placeHolders = []; var textPieces = this.getTextPieces(); // Ignore no-translate nodes at the beginning of the sentence var placeholderId = 1; var ignoreNoTranslate = true; for (var i = 0; i < textPieces.length; i++) { var textPiece = textPieces[i]; if (ignoreNoTranslate && textPiece.isNoTranslate()) { continue; } if (textPiece.isNoTranslate()) { this._placeHolders.push(new Placeholder(placeholderId)); } else { this._placeHolders.push(new Placeholder(placeholderId, textPiece)); } // Ignore subsequent noTranslate pieces ignoreNoTranslate = textPiece.isNoTranslate(); placeholderId++; } // Remove all no-translate elements from the end of the sentence while(this._placeHolders.length && this._placeHolders[this._placeHolders.length - 1].isNoTranslate()) { this._placeHolders.pop(); } } return this._placeHolders; } /** * TextPiece holds text piece for translation * * @param {TranslationNode} textNode TranslationNode which holds current text piece * @param {object} options */ function TextPiece(textNode, options) { if (!(this instanceof TextPiece)) { throw new Error('TextPiece must be created with the new keyword'); } if (!textNode.isTextNode() && !textNode.isAttributeNode()) { throw new Error('TextPiece() - only text nodes or attribute nodes should be added for translation'); } this._textNode = textNode; this._options = options || {}; } /** * Returns text of the current TextPiece * * @return {string} */ TextPiece.prototype.getText = function() { if (this._text === undefined) { this._text = this._textNode.getText(); if (this._options.start !== undefined && this._options.end !== undefined) { this._text = this._text.substr(this._options.start, this._options.end - this._options.start); } } return this._text; } /** * Replaces text node with a new (translated) text * * @param {string} text */ TextPiece.prototype.setText = function(text) { var trimStartMatches = this.getText().match(optionsClass.getTrimStartRegex()); var trimEndMatches = this.getText().match(optionsClass.getTrimEndRegex()); // Restore whitespace before and after text = trimStartMatches[1] + text + trimEndMatches[2]; this._textNode.setText(text, this._options.start, this._options.end); } TextPiece.prototype.highlightText = function() { this._textNode.highlightText(); } /** * If current TextPiece is a white space-only * * @return {boolean} */ TextPiece.prototype.isEmpty = function() { if (this._isEmpty === undefined) { this._isEmpty = this._textNode.trim(this.getText()) == ''; } return this._isEmpty; } /** * If current TextPiece shouldn't be translated * * @return {boolean} */ TextPiece.prototype.isNoTranslate = function() { if (this._isNoTranslate === undefined) { this._isNoTranslate = this._options.noTranslate || this._textNode.isNoTranslate() || this.isEmpty(); } return this._isNoTranslate; } /** * If current TextPiece should be translated as a single piece (without checking for ignoreRegex) * * @return {boolean} */ TextPiece.prototype.isSingleTranslate = function() { if (this._isSingleTranslate === undefined) { this._isSingleTranslate = this._options.singleTranslate || this._textNode.isSingleTranslate(); } return this._isSingleTranslate; } /** * Get corresponding jQuery element * * @return {jQuery} */ TextPiece.prototype.getElem = function() { return this._textNode.getElem(); } /** * Placeholder holds only translatable text pieces. It may be an empty placeholder * for no-translate text (this._textPiece will be null) * * @param {integer} id id for the current placeholder * @param {TextPiece} textPiece TextPiece for the current placeholder (may be undefined) */ function Placeholder(id, textPiece) { if (!(this instanceof Placeholder)) { throw new Error('Placeholder must be created with the new keyword'); } this._id = id; this._textPiece = textPiece || null; } /** * Returns text for the current placeholder * * @param {boolean} wrap whether to wrap text with placeholder * * @return {string} */ Placeholder.prototype.getText = function(wrap) { var textNode = this._textPiece._textNode var text = this.isNoTranslate() ? '' : textNode.trim(this._textPiece.getText()); if (!wrap) { return text; } if (text) { return this.getPlaceholderStart() + text + this.getPlaceholderEnd(); } else { return this.getPlaceholderStart(); } } /** * Replaces text with a new (translated) text * * @param {string} text */ Placeholder.prototype.setText = function(text) { if (this._textPiece) { this._textPiece.setText(text); } } Placeholder.prototype.highlightText = function() { if (this._textPiece) { this._textPiece.highlightText(); } } /** * @return {integer} placeholder id */ Placeholder.prototype.getId = function() { return this._id; } /** * @return {RegExp} regex to match placeholder text */ Placeholder.prototype.getRegExp = function() { return new RegExp('\\[P' + this._id + '\\](.*)\\[/P' + this._id + '\\]'); } /** * @return {string} placeholder start marker */ Placeholder.prototype.getPlaceholderStart = function() { return '[P' + this._id + ']'; } /** * @return {string} placeholder end marker */ Placeholder.prototype.getPlaceholderEnd = function() { return '[/P' + this._id + ']'; } /** * If current Placeholder shouldn't be translated * * @return {boolean} */ Placeholder.prototype.isNoTranslate = function() { return !this._textPiece || this._textPiece.isNoTranslate(); } /** * Translation dictionary */ function Dictionary() { if (!(this instanceof Dictionary)) { throw new Error('Dictionary must be created with the new keyword'); } this._missingTranslations = {}; this._pageTexts = {}; } /** * @return {string} - translated text */ Dictionary.prototype.getTranslation = function(text, lang) { var cachedDictionary = this.getCachedDictionary(lang); this._pageTexts[text] = (this._pageTexts[text] || 0) + 1; if (cachedDictionary.dictionary && cachedDictionary.dictionary[text] !== undefined) { return cachedDictionary.dictionary[text]; } this._missingTranslations[lang] = this._missingTranslations[lang] || {}; this._missingTranslations[lang][text] = (this._missingTranslations[lang][text] || 0) + 1; return null; } Dictionary.prototype.loadLang = function(lang, forceLoad) { var _t = this; var cachedDictionary = this.getCachedDictionary(lang); if (cachedDictionary.dictionary && !forceLoad) { // We already have this lang cached // When using cache, check if we have newer dictionary version (do it in the background) window.setTimeout(function() { _t.checkDictionaryVersion(lang); }, 3000); return $.Deferred().resolve(); } return $.ajax({ url: '/JSTranslate/getDictionary', method: 'POST', dataType: 'json', data: { lang: lang } }).done(function(data) { _t.updateCachedDictionary(lang, data); }); } Dictionary.prototype.redirectLanguageUrl = function(lang) { var _t = this; var cachedDictionary = _t.getCachedDictionary(lang); if (cachedDictionary.meta.subdomain) { var subdomain = cachedDictionary.meta.subdomain; var currentUrl = window.location.href; if (subdomain.slice(-1) !== '/') { subdomain += '/'; } if (!currentUrl.startsWith(subdomain)) { window.location.href = subdomain + currentUrl.substr(currentUrl.lastIndexOf('/') + 1); } } } Dictionary.prototype.checkDictionaryVersion = function(lang) { var _t = this; var cachedDictionary = _t.getCachedDictionary(lang); if (cachedDictionary.meta.lastVersionCheck) { var elapsedMs = new Date() - cachedDictionary.meta.lastVersionCheck; if (elapsedMs < 60 * 1000) { // Elapsed less than 1 minute, don't perform dictionary version check return; } } $.ajax({ url: '/JSTranslate/getMeta', method: 'POST', dataType: 'json', data: { lang: lang } }).done(function(data) { cachedDictionary.meta.lastVersionCheck = + new Date(); _t.updateCachedDictionary(lang, cachedDictionary); if (data && data.meta && cachedDictionary.meta.updated != data.meta.updated) { // Force reload dictionary _t.loadLang(lang, true); } }); } Dictionary.prototype.getCachedDictionary = function(lang) { if (this._cachedDictionary == undefined) { this._cachedDictionary = { meta: { updated: null, lastVersionCheck: null, subdomain: null } }; if (!localStorage) { return this._cachedDictionary; } var cachedDictionary = localStorage.getItem('JSTranslateDictionary'); if (!cachedDictionary) { return this._cachedDictionary; } cachedDictionary = JSON.parse(cachedDictionary); if (!cachedDictionary) { return this._cachedDictionary; } this._cachedDictionary = cachedDictionary; } if (lang) { this._cachedDictionary[lang] = this._cachedDictionary[lang] || {}; return this._cachedDictionary[lang]; } else { return this._cachedDictionary; } } Dictionary.prototype.updateCachedDictionary = function(lang, data) { if (!data || !data.meta) { return; } var cachedDictionary = this.getCachedDictionary(); cachedDictionary[lang] = data; if (!localStorage) { return; } localStorage.setItem('JSTranslateDictionary', JSON.stringify(cachedDictionary)); } /** * Sent all sentences without translation to the backend * * @param {string} lang */ Dictionary.prototype.sendMissingTranslations = function(lang) { var _t = this; if (!$.isEmptyObject(_t._missingTranslations[lang])) { var missingTranslations = JSON.stringify(_t._missingTranslations[lang]); _t._missingTranslations[lang] = {}; var cachedDictionary = _t.getCachedDictionary(lang); // If we had missing translations, force reload of cache next time cachedDictionary.meta.updated = true; _t.updateCachedDictionary(lang, cachedDictionary); return $.post('/JSTranslate/missingTranslations', { page: window.location.href, lang: lang, missingTranslations: missingTranslations }, function() { _t.loadLang(lang, true); }); } else { return $.Deferred().resolve(); } } /** * Show floating div with number of missing translations * * @param {string} lang */ Dictionary.prototype.showMissingTranslations = function(lang) { if (!$.isEmptyObject(this._missingTranslations[lang])) { var newTextsCount = Object.keys(this._missingTranslations[lang]).length; var newTexts = '

' + Object.keys(this._missingTranslations[lang]).join('
'); showInfo('New texts: ' + newTextsCount + newTexts); } //console.log(this._missingTranslations[lang]); } /** * Use MutationObserver to translate dynamically added content */ function UpdateListener() { if (!(this instanceof UpdateListener)) { throw new Error('UpdateListener must be created with the new keyword'); } this._rootElem = null; this._options = null; this._mutationObserver = null; this._changeTriggerTimeout = null; this._pause = false; var MutationObserver = window.MutationObserver || window.WebKitMutationObserver; if (MutationObserver) { this._mutationObserver = new MutationObserver(this.mutationHandler.bind(this)); } } /** * @param {object} options */ UpdateListener.prototype.setOptions = function(options) { this._options = options; } UpdateListener.prototype.monitor = function(elem) { var _t = this; if (!_t._mutationObserver) { return false; } _t._rootElem = elem; if (!elem) { _t._mutationObserver.disconnect(); return false; } var obsConfig = { childList: true, characterData: true, subtree: true }; $(elem).each(function() { _t._mutationObserver.observe(this, obsConfig); }); return true; } /** * MutationObserver handler */ UpdateListener.prototype.mutationHandler = function(mutationList) { if (!this._rootElem) { return; } var translationNeeded = false; for (var i = 0; i < mutationList.length; i++) { var mutationRecord = mutationList[i]; if (mutationRecord.type == 'characterData') { translationNeeded = true; break; } if (mutationRecord.type == 'childList' && mutationRecord.addedNodes.length) { translationNeeded = true; break; } } if (translationNeeded) { // Trigger delayed translation. The delay is needed to collect multiple changes together this.triggerTranslation(100); } } UpdateListener.prototype.triggerTranslation = function(delay) { var _t = this; if (_t._changeTriggerTimeout) { window.clearTimeout(_t._changeTriggerTimeout); _t._changeTriggerTimeout = null; } if (delay) { _t._changeTriggerTimeout = window.setTimeout(function() { _t.triggerTranslation(); _t._changeTriggerTimeout = null; }, delay); } else { if (_t._options && _t._rootElem) { $(_t._rootElem).JSTranslate(_t._options); } } } /** * Override system functions to show translated text * * @param {Dictionary} dictionary */ function Overrides(dictionary) { if (!(this instanceof Overrides)) { throw new Error('Overrides must be created with the new keyword'); } this._dictionary = dictionary; this._options = null; this._overriden = { alert: window.alert, confirm: window.confirm }; window.alert = this.alert.bind(this); window.confirm = this.confirm.bind(this); } /** * @param {object} options */ Overrides.prototype.setOptions = function(options) { this._options = options; } /** * @param {string} text * * @return {string} - translated text */ Overrides.prototype.getTranslation = function(text) { if (!this._options || !this._options.lang) { return text; } var translatedText = this._dictionary.getTranslation(text, this._options.lang); if (!translatedText) { if (this._options.showMissing) { dictionary.showMissingTranslations(this._options.lang); } if (this._options.collectMissing) { dictionary.sendMissingTranslations(this._options.lang); } return text; } return translatedText; } /** * Override window.alert() function */ Overrides.prototype.alert = function(message) { message = this.getTranslation(message); return this._overriden.alert.call(window, message); } /** * Override window.confirm() function */ Overrides.prototype.confirm = function(message) { message = this.getTranslation(message); return this._overriden.confirm.call(window, message); } var dictionary = new Dictionary(); var overrides = new Overrides(dictionary); var updateListener = new UpdateListener(); /** * * @param {jQuery} elem * @param {TranslationNode} parentNode */ function createTranslationNode(elem, parentNode) { var dataName = 'JSTranslateTranslationNode'; var translationNode = $(elem).data(dataName); if (translationNode) { translationNode.revert(true); $(elem).removeData(dataName) } translationNode = new TranslationNode(elem, parentNode); $(elem).data(dataName, translationNode); // Array of TranslationNode objects which represent child elements of the current node translationNode._childTranslationNodes = createChildTranslationNodes(translationNode); return translationNode; } /** * Creates array of child translation nodes * * @param {TranslationNode} translationNode * @return {array} */ function createChildTranslationNodes(translationNode) { if (translationNode.isIgnored() || translationNode.isAttributeNode()) { return []; } var childTranslationNodes = []; var elem = translationNode.getElem(); $(elem).contents().each(function() { var childTranslationNode = createTranslationNode($(this), translationNode); translationNode._isSinglePiece = translationNode._isSinglePiece && childTranslationNode.isInline() && childTranslationNode.isSinglePiece(); childTranslationNodes.push(childTranslationNode); }); // Add attributes as child nodes var translationAttributes = translationNode.getTranslationAttributes(); for (var i in translationAttributes) { var translationAttribute = translationAttributes[i]; var childTranslationNode = new TranslationNode(elem, translationNode); translationNode._isSinglePiece = false; translationNode._isEmpty = false; childTranslationNode._translateAttribute = translationAttribute; childTranslationNodes.push(childTranslationNode); } return childTranslationNodes; } /** * Remove all non-word chars from the beginning and end of the string * * @param {string} str */ function trim(str) { return str .replace(optionsClass.getTrimStartRegex(), '$2') .replace(optionsClass.getTrimEndRegex(), '$1') .replace(optionsClass.getIgnoreRegexFull(), ''); } function showInfo(msg) { if (!$('#JSTranslateInfo').length) { var floatDiv = $('
'); $(document).keyup(function(e) { if (e.key === "Escape") { // escape key maps to keycode `27` $(floatDiv).remove(); } }); $('body').append(floatDiv); } $('#JSTranslateInfo').html(msg); } function getLanguage(defaultLanguage) { if (!localStorage) { return defaultLanguage; } let storageLang = localStorage.getItem('JSTranslateLang'); if (!storageLang) { setLanguage(defaultLanguage) return defaultLanguage } return storageLang } function setLanguage(language) { if (!localStorage) { return; } localStorage.setItem('JSTranslateLang', language); } function getToken() { if (!localStorage) { return null; } return localStorage.getItem('JSTranslateToken'); } function setToken(token) { if (!localStorage) { return; } localStorage.setItem('JSTranslateToken', token); } function checkUserAuth(token) { return new Promise((resolve, reject) => { $.post('/JSTranslate/getUser', { token: token }) .done(function (data) { resolve(true); console.log('The JS Translate user is logged in.'); }) .fail(function ({status}) { if (status === 403) { setToken(''); console.log('The JS Translate user is not logged in.'); } else { console.error('Failed to check user authentication'); } resolve(false); }); }); } class EditDictionary { constructor() { this.eventsLoaded = false; this.isEditable = localStorage && !!localStorage.getItem('JSTranslateEditable'); this.styleHtmlElement = null; this.loadingElementStatus = false; } instance(rootNodes, options) { if (this.eventsLoaded) { return this } this.options = options; this.nodes = this.recursiveGetNodes(rootNodes) this.triggerHandler = this.handlerTrigger.bind(this) this.handleEditableChange = this.handleEditableChange.bind(this) this.handleDestroy = this.handleDestroy.bind(this) this.setListeners() this.addEditableBtn() this.handleEditableChange() this.eventsLoaded = true $('body').append('
'); return this } setLoadingElementStatus(status) { this.loadingElementStatus = status } getLoadingElementStatus() { return this.loadingElementStatus } handleEditableChange() { if (this.isEditable) { this.addHoverStyles() this.nodes.forEach(node => node.add()) } else { this.removeHoverStyles() this.nodes.forEach(node => node.remove()) } } handleDestroy() { this.removeHoverStyles() for (const node of this.nodes) { node.remove() } $(window).off('editableChange', this.handleEditableChange); } setListeners() { $(window).on('editableChange', this.handleEditableChange); $(window).on('beforeunload', this.handleDestroy); } addEditableBtn() { if (!$('#JSTranslate-edit-btn').length) { let btnString = () => 'Edit: ' + (this.isEditable ? 'On' : 'Off'); var editButton = $(''); var editHelp = $('
Use Ctrl / Cmd + Click on any page item to edit translation
'); editButton.css({ position: "fixed", top: "10px", right: "10px", color: "#333", backgroundColor: "#eee", padding: "3px", zIndex: "9999" }); editHelp.css({ position: "fixed", top: "50px", right: "10px", width: "200px", color: "#fff", backgroundColor: "#35424a", borderRadius: "6px", padding: "8px 10px", boxShadow: "0 4px 8px rgba(0,0,0,0.15)", zIndex: "9999", display: "none" }); editButton.on("click", () => { this.isEditable = ! this.isEditable; editButton.html(btnString()) this.triggerHandler(); if (localStorage) { localStorage.setItem('JSTranslateEditable', this.isEditable ? 1 : ''); } if (this.isEditable) { editHelp.fadeIn('fast'); } else { editHelp.fadeOut('fast'); } }); $("body").append(editButton); $("body").append(editHelp); } } addHoverStyles() { if (!this.styleHtmlElement) { this.styleHtmlElement = document.createElement('style'); this.styleHtmlElement.textContent = '' + '.JSTranslate-hover-effect-focus, ' + '.JSTranslate-hover-effect { background-color: rgba(0,255,170,0.1); box-shadow:0 0 3px #0fa,0 0 5px #0fa,0 0 8px #0fa,0 0 10px #0fa,0 0 15px #0fa; z-index:9999999; pointer-events:none; }' + '.JSTranslate-editable:hover { cursor:pointer; text-shadow:1px 0 1px #444; }'; document.head.appendChild(this.styleHtmlElement); } } removeHoverStyles() { if (this.styleHtmlElement) { this.styleHtmlElement.remove(); this.styleHtmlElement = null; } } handlerTrigger() { $(window).trigger('editableChange'); } recursiveGetNodes(nodes) { let result = []; for (let node of nodes) { let elements = node.getSinglePieceNodes() .reduce((acc, el) => { if (el.isAttributeNode() || el.isTextNode()) { el = el._parentNode; } if (!acc[el.getUuid()]) { acc[el.getUuid()] = el } return acc }, {}); Object.values(elements).forEach((el) => { const parentNodes = el.getAllParentNodes(); const parentNodeExists = parentNodes.some(parentNode => elements[parentNode.getUuid()]); if (parentNodeExists) { // If parent node exists within elements, we don't need child element in the list delete elements[el.getUuid()]; } }) elements = Object.values(elements).map(el => new EditableNode(el, this.options)) if (elements.length) { result = result.concat(elements) } } return result; } } class NodeWrapper { constructor(node, options) { if (!(node instanceof TranslationNode)) { throw new Error('NodeWrapper: node must be instance of TranslationNode'); } this.node = node this.options = options this.singlePieceNodesByText = {} this.translations = {} this.htmlCreator = NodeHtmlCreator } getSinglePieceNodesByText() { if (!Object.keys(this.singlePieceNodesByText).length) { this.node.getSinglePieceNodes().forEach(node => { const translationSentence = new TranslationSentence(node, this.options) let text = translationSentence.getText() this.singlePieceNodesByText[text] = NodeWrapperFactory.createNodeWrapper(node, this.options) }) } return this.singlePieceNodesByText } getTexts() { return Object.keys(this.getSinglePieceNodesByText()) } addTranslation(text, translation) { this.translations[text] = translation } hasTranslations() { return Object.keys(this.translations).length } getHtmlDivElement() { const div = document.createElement('div') for (let text in this.singlePieceNodesByText) { const translation = this.translations[text] const wrapper = this.singlePieceNodesByText[text] if (translation.text) { const block = (new wrapper.htmlCreator(wrapper, translation)).create() div.appendChild(block) } } return div } } class ImageWrapper extends NodeWrapper { constructor(node, options) { super(node, options); this.htmlCreator = ImageHtmlCreator } } class AnchorWrapper extends NodeWrapper { constructor(node, options) { super(node, options); this.htmlCreator = AnchorHtmlCreator } } class NodeWrapperFactory { constructor(nodes, options) { this.options = options; this.nodes = nodes; } createWrappers() { return this.nodes.map(node => NodeWrapperFactory.createNodeWrapper(node, this.options)); } static createNodeWrapper(node, options) { switch (node._elem.prop('nodeName')) { case 'IMG': return new ImageWrapper(node, options); case 'A': return new AnchorWrapper(node, options); default: return new NodeWrapper(node, options); } } } class TranslationWrapperManager { constructor(wrappers) { this.wrappers = wrappers; this.prioreties = [AnchorWrapper, ImageWrapper]; } getTexts() { return this.wrappers.reduce((texts, wrapper) => texts.concat(wrapper.getTexts()), []) } initTranslationWrappers(translations) { const grouped = this.groupByClassesWithPriority() for (let array of grouped) { array.forEach(wrapper => { for (let text of wrapper.getTexts()) { const index = translations.findIndex(translation => translation.text === text) if (index !== -1) { wrapper.addTranslation(text, translations[index]) translations.splice(index, 1); } } }) } const result = [] grouped.forEach(array => array.forEach(wrapper => { if (wrapper.hasTranslations()) { result.push(wrapper) } })) return result } sortFragments(originalText, wrappers) { let lastIndex = 0; function findNextIndex(wrapper) { let text = wrapper.node._text if (!text) { text = wrapper.node.getElem().html() } const index = originalText.indexOf(text.trim(), lastIndex); lastIndex = index + text.length; return index; } let indexedFragments = wrappers.map(wrapper => { let index = findNextIndex(wrapper); return { wrapper, index }; }); indexedFragments.sort((a, b) => a.index - b.index); return indexedFragments.map(item => item.wrapper); } getHtmlDivElements(translations, originalText) { let wrappers = this.initTranslationWrappers(translations) wrappers = this.sortFragments(originalText, wrappers) return wrappers.map(wrapper => wrapper.getHtmlDivElement()) } groupByClassesWithPriority() { const grouped = []; this.prioreties.forEach(priorityClass => { grouped[priorityClass.name] = []; }); const outOfPriority = [] this.wrappers.forEach(wrapper => { const className = wrapper.constructor.name; if (grouped[className]) { grouped[className].push(wrapper); } else { outOfPriority.push(wrapper) } }) const result = this.prioreties.map(priorityClass => { return grouped[priorityClass.name].sort((a, b) => b.getTexts().length - a.getTexts().length) }); result.push(outOfPriority) return result } } /** * Class for creating HTML elements to build a table in a modal window. * */ class NodeHtmlCreator { constructor(wrapper, translations) { this.wrapper = wrapper this.translation = translations } create() { const div = document.createElement('div') const textDiv = this.createTextElement(this.translation.text) div.appendChild(textDiv) const translationDiv = document.createElement('div') translationDiv.classList.add('jst-translation') const textarea = this.createTextareaElement( this.translation.id, this.translation.translation, this.translation.text.length ) translationDiv.appendChild(textarea) div.appendChild(translationDiv) return div } createTextareaElement(name, value, length = 0) { const textarea = document.createElement(length < 70 ? 'input': 'textarea') textarea.setAttribute('name', name) textarea.value = value return textarea } createInputWithIconElement(name, value, icon) { const container = document.createElement('div') container.classList.add('jst-input-container') const inputIcon = document.createElement('span') inputIcon.classList.add('jst-input-icon') inputIcon.innerHTML = icon const inputField = document.createElement('input') inputField.classList.add('jst-input-field') inputField.setAttribute('type', 'text') if (name) { inputField.setAttribute('name', name) } inputField.value = value container.appendChild(inputIcon) container.appendChild(inputField) return container } createTextElement(html) { const textDiv = document.createElement('div') textDiv.classList.add('jst-text') textDiv.innerHTML = html return textDiv } createAnchorElement(href, innerText) { const a = document.createElement('a') a.href = href a.innerText = innerText ?? href a.setAttribute('target', '_blank') return this.createTextElement(a.outerHTML) } } class AnchorHtmlCreator extends NodeHtmlCreator { constructor(wrapper, translations) { super(wrapper, translations) this.mailtoRegex = /^mailto:/ } create() { const div = document.createElement('div') let textDiv = this.createTextElement(this.translation.text) const translationDiv = document.createElement('div') translationDiv.classList.add('jst-translation') let input= this.createTextareaElement( this.translation.id, this.translation.translation, this.translation.text.length ) if (this.isMailtoText()) { let text = this.translation.text ? this.translation.text : '' textDiv = this.createAnchorElement(text, text.replace(this.mailtoRegex, '')) input = this.createMailtoInputElement( this.translation.id, this.translation.translation ? this.translation.translation : this.translation.text ) } else if (this.isHrefText()) { textDiv = this.createAnchorElement(this.translation.text) input = this.createAnchorInputElement( this.translation.id, this.translation.translation ? this.translation.translation : this.translation.text ) } translationDiv.appendChild(input) div.appendChild(textDiv) div.appendChild(translationDiv) return div } createAnchorInputElement(name, value) { return this.createInputWithIconElement( name, value, '' ) } createMailtoInputElement(name, value) { let viewValue = value.replace(this.mailtoRegex, '') const viewInput = this.createInputWithIconElement( null, viewValue, '' ) const hiddenInput = document.createElement('input') hiddenInput.style.display = 'none' hiddenInput.setAttribute('name', name) hiddenInput.value = value viewInput.addEventListener('change', (e) => { let v = e.target.value hiddenInput.value = v ? 'mailto:'+v : null }) viewInput.appendChild(hiddenInput) return viewInput } isMailtoText() { return this.mailtoRegex.test(this.wrapper.node.getText()) } isHrefText() { return this.wrapper.node.isAttributeNode() && this.wrapper.node._translateAttribute === 'href' } } class ImageHtmlCreator extends NodeHtmlCreator { create() { const div = document.createElement('div') let textDiv = this.createTextElement(this.translation.text) let singlePieceNode = this.wrapper.node const translationDiv = document.createElement('div') translationDiv.classList.add('jst-translation') let input= this.createTextareaElement( this.translation.id, this.translation.translation, this.translation.text.length ) if (singlePieceNode.isAttributeNode() && singlePieceNode._translateAttribute === 'src') { textDiv = this.createAnchorElement(this.translation.text) input = this.createImageInputElement( this.translation.id, this.translation.translation ? this.translation.translation : this.translation.text ) } translationDiv.appendChild(input) div.appendChild(textDiv) div.appendChild(translationDiv) return div } createImageInputElement(name, value) { return this.createInputWithIconElement( name, value, '' ) } } class EditableNode { constructor(item, options) { this.item = item; this.options = options; this.clickHandler = this.handleClick.bind(this) this.mouseEnterHandler = this.handleMouseEnter.bind(this) this.mouseLeaveHandler = this.handleMouseLeave.bind(this) } remove() { const element = this.item.getElem() element.off('click', this.clickHandler); element.off( "mouseenter", this.mouseEnterHandler); element.off( "mouseleave", this.mouseLeaveHandler); element.removeClass('JSTranslate-editable') element.removeClass('JSTranslate-hover-effect'); element.removeClass('JSTranslate-hover-effect-focus'); } add() { const element = this.item.getElem() element.on('click', this.clickHandler); element.on('mouseenter', this.mouseEnterHandler) element.on('mouseleave', this.mouseLeaveHandler) element.addClass('JSTranslate-editable') } isLoading() { return editDictionaryClass.getLoadingElementStatus() } setLoading(status) { const element = this.item.getElem() if (status) { element.addClass('JSTranslate-hover-effect-focus') } else { element.removeClass('JSTranslate-hover-effect-focus') } editDictionaryClass.setLoadingElementStatus(status) } handleMouseEnter(event) { const element = this.item.getElem(); let offset = element.offset(); let width = element.outerWidth(); let height = element.outerHeight(); if (width < 10 || height < 10) { // Width and Height might not be reported correctly if current element contains floating child elements // Check child elements to get correct width and height element.children().each(function() { if ($(this).outerWidth() >= width && $(this).outerHeight() >= height) { offset = $(this).offset(); width = $(this).outerWidth(); height = $(this).outerHeight(); } }); } $('#JSTranslate-hover-overlay').css({ width: width, height: height, top: offset.top, left: offset.left, position: 'absolute' }).show(); // if (event.ctrlKey || event.metaKey) { // if (!element.hasClass('JSTranslate-hover-effect')) { // element.addClass('JSTranslate-hover-effect') // } // } } handleMouseLeave(event) { const element = this.item.getElem() $('#JSTranslate-hover-overlay').hide(); } handleClick(event) { if (event.ctrlKey || event.metaKey) { event.preventDefault(); event.stopPropagation(); $('#JSTranslate-edit-help').hide(); if (this.isLoading()) return let wrappers = (new NodeWrapperFactory( this.item.getNodeNameGroupNodes(), this.options )).createWrappers(); const translationManager = new TranslationWrapperManager( wrappers ) this.setLoading(true) $.post('/JSTranslate/showEditableNodeModal', { lang: getLanguage(), texts: translationManager.getTexts(), page: window.location.href, token: getToken(), }).done((modalHtml) => { let modal = $(modalHtml) modal.data('global', { preview: this.item.getElem().prop('outerHTML'), dictionary: dictionary, updateListener: updateListener, translationManager: translationManager, }) $("body").append(modal); }).always(() => this.setLoading(false)) } } } var editDictionaryClass = new EditDictionary(); var optionsClass = new Options(); $.fn.JSTranslate = async function(options) { options = optionsClass.instance(options).only([ 'lang', 'collectMissing', 'showMissing', 'test', 'dynamicDataPlaceholders', ]) if (!options.lang) { return this; } var rootNode = this; var loadDictionaryDeferred; if (options.test && !options.collectMissing) { // No need to load dictionary for test mode loadDictionaryDeferred = $.Deferred().resolve(); } else { loadDictionaryDeferred = dictionary.loadLang(options.lang); } // Build translation noes while dictionary is loading var rootNodes = []; $(rootNode).each(function() { rootNodes.push(createTranslationNode($(this))); }); loadDictionaryDeferred.done(function() { // Stop monitoring while translating updateListener.monitor(null); for (var i = 0; i < rootNodes.length; i++) { rootNodes[i].translate(options); } if (options.showMissing) { dictionary.showMissingTranslations(options.lang); } if (options.collectMissing) { dictionary.sendMissingTranslations(options.lang); } overrides.setOptions(options); updateListener.setOptions(options); updateListener.monitor(rootNode); setLanguage(options.lang); }); const authToken = getToken() if (!JSTranslate.isAuthenticated && authToken) { await checkUserAuth(authToken).then(isAuth => JSTranslate.isAuthenticated = isAuth) } if (JSTranslate.isAuthenticated) { editDictionaryClass.instance(rootNodes, options); } return this; }; // Expose some objects globaly window.JSTranslate = { createTranslationNode: createTranslationNode, getLanguage: getLanguage, setLanguage: setLanguage, setToken: setToken, getToken: getToken, isAuthenticated: false }; })(jQuery);