feat: ノートの翻訳機能

Resolve #5213
This commit is contained in:
syuilo 2021-08-15 20:26:44 +09:00
parent 1cd6ba3c1d
commit cced83024b
11 changed files with 210 additions and 17 deletions

View file

@ -10,6 +10,8 @@
## 12.x.x (unreleased) ## 12.x.x (unreleased)
### Improvements ### Improvements
- ノートの翻訳機能を追加
- 有効にするには、サーバー管理者がDeepLの無料アカウントを登録し、取得した認証キーを「インスタンス設定 > その他 > DeepL Auth Key」に設定する必要があります。
- Misskey更新時にダイアログを表示するように - Misskey更新時にダイアログを表示するように
- ジョブキューウィジェットに警報音を鳴らす設定を追加 - ジョブキューウィジェットに警報音を鳴らす設定を追加
UIデザインの調整 UIデザインの調整

View file

@ -775,6 +775,8 @@ useBlurEffect: "UIにぼかし効果を使用"
learnMore: "詳しく" learnMore: "詳しく"
misskeyUpdated: "Misskeyが更新されました" misskeyUpdated: "Misskeyが更新されました"
whatIsNew: "更新情報を見る" whatIsNew: "更新情報を見る"
translate: "翻訳"
translatedFrom: "{x}から翻訳"
_docs: _docs:
continueReading: "続きを読む" continueReading: "続きを読む"

View file

@ -0,0 +1,14 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class deeplIntegration1629024377804 implements MigrationInterface {
name = 'deeplIntegration1629024377804'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "meta" ADD "deeplAuthKey" character varying(128)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "deeplAuthKey"`);
}
}

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="yxspomdl" :class="{ inline, colored }"> <div class="yxspomdl" :class="{ inline, colored, mini }">
<div class="ring"></div> <div class="ring"></div>
</div> </div>
</template> </template>
@ -18,7 +18,12 @@ export default defineComponent({
type: Boolean, type: Boolean,
required: false, required: false,
default: true default: true
} },
mini: {
type: Boolean,
required: false,
default: false
},
} }
}); });
</script> </script>
@ -38,6 +43,8 @@ export default defineComponent({
text-align: center; text-align: center;
cursor: wait; cursor: wait;
--size: 48px;
&.colored { &.colored {
color: var(--accent); color: var(--accent);
} }
@ -45,19 +52,12 @@ export default defineComponent({
&.inline { &.inline {
display: inline; display: inline;
padding: 0; padding: 0;
--size: 32px;
}
> .ring:after { &.mini {
width: 32px; padding: 16px;
height: 32px; --size: 32px;
}
> .ring {
&:before,
&:after {
width: 32px;
height: 32px;
}
}
} }
> .ring { > .ring {
@ -70,8 +70,8 @@ export default defineComponent({
content: " "; content: " ";
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
width: 48px; width: var(--size);
height: 48px; height: var(--size);
border-radius: 50%; border-radius: 50%;
border: solid 4px; border: solid 4px;
} }

View file

@ -67,6 +67,13 @@
<MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA> <MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
<a class="rp" v-if="appearNote.renote != null">RN:</a> <a class="rp" v-if="appearNote.renote != null">RN:</a>
<div class="translation" v-if="translating || translation">
<MkLoading v-if="translating" mini/>
<div class="translated" v-else>
<b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}:</b>
{{ translation.text }}
</div>
</div>
</div> </div>
<div class="files" v-if="appearNote.files.length > 0"> <div class="files" v-if="appearNote.files.length > 0">
<XMediaList :media-list="appearNote.files"/> <XMediaList :media-list="appearNote.files"/>
@ -178,6 +185,8 @@ export default defineComponent({
showContent: false, showContent: false,
isDeleted: false, isDeleted: false,
muted: false, muted: false,
translation: null,
translating: false,
}; };
}, },
@ -619,6 +628,11 @@ export default defineComponent({
text: this.$ts.share, text: this.$ts.share,
action: this.share action: this.share
}, },
this.$instance.translatorAvailable ? {
icon: 'fas fa-language',
text: this.$ts.translate,
action: this.translate
} : undefined,
null, null,
statePromise.then(state => state.isFavorited ? { statePromise.then(state => state.isFavorited ? {
icon: 'fas fa-star', icon: 'fas fa-star',
@ -852,6 +866,17 @@ export default defineComponent({
}); });
}, },
async translate() {
if (this.translation != null) return;
this.translating = true;
const res = await os.api('notes/translate', {
noteId: this.appearNote.id,
targetLang: localStorage.getItem('lang') || navigator.language,
});
this.translating = false;
this.translation = res;
},
focus() { focus() {
this.$el.focus(); this.$el.focus();
}, },
@ -1050,6 +1075,13 @@ export default defineComponent({
font-style: oblique; font-style: oblique;
color: var(--renote); color: var(--renote);
} }
> .translation {
border: solid 0.5px var(--divider);
border-radius: var(--radius);
padding: 12px;
margin-top: 8px;
}
} }
> .url-preview { > .url-preview {

View file

@ -51,6 +51,13 @@
<MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA> <MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
<a class="rp" v-if="appearNote.renote != null">RN:</a> <a class="rp" v-if="appearNote.renote != null">RN:</a>
<div class="translation" v-if="translating || translation">
<MkLoading v-if="translating" mini/>
<div class="translated" v-else>
<b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}:</b>
{{ translation.text }}
</div>
</div>
</div> </div>
<div class="files" v-if="appearNote.files.length > 0"> <div class="files" v-if="appearNote.files.length > 0">
<XMediaList :media-list="appearNote.files"/> <XMediaList :media-list="appearNote.files"/>
@ -164,6 +171,8 @@ export default defineComponent({
collapsed: false, collapsed: false,
isDeleted: false, isDeleted: false,
muted: false, muted: false,
translation: null,
translating: false,
}; };
}, },
@ -594,6 +603,11 @@ export default defineComponent({
text: this.$ts.share, text: this.$ts.share,
action: this.share action: this.share
}, },
this.$instance.translatorAvailable ? {
icon: 'fas fa-language',
text: this.$ts.translate,
action: this.translate
} : undefined,
null, null,
statePromise.then(state => state.isFavorited ? { statePromise.then(state => state.isFavorited ? {
icon: 'fas fa-star', icon: 'fas fa-star',
@ -827,6 +841,17 @@ export default defineComponent({
}); });
}, },
async translate() {
if (this.translation != null) return;
this.translating = true;
const res = await os.api('notes/translate', {
noteId: this.appearNote.id,
targetLang: localStorage.getItem('lang') || navigator.language,
});
this.translating = false;
this.translation = res;
},
focus() { focus() {
this.$el.focus(); this.$el.focus();
}, },
@ -1053,6 +1078,13 @@ export default defineComponent({
font-style: oblique; font-style: oblique;
color: var(--renote); color: var(--renote);
} }
> .translation {
border: solid 0.5px var(--divider);
border-radius: var(--radius);
padding: 12px;
margin-top: 8px;
}
} }
> .url-preview { > .url-preview {

View file

@ -7,7 +7,12 @@
Summaly Proxy URL Summaly Proxy URL
</FormInput> </FormInput>
</FormGroup> </FormGroup>
<FormGroup>
<FormInput v-model:value="deeplAuthKey">
<template #prefix><i class="fas fa-key"></i></template>
DeepL Auth Key
</FormInput>
</FormGroup>
<FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
</FormSuspense> </FormSuspense>
</FormBase> </FormBase>
@ -44,6 +49,7 @@ export default defineComponent({
icon: 'fas fa-cogs' icon: 'fas fa-cogs'
}, },
summalyProxy: '', summalyProxy: '',
deeplAuthKey: '',
} }
}, },
@ -55,10 +61,12 @@ export default defineComponent({
async init() { async init() {
const meta = await os.api('meta', { detail: true }); const meta = await os.api('meta', { detail: true });
this.summalyProxy = meta.summalyProxy; this.summalyProxy = meta.summalyProxy;
this.deeplAuthKey = meta.deeplAuthKey;
}, },
save() { save() {
os.apiWithDialog('admin/update-meta', { os.apiWithDialog('admin/update-meta', {
summalyProxy: this.summalyProxy, summalyProxy: this.summalyProxy,
deeplAuthKey: this.deeplAuthKey,
}).then(() => { }).then(() => {
fetchInstance(); fetchInstance();
}); });

View file

@ -313,6 +313,12 @@ export class Meta {
}) })
public discordClientSecret: string | null; public discordClientSecret: string | null;
@Column('varchar', {
length: 128,
nullable: true
})
public deeplAuthKey: string | null;
@Column('varchar', { @Column('varchar', {
length: 512, length: 512,
nullable: true nullable: true

View file

@ -145,6 +145,10 @@ export const meta = {
validator: $.optional.nullable.str, validator: $.optional.nullable.str,
}, },
deeplAuthKey: {
validator: $.optional.nullable.str,
},
enableTwitterIntegration: { enableTwitterIntegration: {
validator: $.optional.bool, validator: $.optional.bool,
}, },
@ -562,6 +566,14 @@ export default define(meta, async (ps, me) => {
set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle; set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle;
} }
if (ps.deeplAuthKey !== undefined) {
if (ps.deeplAuthKey === '') {
set.deeplAuthKey = null;
} else {
set.deeplAuthKey = ps.deeplAuthKey;
}
}
await getConnection().transaction(async transactionalEntityManager => { await getConnection().transaction(async transactionalEntityManager => {
const meta = await transactionalEntityManager.findOne(Meta, { const meta = await transactionalEntityManager.findOne(Meta, {
order: { order: {

View file

@ -232,6 +232,10 @@ export const meta = {
type: 'boolean' as const, type: 'boolean' as const,
optional: false as const, nullable: false as const optional: false as const, nullable: false as const
}, },
translatorAvailable: {
type: 'boolean' as const,
optional: false as const, nullable: false as const
},
proxyAccountName: { proxyAccountName: {
type: 'string' as const, type: 'string' as const,
optional: false as const, nullable: true as const optional: false as const, nullable: true as const
@ -512,6 +516,8 @@ export default define(meta, async (ps, me) => {
enableServiceWorker: instance.enableServiceWorker, enableServiceWorker: instance.enableServiceWorker,
translatorAvailable: instance.deeplAuthKey != null,
...(ps.detail ? { ...(ps.detail ? {
pinnedPages: instance.pinnedPages, pinnedPages: instance.pinnedPages,
pinnedClipId: instance.pinnedClipId, pinnedClipId: instance.pinnedClipId,

View file

@ -0,0 +1,79 @@
import $ from 'cafy';
import { ID } from '@/misc/cafy-id';
import define from '../../define';
import { getNote } from '../../common/getters';
import { ApiError } from '../../error';
import fetch from 'node-fetch';
import config from '@/config';
import { getAgentByUrl } from '@/misc/fetch';
import { URLSearchParams } from 'url';
import { fetchMeta } from '@/misc/fetch-meta';
export const meta = {
tags: ['notes'],
requireCredential: false as const,
params: {
noteId: {
validator: $.type(ID),
},
targetLang: {
validator: $.str,
},
},
res: {
type: 'object' as const,
optional: false as const, nullable: false as const,
},
errors: {
noSuchNote: {
message: 'No such note.',
code: 'NO_SUCH_NOTE',
id: 'bea9b03f-36e0-49c5-a4db-627a029f8971'
}
}
};
export default define(meta, async (ps, user) => {
const note = await getNote(ps.noteId).catch(e => {
if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
throw e;
});
if (note.text == null) {
return 204;
}
const instance = await fetchMeta();
if (instance.deeplAuthKey == null) {
return 204; // TODO: 良い感じのエラー返す
}
const params = new URLSearchParams();
params.append('auth_key', instance.deeplAuthKey);
params.append('text', note.text);
params.append('target_lang', ps.targetLang);
const res = await fetch('https://api-free.deepl.com/v2/translate', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': config.userAgent,
Accept: 'application/json, */*'
},
body: params,
timeout: 10000,
agent: getAgentByUrl,
});
const json = await res.json();
return {
sourceLang: json.translations[0].detected_source_language,
text: json.translations[0].text
};
});