From f6fc78f578c344172349f7795b168b2992953bd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:13:06 +0900 Subject: [PATCH 01/49] =?UTF-8?q?refactor:=20DriveFileEntityService?= =?UTF-8?q?=E3=81=A8DriveFolderEntityService=E3=81=AE=E8=A4=87=E6=95=B0?= =?UTF-8?q?=E4=BB=B6=E5=8F=96=E5=BE=97=E3=82=92=E3=83=AA=E3=83=95=E3=82=A1?= =?UTF-8?q?=E3=82=AF=E3=82=BF=20(#17064)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: DriveFileEntityServiceとDriveFolderEntityServiceの複数件取得をリファクタ * add test * fix * Update packages/backend/src/core/entities/DriveFolderEntityService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/backend/test/unit/entities/DriveFolderEntityService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/backend/src/core/entities/DriveFileEntityService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Revert "Update packages/backend/src/core/entities/DriveFileEntityService.ts" This reverts commit 83bb9564cfdb699a21b775815439e1e496cd89a9. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../core/entities/DriveFileEntityService.ts | 45 +++- .../core/entities/DriveFolderEntityService.ts | 154 +++++++++++- .../backend/src/misc/split-id-and-objects.ts | 27 +++ packages/backend/src/misc/unique-by-key.ts | 21 ++ .../unit/entities/DriveFileEntityService.ts | 227 ++++++++++++++++++ .../unit/entities/DriveFolderEntityService.ts | 171 +++++++++++++ 6 files changed, 628 insertions(+), 17 deletions(-) create mode 100644 packages/backend/src/misc/split-id-and-objects.ts create mode 100644 packages/backend/src/misc/unique-by-key.ts create mode 100644 packages/backend/test/unit/entities/DriveFileEntityService.ts create mode 100644 packages/backend/test/unit/entities/DriveFolderEntityService.ts diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index a6f7f369a6..1865d494c4 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -17,6 +17,7 @@ import { deepClone } from '@/misc/clone.js'; import { bindThis } from '@/decorators.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; import { IdService } from '@/core/IdService.js'; +import { uniqueByKey } from '@/misc/unique-by-key.js'; import { UtilityService } from '../UtilityService.js'; import { VideoProcessingService } from '../VideoProcessingService.js'; import { UserEntityService } from './UserEntityService.js'; @@ -226,6 +227,7 @@ export class DriveFileEntityService { options?: PackOptions, hint?: { packedUser?: Packed<'UserLite'> + packedFolder?: Packed<'DriveFolder'> }, ): Promise | null> { const opts = Object.assign({ @@ -250,9 +252,9 @@ export class DriveFileEntityService { thumbnailUrl: this.getThumbnailUrl(file), comment: file.comment, folderId: file.folderId, - folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, { + folder: opts.detail && file.folderId ? (hint?.packedFolder ?? this.driveFolderEntityService.pack(file.folderId, { detail: true, - }) : null, + })) : null, userId: file.userId, user: (opts.withUser && file.userId) ? hint?.packedUser ?? this.userEntityService.pack(file.userId) : null, }); @@ -263,10 +265,41 @@ export class DriveFileEntityService { files: MiDriveFile[], options?: PackOptions, ): Promise[]> { - const _user = files.map(({ user, userId }) => user ?? userId).filter(x => x != null); - const _userMap = await this.userEntityService.packMany(_user) - .then(users => new Map(users.map(user => [user.id, user]))); - const items = await Promise.all(files.map(f => this.packNullable(f, options, f.userId ? { packedUser: _userMap.get(f.userId) } : {}))); + // -- ユーザ情報の事前取得 -- + + let userMap: Map> | null = null; + if (options?.withUser) { + const users = files + .map(({ user, userId }) => user ?? userId) + .filter(x => x != null); + + const uniqueUsers = uniqueByKey(users, (user) => typeof user === 'string' ? user : user.id); + const packedUsers = await this.userEntityService.packMany(uniqueUsers); + userMap = new Map(packedUsers.map(user => [user.id, user])); + } + + // -- フォルダ情報の事前取得 -- + + let folderMap: Map> | null = null; + if (options?.detail) { + const folders = files + .map(({ folder, folderId }) => folder ?? folderId) + .filter(x => x != null); + + const uniqueFolders = uniqueByKey(folders, (folder) => typeof folder === 'string' ? folder : folder.id); + const packedFolders = await this.driveFolderEntityService.packMany(uniqueFolders, { detail: true }); + folderMap = new Map(packedFolders.map(folder => [folder.id, folder])); + } + + const items = await Promise.all(files.map(f => this.packNullable( + f, + options, + { + packedUser: f.userId ? userMap?.get(f.userId) : undefined, + packedFolder: f.folderId ? folderMap?.get(f.folderId) : undefined, + }, + ))); + return items.filter(x => x != null); } diff --git a/packages/backend/src/core/entities/DriveFolderEntityService.ts b/packages/backend/src/core/entities/DriveFolderEntityService.ts index 299f23ad38..326421e149 100644 --- a/packages/backend/src/core/entities/DriveFolderEntityService.ts +++ b/packages/backend/src/core/entities/DriveFolderEntityService.ts @@ -12,6 +12,9 @@ import type { } from '@/models/Blocking.js'; import type { MiDriveFolder } from '@/models/DriveFolder.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { In } from 'typeorm'; +import { uniqueByKey } from '@/misc/unique-by-key.js'; +import { splitIdAndObjects } from '@/misc/split-id-and-objects.js'; @Injectable() export class DriveFolderEntityService { @@ -32,12 +35,20 @@ export class DriveFolderEntityService { options?: { detail: boolean }, + hint?: { + folderMap?: Map; + foldersCountMap?: Map | null; + filesCountMap?: Map | null; + parentPacker?: (id: string) => Promise>; + }, ): Promise> { const opts = Object.assign({ detail: false, }, options); - const folder = typeof src === 'object' ? src : await this.driveFoldersRepository.findOneByOrFail({ id: src }); + const folder = typeof src === 'object' + ? src + : hint?.folderMap?.get(src) ?? await this.driveFoldersRepository.findOneByOrFail({ id: src }); return await awaitAll({ id: folder.id, @@ -46,20 +57,141 @@ export class DriveFolderEntityService { parentId: folder.parentId, ...(opts.detail ? { - foldersCount: this.driveFoldersRepository.countBy({ - parentId: folder.id, - }), - filesCount: this.driveFilesRepository.countBy({ - folderId: folder.id, - }), + foldersCount: hint?.foldersCountMap?.get(folder.id) + ?? this.driveFoldersRepository.countBy({ + parentId: folder.id, + }), + filesCount: hint?.filesCountMap?.get(folder.id) + ?? this.driveFilesRepository.countBy({ + folderId: folder.id, + }), ...(folder.parentId ? { - parent: this.pack(folder.parentId, { - detail: true, - }), + parent: hint?.parentPacker + ? hint.parentPacker(folder.parentId) + : this.pack(folder.parentId, { detail: true }, hint), } : {}), } : {}), }); } -} + public async packMany( + src: Array, + options?: { + detail: boolean + }, + ): Promise>> { + /** + * 重複を除去しつつ、必要なDriveFolderオブジェクトをすべて取得する + */ + const collectUniqueObjects = async (src: Array) => { + const uniqueSrc = uniqueByKey( + src, + (s) => typeof s === 'string' ? s : s.id, + ); + const { ids, objects } = splitIdAndObjects(uniqueSrc); + + const uniqueObjects = new Map(objects.map(s => [s.id, s])); + const needsFetchIds = ids.filter(id => !uniqueObjects.has(id)); + + if (needsFetchIds.length > 0) { + const fetchedObjects = await this.driveFoldersRepository.find({ + where: { + id: In(needsFetchIds), + }, + }); + for (const obj of fetchedObjects) { + uniqueObjects.set(obj.id, obj); + } + } + + return uniqueObjects; + }; + + /** + * 親フォルダーを再帰的に収集する + */ + const collectAncestors = async (folderMap: Map) => { + for (;;) { + const parentIds = new Set(); + for (const folder of folderMap.values()) { + if (folder.parentId != null && !folderMap.has(folder.parentId)) { + parentIds.add(folder.parentId); + } + } + + if (parentIds.size === 0) break; + + const fetchedParents = await this.driveFoldersRepository.find({ + where: { + id: In([...parentIds]), + }, + }); + + if (fetchedParents.length === 0) break; + + for (const parent of fetchedParents) { + folderMap.set(parent.id, parent); + } + } + }; + + const opts = Object.assign({ + detail: false, + }, options); + + const folderMap = await collectUniqueObjects(src); + + let foldersCountMap: Map | null = null; + let filesCountMap: Map | null = null; + if (opts.detail) { + await collectAncestors(folderMap); + + const ids = [...folderMap.keys()]; + if (ids.length > 0) { + const folderCounts = await this.driveFoldersRepository.createQueryBuilder('folder') + .select('folder.parentId', 'parentId') + .addSelect('COUNT(*)', 'count') + .where('folder.parentId IN (:...ids)', { ids }) + .groupBy('folder.parentId') + .getRawMany<{ parentId: string; count: string }>(); + + const fileCounts = await this.driveFilesRepository.createQueryBuilder('file') + .select('file.folderId', 'folderId') + .addSelect('COUNT(*)', 'count') + .where('file.folderId IN (:...ids)', { ids }) + .groupBy('file.folderId') + .getRawMany<{ folderId: string; count: string }>(); + + foldersCountMap = new Map(folderCounts.map(row => [row.parentId, Number(row.count)])); + filesCountMap = new Map(fileCounts.map(row => [row.folderId, Number(row.count)])); + } else { + foldersCountMap = new Map(); + filesCountMap = new Map(); + } + } + + const packedMap = new Map>>(); + const packFromId = (id: string): Promise> => { + const cached = packedMap.get(id); + if (cached) return cached; + + const folder = folderMap.get(id); + if (!folder) { + throw new Error(`DriveFolder not found: ${id}`); + } + + const packedPromise = this.pack(folder, options, { + folderMap, + foldersCountMap, + filesCountMap, + parentPacker: packFromId, + }); + packedMap.set(id, packedPromise); + + return packedPromise; + }; + + return Promise.all(src.map(s => packFromId(typeof s === 'string' ? s : s.id))); + } +} diff --git a/packages/backend/src/misc/split-id-and-objects.ts b/packages/backend/src/misc/split-id-and-objects.ts new file mode 100644 index 0000000000..d23bb93695 --- /dev/null +++ b/packages/backend/src/misc/split-id-and-objects.ts @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * idとオブジェクトを分離する + * @param input idまたはオブジェクトの配列 + * @returns idの配列とオブジェクトの配列 + */ +export function splitIdAndObjects(input: (T | string)[]): { ids: string[]; objects: T[] } { + const ids: string[] = []; + const objects : T[] = []; + + for (const item of input) { + if (typeof item === 'string') { + ids.push(item); + } else { + objects.push(item); + } + } + + return { + ids, + objects, + }; +} diff --git a/packages/backend/src/misc/unique-by-key.ts b/packages/backend/src/misc/unique-by-key.ts new file mode 100644 index 0000000000..4308e29d21 --- /dev/null +++ b/packages/backend/src/misc/unique-by-key.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * itemsの中でkey関数が返す値が重複しないようにした配列を返す + * @param items 重複を除去したい配列 + * @param key 重複判定に使うキーを返す関数 + * @returns 重複を除去した配列 + */ +export function uniqueByKey(items: Iterable, key: (item: TItem) => TKey): TItem[] { + const map = new Map(); + for (const item of items) { + const k = key(item); + if (!map.has(k)) { + map.set(k, item); + } + } + return [...map.values()]; +} diff --git a/packages/backend/test/unit/entities/DriveFileEntityService.ts b/packages/backend/test/unit/entities/DriveFileEntityService.ts new file mode 100644 index 0000000000..2e416326ee --- /dev/null +++ b/packages/backend/test/unit/entities/DriveFileEntityService.ts @@ -0,0 +1,227 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { afterAll, beforeAll, beforeEach, describe, expect, jest, test } from '@jest/globals'; +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import type { DriveFilesRepository, DriveFoldersRepository, UsersRepository } from '@/models/_.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { genAidx } from '@/misc/id/aidx.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; + +const describeBenchmark = process.env.RUN_BENCHMARKS === '1' ? describe : describe.skip; + +describe('DriveFileEntityService', () => { + let app: TestingModule; + let service: DriveFileEntityService; + let driveFolderEntityService: DriveFolderEntityService; + let driveFilesRepository: DriveFilesRepository; + let driveFoldersRepository: DriveFoldersRepository; + let usersRepository: UsersRepository; + let idCounter = 0; + + const userEntityServiceMock = { + packMany: jest.fn(async (users: Array) => { + return users.map(u => ({ + id: typeof u === 'string' ? u : u.id, + username: 'user', + })); + }), + pack: jest.fn(async (user: string | { id: string }) => { + return { + id: typeof user === 'string' ? user : user.id, + username: 'user', + }; + }), + }; + + const nextId = () => genAidx(Date.now() + (idCounter++)); + + const createUser = async () => { + const un = secureRndstr(16); + const id = nextId(); + await usersRepository.insert({ + id, + username: un, + usernameLower: un.toLowerCase(), + }); + return usersRepository.findOneByOrFail({ id }); + }; + + const createFolder = async (name: string, parentId: string | null) => { + const id = nextId(); + await driveFoldersRepository.insert({ + id, + name, + userId: null, + parentId, + }); + return driveFoldersRepository.findOneByOrFail({ id }); + }; + + const createFile = async (folderId: string | null, userId: string | null) => { + const id = nextId(); + await driveFilesRepository.insert({ + id, + userId, + userHost: null, + md5: secureRndstr(32), + name: `file-${id}`, + type: 'text/plain', + size: 1, + comment: null, + blurhash: null, + properties: {}, + storedInternal: true, + url: `https://example.com/${id}`, + thumbnailUrl: null, + webpublicUrl: null, + webpublicType: null, + accessKey: null, + thumbnailAccessKey: null, + webpublicAccessKey: null, + uri: null, + src: null, + folderId, + isSensitive: false, + maybeSensitive: false, + maybePorn: false, + isLink: false, + requestHeaders: null, + requestIp: null, + }); + return driveFilesRepository.findOneByOrFail({ id }); + }; + + beforeAll(async () => { + const moduleBuilder = Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + }); + moduleBuilder.overrideProvider(UserEntityService).useValue(userEntityServiceMock as any); + + app = await moduleBuilder.compile(); + await app.init(); + app.enableShutdownHooks(); + + service = app.get(DriveFileEntityService); + driveFolderEntityService = app.get(DriveFolderEntityService); + driveFilesRepository = app.get(DI.driveFilesRepository); + driveFoldersRepository = app.get(DI.driveFoldersRepository); + usersRepository = app.get(DI.usersRepository); + }); + + beforeEach(() => { + userEntityServiceMock.packMany.mockClear(); + userEntityServiceMock.pack.mockClear(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('pack', () => { + test('detail: false', async () => { + const user = await createUser(); + const folder = await createFolder('pack-root', null); + const file = await createFile(folder.id, user.id); + + const packed = await service.pack(file, { detail: false, self: true }) as any; + expect(packed.id).toBe(file.id); + expect(packed.folder).toBeNull(); + expect(packed.user).toBeNull(); + expect(packed.userId).toBeNull(); + }); + + test('detail: true', async () => { + const folder = await createFolder('pack-parent', null); + const child = await createFolder('pack-child', folder.id); + const file = await createFile(child.id, null); + + const packed = await service.pack(file, { detail: true, self: true }) as any; + expect(packed.folder?.id).toBe(child.id); + expect(packed.folder?.parent?.id).toBe(folder.id); + }); + }); + + describe('packNullable', () => { + test('returns null for missing', async () => { + const packed = await service.packNullable('non-existent' as any, { detail: false }); + expect(packed).toBeNull(); + }); + + test('uses packedUser hint when withUser', async () => { + const user = await createUser(); + const file = await createFile(null, user.id); + + const packed = await service.packNullable(file, { withUser: true, self: true }, { + packedUser: { id: user.id, username: 'hint' } as any, + }); + expect(packed?.user?.id).toBe(user.id); + expect(packed?.user?.username).toBe('hint'); + }); + }); + + describe('packMany', () => { + test('withUser: true uses deduped packMany', async () => { + const user = await createUser(); + const fileA = await createFile(null, user.id); + const fileB = await createFile(null, user.id); + + const packed = await service.packMany([fileA, fileB], { withUser: true, self: true }); + expect(packed.length).toBe(2); + expect(userEntityServiceMock.packMany).toHaveBeenCalledTimes(1); + expect(userEntityServiceMock.packMany.mock.calls[0]?.[0]?.length).toBe(1); + expect(packed[0]?.user?.id).toBe(user.id); + }); + + test('detail: true packs folder', async () => { + const folder = await createFolder('packmany-root', null); + const file = await createFile(folder.id, null); + + const packed = await service.packMany([file], { detail: true, self: true }); + expect(packed[0]?.folder?.id).toBe(folder.id); + expect(packed[0]?.folder?.parent).toBeUndefined(); + }); + + test('detail: true uses DriveFolderEntityService pack', async () => { + const folder = await createFolder('packmany-folder', null); + const file = await createFile(folder.id, null); + const packSpy = jest.spyOn(driveFolderEntityService, 'pack'); + + await service.packMany([file], { detail: true, self: true }); + expect(packSpy).toHaveBeenCalled(); + packSpy.mockRestore(); + }); + }); + + describeBenchmark('benchmark', () => { + test('packMany', async () => { + const user = await createUser(); + const folders = []; + for (let i = 0; i < 100; i++) { + folders.push(await createFolder(`bench-${i}`, null)); + } + const files = []; + for (const folder of folders) { + for (let j = 0; j < 20; j++) { + files.push(await createFile(folder.id, user.id)); + } + } + + const start = Date.now(); + await service.packMany(files, { detail: true, withUser: true, self: true }); + const elapsed = Date.now() - start; + + console.log(`DriveFileEntityService.packMany benchmark: ${elapsed}ms`); + }); + }); +}); diff --git a/packages/backend/test/unit/entities/DriveFolderEntityService.ts b/packages/backend/test/unit/entities/DriveFolderEntityService.ts new file mode 100644 index 0000000000..299ee5f42b --- /dev/null +++ b/packages/backend/test/unit/entities/DriveFolderEntityService.ts @@ -0,0 +1,171 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { afterAll, beforeAll, describe, expect, test } from '@jest/globals'; +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import type { DriveFilesRepository, DriveFoldersRepository } from '@/models/_.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { genAidx } from '@/misc/id/aidx.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; + +const describeBenchmark = process.env.RUN_BENCHMARKS === '1' ? describe : describe.skip; + +describe('DriveFolderEntityService', () => { + let app: TestingModule; + let service: DriveFolderEntityService; + let driveFoldersRepository: DriveFoldersRepository; + let driveFilesRepository: DriveFilesRepository; + let idCounter = 0; + + const nextId = () => genAidx(Date.now() + (idCounter++)); + + const createFolder = async (name: string, parentId: string | null) => { + const id = nextId(); + await driveFoldersRepository.insert({ + id, + name, + userId: null, + parentId, + }); + return driveFoldersRepository.findOneByOrFail({ id }); + }; + + const createFile = async (folderId: string | null) => { + const id = nextId(); + await driveFilesRepository.insert({ + id, + userId: null, + userHost: null, + md5: secureRndstr(32), + name: `file-${id}`, + type: 'text/plain', + size: 1, + comment: null, + blurhash: null, + properties: {}, + storedInternal: true, + url: `https://example.com/${id}`, + thumbnailUrl: null, + webpublicUrl: null, + webpublicType: null, + accessKey: null, + thumbnailAccessKey: null, + webpublicAccessKey: null, + uri: null, + src: null, + folderId, + isSensitive: false, + maybeSensitive: false, + maybePorn: false, + isLink: false, + requestHeaders: null, + requestIp: null, + }); + }; + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + }).compile(); + await app.init(); + app.enableShutdownHooks(); + + service = app.get(DriveFolderEntityService); + driveFoldersRepository = app.get(DI.driveFoldersRepository); + driveFilesRepository = app.get(DI.driveFilesRepository); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('pack', () => { + test('detail: false', async () => { + const root = await createFolder('root', null); + const child = await createFolder('child', root.id); + + const packed = await service.pack(child, { detail: false }) as any; + expect(packed.id).toBe(child.id); + expect(packed.parentId).toBe(root.id); + expect(packed.parent).toBeUndefined(); + expect(packed.foldersCount).toBeUndefined(); + expect(packed.filesCount).toBeUndefined(); + }); + + test('detail: true', async () => { + const root = await createFolder('root-detail', null); + const child = await createFolder('child-detail', root.id); + await createFolder('grandchild-detail', child.id); + await createFile(child.id); + await createFile(child.id); + + const packed = await service.pack(child, { detail: true }) as any; + expect(packed.id).toBe(child.id); + expect(packed.foldersCount).toBe(1); + expect(packed.filesCount).toBe(2); + expect(packed.parent?.id).toBe(root.id); + expect(packed.parent?.parent).toBeUndefined(); + }); + + test('detail: true reaches root for deep hierarchy', async () => { + const root = await createFolder('root-deep', null); + const level1 = await createFolder('level-1', root.id); + const level2 = await createFolder('level-2', level1.id); + const level3 = await createFolder('level-3', level2.id); + const level4 = await createFolder('level-4', level3.id); + const level5 = await createFolder('level-5', level4.id); + + const packed = await service.pack(level5, { detail: true }) as any; + expect(packed.id).toBe(level5.id); + expect(packed.parent?.id).toBe(level4.id); + expect(packed.parent?.parent?.id).toBe(level3.id); + expect(packed.parent?.parent?.parent?.id).toBe(level2.id); + expect(packed.parent?.parent?.parent?.parent?.id).toBe(level1.id); + expect(packed.parent?.parent?.parent?.parent?.parent?.id).toBe(root.id); + expect(packed.parent?.parent?.parent?.parent?.parent?.parent).toBeUndefined(); + }); + }); + + describe('packMany', () => { + test('preserves order and packs parents', async () => { + const root = await createFolder('root-many', null); + const childA = await createFolder('child-a', root.id); + const childB = await createFolder('child-b', root.id); + await createFolder('child-a-sub', childA.id); + await createFile(childA.id); + + const packed = await service.packMany([childB, childA], { detail: true }) as any; + expect(packed[0].id).toBe(childB.id); + expect(packed[1].id).toBe(childA.id); + expect(packed[0].parent?.id).toBe(root.id); + expect(packed[1].parent?.id).toBe(root.id); + expect(packed[0].filesCount).toBe(0); + expect(packed[1].filesCount).toBe(1); + expect(packed[0].foldersCount).toBe(0); + expect(packed[1].foldersCount).toBe(1); + }); + }); + + describeBenchmark('benchmark', () => { + test('packMany', async () => { + const root = await createFolder('bench-root', null); + const folders = []; + for (let i = 0; i < 200; i++) { + folders.push(await createFolder(`bench-${i}`, root.id)); + } + + const start = Date.now(); + await service.packMany(folders, { detail: true }); + const elapsed = Date.now() - start; + console.log(`DriveFolderEntityService.packMany benchmark: ${elapsed}ms`); + }); + }); +}); From 38b3eecc8c84ed761604a15c3488cfaff6c417a4 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Tue, 6 Jan 2026 19:23:59 +0900 Subject: [PATCH 02/49] migrate build scripts to esmodules (#17071) * chore: migrate build scripts to esmodules * chore: do not use export default in build script --- package.json | 6 +++--- scripts/build-assets.mjs | 2 +- scripts/{build-pre.js => build-pre.mjs} | 5 ++++- scripts/{clean-all.js => clean-all.mjs} | 6 ++++-- scripts/{clean.js => clean.mjs} | 4 +++- scripts/tarball.mjs | 2 +- 6 files changed, 16 insertions(+), 9 deletions(-) rename scripts/{build-pre.js => build-pre.mjs} (89%) rename scripts/{clean-all.js => clean-all.mjs} (95%) rename scripts/{clean.js => clean.mjs} (94%) diff --git a/package.json b/package.json index 2601c4fc29..af9fb966aa 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "private": true, "scripts": { "compile-config": "cd packages/backend && pnpm compile-config", - "build-pre": "node ./scripts/build-pre.js", + "build-pre": "node scripts/build-pre.mjs", "build-assets": "node ./scripts/build-assets.mjs", "build": "pnpm build-pre && pnpm -r build && pnpm build-assets", "build-storybook": "pnpm --filter frontend build-storybook", @@ -48,8 +48,8 @@ "jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage", "test": "pnpm -r test", "test-and-coverage": "pnpm -r test-and-coverage", - "clean": "node ./scripts/clean.js", - "clean-all": "node ./scripts/clean-all.js", + "clean": "node scripts/clean.mjs", + "clean-all": "node scripts/clean-all.mjs", "cleanall": "pnpm clean-all" }, "resolutions": { diff --git a/scripts/build-assets.mjs b/scripts/build-assets.mjs index 0cfce02fef..1086d5a25a 100644 --- a/scripts/build-assets.mjs +++ b/scripts/build-assets.mjs @@ -7,7 +7,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import * as yaml from 'js-yaml'; -import buildTarball from './tarball.mjs'; +import { buildTarball } from './tarball.mjs'; const configDir = fileURLToPath(new URL('../.config', import.meta.url)); const configPath = process.env.MISSKEY_CONFIG_YML diff --git a/scripts/build-pre.js b/scripts/build-pre.mjs similarity index 89% rename from scripts/build-pre.js rename to scripts/build-pre.mjs index a90d53c75d..23c2d08042 100644 --- a/scripts/build-pre.js +++ b/scripts/build-pre.mjs @@ -3,7 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -const fs = require('fs'); +import * as fs from 'node:fs'; + +const __dirname = import.meta.dirname; + const packageJsonPath = __dirname + '/../package.json' function build() { diff --git a/scripts/clean-all.js b/scripts/clean-all.mjs similarity index 95% rename from scripts/clean-all.js rename to scripts/clean-all.mjs index e669eb2885..424a9c405a 100644 --- a/scripts/clean-all.js +++ b/scripts/clean-all.mjs @@ -3,8 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -const { execSync } = require('child_process'); -const fs = require('fs'); +import { execSync } from 'note:child_process'; +import * as fs from 'note:fs'; + +const __dirname = import.meta.dirname; (async () => { fs.rmSync(__dirname + '/../packages/backend/built', { recursive: true, force: true }); diff --git a/scripts/clean.js b/scripts/clean.mjs similarity index 94% rename from scripts/clean.js rename to scripts/clean.mjs index c1dd5b99f5..3f632289a1 100644 --- a/scripts/clean.js +++ b/scripts/clean.mjs @@ -3,7 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -const fs = require('fs'); +import * as fs from 'note:fs'; + +const __dirname = import.meta.dirname; (async () => { fs.rmSync(__dirname + '/../packages/backend/built', { recursive: true, force: true }); diff --git a/scripts/tarball.mjs b/scripts/tarball.mjs index d1fe4de4f5..dc6ee07773 100644 --- a/scripts/tarball.mjs +++ b/scripts/tarball.mjs @@ -19,7 +19,7 @@ const ignore = [ // Exclude files you don't want to include in the tarball here ]; -export default async function build() { +export async function buildTarball() { const mkdirPromise = mkdir(resolve(cwd, 'built', 'tarball'), { recursive: true }); const pack = new Pack({ cwd, gzip: true }); const patterns = await walk({ path: cwd, ignoreFiles: ['.gitignore'] }); From 2d709ceeb44047b55389d9889dd149115be93b78 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Wed, 7 Jan 2026 20:40:14 +0900 Subject: [PATCH 03/49] fix: typo in import specifier (#17076) --- scripts/clean-all.mjs | 4 ++-- scripts/clean.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/clean-all.mjs b/scripts/clean-all.mjs index 424a9c405a..dc750af413 100644 --- a/scripts/clean-all.mjs +++ b/scripts/clean-all.mjs @@ -3,8 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { execSync } from 'note:child_process'; -import * as fs from 'note:fs'; +import { execSync } from 'node:child_process'; +import * as fs from 'node:fs'; const __dirname = import.meta.dirname; diff --git a/scripts/clean.mjs b/scripts/clean.mjs index 3f632289a1..faa6011ee9 100644 --- a/scripts/clean.mjs +++ b/scripts/clean.mjs @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import * as fs from 'note:fs'; +import * as fs from 'node:fs'; const __dirname = import.meta.dirname; From e18b92823f87a09ae52fa272a6e77d0f3d8a9ed7 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 7 Jan 2026 21:43:31 +0900 Subject: [PATCH 04/49] Update README.md --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 9a37ba86c0..e3261d13c2 100644 --- a/README.md +++ b/README.md @@ -51,3 +51,13 @@ Thanks to [Crowdin](https://crowdin.com/) for providing the localization platfor Docker Thanks to [Docker](https://hub.docker.com/) for providing the container platform that helps us run Misskey in production. + +--- + +
+ +Support us with a ⭐ ! + +[![Star History Chart](https://api.star-history.com/svg?repos=misskey-dev/misskey&type=Date)](https://star-history.com/#misskey-dev/misskey&Date) + +
From 8c5572dd3ba11104493d8386fe56cb6ff96cfc56 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Wed, 7 Jan 2026 21:46:03 +0900 Subject: [PATCH 05/49] enhance(frontend): remove vuedraggable (#17073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * Update page-editor.blocks.vue * Update MkDraggable.vue * refactor * refactor * ✌️ * refactor * Update MkDraggable.vue * ios * 🎨 * 🎨 --- packages/frontend/package.json | 1 - .../frontend/src/components/MkDraggable.vue | 310 ++++++++++++++++++ .../src/components/MkPostFormAttaches.vue | 29 +- .../frontend/src/components/MkWidgets.vue | 38 +-- packages/frontend/src/drag-and-drop.ts | 1 + .../src/pages/admin/RolesEditorFormula.vue | 35 +- .../frontend/src/pages/admin/server-rules.vue | 46 +-- .../frontend/src/pages/channel-editor.vue | 38 +-- .../pages/page-editor/page-editor.blocks.vue | 33 +- .../pages/settings/emoji-palette.palette.vue | 25 +- .../frontend/src/pages/settings/navbar.vue | 27 +- .../frontend/src/pages/settings/profile.vue | 32 +- packages/frontend/src/ui/_common_/widgets.vue | 2 +- pnpm-lock.yaml | 26 +- 14 files changed, 457 insertions(+), 186 deletions(-) create mode 100644 packages/frontend/src/components/MkDraggable.vue diff --git a/packages/frontend/package.json b/packages/frontend/package.json index f04906fd16..d5fdeed249 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -75,7 +75,6 @@ "v-code-diff": "1.13.1", "vite": "7.3.0", "vue": "3.5.26", - "vuedraggable": "next", "wanakana": "5.3.1" }, "devDependencies": { diff --git a/packages/frontend/src/components/MkDraggable.vue b/packages/frontend/src/components/MkDraggable.vue new file mode 100644 index 0000000000..7075306dd4 --- /dev/null +++ b/packages/frontend/src/components/MkDraggable.vue @@ -0,0 +1,310 @@ + + + + + + + + + diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index f429db94df..d198c98404 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -5,23 +5,30 @@ SPDX-License-Identifier: AGPL-3.0-only - - diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue index 43957a0673..e2210e858e 100644 --- a/packages/frontend/src/components/MkRadios.vue +++ b/packages/frontend/src/components/MkRadios.vue @@ -8,15 +8,27 @@ SPDX-License-Identifier: AGPL-3.0-only
+
- -
+ + + + +
@@ -26,8 +38,9 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
+
@@ -51,8 +64,6 @@ export type MkRadiosOption = { diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue index e06ea50453..0efd1a2e28 100644 --- a/packages/frontend/src/pages/admin/ads.vue +++ b/packages/frontend/src/pages/admin/ads.vue @@ -33,15 +33,6 @@ SPDX-License-Identifier: AGPL-3.0-only - - From bd81a6c8adb45067bee9582f84855a60a962e92b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:45:45 +0900 Subject: [PATCH 31/49] =?UTF-8?q?refactor(frontend):=20any=E3=82=92?= =?UTF-8?q?=E9=99=A4=E5=8E=BB2=20(#17092)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * fix types * fix --- packages/backend/src/misc/json-schema.ts | 2 + packages/backend/src/models/Meta.ts | 6 ++- .../backend/src/models/json-schema/meta.ts | 23 +++++++++- .../src/server/api/endpoints/admin/meta.ts | 3 +- .../server/api/endpoints/admin/update-meta.ts | 20 +++++++-- .../src/components/MkAchievements.vue | 6 +-- .../src/components/MkAutocomplete.vue | 17 +++---- .../src/components/MkExtensionInstaller.vue | 9 ++-- .../src/components/MkNotification.vue | 2 +- .../MkPushNotificationAllowButton.vue | 2 +- .../src/components/MkRetentionLineChart.vue | 17 ++++--- .../src/components/MkServerSetupWizard.vue | 2 +- .../src/components/MkWidgetSettingsDialog.vue | 5 ++- .../src/components/global/MkCondensedLine.vue | 2 +- packages/frontend/src/instance.ts | 6 --- .../frontend/src/pages/admin/branding.vue | 12 ++--- packages/frontend/src/pages/auth.form.vue | 8 +++- packages/frontend/src/pages/flash/flash.vue | 2 +- .../frontend/src/pages/settings/plugin.vue | 2 +- .../src/pages/settings/preferences.vue | 4 +- packages/frontend/src/plugin.ts | 6 +-- packages/frontend/src/pref-migrate.ts | 9 ++-- packages/frontend/src/store.ts | 6 +-- packages/frontend/src/utility/autocomplete.ts | 45 +++++++++++-------- .../frontend/src/widgets/WidgetAichan.vue | 2 +- .../frontend/src/widgets/WidgetTimeline.vue | 10 ++--- .../frontend/src/widgets/WidgetTrends.vue | 2 +- packages/frontend/src/widgets/index.ts | 2 + packages/frontend/src/widgets/widget.ts | 3 +- packages/misskey-js/etc/misskey-js.api.md | 4 ++ packages/misskey-js/src/autogen/models.ts | 1 + packages/misskey-js/src/autogen/types.ts | 17 +++++-- 32 files changed, 164 insertions(+), 93 deletions(-) diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 3fa49e3cd1..cf233defd9 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -64,6 +64,7 @@ import { packedMetaDetailedOnlySchema, packedMetaDetailedSchema, packedMetaLiteSchema, + packedMetaClientOptionsSchema, } from '@/models/json-schema/meta.js'; import { packedUserWebhookSchema } from '@/models/json-schema/user-webhook.js'; import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js'; @@ -135,6 +136,7 @@ export const refs = { MetaLite: packedMetaLiteSchema, MetaDetailedOnly: packedMetaDetailedOnlySchema, MetaDetailed: packedMetaDetailedSchema, + MetaClientOptions: packedMetaClientOptionsSchema, UserWebhook: packedUserWebhookSchema, SystemWebhook: packedSystemWebhookSchema, AbuseReportNotificationRecipient: packedAbuseReportNotificationRecipientSchema, diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index a6f68194c5..620853450c 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -725,7 +725,11 @@ export class MiMeta { @Column('jsonb', { default: { }, }) - public clientOptions: Record; + public clientOptions: { + entrancePageStyle: 'classic' | 'simple'; + showTimelineForVisitor: boolean; + showActivitiesForVisitor: boolean; + }; } export type SoftwareSuspension = { diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index a0e7d490b3..0c3ec141bc 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -72,8 +72,7 @@ export const packedMetaLiteSchema = { optional: false, nullable: true, }, clientOptions: { - type: 'object', - optional: false, nullable: false, + ref: 'MetaClientOptions', }, disableRegistration: { type: 'boolean', @@ -397,3 +396,23 @@ export const packedMetaDetailedSchema = { }, ], } as const; + +export const packedMetaClientOptionsSchema = { + type: 'object', + optional: false, nullable: false, + properties: { + entrancePageStyle: { + type: 'string', + enum: ['classic', 'simple'], + optional: false, nullable: false, + }, + showTimelineForVisitor: { + type: 'boolean', + optional: false, nullable: false, + }, + showActivitiesForVisitor: { + type: 'boolean', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 2c7f793584..5beed3a7e8 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -428,8 +428,7 @@ export const meta = { optional: false, nullable: true, }, clientOptions: { - type: 'object', - optional: false, nullable: false, + ref: 'MetaClientOptions', }, description: { type: 'string', 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 b3c2cecc67..7a8dfc4555 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Injectable, Inject } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; import type { MiMeta } from '@/models/Meta.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -67,7 +68,14 @@ export const paramDef = { description: { type: 'string', nullable: true }, defaultLightTheme: { type: 'string', nullable: true }, defaultDarkTheme: { type: 'string', nullable: true }, - clientOptions: { type: 'object', nullable: false }, + clientOptions: { + type: 'object', nullable: false, + properties: { + entrancePageStyle: { type: 'string', nullable: false, enum: ['classic', 'simple'] }, + showTimelineForVisitor: { type: 'boolean', nullable: false }, + showActivitiesForVisitor: { type: 'boolean', nullable: false }, + }, + }, cacheRemoteFiles: { type: 'boolean' }, cacheRemoteSensitiveFiles: { type: 'boolean' }, emailRequiredForSignup: { type: 'boolean' }, @@ -217,6 +225,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + private metaService: MetaService, private moderationLogService: ModerationLogService, ) { @@ -329,7 +340,10 @@ export default class extends Endpoint { // eslint- } if (ps.clientOptions !== undefined) { - set.clientOptions = ps.clientOptions; + set.clientOptions = { + ...serverSettings.clientOptions, + ...ps.clientOptions, + }; } if (ps.cacheRemoteFiles !== undefined) { diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue index d0e138c229..fe6415eabb 100644 --- a/packages/frontend/src/components/MkAchievements.vue +++ b/packages/frontend/src/components/MkAchievements.vue @@ -23,13 +23,13 @@ SPDX-License-Identifier: AGPL-3.0-only
- {{ (i18n.ts._achievements._types as any)['_' + achievement.name].title }} + {{ i18n.ts._achievements._types[`_${achievement.name}`].title }}
-
{{ withDescription ? (i18n.ts._achievements._types as any)['_' + achievement.name].description : '???' }}
-
{{ (i18n.ts._achievements._types as any)['_' + achievement.name].flavor }}
+
{{ withDescription ? i18n.ts._achievements._types[`_${achievement.name}`].description : '???' }}
+
{{ (i18n.ts._achievements._types[`_${achievement.name}`] as { flavor: string; }).flavor }}