feat(client): プロフィールを翻訳する機能を追加

This commit is contained in:
NoriDev 2023-06-11 21:55:01 +09:00
parent 4320dc27f2
commit fc413756e3
10 changed files with 271 additions and 5 deletions

View file

@ -34,6 +34,7 @@
- ruby 표기 지원 ([misskey.design 8826fbf](https://github.com/kiyo4act/misskey.design/commit/8826fbf77a9d6ff5cac7368da999f3d04aa68c97))
- 노트 검색을 전체/로컬/리모트로 나누도록 변경 ([misskey.design 4adad07](https://github.com/kiyo4act/misskey.design/commit/4adad0768ce02bd49207a94678cf3c9130ed9e10))
- 노트/유저 검색 페이지에서 Enter 키를 누르면 검색하도록
- 프로필 번역 기능 추가
### Client
- 모바일에서 UI 흐림 효과를 껐을 때 가독성 향상

View file

@ -1100,6 +1100,7 @@ later: "Later"
goToMisskey: "To CherryPick"
additionalEmojiDictionary: "Additional emoji dictionaries"
installed: "Installed"
translateProfile: "Translate a profile"
_requireRefreshBehavior:
dialog: "Show warning dialog"
quiet: "Show unobtrusive alert"

1
locales/index.d.ts vendored
View file

@ -1104,6 +1104,7 @@ export interface Locale {
"additionalEmojiDictionary": string;
"installed": string;
"branding": string;
"translateProfile": string;
"_requireRefreshBehavior": {
"dialog": string;
"quiet": string;

View file

@ -1101,6 +1101,7 @@ goToMisskey: "CherryPickへ"
additionalEmojiDictionary: "絵文字の追加辞書"
installed: "インストール済み"
branding: "ブランディング"
translateProfile: "プロフィールを翻訳する"
_requireRefreshBehavior:
dialog: "ダイアログで通知"

View file

@ -1101,6 +1101,7 @@ later: "나중에"
goToMisskey: "CherryPick으로"
additionalEmojiDictionary: "이모지 추가 사전"
installed: "설치됨"
translateProfile: "프로필 번역하기"
_requireRefreshBehavior:
dialog: "알림창 표시"
quiet: "조용히 알림"

View file

@ -356,6 +356,7 @@ import * as ep___users_show from './endpoints/users/show.js';
import * as ep___users_stats from './endpoints/users/stats.js';
import * as ep___users_achievements from './endpoints/users/achievements.js';
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
import * as ep___users_translate from './endpoints/users/translate.js';
import * as ep___fetchRss from './endpoints/fetch-rss.js';
import * as ep___retention from './endpoints/retention.js';
import { GetterService } from './GetterService.js';
@ -717,6 +718,7 @@ const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_s
const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default };
const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default };
const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default };
const $users_translate: Provider = { provide: 'ep:users/translate', useClass: ep___users_translate.default };
const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default };
const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default };
@ -1082,6 +1084,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_stats,
$users_achievements,
$users_updateMemo,
$users_translate,
$fetchRss,
$retention,
],
@ -1438,6 +1441,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$users_stats,
$users_achievements,
$users_updateMemo,
$users_translate,
$fetchRss,
$retention,
],

View file

@ -1,8 +1,9 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { NotesRepository, UsersRepository } from '@/models/index.js';
import type { NotesRepository, UsersRepository, UserProfilesRepository } from '@/models/index.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
import type { UserProfile } from '@/models/entities/UserProfile.js';
import type { Note } from '@/models/entities/Note.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
@ -16,6 +17,9 @@ export class GetterService {
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private userEntityService: UserEntityService,
) {
}
@ -75,5 +79,16 @@ export class GetterService {
return user;
}
@bindThis
public async getUserProfiles(userId: UserProfile['userId']) {
const user = await this.userProfilesRepository.findOneBy({ userId: userId });
if (user == null) {
throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', 'No such user.');
}
return user;
}
}

View file

@ -356,6 +356,7 @@ import * as ep___users_show from './endpoints/users/show.js';
import * as ep___users_stats from './endpoints/users/stats.js';
import * as ep___users_achievements from './endpoints/users/achievements.js';
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
import * as ep___users_translate from './endpoints/users/translate.js';
import * as ep___fetchRss from './endpoints/fetch-rss.js';
import * as ep___retention from './endpoints/retention.js';
@ -715,6 +716,7 @@ const eps = [
['users/stats', ep___users_stats],
['users/achievements', ep___users_achievements],
['users/update-memo', ep___users_updateMemo],
['users/translate', ep___users_translate],
['fetch-rss', ep___fetchRss],
['retention', ep___retention],
];

View file

@ -0,0 +1,204 @@
import { URLSearchParams } from 'node:url';
import fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common';
import { translate } from '@vitalets/google-translate-api';
import { TranslationServiceClient } from '@google-cloud/translate';
import type { UserProfilesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { MetaService } from '@/core/MetaService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { createTemp } from '@/misc/create-temp.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['users'],
requireCredential: false,
res: {
type: 'object',
optional: false, nullable: false,
},
errors: {
noSuchDescription: {
message: 'No such description.',
code: 'NO_SUCH_DESCRIPTION',
id: 'bea9b03f-36e0-49c5-a4db-627a029f8971',
},
noTranslateService: {
message: 'Translate service is not available.',
code: 'NO_TRANSLATE_SERVICE',
id: 'bef6e895-c05d-4499-9815-035ed18b0e31',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
targetLang: { type: 'string' },
},
required: ['userId', 'targetLang'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private userEntityService: UserEntityService,
private getterService: GetterService,
private metaService: MetaService,
private httpRequestService: HttpRequestService,
) {
super(meta, paramDef, async (ps) => {
const target = await this.getterService.getUserProfiles(ps.userId).catch(err => {
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchDescription);
throw err;
});
if (target.description == null) {
return 204;
}
const instance = await this.metaService.fetch();
const translatorServices = [
'deepl',
'google_no_api',
'ctav3',
];
if (instance.translatorType == null || !translatorServices.includes(instance.translatorType)) {
throw new ApiError(meta.errors.noTranslateService);
}
let targetLang = ps.targetLang;
if (targetLang.includes('-')) targetLang = targetLang.split('-')[0];
let translationResult;
if (instance.translatorType === 'deepl') {
if (instance.deeplAuthKey == null) {
return 204; // TODO: 良い感じのエラー返す
}
translationResult = await this.translateDeepL(target.description, targetLang, instance.deeplAuthKey, instance.deeplIsPro, instance.translatorType);
} else if (instance.translatorType === 'google_no_api') {
let targetLang = ps.targetLang;
if (targetLang.includes('-')) targetLang = targetLang.split('-')[0];
const { text, raw } = await translate(target.description, { to: targetLang });
return {
sourceLang: raw.src,
text: text,
translator: translatorServices,
};
} else if (instance.translatorType === 'ctav3') {
if (instance.ctav3SaKey == null) { return 204; } else if (instance.ctav3ProjectId == null) { return 204; }
else if (instance.ctav3Location == null) { return 204; }
translationResult = await this.apiCloudTranslationAdvanced(
target.description, targetLang, instance.ctav3SaKey, instance.ctav3ProjectId, instance.ctav3Location, instance.ctav3Model, instance.ctav3Glossary, instance.translatorType,
);
} else {
throw new Error('Unsupported translator type');
}
return {
sourceLang: translationResult.sourceLang,
text: translationResult.text,
translator: translationResult.translator,
};
});
}
private async translateDeepL(text: string, targetLang: string, authKey: string, isPro: boolean, provider: string) {
const params = new URLSearchParams();
params.append('auth_key', authKey);
params.append('text', text);
params.append('target_lang', targetLang);
const endpoint = isPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate';
const res = await this.httpRequestService.send(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json, */*',
},
body: params.toString(),
});
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,
translator: provider,
};
}
private async apiCloudTranslationAdvanced(text: string, targetLang: string, saKey: string, projectId: string, location: string, model: string | null, glossary: string | null, provider: string) {
const [path, cleanup] = await createTemp();
fs.writeFileSync(path, saKey);
process.env.GOOGLE_APPLICATION_CREDENTIALS = path;
const translationClient = new TranslationServiceClient();
const detectRequest = {
parent: `projects/${projectId}/locations/${location}`,
content: text,
};
let detectedLanguage = null;
let glossaryConfig = null;
if (glossary !== '' && glossary !== null) {
glossaryConfig = {
glossary: `projects/${projectId}/locations/${location}/glossaries/${glossary}`,
};
const [detectResponse] = await translationClient.detectLanguage(detectRequest);
detectedLanguage = detectResponse.languages && detectResponse.languages[0]?.languageCode;
}
let modelConfig = null;
if (model !== '' && model !== null) {
modelConfig = `projects/${projectId}/locations/${location}/models/${model}`;
}
const translateRequest = {
parent: `projects/${projectId}/locations/${location}`,
contents: [text],
mimeType: 'text/plain',
sourceLanguageCode: null,
targetLanguageCode: detectedLanguage !== null ? detectedLanguage : targetLang,
model: modelConfig,
glossaryConfig: glossaryConfig,
};
const [translateResponse] = await translationClient.translateText(translateRequest);
const translatedText = translateResponse.translations && translateResponse.translations[0]?.translatedText;
const detectedLanguageCode = translateResponse.translations && translateResponse.translations[0]?.detectedLanguageCode;
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
cleanup();
return {
sourceLang: detectedLanguage !== null ? detectedLanguage : detectedLanguageCode,
text: translatedText,
translator: provider,
};
}
}

View file

@ -71,6 +71,18 @@
<MkOmit>
<Mfm v-if="user.description" :text="user.description" :isNote="false" :author="user" :i="$i"/>
<p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p>
<MkButton v-if="user.description" style="margin-top: 10px" small @click="translate">{{ i18n.ts.translateProfile }}</MkButton>
<div v-if="translating || translation" class="translation">
<MkLoading v-if="translating" mini/>
<div v-else>
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}:</b><hr style="margin: 10px 0;">
<Mfm :text="translation.text" :isNote="false" :author="user" :i="$i"/>
<div v-if="translation.translator == 'ctav3'" style="margin-top: 10px; padding: 0 0 15px;">
<img v-if="!defaultStore.state.darkMode" src="/client-assets/color-short.svg" alt="" style="float: right;">
<img v-else src="/client-assets/white-short.svg" alt="" style="float: right;"/>
</div>
</div>
</div>
</MkOmit>
</div>
<div class="fields system">
@ -147,7 +159,7 @@
</template>
<script lang="ts" setup>
import { defineAsyncComponent, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
import { defineAsyncComponent, computed, onMounted, onUnmounted, ref, nextTick, watch } from 'vue';
import calcAge from 's-age';
import * as misskey from 'misskey-js';
import MkNote from '@/components/MkNote.vue';
@ -172,6 +184,7 @@ import MkNotes from '@/components/MkNotes.vue';
import { api } from '@/os';
import { isFfVisibility } from '@/scripts/is-ff-visibility';
import { defaultStore } from '@/store';
import { miLocalStorage } from '@/local-storage';
const XPhotos = defineAsyncComponent(() => import('./index.photos.vue'));
const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
@ -196,6 +209,9 @@ let isEditingMemo = $ref(false);
let moderationNote = $ref(props.user.moderationNote);
let editModerationNote = $ref(false);
const translation = ref<any>(null);
const translating = ref(false);
watch($$(moderationNote), async () => {
await os.api('admin/update-user-note', { userId: props.user.id, text: moderationNote });
});
@ -264,6 +280,17 @@ async function updateMemo() {
isEditingMemo = false;
}
async function translate(): Promise<void> {
if (translation.value != null) return;
translating.value = true;
const res = await os.api('users/translate', {
userId: props.user.id,
targetLang: miLocalStorage.getItem('lang') ?? navigator.language,
});
translating.value = false;
translation.value = res;
}
watch([props.user], () => {
memoDraft = props.user.memo;
});
@ -498,9 +525,18 @@ onUnmounted(() => {
padding: 24px 24px 24px 154px;
font-size: 0.95em;
> .empty {
margin: 0;
opacity: 0.5;
div {
> .empty {
margin: 0;
opacity: 0.5;
}
> .translation {
border: solid 0.5px var(--divider);
border-radius: var(--radius);
padding: 12px;
margin-top: 8px;
}
}
}