parent
							
								
									1cd6ba3c1d
								
							
						
					
					
						commit
						cced83024b
					
				
					 11 changed files with 210 additions and 17 deletions
				
			
		|  | @ -10,6 +10,8 @@ | |||
| ## 12.x.x (unreleased) | ||||
| 
 | ||||
| ### Improvements | ||||
| - ノートの翻訳機能を追加 | ||||
|   - 有効にするには、サーバー管理者がDeepLの無料アカウントを登録し、取得した認証キーを「インスタンス設定 > その他 > DeepL Auth Key」に設定する必要があります。 | ||||
| - Misskey更新時にダイアログを表示するように | ||||
| - ジョブキューウィジェットに警報音を鳴らす設定を追加 | ||||
| ‐ UIデザインの調整 | ||||
|  |  | |||
|  | @ -775,6 +775,8 @@ useBlurEffect: "UIにぼかし効果を使用" | |||
| learnMore: "詳しく" | ||||
| misskeyUpdated: "Misskeyが更新されました!" | ||||
| whatIsNew: "更新情報を見る" | ||||
| translate: "翻訳" | ||||
| translatedFrom: "{x}から翻訳" | ||||
| 
 | ||||
| _docs:  | ||||
|   continueReading: "続きを読む" | ||||
|  |  | |||
							
								
								
									
										14
									
								
								migration/1629024377804-deepl-integration.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								migration/1629024377804-deepl-integration.ts
									
										
									
									
									
										Normal 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"`); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,5 +1,5 @@ | |||
| <template> | ||||
| <div class="yxspomdl" :class="{ inline, colored }"> | ||||
| <div class="yxspomdl" :class="{ inline, colored, mini }"> | ||||
| 	<div class="ring"></div> | ||||
| </div> | ||||
| </template> | ||||
|  | @ -18,7 +18,12 @@ export default defineComponent({ | |||
| 			type: Boolean, | ||||
| 			required: false, | ||||
| 			default: true | ||||
| 		} | ||||
| 		}, | ||||
| 		mini: { | ||||
| 			type: Boolean, | ||||
| 			required: false, | ||||
| 			default: false | ||||
| 		}, | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
|  | @ -38,6 +43,8 @@ export default defineComponent({ | |||
| 	text-align: center; | ||||
| 	cursor: wait; | ||||
| 
 | ||||
| 	--size: 48px; | ||||
| 
 | ||||
| 	&.colored { | ||||
| 		color: var(--accent); | ||||
| 	} | ||||
|  | @ -45,19 +52,12 @@ export default defineComponent({ | |||
| 	&.inline { | ||||
| 		display: inline; | ||||
| 		padding: 0; | ||||
| 		--size: 32px; | ||||
| 	} | ||||
| 
 | ||||
| 		> .ring:after { | ||||
| 			width: 32px; | ||||
| 			height: 32px; | ||||
| 		} | ||||
| 
 | ||||
| 		> .ring { | ||||
| 			&:before, | ||||
| 			&:after { | ||||
| 				width: 32px; | ||||
| 				height: 32px; | ||||
| 			} | ||||
| 		} | ||||
| 	&.mini { | ||||
| 		padding: 16px; | ||||
| 		--size: 32px; | ||||
| 	} | ||||
| 
 | ||||
| 	> .ring { | ||||
|  | @ -70,8 +70,8 @@ export default defineComponent({ | |||
| 			content: " "; | ||||
| 			display: block; | ||||
| 			box-sizing: border-box; | ||||
| 			width: 48px; | ||||
| 			height: 48px; | ||||
| 			width: var(--size); | ||||
| 			height: var(--size); | ||||
| 			border-radius: 50%; | ||||
| 			border: solid 4px; | ||||
| 		} | ||||
|  |  | |||
|  | @ -67,6 +67,13 @@ | |||
| 						<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"/> | ||||
| 						<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 class="files" v-if="appearNote.files.length > 0"> | ||||
| 						<XMediaList :media-list="appearNote.files"/> | ||||
|  | @ -178,6 +185,8 @@ export default defineComponent({ | |||
| 			showContent: false, | ||||
| 			isDeleted: false, | ||||
| 			muted: false, | ||||
| 			translation: null, | ||||
| 			translating: false, | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
|  | @ -619,6 +628,11 @@ export default defineComponent({ | |||
| 					text: this.$ts.share, | ||||
| 					action: this.share | ||||
| 				}, | ||||
| 				this.$instance.translatorAvailable ? { | ||||
| 					icon: 'fas fa-language', | ||||
| 					text: this.$ts.translate, | ||||
| 					action: this.translate | ||||
| 				} : undefined, | ||||
| 				null, | ||||
| 				statePromise.then(state => state.isFavorited ? { | ||||
| 					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() { | ||||
| 			this.$el.focus(); | ||||
| 		}, | ||||
|  | @ -1050,6 +1075,13 @@ export default defineComponent({ | |||
| 							font-style: oblique; | ||||
| 							color: var(--renote); | ||||
| 						} | ||||
| 
 | ||||
| 						> .translation { | ||||
| 							border: solid 0.5px var(--divider); | ||||
| 							border-radius: var(--radius); | ||||
| 							padding: 12px; | ||||
| 							margin-top: 8px; | ||||
| 						} | ||||
| 					} | ||||
| 
 | ||||
| 					> .url-preview { | ||||
|  |  | |||
|  | @ -51,6 +51,13 @@ | |||
| 						<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"/> | ||||
| 						<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 class="files" v-if="appearNote.files.length > 0"> | ||||
| 						<XMediaList :media-list="appearNote.files"/> | ||||
|  | @ -164,6 +171,8 @@ export default defineComponent({ | |||
| 			collapsed: false, | ||||
| 			isDeleted: false, | ||||
| 			muted: false, | ||||
| 			translation: null, | ||||
| 			translating: false, | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
|  | @ -594,6 +603,11 @@ export default defineComponent({ | |||
| 					text: this.$ts.share, | ||||
| 					action: this.share | ||||
| 				}, | ||||
| 				this.$instance.translatorAvailable ? { | ||||
| 					icon: 'fas fa-language', | ||||
| 					text: this.$ts.translate, | ||||
| 					action: this.translate | ||||
| 				} : undefined, | ||||
| 				null, | ||||
| 				statePromise.then(state => state.isFavorited ? { | ||||
| 					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() { | ||||
| 			this.$el.focus(); | ||||
| 		}, | ||||
|  | @ -1053,6 +1078,13 @@ export default defineComponent({ | |||
| 							font-style: oblique; | ||||
| 							color: var(--renote); | ||||
| 						} | ||||
| 
 | ||||
| 						> .translation { | ||||
| 							border: solid 0.5px var(--divider); | ||||
| 							border-radius: var(--radius); | ||||
| 							padding: 12px; | ||||
| 							margin-top: 8px; | ||||
| 						} | ||||
| 					} | ||||
| 
 | ||||
| 					> .url-preview { | ||||
|  |  | |||
|  | @ -7,7 +7,12 @@ | |||
| 				Summaly Proxy URL | ||||
| 			</FormInput> | ||||
| 		</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> | ||||
| 	</FormSuspense> | ||||
| </FormBase> | ||||
|  | @ -44,6 +49,7 @@ export default defineComponent({ | |||
| 				icon: 'fas fa-cogs' | ||||
| 			}, | ||||
| 			summalyProxy: '', | ||||
| 			deeplAuthKey: '', | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
|  | @ -55,10 +61,12 @@ export default defineComponent({ | |||
| 		async init() { | ||||
| 			const meta = await os.api('meta', { detail: true }); | ||||
| 			this.summalyProxy = meta.summalyProxy; | ||||
| 			this.deeplAuthKey = meta.deeplAuthKey; | ||||
| 		}, | ||||
| 		save() { | ||||
| 			os.apiWithDialog('admin/update-meta', { | ||||
| 				summalyProxy: this.summalyProxy, | ||||
| 				deeplAuthKey: this.deeplAuthKey, | ||||
| 			}).then(() => { | ||||
| 				fetchInstance(); | ||||
| 			}); | ||||
|  |  | |||
|  | @ -313,6 +313,12 @@ export class Meta { | |||
| 	}) | ||||
| 	public discordClientSecret: string | null; | ||||
| 
 | ||||
| 	@Column('varchar', { | ||||
| 		length: 128, | ||||
| 		nullable: true | ||||
| 	}) | ||||
| 	public deeplAuthKey: string | null; | ||||
| 
 | ||||
| 	@Column('varchar', { | ||||
| 		length: 512, | ||||
| 		nullable: true | ||||
|  |  | |||
|  | @ -145,6 +145,10 @@ export const meta = { | |||
| 			validator: $.optional.nullable.str, | ||||
| 		}, | ||||
| 
 | ||||
| 		deeplAuthKey: { | ||||
| 			validator: $.optional.nullable.str, | ||||
| 		}, | ||||
| 
 | ||||
| 		enableTwitterIntegration: { | ||||
| 			validator: $.optional.bool, | ||||
| 		}, | ||||
|  | @ -562,6 +566,14 @@ export default define(meta, async (ps, me) => { | |||
| 		set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle; | ||||
| 	} | ||||
| 
 | ||||
| 	if (ps.deeplAuthKey !== undefined) { | ||||
| 		if (ps.deeplAuthKey === '') { | ||||
| 			set.deeplAuthKey = null; | ||||
| 		} else { | ||||
| 			set.deeplAuthKey = ps.deeplAuthKey; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	await getConnection().transaction(async transactionalEntityManager => { | ||||
| 		const meta = await transactionalEntityManager.findOne(Meta, { | ||||
| 			order: { | ||||
|  |  | |||
|  | @ -232,6 +232,10 @@ export const meta = { | |||
| 				type: 'boolean' as const, | ||||
| 				optional: false as const, nullable: false as const | ||||
| 			}, | ||||
| 			translatorAvailable: { | ||||
| 				type: 'boolean' as const, | ||||
| 				optional: false as const, nullable: false as const | ||||
| 			}, | ||||
| 			proxyAccountName: { | ||||
| 				type: 'string' as const, | ||||
| 				optional: false as const, nullable: true as const | ||||
|  | @ -512,6 +516,8 @@ export default define(meta, async (ps, me) => { | |||
| 
 | ||||
| 		enableServiceWorker: instance.enableServiceWorker, | ||||
| 
 | ||||
| 		translatorAvailable: instance.deeplAuthKey != null, | ||||
| 
 | ||||
| 		...(ps.detail ? { | ||||
| 			pinnedPages: instance.pinnedPages, | ||||
| 			pinnedClipId: instance.pinnedClipId, | ||||
|  |  | |||
							
								
								
									
										79
									
								
								src/server/api/endpoints/notes/translate.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								src/server/api/endpoints/notes/translate.ts
									
										
									
									
									
										Normal 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 | ||||
| 	}; | ||||
| }); | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue