diff --git a/builders/t9n/index.ts b/builders/t9n/index.ts index 373b10a..34ace1a 100644 --- a/builders/t9n/index.ts +++ b/builders/t9n/index.ts @@ -42,16 +42,19 @@ export async function t9n(options: Options, context: BuilderContext): Promise join(targetTranslationPath, f)), includeContextInTarget: options.includeContextInTarget, encoding: options.encoding || 'UTF-8' }); - const server = new TranslationServer(context.logger, translationContext, options.port); - await server.shutdown.toPromise(); + const server = new TranslationServer(context.logger, translationContext); + server.listen(options.port, () => + context.logger.info(`Translation server started: http://localhost:${options.port}\n`) + ); - return { success: true }; + return new Promise(() => {}); } function isFile(path: string) { diff --git a/builders/t9n/server/responses/root-response.ts b/builders/t9n/server/responses/root-response.ts index 9e81b01..78bb007 100644 --- a/builders/t9n/server/responses/root-response.ts +++ b/builders/t9n/server/responses/root-response.ts @@ -4,6 +4,8 @@ import { Hal, HalLink, Links } from '../hal'; import { UrlFactory } from '../url-factory'; export class RootResponse implements Hal { + project: string; + sourceFile: string; sourceLanguage: string; languages: string[]; unitCount: number; @@ -11,6 +13,8 @@ export class RootResponse implements Hal { _embedded?: { [key: string]: unknown }; constructor(context: TranslationContext, urlFactory: UrlFactory) { + this.project = context.project; + this.sourceFile = context.source.file; this.sourceLanguage = context.source.language; this.languages = context.languages; this.unitCount = context.source.units.length; diff --git a/builders/t9n/server/translation-server.ts b/builders/t9n/server/translation-server.ts index 196102f..c84415d 100644 --- a/builders/t9n/server/translation-server.ts +++ b/builders/t9n/server/translation-server.ts @@ -5,9 +5,8 @@ import { readFile } from 'fs'; import Koa from 'koa'; import koaBody from 'koa-body'; import koaStatic from 'koa-static'; -import { Server, Socket } from 'net'; +import { Server } from 'net'; import { join } from 'path'; -import { Observable, Subject } from 'rxjs'; import { promisify } from 'util'; import { TranslationContext, TranslationTargetUnit } from '../translation'; @@ -34,32 +33,18 @@ import { const readFileAsync = promisify(readFile); export class TranslationServer { - readonly shutdown: Observable; - - private _connections: { [key: string]: Socket } = {}; - private _shutdown = new Subject(); - private _server: Server; + private readonly _server: Koa; constructor( private readonly _logger: logging.LoggerApi, - private readonly _context: TranslationContext, - private readonly _port: number + private readonly _context: TranslationContext ) { this._logger.info(`Current languages: ${this._context.languages.join(', ')}\n`); - this._server = this._createServer(); - this.shutdown = this._shutdown.asObservable(); + this._server = this._createApp(); } - private _createServer() { - const server = this._createApp().listen(this._port, () => - this._logger.info(`Translation server started: http://localhost:${this._port}\n`) - ); - server.on('connection', connection => { - const key = `${connection.remoteAddress}:${connection.remotePort}`; - this._connections[key] = connection; - connection.on('close', () => delete this._connections[key]); - }); - return server; + listen(port: number, listeningListener?: () => void): Server { + return this._server.listen(port, listeningListener); } private _createApp() { @@ -84,16 +69,6 @@ export class TranslationServer { options?: Router.UrlOptionsQuery ) => `${ctx.protocol}://${ctx.host}${ctx.router.url(name, params, options)}`; return new Router({ prefix: '/api' }) - .delete('/', async () => { - this._logger.info('\nClosing connections'); - await new Promise((resolve, reject) => { - this._server.close(err => (err ? reject(err) : resolve())); - Object.values(this._connections).forEach(c => c.destroy()); - }); - this._logger.info('Shutting down translation server'); - this._shutdown.next(); - this._shutdown.complete(); - }) .get(ROOT_ROUTE, '/', ctx => { ctx.body = new RootResponse(this._context, toUrlFactory(ctx)); }) diff --git a/builders/t9n/translation/debounce-scheduler.ts b/builders/t9n/translation/debounce-scheduler.ts new file mode 100644 index 0000000..0fcf5be --- /dev/null +++ b/builders/t9n/translation/debounce-scheduler.ts @@ -0,0 +1,20 @@ +export class DebounceScheduler { + private readonly _scheduleMap = new Map(); + + constructor( + private readonly _action: (trigger: T) => Promise, + private readonly _debounceTime = 500 + ) {} + + schedule(trigger: T) { + const entry = this._scheduleMap.get(trigger); + if (entry !== undefined) { + clearTimeout(entry); + } + + this._scheduleMap.set( + trigger, + setTimeout(() => this._action(trigger), this._debounceTime) + ); + } +} diff --git a/builders/t9n/translation/deserialization/translation-deserializer.ts b/builders/t9n/translation/deserialization/translation-deserializer.ts index 5496783..fb1a378 100644 --- a/builders/t9n/translation/deserialization/translation-deserializer.ts +++ b/builders/t9n/translation/deserialization/translation-deserializer.ts @@ -6,12 +6,12 @@ export interface TranslationDeserializer { file: string, encoding: string ): Promise<{ - sourceLanguage: string; + language: string; original: string; unitMap: Map; }>; deserializeTarget( file: string, encoding: string - ): Promise<{ targetLanguage: string; unitMap: Map }>; + ): Promise<{ language: string; unitMap: Map }>; } diff --git a/builders/t9n/translation/deserialization/xlf-deserializer-base.ts b/builders/t9n/translation/deserialization/xlf-deserializer-base.ts index 5670353..9801865 100644 --- a/builders/t9n/translation/deserialization/xlf-deserializer-base.ts +++ b/builders/t9n/translation/deserialization/xlf-deserializer-base.ts @@ -17,7 +17,7 @@ export abstract class XlfDeserializerBase implements TranslationDeserializer { file: string, encoding: string ): Promise<{ - sourceLanguage: string; + language: string; original: string; unitMap: Map; }>; @@ -25,7 +25,7 @@ export abstract class XlfDeserializerBase implements TranslationDeserializer { abstract deserializeTarget( file: string, encoding: string - ): Promise<{ targetLanguage: string; unitMap: Map }>; + ): Promise<{ language: string; unitMap: Map }>; protected async _createDocument(file: string, encoding: string) { const content = await readFileAsync(file, encoding || 'utf8'); diff --git a/builders/t9n/translation/deserialization/xlf-deserializer.ts b/builders/t9n/translation/deserialization/xlf-deserializer.ts index 3469d70..b96e319 100644 --- a/builders/t9n/translation/deserialization/xlf-deserializer.ts +++ b/builders/t9n/translation/deserialization/xlf-deserializer.ts @@ -7,7 +7,7 @@ export class XlfDeserializer extends XlfDeserializerBase { async deserializeSource(file: string, encoding: string) { const doc = await this._createDocument(file, encoding); const fileNode = this._getFileNode(doc); - const sourceLanguage = fileNode.getAttribute('source-language')!; + const language = fileNode.getAttribute('source-language')!; const original = fileNode.getAttribute('original') || ''; const unitMap = Array.from(fileNode.getElementsByTagName('trans-unit')) .map(u => this._deserializeSourceUnit(u)) @@ -15,21 +15,21 @@ export class XlfDeserializer extends XlfDeserializerBase { (current, next) => current.set(next.id, next), new Map() ); - return { sourceLanguage, original, unitMap }; + return { language, original, unitMap }; } async deserializeTarget(file: string, encoding: string) { const doc = await this._createDocument(file, encoding); const fileNode = this._getFileNode(doc); - const targetLanguage = fileNode.getAttribute('target-language')!; - this._assertTargetLanguage(targetLanguage, file); + const language = fileNode.getAttribute('target-language')!; + this._assertTargetLanguage(language, file); const unitMap = Array.from(fileNode.getElementsByTagName('trans-unit')) .map(u => this._deserializeTargetUnit(u)) .reduce( (current, next) => current.set(next.id, next), new Map() ); - return { targetLanguage, unitMap }; + return { language, unitMap }; } protected _assertXliff(doc: Document) { diff --git a/builders/t9n/translation/deserialization/xlf2-deserializer.ts b/builders/t9n/translation/deserialization/xlf2-deserializer.ts index 16bfe9b..f543b08 100644 --- a/builders/t9n/translation/deserialization/xlf2-deserializer.ts +++ b/builders/t9n/translation/deserialization/xlf2-deserializer.ts @@ -6,7 +6,7 @@ import { XlfDeserializerBase } from './xlf-deserializer-base'; export class Xlf2Deserializer extends XlfDeserializerBase { async deserializeSource(file: string, encoding: string) { const doc = await this._createDocument(file, encoding); - const sourceLanguage = doc.documentElement.getAttribute('srcLang')!; + const language = doc.documentElement.getAttribute('srcLang')!; const fileNode = this._getFileNode(doc); const original = fileNode.getAttribute('original') || ''; const unitMap = Array.from(fileNode.childNodes) @@ -16,13 +16,13 @@ export class Xlf2Deserializer extends XlfDeserializerBase { (current, next) => current.set(next.id, next), new Map() ); - return { sourceLanguage, original, unitMap }; + return { language, original, unitMap }; } async deserializeTarget(file: string, encoding: string) { const doc = await this._createDocument(file, encoding); - const targetLanguage = doc.documentElement.getAttribute('trgLang')!; - this._assertTargetLanguage(targetLanguage, file); + const language = doc.documentElement.getAttribute('trgLang')!; + this._assertTargetLanguage(language, file); const fileNode = this._getFileNode(doc); const unitMap = Array.from(fileNode.childNodes) .filter(c => c.nodeName === 'unit') @@ -31,7 +31,7 @@ export class Xlf2Deserializer extends XlfDeserializerBase { (current, next) => current.set(next.id, next), new Map() ); - return { targetLanguage, unitMap }; + return { language, unitMap }; } protected _assertXliff(doc: Document) { diff --git a/builders/t9n/translation/serialization/translation-serializer.ts b/builders/t9n/translation/serialization/translation-serializer.ts index 5a00d3d..8610b3d 100644 --- a/builders/t9n/translation/serialization/translation-serializer.ts +++ b/builders/t9n/translation/serialization/translation-serializer.ts @@ -2,7 +2,6 @@ import { TranslationTarget } from '../translation-target'; export interface TranslationSerializer { serializeTarget( - file: string, target: TranslationTarget, options: { encoding: string; diff --git a/builders/t9n/translation/serialization/xlf-serializer.ts b/builders/t9n/translation/serialization/xlf-serializer.ts index a1ca409..7b6c8b6 100644 --- a/builders/t9n/translation/serialization/xlf-serializer.ts +++ b/builders/t9n/translation/serialization/xlf-serializer.ts @@ -11,7 +11,6 @@ const writeFileAsync = promisify(writeFile); export class XlfSerializer implements TranslationSerializer { async serializeTarget( - file: string, target: TranslationTarget, options: { encoding: string; @@ -40,7 +39,7 @@ ${units `; - await writeFileAsync(file, content + EOL, options.encoding); + await writeFileAsync(target.file, content + EOL, options.encoding); } private _transformState(state: string) { diff --git a/builders/t9n/translation/serialization/xlf2-serializer.ts b/builders/t9n/translation/serialization/xlf2-serializer.ts index ef2a5cc..602513e 100644 --- a/builders/t9n/translation/serialization/xlf2-serializer.ts +++ b/builders/t9n/translation/serialization/xlf2-serializer.ts @@ -11,7 +11,6 @@ const writeFileAsync = promisify(writeFile); export class Xlf2Serializer implements TranslationSerializer { async serializeTarget( - file: string, target: TranslationTarget, options: { encoding: string; @@ -40,7 +39,7 @@ ${units `; - await writeFileAsync(file, content + EOL, options.encoding); + await writeFileAsync(target.file, content + EOL, options.encoding); } private _serializeNotes(unit: TranslationTargetUnit, target: TranslationTarget) { diff --git a/builders/t9n/translation/translation-context-configuration.ts b/builders/t9n/translation/translation-context-configuration.ts index f2f416d..d29b6a7 100644 --- a/builders/t9n/translation/translation-context-configuration.ts +++ b/builders/t9n/translation/translation-context-configuration.ts @@ -1,13 +1,18 @@ +import { logging } from '@angular-devkit/core'; + import { TranslationSerializer } from './serialization/translation-serializer'; import { TranslationSource } from './translation-source'; import { TranslationTarget } from './translation-target'; export interface TranslationContextConfiguration { + logger: logging.LoggerApi; + project: string; encoding: string; includeContextInTarget: boolean; source: TranslationSource; + sourceFile: string; targets: Map; serializer: TranslationSerializer; original: string; - filenameFactory: (target: TranslationTarget) => string; + filenameFactory: (language: string) => string; } diff --git a/builders/t9n/translation/translation-context.ts b/builders/t9n/translation/translation-context.ts index 7b68f0f..c5e7335 100644 --- a/builders/t9n/translation/translation-context.ts +++ b/builders/t9n/translation/translation-context.ts @@ -1,7 +1,6 @@ import { logging } from '@angular-devkit/core'; -import { Subject } from 'rxjs'; -import { debounceTime, map, switchMap } from 'rxjs/operators'; +import { DebounceScheduler } from './debounce-scheduler'; import { TranslationSerializer } from './serialization/translation-serializer'; import { TranslationContextConfiguration } from './translation-context-configuration'; import { TranslationSource } from './translation-source'; @@ -9,8 +8,11 @@ import { TranslationTarget } from './translation-target'; import { TranslationTargetUnit } from './translation-target-unit'; export class TranslationContext { + readonly project: string; readonly source: TranslationSource; + readonly sourceFile: string; + private readonly _logger: logging.LoggerApi; private readonly _serializer: TranslationSerializer; private readonly _targets: Map; private readonly _options: { @@ -18,22 +20,27 @@ export class TranslationContext { original: string; includeContextInTarget: boolean; }; - private readonly _filenameFactory: (target: TranslationTarget) => string; - private readonly _serializeScheduler = new Map>(); + private readonly _filenameFactory: (language: string) => string; + private readonly _serializeScheduler: DebounceScheduler; get languages() { return Array.from(this._targets.keys()).sort(); } - constructor( - private readonly _logger: logging.LoggerApi, - configuration: TranslationContextConfiguration - ) { - this._serializer = configuration.serializer; + constructor(configuration: TranslationContextConfiguration) { + this.project = configuration.project; this.source = configuration.source; + this.sourceFile = configuration.sourceFile; + this._logger = configuration.logger; + this._serializer = configuration.serializer; this._targets = configuration.targets; this._filenameFactory = configuration.filenameFactory; this._options = { ...configuration }; + this._serializeScheduler = new DebounceScheduler(async language => { + const target = this._targets.get(language)!; + await this._serializer.serializeTarget(target, this._options); + this._logger.info(`${this._timestamp()}: Updated ${target.file}`); + }); } target(language: string) { @@ -45,9 +52,9 @@ export class TranslationContext { throw new Error(`${language} already exists as target!`); } - const target = new TranslationTarget(this.source, language); - const file = await this._serialize(target); - this._logger.info(`${this._timestamp()}: Created ${file}`); + const target = new TranslationTarget(this.source, this._filenameFactory(language), language); + await this._serializer.serializeTarget(target, this._options); + this._logger.info(`${this._timestamp()}: Created ${target.file}`); this._targets.set(language, target); return target; } @@ -61,34 +68,10 @@ export class TranslationContext { existingUnit.target = unit.target; existingUnit.state = unit.state; - this._scheduleSerialization(language); + this._serializeScheduler.schedule(language); return existingUnit; } - private _scheduleSerialization(language: string) { - const subject = this._serializeScheduler.get(language) || this._createScheduler(language); - subject.next(); - } - - private _createScheduler(language: string) { - const subject = new Subject(); - subject - .pipe( - debounceTime(500), - map(() => this._targets.get(language)!), - switchMap(t => this._serialize(t)) - ) - .subscribe(f => this._logger.info(`${this._timestamp()}: Updated ${f}`)); - this._serializeScheduler.set(language, subject); - return subject; - } - - private async _serialize(target: TranslationTarget) { - const file = this._filenameFactory(target); - await this._serializer.serializeTarget(file, target, this._options); - return file; - } - private _timestamp() { const now = new Date(); const pad = (value: number) => value.toString().padStart(2, '0'); diff --git a/builders/t9n/translation/translation-factory-configuration.ts b/builders/t9n/translation/translation-factory-configuration.ts index 034e43c..0677570 100644 --- a/builders/t9n/translation/translation-factory-configuration.ts +++ b/builders/t9n/translation/translation-factory-configuration.ts @@ -2,9 +2,10 @@ import { logging } from '@angular-devkit/core'; export interface TranslationFactoryConfiguration { logger: logging.LoggerApi; - source: string; + sourceFile: string; targetPath: string; targets: string[]; includeContextInTarget: boolean; encoding: string; + project: string; } diff --git a/builders/t9n/translation/translation-factory.ts b/builders/t9n/translation/translation-factory.ts index 18c8e9c..923647c 100644 --- a/builders/t9n/translation/translation-factory.ts +++ b/builders/t9n/translation/translation-factory.ts @@ -15,7 +15,7 @@ const readFileAsync = promisify(readFile); export class TranslationFactory { static async createTranslationContext(configuration: TranslationFactoryConfiguration) { - const xlfContent = await readFileAsync(configuration.source, configuration.encoding); + const xlfContent = await readFileAsync(configuration.sourceFile, configuration.encoding); const doc = new DOMParser().parseFromString(xlfContent); const version = doc.documentElement.getAttribute('version'); if (doc.documentElement.tagName !== 'xliff') { @@ -34,71 +34,46 @@ export class TranslationFactory { deserializer: TranslationDeserializer, serializer: TranslationSerializer ) { - const { sourceLanguage, original, unitMap } = await deserializer.deserializeSource( - configuration.source, + const { language, original, unitMap } = await deserializer.deserializeSource( + configuration.sourceFile, configuration.encoding ); - const source = new TranslationSource(sourceLanguage, unitMap); - const deserializedTargets = await Promise.all( + const source = new TranslationSource(configuration.sourceFile, language, unitMap); + const filenameFactory = this._createFilenameFactory(configuration); + const targets = await Promise.all( configuration.targets.map(target => - deserializer - .deserializeTarget(target, configuration.encoding) - .then(t => new TranslationTarget(source, t.targetLanguage, t.unitMap)) + deserializer.deserializeTarget(target, configuration.encoding) ) - ); - const targets = deserializedTargets.reduce( - (map, t) => map.set(t.language, t), - new Map() + ).then(deserializedTargets => + deserializedTargets + .map(t => new TranslationTarget(source, filenameFactory(t.language), t.language, t.unitMap)) + .reduce((map, t) => map.set(t.language, t), new Map()) ); const contextConfiguration: TranslationContextConfiguration = { - encoding: configuration.encoding, - includeContextInTarget: configuration.includeContextInTarget, - filenameFactory: this._createFilenameFactory(configuration.source, configuration.targetPath), - original, - serializer, + ...configuration, source, - targets + targets, + filenameFactory, + original, + serializer }; - await this._migrateSourceToTargetLanguage(contextConfiguration); - return new TranslationContext(configuration.logger, contextConfiguration); + const context = new TranslationContext(contextConfiguration); + const sourceTarget = + context.target(source.language) || (await context.createTarget(source.language)); + sourceTarget.units.forEach(u => Object.assign(u, { target: u.source, state: 'final' })); + await serializer.serializeTarget(sourceTarget, contextConfiguration); + return context; } - private static _createFilenameFactory( - source: string, - targetPath: string - ): (target: TranslationTarget) => string { - const extension = extname(source); - const filename = basename(source); + private static _createFilenameFactory(configuration: { + sourceFile: string; + targetPath: string; + }): (language: string) => string { + const extension = extname(configuration.sourceFile); + const filename = basename(configuration.sourceFile); const namePart = filename.substring(0, filename.length - extension.length); - return t => join(targetPath, `${namePart}.${t.language}${extension}`); - } - - private static async _migrateSourceToTargetLanguage({ - encoding, - includeContextInTarget, - filenameFactory, - original, - source, - targets, - serializer - }: TranslationContextConfiguration) { - let target = targets.get(source.language); - if (!target) { - target = new TranslationTarget(source, source.language); - targets.set(source.language, target); - } - - target.units.forEach(u => { - u.target = u.source; - u.state = 'final'; - }); - - await serializer.serializeTarget(filenameFactory(target), target, { - original, - encoding, - includeContextInTarget - }); + return l => join(configuration.targetPath, `${namePart}.${l}${extension}`); } } diff --git a/builders/t9n/translation/translation-source.ts b/builders/t9n/translation/translation-source.ts index f2a8172..c804106 100644 --- a/builders/t9n/translation/translation-source.ts +++ b/builders/t9n/translation/translation-source.ts @@ -3,7 +3,11 @@ import { TranslationSourceUnit } from './translation-source-unit'; export class TranslationSource { readonly units: TranslationSourceUnit[]; - constructor(readonly language: string, readonly unitMap: Map) { + constructor( + readonly file: string, + readonly language: string, + readonly unitMap: Map + ) { this.units = Array.from(this.unitMap.values()); } } diff --git a/builders/t9n/translation/translation-target.ts b/builders/t9n/translation/translation-target.ts index e472679..bec8f2a 100644 --- a/builders/t9n/translation/translation-target.ts +++ b/builders/t9n/translation/translation-target.ts @@ -11,6 +11,7 @@ export class TranslationTarget { constructor( readonly source: TranslationSource, + readonly file: string, readonly language: string, unitMap = new Map() ) {