diff --git a/packages/backend/migration/1668661888188-add-libretranslate.js b/packages/backend/migration/1668661888188-add-libretranslate.js new file mode 100644 index 000000000..cc5f47939 --- /dev/null +++ b/packages/backend/migration/1668661888188-add-libretranslate.js @@ -0,0 +1,19 @@ +export class addLibretranslate1668661888188 { + name = 'addLibretranslate1668661888188' + + async up(queryRunner) { + await queryRunner.query(`CREATE TYPE "public"."meta_translationservice_enum" AS ENUM('deepl', 'libretranslate')`); + await queryRunner.query(`ALTER TABLE "meta" ADD "translationService" "public"."meta_translationservice_enum"`); + await queryRunner.query(`ALTER TABLE "meta" ADD "libreTranslateAuthKey" character varying(128)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "libreTranslateEndpoint" character varying(2048)`); + // Set translationService to 'deepl' if auth key is already set + await queryRunner.query(`UPDATE "meta" SET "translationService"='deepl' WHERE "deeplAuthKey" IS NOT NULL`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "libreTranslateEndpoint"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "libreTranslateAuthKey"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "translationService"`); + await queryRunner.query(`DROP TYPE "public"."meta_translationservice_enum"`); + } +} diff --git a/packages/backend/src/models/entities/meta.ts b/packages/backend/src/models/entities/meta.ts index 0afb7c7fc..565d95a32 100644 --- a/packages/backend/src/models/entities/meta.ts +++ b/packages/backend/src/models/entities/meta.ts @@ -3,6 +3,11 @@ import { id } from '../id.js'; import { User } from './user.js'; import { Clip } from './clip.js'; +export enum TranslationService { + DeepL = 'deepl', + LibreTranslate = 'libretranslate', +} + @Entity() export class Meta { @PrimaryColumn({ @@ -299,6 +304,12 @@ export class Meta { }) public discordClientSecret: string | null; + @Column('enum', { + enum: TranslationService, + nullable: true, + }) + public translationService: TranslationService | null; + @Column('varchar', { length: 128, nullable: true, @@ -310,6 +321,18 @@ export class Meta { }) public deeplIsPro: boolean; + @Column('varchar', { + length: 128, + nullable: true, + }) + public libreTranslateAuthKey: string | null; + + @Column('varchar', { + length: 2048, + nullable: true, + }) + public libreTranslateEndpoint: string | null; + @Column('varchar', { length: 512, nullable: true, diff --git a/packages/backend/src/server/api/common/translator.ts b/packages/backend/src/server/api/common/translator.ts new file mode 100644 index 000000000..dde4686b3 --- /dev/null +++ b/packages/backend/src/server/api/common/translator.ts @@ -0,0 +1,12 @@ +import { Meta, TranslationService } from '@/models/entities/meta.js'; + +export function translatorAvailable(instance: Meta): boolean { + switch (instance.translationService) { + case TranslationService.DeepL: + return instance.deeplAuthKey != null; + case TranslationService.LibreTranslate: + return instance.libreTranslateEndpoint != null; + default: + return false; + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 0c53b1382..5107cfccf 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -1,5 +1,6 @@ import config from '@/config/index.js'; import { fetchMeta } from '@/misc/fetch-meta.js'; +import { translatorAvailable } from '../../common/translator.js'; import define from '../../define.js'; export const meta = { @@ -317,7 +318,7 @@ export default define(meta, paramDef, async (ps, me) => { enableGithubIntegration: instance.enableGithubIntegration, enableDiscordIntegration: instance.enableDiscordIntegration, enableServiceWorker: instance.enableServiceWorker, - translatorAvailable: instance.deeplAuthKey != null, + translatorAvailable: translatorAvailable(instance), pinnedPages: instance.pinnedPages, pinnedClipId: instance.pinnedClipId, cacheRemoteFiles: instance.cacheRemoteFiles, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index e83252c29..5958b3c98 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -1,5 +1,6 @@ import { insertModerationLog } from '@/services/insert-moderation-log.js'; import { fetchMeta, setMeta } from '@/misc/fetch-meta.js'; +import { TranslationService } from '@/models/entities/meta.js'; import define from '../../define.js'; export const meta = { @@ -55,8 +56,11 @@ export const paramDef = { type: 'string', } }, summalyProxy: { type: 'string', nullable: true }, + translationService: { type: 'string', nullable: true, enum: [null, ...Object.values(TranslationService)] }, deeplAuthKey: { type: 'string', nullable: true }, deeplIsPro: { type: 'boolean' }, + libreTranslateAuthKey: { type: 'string', nullable: true }, + libreTranslateEndpoint: { type: 'string', nullable: true }, enableTwitterIntegration: { type: 'boolean' }, twitterConsumerKey: { type: 'string', nullable: true }, twitterConsumerSecret: { type: 'string', nullable: true }, @@ -362,6 +366,10 @@ export default define(meta, paramDef, async (ps, me) => { set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle; } + if (ps.translationService !== undefined) { + set.translationService = ps.translationService; + } + if (ps.deeplAuthKey !== undefined) { if (ps.deeplAuthKey === '') { set.deeplAuthKey = null; @@ -374,6 +382,22 @@ export default define(meta, paramDef, async (ps, me) => { set.deeplIsPro = ps.deeplIsPro; } + if (ps.libreTranslateEndpoint !== undefined) { + if (ps.libreTranslateEndpoint === '') { + set.libreTranslateEndpoint = null; + } else { + set.libreTranslateEndpoint = ps.libreTranslateEndpoint; + } + } + + if (ps.libreTranslateAuthKey !== undefined) { + if (ps.libreTranslateAuthKey === '') { + set.libreTranslateAuthKey = null; + } else { + set.libreTranslateAuthKey = ps.libreTranslateAuthKey; + } + } + const meta = await fetchMeta(); await setMeta({ ...meta, diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 4357b8cad..511c97ddc 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -3,6 +3,7 @@ import config from '@/config/index.js'; import { fetchMeta } from '@/misc/fetch-meta.js'; import { Emojis, Users } from '@/models/index.js'; import define from '../define.js'; +import { translatorAvailable } from '../common/translator.js'; export const meta = { tags: ['meta'], @@ -322,7 +323,7 @@ export default define(meta, paramDef, async (ps, me) => { enableServiceWorker: instance.enableServiceWorker, - translatorAvailable: instance.deeplAuthKey != null, + translatorAvailable: translatorAvailable(instance), pinnedPages: instance.pinnedPages, pinnedClipId: instance.pinnedClipId, diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index f62e07b4e..44057415a 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -4,6 +4,7 @@ import config from '@/config/index.js'; import { getAgentByUrl } from '@/misc/fetch.js'; import { fetchMeta } from '@/misc/fetch-meta.js'; import { Notes } from '@/models/index.js'; +import { TranslationService } from '@/models/entities/meta.js'; import { ApiError } from '../../error.js'; import { getNote } from '../../common/getters.js'; import define from '../../define.js'; @@ -113,50 +114,101 @@ export default define(meta, paramDef, async (ps, user) => { if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError('NO_SUCH_NOTE'); throw err; }); + const instance = await fetchMeta(); - if (note.text == null) { + if (instance.translationService == null) { return 204; } - const instance = await fetchMeta(); + type Translation = { + sourceLang: string; + text: string; + }; - if (instance.deeplAuthKey == null) { - return 204; // TODO: 良い感じのエラー返す + async function translateDeepL(): Promise { + if (note.text == null || instance.deeplAuthKey == null) { + return 204; // TODO: Return a better error + } + + const sourceLang = ps.sourceLang; + const targetLang = ps.targetLang; + + const params = new URLSearchParams(); + params.append('auth_key', instance.deeplAuthKey); + params.append('text', note.text); + params.append('target_lang', targetLang); + if (sourceLang) params.append('source_lang', sourceLang); + + const endpoint = instance.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; + + const res = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': config.userAgent, + Accept: 'application/json, */*', + }, + body: params, + // TODO + //timeout: 10000, + agent: getAgentByUrl, + }); + + const json = (await res.json()) as { + translations: { + detected_source_language: string; + text: string; + }[]; + }; + + return { + sourceLang: json.translations[0].detected_source_language, + text: json.translations[0].text, + }; + } + + async function translateLibreTranslate(): Promise { + if (note.text == null || instance.libreTranslateEndpoint == null) { + return 204; + } + + // LibteTranslate only understands 2-letter codes + const source = ps.sourceLang?.toLowerCase().split('-', 1)[0] ?? 'auto'; + const target = ps.targetLang.toLowerCase().split('-', 1)[0]; + const api_key = instance.libreTranslateAuthKey ?? undefined; + const endpoint = instance.libreTranslateEndpoint; + + const params = { + q: note.text, + source, + target, + api_key, + }; + + const res = await fetch(endpoint, { + method: 'POST', + body: JSON.stringify(params), + headers: { 'Content-Type': 'application/json' }, + }); + + const json = (await res.json()) as { + detectedLanguage?: { + confidence: number; + language: string; + }; + translatedText: string; + }; + + return { + sourceLang: (json.detectedLanguage?.language ?? source).toUpperCase(), + text: json.translatedText, + }; } - const sourceLang = ps.sourceLang; - const targetLang = ps.targetLang; - - const params = new URLSearchParams(); - params.append('auth_key', instance.deeplAuthKey); - params.append('text', note.text); - params.append('target_lang', targetLang); - if (sourceLang) params.append('source_lang', sourceLang); - - const endpoint = instance.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; - - const res = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': config.userAgent, - Accept: 'application/json, */*', - }, - body: params, - // TODO - //timeout: 10000, - agent: getAgentByUrl, - }); - - const json = (await res.json()) as { - translations: { - detected_source_language: string; - text: string; - }[]; - }; - - return { - sourceLang: json.translations[0].detected_source_language, - text: json.translations[0].text, - }; + switch (instance.translationService) { + case TranslationService.DeepL: + return await translateDeepL(); + case TranslationService.LibreTranslate: + return await translateLibreTranslate(); + } });