import {STATE_NEW, STATE_TRANSLATED, STATE_FINAL} from '../api/constants'; import {ITranslationMessagesFile} from '../api/i-translation-messages-file'; import {INormalizedMessage} from '../api/i-normalized-message'; import {ITransUnit} from '../api/i-trans-unit'; import {INote} from '../api/i-note'; import {DOMUtilities} from './dom-utilities'; import {AbstractTransUnit} from './abstract-trans-unit'; import {XliffMessageParser} from './xliff-message-parser'; import {ParsedMessage} from './parsed-message'; import {AbstractMessageParser} from './abstract-message-parser'; import {isNullOrUndefined} from '../util/tools'; /** * Created by martin on 01.05.2017. * A Translation Unit in an XLIFF 1.2 file. */ export class XliffTransUnit extends AbstractTransUnit implements ITransUnit { constructor(_element: Element, _id: string, _translationMessagesFile: ITranslationMessagesFile) { super(_element, _id, _translationMessagesFile); } public sourceContent(): string { const sourceElement = DOMUtilities.getFirstElementByTagName(this._element, 'source'); return DOMUtilities.getXMLContent(sourceElement); } /** * Set new source content in the transunit. * Normally, this is done by ng-extract. * Method only exists to allow xliffmerge to merge missing changed source content. * @param newContent the new content. */ public setSourceContent(newContent: string) { let source = DOMUtilities.getFirstElementByTagName(this._element, 'source'); if (!source) { // should not happen, there always has to be a source, but who knows.. source = this._element.appendChild(this._element.ownerDocument.createElement('source')); } DOMUtilities.replaceContentWithXMLContent(source, newContent); } /** * Return a parser used for normalized messages. */ protected messageParser(): AbstractMessageParser { return new XliffMessageParser(); } /** * The original text value, that is to be translated, as normalized message. */ public createSourceContentNormalized(): ParsedMessage { const sourceElement = DOMUtilities.getFirstElementByTagName(this._element, 'source'); if (sourceElement) { return this.messageParser().createNormalizedMessageFromXML(sourceElement, null); } else { return null; } } /** * the translated value (containing all markup, depends on the concrete format used). */ public targetContent(): string { const targetElement = DOMUtilities.getFirstElementByTagName(this._element, 'target'); return DOMUtilities.getXMLContent(targetElement); } /** * the translated value, but all placeholders are replaced with {{n}} (starting at 0) * and all embedded html is replaced by direct html markup. */ targetContentNormalized(): INormalizedMessage { const targetElement = DOMUtilities.getFirstElementByTagName(this._element, 'target'); return new XliffMessageParser().createNormalizedMessageFromXML(targetElement, this.sourceContentNormalized()); } /** * State of the translation as stored in the xml. */ public nativeTargetState(): string { const targetElement = DOMUtilities.getFirstElementByTagName(this._element, 'target'); if (targetElement) { return targetElement.getAttribute('state'); } else { return null; } } /** * set state in xml. * @param nativeState nativeState */ protected setNativeTargetState(nativeState: string) { const targetElement = DOMUtilities.getFirstElementByTagName(this._element, 'target'); if (targetElement) { targetElement.setAttribute('state', nativeState); } } /** * Map an abstract state (new, translated, final) to a concrete state used in the xml. * Returns the state to be used in the xml. * @param state one of Constants.STATE... * @returns a native state (depends on concrete format) * @throws error, if state is invalid. */ protected mapStateToNativeState(state: string): string { switch ( state) { case STATE_NEW: return 'new'; case STATE_TRANSLATED: return 'translated'; case STATE_FINAL: return 'final'; default: throw new Error('unknown state ' + state); } } /** * Map a native state (found in the document) to an abstract state (new, translated, final). * Returns the abstract state. * @param nativeState nativeState */ protected mapNativeStateToState(nativeState: string): string { switch ( nativeState) { case 'new': return STATE_NEW; case 'needs-translation': return STATE_NEW; case 'translated': return STATE_TRANSLATED; case 'needs-adaptation': return STATE_TRANSLATED; case 'needs-l10n': return STATE_TRANSLATED; case 'needs-review-adaptation': return STATE_TRANSLATED; case 'needs-review-l10n': return STATE_TRANSLATED; case 'needs-review-translation': return STATE_TRANSLATED; case 'final': return STATE_FINAL; case 'signed-off': return STATE_FINAL; default: return STATE_NEW; } } /** * All the source elements in the trans unit. * The source element is a reference to the original template. * It contains the name of the template file and a line number with the position inside the template. * It is just a help for translators to find the context for the translation. * This is set when using Angular 4.0 or greater. * Otherwise it just returns an empty array. */ public sourceReferences(): {sourcefile: string, linenumber: number}[] { const sourceElements = this._element.getElementsByTagName('context-group'); const sourceRefs: { sourcefile: string, linenumber: number }[] = []; for (let i = 0; i < sourceElements.length; i++) { const elem = sourceElements.item(i); if (elem.getAttribute('purpose') === 'location') { const contextElements = elem.getElementsByTagName('context'); let sourcefile = null; let linenumber = 0; for (let j = 0; j < contextElements.length; j++) { const contextElem = contextElements.item(j); if (contextElem.getAttribute('context-type') === 'sourcefile') { sourcefile = DOMUtilities.getPCDATA(contextElem); } if (contextElem.getAttribute('context-type') === 'linenumber') { linenumber = Number.parseInt(DOMUtilities.getPCDATA(contextElem), 10); } } sourceRefs.push({sourcefile: sourcefile, linenumber: linenumber}); } } return sourceRefs; } /** * Set source ref elements in the transunit. * Normally, this is done by ng-extract. * Method only exists to allow xliffmerge to merge missing source refs. * @param sourceRefs the sourcerefs to set. Old ones are removed. */ public setSourceReferences(sourceRefs: {sourcefile: string, linenumber: number}[]) { this.removeAllSourceReferences(); sourceRefs.forEach((ref) => { const contextGroup = this._element.ownerDocument.createElement('context-group'); contextGroup.setAttribute('purpose', 'location'); const contextSource = this._element.ownerDocument.createElement('context'); contextSource.setAttribute('context-type', 'sourcefile'); contextSource.appendChild(this._element.ownerDocument.createTextNode(ref.sourcefile)); const contextLine = this._element.ownerDocument.createElement('context'); contextLine.setAttribute('context-type', 'linenumber'); contextLine.appendChild(this._element.ownerDocument.createTextNode(ref.linenumber.toString(10))); contextGroup.appendChild(contextSource); contextGroup.appendChild(contextLine); this._element.appendChild(contextGroup); }); } private removeAllSourceReferences() { const sourceElements = this._element.getElementsByTagName('context-group'); const toBeRemoved = []; for (let i = 0; i < sourceElements.length; i++) { const elem = sourceElements.item(i); if (elem.getAttribute('purpose') === 'location') { toBeRemoved.push(elem); } } toBeRemoved.forEach((elem) => {elem.parentNode.removeChild(elem); }); } /** * The description set in the template as value of the i18n-attribute. * e.g. i18n="mydescription". * In xliff this is stored as a note element with attribute from="description". */ public description(): string { const noteElem = this.findNoteElementWithFromAttribute('description'); if (noteElem) { return DOMUtilities.getPCDATA(noteElem); } else { return null; } } /** * Change description property of trans-unit. * @param description description */ public setDescription(description: string) { let noteElem = this.findNoteElementWithFromAttribute('description'); if (description) { if (isNullOrUndefined(noteElem)) { // create it noteElem = this.createNoteElementWithFromAttribute('description', description); } else { DOMUtilities.replaceContentWithXMLContent(noteElem, description); } } else { if (!isNullOrUndefined(noteElem)) { // remove node this.removeNoteElementWithFromAttribute('description'); } } } /** * Find a note element with attribute from='' * @param attrValue attrValue * @return element or null is absent */ private findNoteElementWithFromAttribute(attrValue: string): Element { const noteElements = this._element.getElementsByTagName('note'); for (let i = 0; i < noteElements.length; i++) { const noteElem = noteElements.item(i); if (noteElem.getAttribute('from') === attrValue) { return noteElem; } } return null; } /** * Get all note elements where from attribute is not description or meaning * @return elements */ private findAllAdditionalNoteElements(): Element[] { const noteElements = this._element.getElementsByTagName('note'); const result: Element[] = []; for (let i = 0; i < noteElements.length; i++) { const noteElem = noteElements.item(i); const fromAttribute = noteElem.getAttribute('from'); if (fromAttribute !== 'description' && fromAttribute !== 'meaning') { result.push(noteElem); } } return result; } /** * Create a new note element with attribute from='' * @param fromAttrValue value of "from" attribute * @param content text value of note element * @return the new created element */ private createNoteElementWithFromAttribute(fromAttrValue: string, content: string): Element { const noteElement = this._element.ownerDocument.createElement('note'); if (fromAttrValue) { noteElement.setAttribute('from', fromAttrValue); } noteElement.setAttribute('priority', '1'); if (content) { DOMUtilities.replaceContentWithXMLContent(noteElement, content); } this._element.appendChild(noteElement); return noteElement; } /** * Remove note element with attribute from='' * @param attrValue attrValue */ private removeNoteElementWithFromAttribute(attrValue: string) { const noteElement = this.findNoteElementWithFromAttribute(attrValue); if (noteElement) { this._element.removeChild(noteElement); } } /** * Remove all note elements where attribute "from" is not description or meaning. */ private removeAllAdditionalNoteElements() { const noteElements = this.findAllAdditionalNoteElements(); noteElements.forEach((noteElement) => { this._element.removeChild(noteElement); }); } /** * The meaning (intent) set in the template as value of the i18n-attribute. * This is the part in front of the | symbol. * e.g. i18n="meaning|mydescription". * In xliff this is stored as a note element with attribute from="meaning". */ public meaning(): string { const noteElem = this.findNoteElementWithFromAttribute('meaning'); if (noteElem) { return DOMUtilities.getPCDATA(noteElem); } else { return null; } } /** * Change meaning property of trans-unit. * @param meaning meaning */ public setMeaning(meaning: string) { let noteElem = this.findNoteElementWithFromAttribute('meaning'); if (meaning) { if (isNullOrUndefined(noteElem)) { // create it noteElem = this.createNoteElementWithFromAttribute('meaning', meaning); } else { DOMUtilities.replaceContentWithXMLContent(noteElem, meaning); } } else { if (!isNullOrUndefined(noteElem)) { // remove node this.removeNoteElementWithFromAttribute('meaning'); } } } /** * Get all notes of the trans-unit. * Notes are remarks made by a translator. * (description and meaning are not included here!) */ public notes(): INote[] { const noteElememts: Element[] = this.findAllAdditionalNoteElements(); return noteElememts.map(elem => { return { from: elem.getAttribute('from'), text: DOMUtilities.getPCDATA(elem) }; }); } /** * Test, wether setting of notes is supported. * If not, setNotes will do nothing. * xtb does not support this, all other formats do. */ public supportsSetNotes(): boolean { return true; } /** * Add notes to trans unit. * @param newNotes the notes to add. * @throws an Error if any note contains description or meaning as from attribute. */ public setNotes(newNotes: INote[]) { if (!isNullOrUndefined(newNotes)) { this.checkNotes(newNotes); } this.removeAllAdditionalNoteElements(); if (!isNullOrUndefined(newNotes)) { newNotes.forEach((note) => { const noteElem = this.createNoteElementWithFromAttribute(note.from, note.text); }); } } /** * Set the translation to a given string (including markup). * @param translation translation */ protected translateNative(translation: string) { let target = DOMUtilities.getFirstElementByTagName(this._element, 'target'); if (!target) { const source = DOMUtilities.getFirstElementByTagName(this._element, 'source'); target = DOMUtilities.createFollowingSibling('target', source); } DOMUtilities.replaceContentWithXMLContent(target, translation); this.setTargetState(STATE_TRANSLATED); } /** * Copy source to target to use it as dummy translation. * Returns a changed copy of this trans unit. * receiver is not changed. * (internal usage only, a client should call importNewTransUnit on ITranslationMessageFile) */ public cloneWithSourceAsTarget(isDefaultLang: boolean, copyContent: boolean, targetFile: ITranslationMessagesFile): AbstractTransUnit { const element = this._element.cloneNode(true); const clone = new XliffTransUnit(element, this._id, targetFile); clone.useSourceAsTarget(isDefaultLang, copyContent); return clone; } /** * Copy source to target to use it as dummy translation. * (internal usage only, a client should call createTranslationFileForLang on ITranslationMessageFile) */ public useSourceAsTarget(isDefaultLang: boolean, copyContent: boolean) { const source = DOMUtilities.getFirstElementByTagName(this._element, 'source'); let target = DOMUtilities.getFirstElementByTagName(this._element, 'target'); if (!target) { target = DOMUtilities.createFollowingSibling('target', source); } if (isDefaultLang || copyContent) { const sourceString = DOMUtilities.getXMLContent(source); let newTargetString = sourceString; if (!this.isICUMessage(sourceString)) { newTargetString = this.translationMessagesFile().getNewTransUnitTargetPraefix() + sourceString + this.translationMessagesFile().getNewTransUnitTargetSuffix(); } DOMUtilities.replaceContentWithXMLContent(target, newTargetString); } else { DOMUtilities.replaceContentWithXMLContent(target, ''); } if (isDefaultLang) { target.setAttribute('state', this.mapStateToNativeState(STATE_FINAL)); } else { target.setAttribute('state', this.mapStateToNativeState(STATE_NEW)); } } }