From 9469fff6ac4cd95bdb99a0c37b763c6c32af392f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 10 Oct 2024 13:48:50 -0500 Subject: [PATCH 1/6] Rename translation variables from _ENDPOINT to _BASE_URL --- src/config.ts | 12 +++--- src/middleware/translatorMiddleware.ts | 37 +++++++------------ src/translators/DeepLTranslator.test.ts | 19 ++++++---- src/translators/DeepLTranslator.ts | 37 +++++++++---------- .../LibreTranslateTranslator.test.ts | 19 ++++++---- src/translators/LibreTranslateTranslator.ts | 16 +++----- src/translators/translator.ts | 3 +- 7 files changed, 66 insertions(+), 77 deletions(-) diff --git a/src/config.ts b/src/config.ts index 0e4fc816..806fe196 100644 --- a/src/config.ts +++ b/src/config.ts @@ -276,19 +276,19 @@ class Conf { return Deno.env.get('TRANSLATION_PROVIDER'); } /** DeepL URL endpoint. */ - static get deepLendpoint(): string | undefined { - return Deno.env.get('DEEPL_ENDPOINT'); + static get deeplBaseUrl(): string | undefined { + return Deno.env.get('DEEPL_BASE_URL'); } /** DeepL API KEY. */ - static get deepLapiKey(): string | undefined { + static get deeplApiKey(): string | undefined { return Deno.env.get('DEEPL_API_KEY'); } /** LibreTranslate URL endpoint. */ - static get libreTranslateEndpoint(): string | undefined { - return Deno.env.get('LIBRETRANSLATE_ENDPOINT'); + static get libretranslateBaseUrl(): string | undefined { + return Deno.env.get('LIBRETRANSLATE_BASE_URL'); } /** LibreTranslate API KEY. */ - static get libreTranslateApiKey(): string | undefined { + static get libretranslateApiKey(): string | undefined { return Deno.env.get('LIBRETRANSLATE_API_KEY'); } /** Cache settings. */ diff --git a/src/middleware/translatorMiddleware.ts b/src/middleware/translatorMiddleware.ts index b8a07686..f5a6baa2 100644 --- a/src/middleware/translatorMiddleware.ts +++ b/src/middleware/translatorMiddleware.ts @@ -6,33 +6,22 @@ import { LibreTranslateTranslator } from '@/translators/LibreTranslateTranslator /** Set the translator used for translating posts. */ export const translatorMiddleware: AppMiddleware = async (c, next) => { - const deepLendpoint = Conf.deepLendpoint; - const deepLapiKey = Conf.deepLapiKey; - const libreTranslateEndpoint = Conf.libreTranslateEndpoint; - const libreTranslateApiKey = Conf.libreTranslateApiKey; - const translationProvider = Conf.translationProvider; + switch (Conf.translationProvider) { + case 'deepl': { + const { deeplApiKey: apiKey, deeplBaseUrl: baseUrl } = Conf; + if (apiKey) { + c.set('translator', new DeepLTranslator({ baseUrl, apiKey, fetch: fetchWorker })); + } + break; + } - switch (translationProvider) { - case 'deepl': - if (deepLapiKey) { - c.set( - 'translator', - new DeepLTranslator({ endpoint: deepLendpoint, apiKey: deepLapiKey, fetch: fetchWorker }), - ); - } - break; - case 'libretranslate': - if (libreTranslateApiKey) { - c.set( - 'translator', - new LibreTranslateTranslator({ - endpoint: libreTranslateEndpoint, - apiKey: libreTranslateApiKey, - fetch: fetchWorker, - }), - ); + case 'libretranslate': { + const { libretranslateApiKey: apiKey, libretranslateBaseUrl: baseUrl } = Conf; + if (apiKey) { + c.set('translator', new LibreTranslateTranslator({ baseUrl, apiKey, fetch: fetchWorker })); } break; + } } await next(); diff --git a/src/translators/DeepLTranslator.test.ts b/src/translators/DeepLTranslator.test.ts index 385c10fc..d78c0a0a 100644 --- a/src/translators/DeepLTranslator.test.ts +++ b/src/translators/DeepLTranslator.test.ts @@ -4,15 +4,18 @@ import { Conf } from '@/config.ts'; import { DeepLTranslator } from '@/translators/DeepLTranslator.ts'; import { getLanguage } from '@/test.ts'; -const endpoint = Conf.deepLendpoint; -const apiKey = Conf.deepLapiKey; -const translationProvider = Conf.translationProvider; -const deepL = 'deepl'; +const { + deeplBaseUrl: baseUrl, + deeplApiKey: apiKey, + translationProvider, +} = Conf; + +const deepl = 'deepl'; Deno.test('DeepL translation with source language omitted', { - ignore: !(translationProvider === deepL && apiKey), + ignore: !(translationProvider === deepl && apiKey), }, async () => { - const translator = new DeepLTranslator({ fetch: fetch, endpoint, apiKey: apiKey as string }); + const translator = new DeepLTranslator({ fetch: fetch, baseUrl, apiKey: apiKey! }); const data = await translator.translate( [ @@ -31,9 +34,9 @@ Deno.test('DeepL translation with source language omitted', { }); Deno.test('DeepL translation with source language set', { - ignore: !(translationProvider === deepL && apiKey), + ignore: !(translationProvider === deepl && apiKey), }, async () => { - const translator = new DeepLTranslator({ fetch: fetch, endpoint, apiKey: apiKey as string }); + const translator = new DeepLTranslator({ fetch: fetch, baseUrl, apiKey: apiKey as string }); const data = await translator.translate( [ diff --git a/src/translators/DeepLTranslator.ts b/src/translators/DeepLTranslator.ts index f30d414a..2b4da175 100644 --- a/src/translators/DeepLTranslator.ts +++ b/src/translators/DeepLTranslator.ts @@ -5,8 +5,8 @@ import { DittoTranslator, SourceLanguage, TargetLanguage } from '@/translators/t import { languageSchema } from '@/schema.ts'; interface DeepLTranslatorOpts { - /** DeepL endpoint to use. Default: 'https://api.deepl.com' */ - endpoint?: string; + /** DeepL base URL to use. Default: 'https://api.deepl.com' */ + baseUrl?: string; /** DeepL API key. */ apiKey: string; /** Custom fetch implementation. */ @@ -14,13 +14,14 @@ interface DeepLTranslatorOpts { } export class DeepLTranslator implements DittoTranslator { - private readonly endpoint: string; + private readonly baseUrl: string; private readonly apiKey: string; private readonly fetch: typeof fetch; - private static provider = 'DeepL.com'; + + readonly provider = 'DeepL.com'; constructor(opts: DeepLTranslatorOpts) { - this.endpoint = opts.endpoint ?? 'https://api.deepl.com'; + this.baseUrl = opts.baseUrl ?? 'https://api.deepl.com'; this.fetch = opts.fetch ?? globalThis.fetch; this.apiKey = opts.apiKey; } @@ -31,11 +32,11 @@ export class DeepLTranslator implements DittoTranslator { dest: TargetLanguage, opts?: { signal?: AbortSignal }, ) { - const data = (await this.translateMany(texts, source, dest, opts)).translations; + const { translations } = await this.translateMany(texts, source, dest, opts); return { - results: data.map((value) => value.text), - source_lang: data[0].detected_source_language as LanguageCode, + results: translations.map((value) => value.text), + source_lang: translations[0]?.detected_source_language as LanguageCode, }; } @@ -56,25 +57,26 @@ export class DeepLTranslator implements DittoTranslator { body.source_lang = source.toUpperCase(); } - const headers = new Headers(); - headers.append('Authorization', 'DeepL-Auth-Key' + ' ' + this.apiKey); - headers.append('Content-Type', 'application/json'); + const url = new URL('/v2/translate', this.baseUrl); - const request = new Request(this.endpoint + '/v2/translate', { + const request = new Request(url, { method: 'POST', body: JSON.stringify(body), - headers, + headers: { + 'Authorization': `DeepL-Auth-Key ${this.apiKey}`, + 'Content-Type': 'application/json', + }, signal: opts?.signal, }); const response = await this.fetch(request); const json = await response.json(); + if (!response.ok) { throw new Error(json['message']); } - const data = DeepLTranslator.schema().parse(json); - return data; + return DeepLTranslator.schema().parse(json); } /** DeepL response schema. @@ -89,9 +91,4 @@ export class DeepLTranslator implements DittoTranslator { ), }); } - - /** DeepL provider. */ - getProvider(): string { - return DeepLTranslator.provider; - } } diff --git a/src/translators/LibreTranslateTranslator.test.ts b/src/translators/LibreTranslateTranslator.test.ts index 6b87cc91..edda3039 100644 --- a/src/translators/LibreTranslateTranslator.test.ts +++ b/src/translators/LibreTranslateTranslator.test.ts @@ -4,15 +4,18 @@ import { Conf } from '@/config.ts'; import { LibreTranslateTranslator } from '@/translators/LibreTranslateTranslator.ts'; import { getLanguage } from '@/test.ts'; -const endpoint = Conf.libreTranslateEndpoint; -const apiKey = Conf.libreTranslateApiKey; -const translationProvider = Conf.translationProvider; -const libreTranslate = 'libretranslate'; +const { + libretranslateBaseUrl: baseUrl, + libretranslateApiKey: apiKey, + translationProvider, +} = Conf; + +const libretranslate = 'libretranslate'; Deno.test('LibreTranslate translation with source language omitted', { - ignore: !(translationProvider === libreTranslate && apiKey), + ignore: !(translationProvider === libretranslate && apiKey), }, async () => { - const translator = new LibreTranslateTranslator({ fetch: fetch, endpoint, apiKey: apiKey as string }); + const translator = new LibreTranslateTranslator({ fetch: fetch, baseUrl, apiKey: apiKey! }); const data = await translator.translate( [ @@ -31,9 +34,9 @@ Deno.test('LibreTranslate translation with source language omitted', { }); Deno.test('LibreTranslate translation with source language set', { - ignore: !(translationProvider === libreTranslate && apiKey), + ignore: !(translationProvider === libretranslate && apiKey), }, async () => { - const translator = new LibreTranslateTranslator({ fetch: fetch, endpoint, apiKey: apiKey as string }); + const translator = new LibreTranslateTranslator({ fetch: fetch, baseUrl, apiKey: apiKey! }); const data = await translator.translate( [ diff --git a/src/translators/LibreTranslateTranslator.ts b/src/translators/LibreTranslateTranslator.ts index 2c201575..bd2850ff 100644 --- a/src/translators/LibreTranslateTranslator.ts +++ b/src/translators/LibreTranslateTranslator.ts @@ -6,7 +6,7 @@ import { languageSchema } from '@/schema.ts'; interface LibreTranslateTranslatorOpts { /** Libretranslate endpoint to use. Default: 'https://libretranslate.com' */ - endpoint?: string; + baseUrl?: string; /** Libretranslate API key. */ apiKey: string; /** Custom fetch implementation. */ @@ -14,13 +14,14 @@ interface LibreTranslateTranslatorOpts { } export class LibreTranslateTranslator implements DittoTranslator { - private readonly endpoint: string; + private readonly baseUrl: string; private readonly apiKey: string; private readonly fetch: typeof fetch; - private static provider = 'libretranslate.com'; + + readonly provider = 'libretranslate.com'; constructor(opts: LibreTranslateTranslatorOpts) { - this.endpoint = opts.endpoint ?? 'https://libretranslate.com'; + this.baseUrl = opts.baseUrl ?? 'https://libretranslate.com'; this.fetch = opts.fetch ?? globalThis.fetch; this.apiKey = opts.apiKey; } @@ -59,7 +60,7 @@ export class LibreTranslateTranslator implements DittoTranslator { const headers = new Headers(); headers.append('Content-Type', 'application/json'); - const request = new Request(this.endpoint + '/translate', { + const request = new Request(new URL('/translate', this.baseUrl), { method: 'POST', body: JSON.stringify(body), headers, @@ -87,9 +88,4 @@ export class LibreTranslateTranslator implements DittoTranslator { }).optional(), }); } - - /** LibreTranslate provider. */ - getProvider(): string { - return LibreTranslateTranslator.provider; - } } diff --git a/src/translators/translator.ts b/src/translators/translator.ts index 98adf5ee..df3c7929 100644 --- a/src/translators/translator.ts +++ b/src/translators/translator.ts @@ -41,7 +41,8 @@ export interface DittoTranslator { /** Custom options. */ opts?: { signal?: AbortSignal }, ): Promise<{ results: string[]; source_lang: SourceLanguage }>; - getProvider(): string; + /** Provider name, eg `DeepL.com` */ + provider: string; } /** Includes the TARGET language and the status id. From 874da1baad5bac890431ae9de1462a3673038b1c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 10 Oct 2024 14:01:18 -0500 Subject: [PATCH 2/6] Delete unused cacheMiddleware and ExpiringCache module --- src/middleware/cacheMiddleware.ts | 28 ------------- src/utils/expiring-cache.test.ts | 18 -------- src/utils/expiring-cache.ts | 68 ------------------------------- 3 files changed, 114 deletions(-) delete mode 100644 src/middleware/cacheMiddleware.ts delete mode 100644 src/utils/expiring-cache.test.ts delete mode 100644 src/utils/expiring-cache.ts diff --git a/src/middleware/cacheMiddleware.ts b/src/middleware/cacheMiddleware.ts deleted file mode 100644 index baa4976d..00000000 --- a/src/middleware/cacheMiddleware.ts +++ /dev/null @@ -1,28 +0,0 @@ -import Debug from '@soapbox/stickynotes/debug'; -import { type MiddlewareHandler } from 'hono'; - -import ExpiringCache from '@/utils/expiring-cache.ts'; - -const debug = Debug('ditto:middleware:cache'); - -export const cacheMiddleware = (options: { - cacheName: string; - expires?: number; -}): MiddlewareHandler => { - return async (c, next) => { - const key = c.req.url.replace('http://', 'https://'); - const cache = new ExpiringCache(await caches.open(options.cacheName)); - const response = await cache.match(key); - if (!response) { - debug('Building cache for page', c.req.url); - await next(); - const response = c.res.clone(); - if (response.status < 500) { - await cache.putExpiring(key, response, options.expires ?? 0); - } - } else { - debug('Serving page from cache', c.req.url); - return response; - } - }; -}; diff --git a/src/utils/expiring-cache.test.ts b/src/utils/expiring-cache.test.ts deleted file mode 100644 index 8c6d7b18..00000000 --- a/src/utils/expiring-cache.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { assert } from '@std/assert'; - -import ExpiringCache from './expiring-cache.ts'; - -Deno.test('ExpiringCache', async () => { - const cache = new ExpiringCache(await caches.open('test')); - - await cache.putExpiring('http://mostr.local/1', new Response('hello world'), 300); - await cache.putExpiring('http://mostr.local/2', new Response('hello world'), -1); - - // const resp1 = await cache.match('http://mostr.local/1'); - const resp2 = await cache.match('http://mostr.local/2'); - - // assert(resp1!.headers.get('Expires')); - assert(!resp2); - - // await resp1!.text(); -}); diff --git a/src/utils/expiring-cache.ts b/src/utils/expiring-cache.ts deleted file mode 100644 index ebb5d2ee..00000000 --- a/src/utils/expiring-cache.ts +++ /dev/null @@ -1,68 +0,0 @@ -class ExpiringCache implements Cache { - #cache: Cache; - - constructor(cache: Cache) { - this.#cache = cache; - } - - add(request: RequestInfo | URL): Promise { - return this.#cache.add(request); - } - - addAll(requests: RequestInfo[]): Promise { - return this.#cache.addAll(requests); - } - - keys(request?: RequestInfo | URL | undefined, options?: CacheQueryOptions | undefined): Promise { - return this.#cache.keys(request, options); - } - - matchAll( - request?: RequestInfo | URL | undefined, - options?: CacheQueryOptions | undefined, - ): Promise { - return this.#cache.matchAll(request, options); - } - - put(request: RequestInfo | URL, response: Response): Promise { - return this.#cache.put(request, response); - } - - putExpiring(request: RequestInfo | URL, response: Response, expiresIn: number): Promise { - const expires = Date.now() + expiresIn; - - const clone = new Response(response.body, { - status: response.status, - headers: { - expires: new Date(expires).toUTCString(), - ...Object.fromEntries(response.headers.entries()), - }, - }); - - return this.#cache.put(request, clone); - } - - async match(request: RequestInfo | URL, options?: CacheQueryOptions | undefined): Promise { - const response = await this.#cache.match(request, options); - const expires = response?.headers.get('Expires'); - - if (response && expires) { - if (new Date(expires).getTime() > Date.now()) { - return response; - } else { - await Promise.all([ - this.delete(request), - response.text(), // Prevent memory leaks - ]); - } - } else if (response) { - return response; - } - } - - delete(request: RequestInfo | URL, options?: CacheQueryOptions | undefined): Promise { - return this.#cache.delete(request, options); - } -} - -export default ExpiringCache; From d639d9a14d1cbae36b00c360b571217e9676f7d8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 10 Oct 2024 14:06:04 -0500 Subject: [PATCH 3/6] Reorganize translation interfaces/files --- src/app.ts | 2 +- src/caches/translationCache.ts | 16 ++++++ src/controllers/api/translate.ts | 21 ++++---- src/entities/MastodonTranslation.ts | 17 ++++++ src/interfaces/DittoTranslator.ts | 18 +++++++ src/translators/DeepLTranslator.ts | 10 ++-- src/translators/LibreTranslateTranslator.ts | 6 +-- src/translators/translator.ts | 57 --------------------- 8 files changed, 71 insertions(+), 76 deletions(-) create mode 100644 src/caches/translationCache.ts create mode 100644 src/entities/MastodonTranslation.ts create mode 100644 src/interfaces/DittoTranslator.ts delete mode 100644 src/translators/translator.ts diff --git a/src/app.ts b/src/app.ts index 3f6f7413..ad80cbb1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -120,6 +120,7 @@ import { indexController } from '@/controllers/site.ts'; import { manifestController } from '@/controllers/manifest.ts'; import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts'; import { nostrController } from '@/controllers/well-known/nostr.ts'; +import { DittoTranslator } from '@/interfaces/DittoTranslator.ts'; import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts'; import { cspMiddleware } from '@/middleware/cspMiddleware.ts'; import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts'; @@ -129,7 +130,6 @@ import { requireSigner } from '@/middleware/requireSigner.ts'; import { signerMiddleware } from '@/middleware/signerMiddleware.ts'; import { storeMiddleware } from '@/middleware/storeMiddleware.ts'; import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts'; -import { DittoTranslator } from '@/translators/translator.ts'; import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts'; interface AppEnv extends HonoEnv { diff --git a/src/caches/translationCache.ts b/src/caches/translationCache.ts new file mode 100644 index 00000000..88121c1f --- /dev/null +++ b/src/caches/translationCache.ts @@ -0,0 +1,16 @@ +import { LanguageCode } from 'iso-639-1'; +import { LRUCache } from 'lru-cache'; + +import { MastodonTranslation } from '@/entities/MastodonTranslation.ts'; +import { Time } from '@/utils/time.ts'; + +/** Entity returned by DittoTranslator and LRUCache */ +interface DittoTranslation { + data: MastodonTranslation; +} + +/** Translations LRU cache. */ +export const translationCache = new LRUCache<`${LanguageCode}-${string}`, DittoTranslation>({ + max: 1000, + ttl: Time.hours(6), +}); diff --git a/src/controllers/api/translate.ts b/src/controllers/api/translate.ts index e1577478..56bd1b8d 100644 --- a/src/controllers/api/translate.ts +++ b/src/controllers/api/translate.ts @@ -2,10 +2,11 @@ import { LanguageCode } from 'iso-639-1'; import { z } from 'zod'; import { AppController } from '@/app.ts'; -import { localeSchema } from '@/schema.ts'; -import { dittoTranslations, dittoTranslationsKey, MastodonTranslation } from '@/translators/translator.ts'; -import { parseBody } from '@/utils/api.ts'; +import { translationCache } from '@/caches/translationCache.ts'; +import { MastodonTranslation } from '@/entities/MastodonTranslation.ts'; import { getEvent } from '@/queries.ts'; +import { localeSchema } from '@/schema.ts'; +import { parseBody } from '@/utils/api.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; const translateSchema = z.object({ @@ -45,11 +46,11 @@ const translateController: AppController = async (c) => { return c.json({ error: 'Bad request.', schema: result.error }, 400); } - const translatedId = `${lang}-${id}` as dittoTranslationsKey; - const translationCache = dittoTranslations.get(translatedId); + const cacheKey: `${LanguageCode}-${string}` = `${lang}-${id}`; + const cached = translationCache.get(cacheKey); - if (translationCache) { - return c.json(translationCache.data, 200); + if (cached) { + return c.json(cached.data, 200); } const mediaAttachments = status?.media_attachments.map((value) => { @@ -68,7 +69,7 @@ const translateController: AppController = async (c) => { media_attachments: [], poll: null, detected_source_language: event.language ?? 'en', - provider: translator.getProvider(), + provider: translator.provider, }; if ((status?.poll as MastodonTranslation['poll'])?.options) { @@ -130,10 +131,10 @@ const translateController: AppController = async (c) => { mastodonTranslation.detected_source_language = data.source_lang; - dittoTranslations.set(translatedId, { data: mastodonTranslation }); + translationCache.set(cacheKey, { data: mastodonTranslation }); return c.json(mastodonTranslation, 200); } catch (e) { - if (e instanceof Error && e.message?.includes('not supported')) { + if (e instanceof Error && e.message.includes('not supported')) { return c.json({ error: `Translation of source language '${event.language}' not supported` }, 422); } return c.json({ error: 'Service Unavailable' }, 503); diff --git a/src/entities/MastodonTranslation.ts b/src/entities/MastodonTranslation.ts new file mode 100644 index 00000000..d59b9aad --- /dev/null +++ b/src/entities/MastodonTranslation.ts @@ -0,0 +1,17 @@ +import { LanguageCode } from 'iso-639-1'; + +/** https://docs.joinmastodon.org/entities/Translation/ */ +export interface MastodonTranslation { + /** HTML-encoded translated content of the status. */ + content: string; + /** The translated spoiler warning of the status. */ + spoiler_text: string; + /** The translated media descriptions of the status. */ + media_attachments: { id: string; description: string }[]; + /** The translated poll of the status. */ + poll: { id: string; options: { title: string }[] } | null; + //** The language of the source text, as auto-detected by the machine translation provider. */ + detected_source_language: LanguageCode; + /** The service that provided the machine translation. */ + provider: string; +} diff --git a/src/interfaces/DittoTranslator.ts b/src/interfaces/DittoTranslator.ts new file mode 100644 index 00000000..426e1428 --- /dev/null +++ b/src/interfaces/DittoTranslator.ts @@ -0,0 +1,18 @@ +import type { LanguageCode } from 'iso-639-1'; + +/** DittoTranslator class, used for status translation. */ +export interface DittoTranslator { + /** Translate the 'content' into 'targetLanguage'. */ + translate( + /** Texts to translate. */ + texts: string[], + /** The language of the source texts. */ + sourceLanguage: LanguageCode | undefined, + /** The texts will be translated into this language. */ + targetLanguage: LanguageCode, + /** Custom options. */ + opts?: { signal?: AbortSignal }, + ): Promise<{ results: string[]; source_lang: LanguageCode }>; + /** Provider name, eg `DeepL.com` */ + provider: string; +} diff --git a/src/translators/DeepLTranslator.ts b/src/translators/DeepLTranslator.ts index 2b4da175..b856d1ec 100644 --- a/src/translators/DeepLTranslator.ts +++ b/src/translators/DeepLTranslator.ts @@ -1,7 +1,7 @@ import { LanguageCode } from 'iso-639-1'; import { z } from 'zod'; -import { DittoTranslator, SourceLanguage, TargetLanguage } from '@/translators/translator.ts'; +import { DittoTranslator } from '@/interfaces/DittoTranslator.ts'; import { languageSchema } from '@/schema.ts'; interface DeepLTranslatorOpts { @@ -28,8 +28,8 @@ export class DeepLTranslator implements DittoTranslator { async translate( texts: string[], - source: SourceLanguage | undefined, - dest: TargetLanguage, + source: LanguageCode | undefined, + dest: LanguageCode, opts?: { signal?: AbortSignal }, ) { const { translations } = await this.translateMany(texts, source, dest, opts); @@ -43,8 +43,8 @@ export class DeepLTranslator implements DittoTranslator { /** DeepL translate request. */ private async translateMany( texts: string[], - source: SourceLanguage | undefined, - targetLanguage: TargetLanguage, + source: LanguageCode | undefined, + targetLanguage: LanguageCode, opts?: { signal?: AbortSignal }, ) { const body: any = { diff --git a/src/translators/LibreTranslateTranslator.ts b/src/translators/LibreTranslateTranslator.ts index bd2850ff..b7a749cf 100644 --- a/src/translators/LibreTranslateTranslator.ts +++ b/src/translators/LibreTranslateTranslator.ts @@ -1,7 +1,7 @@ import { LanguageCode } from 'iso-639-1'; import { z } from 'zod'; -import { DittoTranslator, SourceLanguage, TargetLanguage } from '@/translators/translator.ts'; +import { DittoTranslator } from '@/interfaces/DittoTranslator.ts'; import { languageSchema } from '@/schema.ts'; interface LibreTranslateTranslatorOpts { @@ -28,8 +28,8 @@ export class LibreTranslateTranslator implements DittoTranslator { async translate( texts: string[], - source: SourceLanguage | undefined, - dest: TargetLanguage, + source: LanguageCode | undefined, + dest: LanguageCode, opts?: { signal?: AbortSignal }, ) { const translations = await Promise.all( diff --git a/src/translators/translator.ts b/src/translators/translator.ts deleted file mode 100644 index df3c7929..00000000 --- a/src/translators/translator.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { LanguageCode } from 'iso-639-1'; -import { LRUCache } from 'lru-cache'; - -import { Time } from '@/utils/time.ts'; - -/** Original language of the post */ -export type SourceLanguage = LanguageCode; - -/** Content will be translated to this language */ -export type TargetLanguage = LanguageCode; - -/** Entity returned by DittoTranslator and LRUCache */ -type DittoTranslation = { - data: MastodonTranslation; -}; - -export type MastodonTranslation = { - /** HTML-encoded translated content of the status. */ - content: string; - /** The translated spoiler warning of the status. */ - spoiler_text: string; - /** The translated media descriptions of the status. */ - media_attachments: { id: string; description: string }[]; - /** The translated poll of the status. */ - poll: { id: string; options: { title: string }[] } | null; - //** The language of the source text, as auto-detected by the machine translation provider. */ - detected_source_language: SourceLanguage; - /** The service that provided the machine translation. */ - provider: string; -}; - -/** DittoTranslator class, used for status translation. */ -export interface DittoTranslator { - /** Translate the 'content' into 'targetLanguage'. */ - translate( - texts: string[], - /** The language of the source text/status. */ - sourceLanguage: SourceLanguage | undefined, - /** The status content will be translated into this language. */ - targetLanguage: TargetLanguage, - /** Custom options. */ - opts?: { signal?: AbortSignal }, - ): Promise<{ results: string[]; source_lang: SourceLanguage }>; - /** Provider name, eg `DeepL.com` */ - provider: string; -} - -/** Includes the TARGET language and the status id. - * Example: en-390f5b01b49a8ee6e13fe917420c023d889b3da8e983a14c9e84587e43d12c15 - * The example above means: - * I want the status 390f5b01b49a8ee6e13fe917420c023d889b3da8e983a14c9e84587e43d12c15 translated to english (if it exists in the LRUCache). */ -export type dittoTranslationsKey = `${TargetLanguage}-${string}`; - -export const dittoTranslations = new LRUCache({ - max: 1000, - ttl: Time.hours(6), -}); From 12d643e150edef61916d35a85cab92e1ffe4af3e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 10 Oct 2024 14:12:20 -0500 Subject: [PATCH 4/6] Add translation cache metrics, let the cache be configurable --- src/caches/translationCache.ts | 13 ++++--------- src/config.ts | 7 +++++++ src/controllers/api/translate.ts | 7 +++++-- src/metrics.ts | 5 +++++ 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/caches/translationCache.ts b/src/caches/translationCache.ts index 88121c1f..7bd27946 100644 --- a/src/caches/translationCache.ts +++ b/src/caches/translationCache.ts @@ -1,16 +1,11 @@ import { LanguageCode } from 'iso-639-1'; import { LRUCache } from 'lru-cache'; +import { Conf } from '@/config.ts'; import { MastodonTranslation } from '@/entities/MastodonTranslation.ts'; -import { Time } from '@/utils/time.ts'; - -/** Entity returned by DittoTranslator and LRUCache */ -interface DittoTranslation { - data: MastodonTranslation; -} /** Translations LRU cache. */ -export const translationCache = new LRUCache<`${LanguageCode}-${string}`, DittoTranslation>({ - max: 1000, - ttl: Time.hours(6), +export const translationCache = new LRUCache<`${LanguageCode}-${string}`, MastodonTranslation>({ + max: Conf.caches.translation.max, + ttl: Conf.caches.translation.ttl, }); diff --git a/src/config.ts b/src/config.ts index 806fe196..c0daf894 100644 --- a/src/config.ts +++ b/src/config.ts @@ -314,6 +314,13 @@ class Conf { ttl: Number(Deno.env.get('DITTO_CACHE_LINK_PREVIEW_TTL') || 12 * 60 * 60 * 1000), }; }, + /** Translation cache settings. */ + get translation(): { max: number; ttl: number } { + return { + max: Number(Deno.env.get('DITTO_CACHE_TRANSLATION_MAX') || 1000), + ttl: Number(Deno.env.get('DITTO_CACHE_TRANSLATION_TTL') || 6 * 60 * 60 * 1000), + }; + }, }; } diff --git a/src/controllers/api/translate.ts b/src/controllers/api/translate.ts index 56bd1b8d..d763c713 100644 --- a/src/controllers/api/translate.ts +++ b/src/controllers/api/translate.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { AppController } from '@/app.ts'; import { translationCache } from '@/caches/translationCache.ts'; import { MastodonTranslation } from '@/entities/MastodonTranslation.ts'; +import { cachedTranslationsSizeGauge } from '@/metrics.ts'; import { getEvent } from '@/queries.ts'; import { localeSchema } from '@/schema.ts'; import { parseBody } from '@/utils/api.ts'; @@ -50,7 +51,7 @@ const translateController: AppController = async (c) => { const cached = translationCache.get(cacheKey); if (cached) { - return c.json(cached.data, 200); + return c.json(cached, 200); } const mediaAttachments = status?.media_attachments.map((value) => { @@ -131,7 +132,9 @@ const translateController: AppController = async (c) => { mastodonTranslation.detected_source_language = data.source_lang; - translationCache.set(cacheKey, { data: mastodonTranslation }); + translationCache.set(cacheKey, mastodonTranslation); + cachedTranslationsSizeGauge.set(translationCache.size); + return c.json(mastodonTranslation, 200); } catch (e) { if (e instanceof Error && e.message.includes('not supported')) { diff --git a/src/metrics.ts b/src/metrics.ts index c005a6c7..2cb3eb2d 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -121,6 +121,11 @@ export const cachedLinkPreviewSizeGauge = new Gauge({ help: 'Number of link previews in cache', }); +export const cachedTranslationsSizeGauge = new Gauge({ + name: 'ditto_cached_translations_size', + help: 'Number of translated statuses in cache', +}); + export const internalSubscriptionsSizeGauge = new Gauge({ name: 'ditto_internal_subscriptions_size', help: "Number of active subscriptions to Ditto's internal relay", From 655e94ef91941d93e5362cb0998726ca44ddfb5c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 10 Oct 2024 14:14:29 -0500 Subject: [PATCH 5/6] DittoTranslator: move `provider` to top of interface --- src/interfaces/DittoTranslator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/interfaces/DittoTranslator.ts b/src/interfaces/DittoTranslator.ts index 426e1428..7e5e1d50 100644 --- a/src/interfaces/DittoTranslator.ts +++ b/src/interfaces/DittoTranslator.ts @@ -2,6 +2,8 @@ import type { LanguageCode } from 'iso-639-1'; /** DittoTranslator class, used for status translation. */ export interface DittoTranslator { + /** Provider name, eg `DeepL.com` */ + provider: string; /** Translate the 'content' into 'targetLanguage'. */ translate( /** Texts to translate. */ @@ -13,6 +15,4 @@ export interface DittoTranslator { /** Custom options. */ opts?: { signal?: AbortSignal }, ): Promise<{ results: string[]; source_lang: LanguageCode }>; - /** Provider name, eg `DeepL.com` */ - provider: string; } From df3b8863df6ff7e54467ca7850d48c98680a9e19 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 10 Oct 2024 14:16:29 -0500 Subject: [PATCH 6/6] LibreTranslateTranslator: move headers to plain object, add url variable --- src/translators/LibreTranslateTranslator.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/translators/LibreTranslateTranslator.ts b/src/translators/LibreTranslateTranslator.ts index b7a749cf..ef7fb1f8 100644 --- a/src/translators/LibreTranslateTranslator.ts +++ b/src/translators/LibreTranslateTranslator.ts @@ -57,13 +57,14 @@ export class LibreTranslateTranslator implements DittoTranslator { api_key: this.apiKey, }; - const headers = new Headers(); - headers.append('Content-Type', 'application/json'); + const url = new URL('/translate', this.baseUrl); - const request = new Request(new URL('/translate', this.baseUrl), { + const request = new Request(url, { method: 'POST', body: JSON.stringify(body), - headers, + headers: { + 'Content-Type': 'application/json', + }, signal: opts?.signal, });