From 0815a5235d226434e17ead0166227f5ec60133b8 Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 7 Jan 2024 09:24:04 +0900 Subject: [PATCH 1/4] tweak game --- .../assets/drop-and-fusion/dropper.png | Bin 0 -> 32415 bytes .../assets/drop-and-fusion/frame-dark.svg | 28 ++++++ .../assets/drop-and-fusion/frame-light.svg | 28 ++++++ .../frontend/assets/drop-and-fusion/frame.svg | 25 ------ .../frontend/src/pages/drop-and-fusion.vue | 80 ++++++++++++++---- 5 files changed, 120 insertions(+), 41 deletions(-) create mode 100644 packages/frontend/assets/drop-and-fusion/dropper.png create mode 100644 packages/frontend/assets/drop-and-fusion/frame-dark.svg create mode 100644 packages/frontend/assets/drop-and-fusion/frame-light.svg delete mode 100644 packages/frontend/assets/drop-and-fusion/frame.svg diff --git a/packages/frontend/assets/drop-and-fusion/dropper.png b/packages/frontend/assets/drop-and-fusion/dropper.png new file mode 100644 index 0000000000000000000000000000000000000000..f4300aa5c06f6001a87c01f8525847b294f43be3 GIT binary patch literal 32415 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 A_y7O^ literal 0 HcmV?d00001 diff --git a/packages/frontend/assets/drop-and-fusion/frame-dark.svg b/packages/frontend/assets/drop-and-fusion/frame-dark.svg new file mode 100644 index 0000000000..3fa7c0da81 --- /dev/null +++ b/packages/frontend/assets/drop-and-fusion/frame-dark.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/frontend/assets/drop-and-fusion/frame-light.svg b/packages/frontend/assets/drop-and-fusion/frame-light.svg new file mode 100644 index 0000000000..6052ccbaa0 --- /dev/null +++ b/packages/frontend/assets/drop-and-fusion/frame-light.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/frontend/assets/drop-and-fusion/frame.svg b/packages/frontend/assets/drop-and-fusion/frame.svg deleted file mode 100644 index 4276dae833..0000000000 --- a/packages/frontend/assets/drop-and-fusion/frame.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue index d0ca5157ef..7f4a885b44 100644 --- a/packages/frontend/src/pages/drop-and-fusion.vue +++ b/packages/frontend/src/pages/drop-and-fusion.vue @@ -11,7 +11,8 @@ SPDX-License-Identifier: AGPL-3.0-only
- SCORE: +
SCORE:
+
HIGH SCORE: -
@@ -33,7 +34,8 @@ SPDX-License-Identifier: AGPL-3.0-only
- + +
{{ comboPrev }} Chain!
+ (); const canvasEl = shallowRef(); @@ -191,7 +196,7 @@ const FRUITS = [{ const GAME_WIDTH = 450; const GAME_HEIGHT = 600; -const PHYSICS_QUALITY_FACTOR = 32; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる +const PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる let viewScaleX = 1; let viewScaleY = 1; @@ -203,6 +208,7 @@ const comboPrev = ref(0); const dropReady = ref(true); const gameOver = ref(false); const gameStarted = ref(false); +const highScore = ref(null); class Game extends EventEmitter<{ changeScore: (score: number) => void; @@ -251,6 +257,8 @@ class Game extends EventEmitter<{ this.emit('changeScore', value); } + private comboIntervalId: number | null = null; + constructor() { super(); @@ -294,6 +302,8 @@ class Game extends EventEmitter<{ //#region walls const WALL_OPTIONS: Matter.IChamferableBodyDefinition = { isStatic: true, + friction: 0.7, + slop: 1.0, render: { strokeStyle: 'transparent', fillStyle: 'transparent', @@ -308,7 +318,7 @@ class Game extends EventEmitter<{ ]); //#endregion - this.overflowCollider = Matter.Bodies.rectangle(GAME_WIDTH / 2, 0, GAME_WIDTH, 125, { + this.overflowCollider = Matter.Bodies.rectangle(GAME_WIDTH / 2, 0, GAME_WIDTH, 200, { isStatic: true, isSensor: true, render: { @@ -328,11 +338,13 @@ class Game extends EventEmitter<{ private createBody(fruit: typeof FRUITS[number], x: number, y: number) { return Matter.Bodies.circle(x, y, fruit.size / 2, { label: fruit.id, - density: 0.0005, + //density: 0.0005, + density: fruit.size / 1000, + restitution: 0.2, frictionAir: 0.01, - restitution: 0.4, - friction: 0.5, + friction: 0.7, frictionStatic: 5, + slop: 1.0, //mass: 0, render: { sprite: { @@ -372,7 +384,7 @@ class Game extends EventEmitter<{ this.activeBodyIds.push(body.id); }, 100); - const additionalScore = Math.round(currentFruit.score * (1 + (this.combo / 3))); + const additionalScore = Math.round(currentFruit.score * (1 + ((this.combo - 1) / 3))); this.score += additionalScore; const pan = ((newX / GAME_WIDTH) - 0.5) * 2; @@ -449,7 +461,7 @@ class Game extends EventEmitter<{ } }); - window.setInterval(() => { + this.comboIntervalId = window.setInterval(() => { if (this.latestFusionedAt < Date.now() - this.COMBO_INTERVAL) { this.combo = 0; } @@ -469,7 +481,7 @@ class Game extends EventEmitter<{ this.emit('changeStock', this.stock); const x = Math.min(GAME_WIDTH - this.PLAYAREA_MARGIN - (st.fruit.size / 2), Math.max(this.PLAYAREA_MARGIN + (st.fruit.size / 2), _x)); - const body = this.createBody(st.fruit, x, st.fruit.size / 2); + const body = this.createBody(st.fruit, x, 50 + st.fruit.size / 2); Matter.Composite.add(this.engine.world, body); this.activeBodyIds.push(body.id); this.latestDroppedBodyId = body.id; @@ -480,6 +492,7 @@ class Game extends EventEmitter<{ } public dispose() { + if (this.comboIntervalId) window.clearInterval(this.comboIntervalId); Matter.Render.stop(this.render); Matter.Runner.stop(this.runner); Matter.World.clear(this.engine.world, false); @@ -567,10 +580,28 @@ function attachGame() { currentPick.value = null; dropReady.value = false; gameOver.value = true; + + if (score.value > (highScore.value ?? 0)) { + highScore.value = score.value; + + misskeyApi('i/registry/set', { + scope: ['dropAndFusionGame'], + key: 'highScore', + value: highScore.value, + }); + } }); } -onMounted(() => { +onMounted(async () => { + try { + highScore.value = await misskeyApi('i/registry/get', { + scope: ['dropAndFusionGame'], + key: 'highScore', + }); + } catch (err) { + } + game = new Game(); attachGame(); @@ -667,7 +698,9 @@ definePageMetadata({ top: 0; left: 0; width: 100%; - filter: drop-shadow(0 6px 16px #0007); + // なんかiOSでちらつく + //filter: drop-shadow(0 6px 16px #0007); + border-radius: 16px; pointer-events: none; user-select: none; } @@ -699,13 +732,28 @@ definePageMetadata({ text-align: center; font-weight: bold; font-style: oblique; + color: #fff; + -webkit-text-stroke: 1px rgb(255, 145, 0); + text-shadow: 0 0 6px #0005; pointer-events: none; user-select: none; } .currentFruit { position: absolute; - margin-top: 20px; + margin-top: 80px; + z-index: 2; + filter: drop-shadow(0 6px 16px #0007); + pointer-events: none; + user-select: none; +} + +.dropper { + position: absolute; + top: 0; + width: 70px; + margin-top: -10px; + margin-left: -30px; z-index: 2; filter: drop-shadow(0 6px 16px #0007); pointer-events: none; @@ -714,7 +762,7 @@ definePageMetadata({ .currentFruitArrow { position: absolute; - margin-top: 20px; + margin-top: 100px; z-index: 3; animation: currentFruitArrow 2s ease infinite; pointer-events: none; @@ -723,10 +771,10 @@ definePageMetadata({ .dropGuide { position: absolute; - top: 50px; + top: 120px; z-index: 3; width: 3px; - height: calc(100% - 50px); + height: calc(100% - 120px); background: #f002; pointer-events: none; user-select: none; From f2dee7b25eb473796ff77e2abfae88f174fd5b90 Mon Sep 17 00:00:00 2001 From: _ Date: Sun, 7 Jan 2024 09:57:01 +0900 Subject: [PATCH 2/4] =?UTF-8?q?Fix:=20=E3=83=AA=E3=82=B9=E3=83=88=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=A0=E3=83=A9=E3=82=A4=E3=83=B3=E3=81=AE=E3=80=8C?= =?UTF-8?q?=E3=83=AA=E3=83=8E=E3=83=BC=E3=83=88=E3=82=92=E8=A1=A8=E7=A4=BA?= =?UTF-8?q?=E3=80=8D=E3=81=8C=E6=AD=A3=E3=81=97=E3=81=8F=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=81=97=E3=81=AA=E3=81=84=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20(#12932)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: list timeline withRenotes * add CHANGELOG --- CHANGELOG.md | 1 + packages/backend/src/server/api/stream/channels/user-list.ts | 4 ++++ packages/frontend/src/components/MkTimeline.vue | 5 ++++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a6e2db950..8c27349f61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ### General - Feat: [mCaptcha](https://github.com/mCaptcha/mCaptcha)のサポートを追加 +- Fix: リストライムラインの「リノートを表示」が正しく機能しない問題を修正 ### Client - Feat: 新しいゲームを追加 diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 909b5a5e03..e0245814c4 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -21,6 +21,7 @@ class UserListChannel extends Channel { private membershipsMap: Record | undefined> = {}; private listUsersClock: NodeJS.Timeout; private withFiles: boolean; + private withRenotes: boolean; constructor( private userListsRepository: UserListsRepository, @@ -39,6 +40,7 @@ class UserListChannel extends Channel { public async init(params: any) { this.listId = params.listId as string; this.withFiles = params.withFiles ?? false; + this.withRenotes = params.withRenotes ?? true; // Check existence and owner const listExist = await this.userListsRepository.exist({ @@ -104,6 +106,8 @@ class UserListChannel extends Channel { } } + if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index d5adc02ca7..63f779dbde 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -132,6 +132,7 @@ function connectChannel() { connection.on('mention', onNote); } else if (props.src === 'list') { connection = stream.useChannel('userList', { + withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, listId: props.list, }); @@ -198,6 +199,7 @@ function updatePaginationQuery() { } else if (props.src === 'list') { endpoint = 'notes/user-list-timeline'; query = { + withRenotes: props.withRenotes, withFiles: props.onlyFiles ? true : undefined, listId: props.list, }; @@ -236,8 +238,9 @@ function refreshEndpointAndChannel() { updatePaginationQuery(); } +// デッキのリストカラムでwithRenotesを変更した場合に自動的に更新されるようにさせる // IDが切り替わったら切り替え先のTLを表示させたい -watch(() => [props.list, props.antenna, props.channel, props.role], refreshEndpointAndChannel); +watch(() => [props.list, props.antenna, props.channel, props.role, props.withRenotes], refreshEndpointAndChannel); // 初回表示用 refreshEndpointAndChannel(); From 4ea030d66916777595bf1429fab4d5c1b93d4a5d Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 7 Jan 2024 10:35:39 +0900 Subject: [PATCH 3/4] tweak game --- .../frontend/src/pages/drop-and-fusion.vue | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue index 7f4a885b44..6014931562 100644 --- a/packages/frontend/src/pages/drop-and-fusion.vue +++ b/packages/frontend/src/pages/drop-and-fusion.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only -
+
@@ -221,10 +221,10 @@ class Game extends EventEmitter<{ private COMBO_INTERVAL = 1000; public readonly DROP_INTERVAL = 500; private PLAYAREA_MARGIN = 25; + private STOCK_MAX = 4; private engine: Matter.Engine; private render: Matter.Render; private runner: Matter.Runner; - private detector: Matter.Detector; private overflowCollider: Matter.Body; private isGameOver = false; @@ -286,7 +286,7 @@ class Game extends EventEmitter<{ wireframeBackground: 'transparent', // transparent to hide wireframes: false, showSleeping: false, - pixelRatio: window.devicePixelRatio, + pixelRatio: Math.max(2, window.devicePixelRatio), }, }); @@ -295,8 +295,6 @@ class Game extends EventEmitter<{ this.runner = Matter.Runner.create(); Matter.Runner.run(this.runner, this.engine); - this.detector = Matter.Detector.create(); - this.engine.world.bodies = []; //#region walls @@ -412,7 +410,7 @@ class Game extends EventEmitter<{ } public start() { - for (let i = 0; i < 4; i++) { + for (let i = 0; i < this.STOCK_MAX; i++) { this.stock.push({ id: Math.random().toString(), fruit: FRUITS.filter(x => x.available)[Math.floor(Math.random() * FRUITS.filter(x => x.available).length)], @@ -423,8 +421,8 @@ class Game extends EventEmitter<{ // TODO: fusion予約状態のアイテムは光らせるなどの演出をすると楽しそう let fusionReservedPairs: { bodyA: Matter.Body; bodyB: Matter.Body }[] = []; - const minCollisionDepthForSound = 2.5; - const maxCollisionDepthForSound = 9; + const minCollisionEnergyForSound = 2.5; + const maxCollisionEnergyForSound = 9; const soundPitchMax = 4; const soundPitchMin = 0.5; @@ -451,8 +449,8 @@ class Game extends EventEmitter<{ } } else { const energy = pairs.collision.depth; - if (energy > minCollisionDepthForSound) { - const vol = (Math.min(maxCollisionDepthForSound, energy - minCollisionDepthForSound) / maxCollisionDepthForSound) / 4; + if (energy > minCollisionEnergyForSound) { + const vol = (Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4; const pan = ((((bodyA.position.x + bodyB.position.x) / 2) / GAME_WIDTH) - 0.5) * 2; const pitch = soundPitchMin + ((soundPitchMax - soundPitchMin) * (1 - (Math.min(10, energy) / 10))); sound.playRaw('syuilo/poi1', vol, pan, pitch); @@ -700,7 +698,6 @@ definePageMetadata({ width: 100%; // なんかiOSでちらつく //filter: drop-shadow(0 6px 16px #0007); - border-radius: 16px; pointer-events: none; user-select: none; } @@ -710,7 +707,8 @@ definePageMetadata({ display: block; z-index: 1; margin-top: -50px; - max-width: 100%; + width: 100% !important; + height: auto !important; pointer-events: none; user-select: none; } From 2a9db983fcd79e1993d5ea5b03e4979c1a578d7d Mon Sep 17 00:00:00 2001 From: Kagami Sascha Rosylight Date: Sun, 7 Jan 2024 02:35:58 +0100 Subject: [PATCH 4/4] feat: export clips (#12931) * feat: export clips * Update CHANGELOG.md --- CHANGELOG.md | 1 + locales/index.d.ts | 1 + locales/ja-JP.yml | 1 + packages/backend/src/core/QueueService.ts | 10 + .../backend/src/queue/QueueProcessorModule.ts | 2 + .../src/queue/QueueProcessorService.ts | 3 + .../processors/ExportClipsProcessorService.ts | 206 ++++++++++++++++++ .../backend/src/server/api/EndpointsModule.ts | 4 + packages/backend/src/server/api/endpoints.ts | 4 +- .../server/api/endpoints/i/export-clips.ts | 35 +++ packages/backend/test/e2e/exports.ts | 194 +++++++++++++++++ packages/backend/test/utils.ts | 2 +- .../src/pages/settings/import-export.vue | 12 + 13 files changed, 473 insertions(+), 2 deletions(-) create mode 100644 packages/backend/src/queue/processors/ExportClipsProcessorService.ts create mode 100644 packages/backend/src/server/api/endpoints/i/export-clips.ts create mode 100644 packages/backend/test/e2e/exports.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c27349f61..0d2fb4ccd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ ### Server - Enhance: 連合先のレートリミットに引っかかった際にリトライするようになりました - Enhance: ActivityPub Deliver queueでBodyを事前処理するように (#12916) +- Enhance: クリップをエクスポートできるように ## 2023.12.2 diff --git a/locales/index.d.ts b/locales/index.d.ts index 99bc0fc04f..75517fa2ad 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -2256,6 +2256,7 @@ export interface Locale { "_exportOrImport": { "allNotes": string; "favoritedNotes": string; + "clips": string; "followingList": string; "muteList": string; "blockingList": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 7cf5663a72..8b6b119d7e 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2159,6 +2159,7 @@ _profile: _exportOrImport: allNotes: "全てのノート" favoritedNotes: "お気に入りにしたノート" + clips: "クリップ" followingList: "フォロー" muteList: "ミュート" blockingList: "ブロック" diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 4f99dee64e..dc3f248da4 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -182,6 +182,16 @@ export class QueueService { }); } + @bindThis + public createExportClipsJob(user: ThinUser) { + return this.dbQueue.add('exportClips', { + user: { id: user.id }, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + @bindThis public createExportFavoritesJob(user: ThinUser) { return this.dbQueue.add('exportFavorites', { diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index e6327002c5..9c52c7d76a 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -24,6 +24,7 @@ import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmo import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js'; import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js'; import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js'; +import { ExportClipsProcessorService } from './processors/ExportClipsProcessorService.js'; import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js'; import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js'; import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js'; @@ -53,6 +54,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor DeleteDriveFilesProcessorService, ExportCustomEmojisProcessorService, ExportNotesProcessorService, + ExportClipsProcessorService, ExportFavoritesProcessorService, ExportFollowingProcessorService, ExportMutingProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index b872dd65f7..bcc1a69f80 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -16,6 +16,7 @@ import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js'; import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmojisProcessorService.js'; import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js'; +import { ExportClipsProcessorService } from './processors/ExportClipsProcessorService.js'; import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js'; import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js'; import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js'; @@ -91,6 +92,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService, private exportCustomEmojisProcessorService: ExportCustomEmojisProcessorService, private exportNotesProcessorService: ExportNotesProcessorService, + private exportClipsProcessorService: ExportClipsProcessorService, private exportFavoritesProcessorService: ExportFavoritesProcessorService, private exportFollowingProcessorService: ExportFollowingProcessorService, private exportMutingProcessorService: ExportMutingProcessorService, @@ -164,6 +166,7 @@ export class QueueProcessorService implements OnApplicationShutdown { case 'deleteDriveFiles': return this.deleteDriveFilesProcessorService.process(job); case 'exportCustomEmojis': return this.exportCustomEmojisProcessorService.process(job); case 'exportNotes': return this.exportNotesProcessorService.process(job); + case 'exportClips': return this.exportClipsProcessorService.process(job); case 'exportFavorites': return this.exportFavoritesProcessorService.process(job); case 'exportFollowing': return this.exportFollowingProcessorService.process(job); case 'exportMuting': return this.exportMutingProcessorService.process(job); diff --git a/packages/backend/src/queue/processors/ExportClipsProcessorService.ts b/packages/backend/src/queue/processors/ExportClipsProcessorService.ts new file mode 100644 index 0000000000..5221497bd3 --- /dev/null +++ b/packages/backend/src/queue/processors/ExportClipsProcessorService.ts @@ -0,0 +1,206 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import { Writable } from 'node:stream'; +import { Inject, Injectable, StreamableFile } from '@nestjs/common'; +import { MoreThan } from 'typeorm'; +import { format as dateFormat } from 'date-fns'; +import { DI } from '@/di-symbols.js'; +import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, NotesRepository, PollsRepository, UsersRepository } from '@/models/_.js'; +import type Logger from '@/logger.js'; +import { DriveService } from '@/core/DriveService.js'; +import { createTemp } from '@/misc/create-temp.js'; +import type { MiPoll } from '@/models/Poll.js'; +import type { MiNote } from '@/models/Note.js'; +import { bindThis } from '@/decorators.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { Packed } from '@/misc/json-schema.js'; +import { IdService } from '@/core/IdService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; +import type { DbJobDataWithUser } from '../types.js'; + +@Injectable() +export class ExportClipsProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, + + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, + + @Inject(DI.clipNotesRepository) + private clipNotesRepository: ClipNotesRepository, + + private driveService: DriveService, + private queueLoggerService: QueueLoggerService, + private idService: IdService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('export-clips'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + this.logger.info(`Exporting clips of ${job.data.user.id} ...`); + + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + return; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + this.logger.info(`Temp file is ${path}`); + + try { + const stream = Writable.toWeb(fs.createWriteStream(path, { flags: 'a' })); + const writer = stream.getWriter(); + writer.closed.catch(this.logger.error); + + await writer.write('['); + + await this.processClips(writer, user, job); + + await writer.write(']'); + await writer.close(); + + this.logger.succ(`Exported to: ${path}`); + + const fileName = 'clips-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); + + this.logger.succ(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + } + + async processClips(writer: WritableStreamDefaultWriter, user: MiUser, job: Bull.Job) { + let exportedClipsCount = 0; + let cursor: MiClip['id'] | null = null; + + while (true) { + const clips = await this.clipsRepository.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }); + + if (clips.length === 0) { + job.updateProgress(100); + break; + } + + cursor = clips.at(-1)?.id ?? null; + + for (const clip of clips) { + // Stringify but remove the last `]}` + const content = JSON.stringify(this.serializeClip(clip)).slice(0, -2); + const isFirst = exportedClipsCount === 0; + await writer.write(isFirst ? content : ',\n' + content); + + await this.processClipNotes(writer, clip.id); + + await writer.write(']}'); + exportedClipsCount++; + } + + const total = await this.clipsRepository.countBy({ + userId: user.id, + }); + + job.updateProgress(exportedClipsCount / total); + } + } + + async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string): Promise { + let exportedClipNotesCount = 0; + let cursor: MiClipNote['id'] | null = null; + + while (true) { + const clipNotes = await this.clipNotesRepository.find({ + where: { + clipId, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + relations: ['note', 'note.user'], + }) as (MiClipNote & { note: MiNote & { user: MiUser } })[]; + + if (clipNotes.length === 0) { + break; + } + + cursor = clipNotes.at(-1)?.id ?? null; + + for (const clipNote of clipNotes) { + let poll: MiPoll | undefined; + if (clipNote.note.hasPoll) { + poll = await this.pollsRepository.findOneByOrFail({ noteId: clipNote.note.id }); + } + const content = JSON.stringify(this.serializeClipNote(clipNote, poll)); + const isFirst = exportedClipNotesCount === 0; + await writer.write(isFirst ? content : ',\n' + content); + + exportedClipNotesCount++; + } + } + } + + private serializeClip(clip: MiClip): Record { + return { + id: clip.id, + name: clip.name, + description: clip.description, + lastClippedAt: clip.lastClippedAt?.toISOString(), + clipNotes: [], + }; + } + + private serializeClipNote(clip: MiClipNote & { note: MiNote & { user: MiUser } }, poll: MiPoll | undefined): Record { + return { + id: clip.id, + createdAt: this.idService.parse(clip.id).date.toISOString(), + note: { + id: clip.note.id, + text: clip.note.text, + createdAt: this.idService.parse(clip.note.id).date.toISOString(), + fileIds: clip.note.fileIds, + replyId: clip.note.replyId, + renoteId: clip.note.renoteId, + poll: poll, + cw: clip.note.cw, + visibility: clip.note.visibility, + visibleUserIds: clip.note.visibleUserIds, + localOnly: clip.note.localOnly, + reactionAcceptance: clip.note.reactionAcceptance, + uri: clip.note.uri, + url: clip.note.url, + user: { + id: clip.note.user.id, + name: clip.note.user.name, + username: clip.note.user.username, + host: clip.note.user.host, + uri: clip.note.user.uri, + }, + }, + }; + } +} diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 86a64d7121..a3a9805444 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -208,6 +208,7 @@ import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js'; import * as ep___i_exportFollowing from './endpoints/i/export-following.js'; import * as ep___i_exportMute from './endpoints/i/export-mute.js'; import * as ep___i_exportNotes from './endpoints/i/export-notes.js'; +import * as ep___i_exportClips from './endpoints/i/export-clips.js'; import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js'; import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js'; import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; @@ -569,6 +570,7 @@ const $i_exportBlocking: Provider = { provide: 'ep:i/export-blocking', useClass: const $i_exportFollowing: Provider = { provide: 'ep:i/export-following', useClass: ep___i_exportFollowing.default }; const $i_exportMute: Provider = { provide: 'ep:i/export-mute', useClass: ep___i_exportMute.default }; const $i_exportNotes: Provider = { provide: 'ep:i/export-notes', useClass: ep___i_exportNotes.default }; +const $i_exportClips: Provider = { provide: 'ep:i/export-clips', useClass: ep___i_exportClips.default }; const $i_exportFavorites: Provider = { provide: 'ep:i/export-favorites', useClass: ep___i_exportFavorites.default }; const $i_exportUserLists: Provider = { provide: 'ep:i/export-user-lists', useClass: ep___i_exportUserLists.default }; const $i_exportAntennas: Provider = { provide: 'ep:i/export-antennas', useClass: ep___i_exportAntennas.default }; @@ -934,6 +936,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_exportFollowing, $i_exportMute, $i_exportNotes, + $i_exportClips, $i_exportFavorites, $i_exportUserLists, $i_exportAntennas, @@ -1293,6 +1296,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_exportFollowing, $i_exportMute, $i_exportNotes, + $i_exportClips, $i_exportFavorites, $i_exportUserLists, $i_exportAntennas, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 41232091c6..bd8aa4af72 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -3,8 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Schema } from '@/misc/json-schema.js'; import { permissions } from 'misskey-js'; +import type { Schema } from '@/misc/json-schema.js'; import { RolePolicies } from '@/core/RoleService.js'; import * as ep___admin_meta from './endpoints/admin/meta.js'; @@ -209,6 +209,7 @@ import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js'; import * as ep___i_exportFollowing from './endpoints/i/export-following.js'; import * as ep___i_exportMute from './endpoints/i/export-mute.js'; import * as ep___i_exportNotes from './endpoints/i/export-notes.js'; +import * as ep___i_exportClips from './endpoints/i/export-clips.js'; import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js'; import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js'; import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; @@ -568,6 +569,7 @@ const eps = [ ['i/export-following', ep___i_exportFollowing], ['i/export-mute', ep___i_exportMute], ['i/export-notes', ep___i_exportNotes], + ['i/export-clips', ep___i_exportClips], ['i/export-favorites', ep___i_exportFavorites], ['i/export-user-lists', ep___i_exportUserLists], ['i/export-antennas', ep___i_exportAntennas], diff --git a/packages/backend/src/server/api/endpoints/i/export-clips.ts b/packages/backend/src/server/api/endpoints/i/export-clips.ts new file mode 100644 index 0000000000..9435a2b23c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-clips.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; + +export const meta = { + secure: true, + requireCredential: true, + limit: { + duration: ms('1day'), + max: 1, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.createExportClipsJob(me); + }); + } +} diff --git a/packages/backend/test/e2e/exports.ts b/packages/backend/test/e2e/exports.ts new file mode 100644 index 0000000000..9686f2b7fd --- /dev/null +++ b/packages/backend/test/e2e/exports.ts @@ -0,0 +1,194 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { signup, api, startServer, startJobQueue, port, post } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; +import type * as misskey from 'misskey-js'; + +describe('export-clips', () => { + let app: INestApplicationContext; + let alice: misskey.entities.SignupResponse; + let bob: misskey.entities.SignupResponse; + + // XXX: Any better way to get the result? + async function pollFirstDriveFile() { + while (true) { + const files = (await api('/drive/files', {}, alice)).body; + if (!files.length) { + await new Promise(r => setTimeout(r, 100)); + continue; + } + if (files.length > 1) { + throw new Error('Too many files?'); + } + const file = (await api('/drive/files/show', { fileId: files[0].id }, alice)).body; + const res = await fetch(new URL(new URL(file.url).pathname, `http://127.0.0.1:${port}`)); + return await res.json(); + } + } + + beforeAll(async () => { + app = await startServer(); + await startJobQueue(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + }, 1000 * 60 * 2); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + // Clean all clips and files of alice + const clips = (await api('/clips/list', {}, alice)).body; + for (const clip of clips) { + const res = await api('/clips/delete', { clipId: clip.id }, alice); + if (res.status !== 204) { + throw new Error('Failed to delete clip'); + } + } + const files = (await api('/drive/files', {}, alice)).body; + for (const file of files) { + const res = await api('/drive/files/delete', { fileId: file.id }, alice); + if (res.status !== 204) { + throw new Error('Failed to delete file'); + } + } + }); + + test('basic export', async () => { + let res = await api('/clips/create', { + name: 'foo', + description: 'bar', + }, alice); + assert.strictEqual(res.status, 200); + + res = await api('/i/export-clips', {}, alice); + assert.strictEqual(res.status, 204); + + const exported = await pollFirstDriveFile(); + assert.strictEqual(exported[0].name, 'foo'); + assert.strictEqual(exported[0].description, 'bar'); + assert.strictEqual(exported[0].clipNotes.length, 0); + }); + + test('export with notes', async () => { + let res = await api('/clips/create', { + name: 'foo', + description: 'bar', + }, alice); + assert.strictEqual(res.status, 200); + const clip = res.body; + + const note1 = await post(alice, { + text: 'baz1', + }); + + const note2 = await post(alice, { + text: 'baz2', + poll: { + choices: ['sakura', 'izumi', 'ako'], + }, + }); + + for (const note of [note1, note2]) { + res = await api('/clips/add-note', { + clipId: clip.id, + noteId: note.id, + }, alice); + assert.strictEqual(res.status, 204); + } + + res = await api('/i/export-clips', {}, alice); + assert.strictEqual(res.status, 204); + + const exported = await pollFirstDriveFile(); + assert.strictEqual(exported[0].name, 'foo'); + assert.strictEqual(exported[0].description, 'bar'); + assert.strictEqual(exported[0].clipNotes.length, 2); + assert.strictEqual(exported[0].clipNotes[0].note.text, 'baz1'); + assert.strictEqual(exported[0].clipNotes[1].note.text, 'baz2'); + assert.deepStrictEqual(exported[0].clipNotes[1].note.poll.choices[0], 'sakura'); + }); + + test('multiple clips', async () => { + let res = await api('/clips/create', { + name: 'kawaii', + description: 'kawaii', + }, alice); + assert.strictEqual(res.status, 200); + const clip1 = res.body; + + res = await api('/clips/create', { + name: 'yuri', + description: 'yuri', + }, alice); + assert.strictEqual(res.status, 200); + const clip2 = res.body; + + const note1 = await post(alice, { + text: 'baz1', + }); + + const note2 = await post(alice, { + text: 'baz2', + }); + + res = await api('/clips/add-note', { + clipId: clip1.id, + noteId: note1.id, + }, alice); + assert.strictEqual(res.status, 204); + + res = await api('/clips/add-note', { + clipId: clip2.id, + noteId: note2.id, + }, alice); + assert.strictEqual(res.status, 204); + + res = await api('/i/export-clips', {}, alice); + assert.strictEqual(res.status, 204); + + const exported = await pollFirstDriveFile(); + assert.strictEqual(exported[0].name, 'kawaii'); + assert.strictEqual(exported[0].clipNotes.length, 1); + assert.strictEqual(exported[0].clipNotes[0].note.text, 'baz1'); + assert.strictEqual(exported[1].name, 'yuri'); + assert.strictEqual(exported[1].clipNotes.length, 1); + assert.strictEqual(exported[1].clipNotes[0].note.text, 'baz2'); + }); + + test('Clipping other user\'s note', async () => { + let res = await api('/clips/create', { + name: 'kawaii', + description: 'kawaii', + }, alice); + assert.strictEqual(res.status, 200); + const clip = res.body; + + const note = await post(bob, { + text: 'baz', + visibility: 'followers', + }); + + res = await api('/clips/add-note', { + clipId: clip.id, + noteId: note.id, + }, alice); + assert.strictEqual(res.status, 204); + + res = await api('/i/export-clips', {}, alice); + assert.strictEqual(res.status, 204); + + const exported = await pollFirstDriveFile(); + assert.strictEqual(exported[0].name, 'kawaii'); + assert.strictEqual(exported[0].clipNotes.length, 1); + assert.strictEqual(exported[0].clipNotes[0].note.text, 'baz'); + assert.strictEqual(exported[0].clipNotes[0].note.user.username, 'bob'); + }); +}); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 46b8ea9cdd..7c9428d476 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -17,7 +17,7 @@ import { entities } from '../src/postgres.js'; import { loadConfig } from '../src/config.js'; import type * as misskey from 'misskey-js'; -export { server as startServer } from '@/boot/common.js'; +export { server as startServer, jobQueue as startJobQueue } from '@/boot/common.js'; interface UserToken { token: string; diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue index 990eff99c1..70d718f1ab 100644 --- a/packages/frontend/src/pages/settings/import-export.vue +++ b/packages/frontend/src/pages/settings/import-export.vue @@ -21,6 +21,14 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.export }} + + + + + + {{ i18n.ts.export }} + +
@@ -157,6 +165,10 @@ const exportFavorites = () => { misskeyApi('i/export-favorites', {}).then(onExportSuccess).catch(onError); }; +const exportClips = () => { + misskeyApi('i/export-clips', {}).then(onExportSuccess).catch(onError); +}; + const exportFollowing = () => { misskeyApi('i/export-following', { excludeMuting: excludeMutingUsers.value,