Merge commit from fork

This commit is contained in:
おさむのひと 2025-12-06 18:25:20 +09:00 committed by GitHub
parent 2d0dae236f
commit dc77d59f87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 221 additions and 52 deletions

View File

@ -15,6 +15,7 @@ import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepos
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { DebounceLoader } from '@/misc/loader.js'; import { DebounceLoader } from '@/misc/loader.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js';
@ -116,12 +117,7 @@ export class NoteEntityService implements OnModuleInit {
private treatVisibility(packedNote: Packed<'Note'>): Packed<'Note'>['visibility'] { private treatVisibility(packedNote: Packed<'Note'>): Packed<'Note'>['visibility'] {
if (packedNote.visibility === 'public' || packedNote.visibility === 'home') { if (packedNote.visibility === 'public' || packedNote.visibility === 'home') {
const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore; const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore;
if ((followersOnlyBefore != null) if (shouldHideNoteByTime(followersOnlyBefore, packedNote.createdAt)) {
&& (
(followersOnlyBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (followersOnlyBefore * 1000)))
|| (followersOnlyBefore > 0 && (new Date(packedNote.createdAt).getTime() < followersOnlyBefore * 1000))
)
) {
packedNote.visibility = 'followers'; packedNote.visibility = 'followers';
} }
} }
@ -141,12 +137,7 @@ export class NoteEntityService implements OnModuleInit {
if (!hide) { if (!hide) {
const hiddenBefore = packedNote.user.makeNotesHiddenBefore; const hiddenBefore = packedNote.user.makeNotesHiddenBefore;
if ((hiddenBefore != null) if (shouldHideNoteByTime(hiddenBefore, packedNote.createdAt)) {
&& (
(hiddenBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (hiddenBefore * 1000)))
|| (hiddenBefore > 0 && (new Date(packedNote.createdAt).getTime() < hiddenBefore * 1000))
)
) {
hide = true; hide = true;
} }
} }

View File

@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
*
* @param hiddenBefore 負の値: 作成からの経過秒数正の値: UNIXタイムスタンプ秒null:
* @param createdAt ISO 8601 Date
* @returns true
*/
export function shouldHideNoteByTime(hiddenBefore: number | null | undefined, createdAt: string | Date): boolean {
if (hiddenBefore == null) {
return false;
}
const createdAtTime = typeof createdAt === 'string' ? new Date(createdAt).getTime() : createdAt.getTime();
if (hiddenBefore <= 0) {
// 負の値: 作成からの経過時間(秒)で判定
const elapsedSeconds = (Date.now() - createdAtTime) / 1000;
const hideAfterSeconds = Math.abs(hiddenBefore);
return elapsedSeconds > hideAfterSeconds;
} else {
// 正の値: 絶対的なタイムスタンプ(秒)で判定
const createdAtSeconds = createdAtTime / 1000;
return createdAtSeconds < hiddenBefore;
}
}

View File

@ -5,21 +5,20 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { Writable } from 'node:stream'; import { Writable } from 'node:stream';
import { Inject, Injectable, StreamableFile } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { MoreThan } from 'typeorm';
import { format as dateFormat } from 'date-fns'; import { format as dateFormat } from 'date-fns';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, NotesRepository, PollsRepository, UsersRepository } from '@/models/_.js'; import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, PollsRepository, UsersRepository } from '@/models/_.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js'; import { DriveService } from '@/core/DriveService.js';
import { createTemp } from '@/misc/create-temp.js'; import { createTemp } from '@/misc/create-temp.js';
import type { MiPoll } from '@/models/Poll.js'; import type { MiPoll } from '@/models/Poll.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.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 { IdService } from '@/core/IdService.js';
import { NotificationService } from '@/core/NotificationService.js'; import { NotificationService } from '@/core/NotificationService.js';
import { QueryService } from '@/core/QueryService.js';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
@ -43,6 +42,7 @@ export class ExportClipsProcessorService {
private driveService: DriveService, private driveService: DriveService,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
private queryService: QueryService,
private idService: IdService, private idService: IdService,
private notificationService: NotificationService, private notificationService: NotificationService,
) { ) {
@ -100,16 +100,16 @@ export class ExportClipsProcessorService {
}); });
while (true) { while (true) {
const clips = await this.clipsRepository.find({ const query = this.clipsRepository.createQueryBuilder('clip')
where: { .where('clip.userId = :userId', { userId: user.id })
userId: user.id, .orderBy('clip.id', 'ASC')
...(cursor ? { id: MoreThan(cursor) } : {}), .take(100);
},
take: 100, if (cursor) {
order: { query.andWhere('clip.id > :cursor', { cursor });
id: 1, }
},
}); const clips = await query.getMany();
if (clips.length === 0) { if (clips.length === 0) {
job.updateProgress(100); job.updateProgress(100);
@ -124,7 +124,7 @@ export class ExportClipsProcessorService {
const isFirst = exportedClipsCount === 0; const isFirst = exportedClipsCount === 0;
await writer.write(isFirst ? content : ',\n' + content); await writer.write(isFirst ? content : ',\n' + content);
await this.processClipNotes(writer, clip.id); await this.processClipNotes(writer, clip.id, user.id);
await writer.write(']}'); await writer.write(']}');
exportedClipsCount++; exportedClipsCount++;
@ -134,22 +134,25 @@ export class ExportClipsProcessorService {
} }
} }
async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string): Promise<void> { async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string, userId: string): Promise<void> {
let exportedClipNotesCount = 0; let exportedClipNotesCount = 0;
let cursor: MiClipNote['id'] | null = null; let cursor: MiClipNote['id'] | null = null;
while (true) { while (true) {
const clipNotes = await this.clipNotesRepository.find({ const query = this.clipNotesRepository.createQueryBuilder('clipNote')
where: { .leftJoinAndSelect('clipNote.note', 'note')
clipId, .leftJoinAndSelect('note.user', 'user')
...(cursor ? { id: MoreThan(cursor) } : {}), .where('clipNote.clipId = :clipId', { clipId })
}, .orderBy('clipNote.id', 'ASC')
take: 100, .take(100);
order: {
id: 1, if (cursor) {
}, query.andWhere('clipNote.id > :cursor', { cursor });
relations: ['note', 'note.user'], }
}) as (MiClipNote & { note: MiNote & { user: MiUser } })[];
this.queryService.generateVisibilityQuery(query, { id: userId });
const clipNotes = await query.getMany() as (MiClipNote & { note: MiNote & { user: MiUser } })[];
if (clipNotes.length === 0) { if (clipNotes.length === 0) {
break; break;
@ -158,6 +161,11 @@ export class ExportClipsProcessorService {
cursor = clipNotes.at(-1)?.id ?? null; cursor = clipNotes.at(-1)?.id ?? null;
for (const clipNote of clipNotes) { for (const clipNote of clipNotes) {
const noteCreatedAt = this.idService.parse(clipNote.note.id).date;
if (shouldHideNoteByTime(clipNote.note.user.makeNotesHiddenBefore, noteCreatedAt)) {
continue;
}
let poll: MiPoll | undefined; let poll: MiPoll | undefined;
if (clipNote.note.hasPoll) { if (clipNote.note.hasPoll) {
poll = await this.pollsRepository.findOneByOrFail({ noteId: clipNote.note.id }); poll = await this.pollsRepository.findOneByOrFail({ noteId: clipNote.note.id });

View File

@ -5,7 +5,6 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { MoreThan } from 'typeorm';
import { format as dateFormat } from 'date-fns'; import { format as dateFormat } from 'date-fns';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { MiNoteFavorite, NoteFavoritesRepository, PollsRepository, MiUser, UsersRepository } from '@/models/_.js'; import type { MiNoteFavorite, NoteFavoritesRepository, PollsRepository, MiUser, UsersRepository } from '@/models/_.js';
@ -17,6 +16,8 @@ import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { NotificationService } from '@/core/NotificationService.js'; import { NotificationService } from '@/core/NotificationService.js';
import { QueryService } from '@/core/QueryService.js';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { QueueLoggerService } from '../QueueLoggerService.js'; import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js'; import type { DbJobDataWithUser } from '../types.js';
@ -37,6 +38,7 @@ export class ExportFavoritesProcessorService {
private driveService: DriveService, private driveService: DriveService,
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
private queryService: QueryService,
private idService: IdService, private idService: IdService,
private notificationService: NotificationService, private notificationService: NotificationService,
) { ) {
@ -83,17 +85,20 @@ export class ExportFavoritesProcessorService {
}); });
while (true) { while (true) {
const favorites = await this.noteFavoritesRepository.find({ const query = this.noteFavoritesRepository.createQueryBuilder('favorite')
where: { .leftJoinAndSelect('favorite.note', 'note')
userId: user.id, .leftJoinAndSelect('note.user', 'user')
...(cursor ? { id: MoreThan(cursor) } : {}), .where('favorite.userId = :userId', { userId: user.id })
}, .orderBy('favorite.id', 'ASC')
take: 100, .take(100);
order: {
id: 1, if (cursor) {
}, query.andWhere('favorite.id > :cursor', { cursor });
relations: ['note', 'note.user'], }
}) as (MiNoteFavorite & { note: MiNote & { user: MiUser } })[];
this.queryService.generateVisibilityQuery(query, { id: user.id });
const favorites = await query.getMany() as (MiNoteFavorite & { note: MiNote & { user: MiUser } })[];
if (favorites.length === 0) { if (favorites.length === 0) {
job.updateProgress(100); job.updateProgress(100);
@ -103,6 +108,11 @@ export class ExportFavoritesProcessorService {
cursor = favorites.at(-1)?.id ?? null; cursor = favorites.at(-1)?.id ?? null;
for (const favorite of favorites) { for (const favorite of favorites) {
const noteCreatedAt = this.idService.parse(favorite.note.id).date;
if (shouldHideNoteByTime(favorite.note.user.makeNotesHiddenBefore, noteCreatedAt)) {
continue;
}
let poll: MiPoll | undefined; let poll: MiPoll | undefined;
if (favorite.note.hasPoll) { if (favorite.note.hasPoll) {
poll = await this.pollsRepository.findOneByOrFail({ noteId: favorite.note.id }); poll = await this.pollsRepository.findOneByOrFail({ noteId: favorite.note.id });

View File

@ -0,0 +1,131 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, beforeEach, afterEach, jest } from '@jest/globals';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
describe('misc:should-hide-note-by-time', () => {
let now: number;
beforeEach(() => {
now = Date.now();
jest.useFakeTimers();
jest.setSystemTime(now);
});
afterEach(() => {
jest.useRealTimers();
});
describe('hiddenBefore が null または undefined の場合', () => {
test('hiddenBefore が null のときは false を返す(非表示機能が有効でない)', () => {
const createdAt = new Date(now - 86400000); // 1 day ago
expect(shouldHideNoteByTime(null, createdAt)).toBe(false);
});
test('hiddenBefore が undefined のときは false を返す(非表示機能が有効でない)', () => {
const createdAt = new Date(now - 86400000); // 1 day ago
expect(shouldHideNoteByTime(undefined, createdAt)).toBe(false);
});
});
describe('相対時間モード (hiddenBefore <= 0)', () => {
test('閾値内に作成されたノートは false を返す(作成からの経過時間がまだ短い→表示)', () => {
const hiddenBefore = -86400; // 1 day in seconds
const createdAt = new Date(now - 3600000); // 1 hour ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(false);
});
test('閾値を超えて作成されたノートは true を返す(指定期間以上経過している→非表示)', () => {
const hiddenBefore = -86400; // 1 day in seconds
const createdAt = new Date(now - 172800000); // 2 days ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true);
});
test('ちょうど閾値で作成されたノートは true を返す(閾値に達したら非表示)', () => {
const hiddenBefore = -86400; // 1 day in seconds
const createdAt = new Date(now - 86400000); // exactly 1 day ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true);
});
test('異なる相対時間値で判定できる1時間設定と3時間設定の異なる結果', () => {
const createdAt = new Date(now - 7200000); // 2 hours ago
expect(shouldHideNoteByTime(-3600, createdAt)).toBe(true); // 1時間経過→非表示
expect(shouldHideNoteByTime(-10800, createdAt)).toBe(false); // 3時間未経過→表示
});
test('ISO 8601 形式の文字列の createdAt に対応できる(文字列でも正しく判定)', () => {
const createdAtString = new Date(now - 86400000).toISOString();
const hiddenBefore = -86400; // 1 day in seconds
expect(shouldHideNoteByTime(hiddenBefore, createdAtString)).toBe(true);
});
test('hiddenBefore が 0 の場合に対応できる0秒以上経過で非表示→ほぼ全て非表示', () => {
const hiddenBefore = 0;
const createdAt = new Date(now - 1); // 1ms ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true);
});
});
describe('絶対時間モード (hiddenBefore > 0)', () => {
test('閾値タイムスタンプより後に作成されたノートは false を返す(指定日時より後→表示)', () => {
const thresholdSeconds = Math.floor(now / 1000);
const createdAt = new Date(now + 3600000); // 1 hour from now
expect(shouldHideNoteByTime(thresholdSeconds, createdAt)).toBe(false);
});
test('閾値タイムスタンプより前に作成されたノートは true を返す(指定日時より前→非表示)', () => {
const thresholdSeconds = Math.floor(now / 1000);
const createdAt = new Date(now - 3600000); // 1 hour ago
expect(shouldHideNoteByTime(thresholdSeconds, createdAt)).toBe(true);
});
test('ちょうど閾値タイムスタンプで作成されたノートは true を返す(指定日時に達したら非表示)', () => {
const thresholdSeconds = Math.floor(now / 1000);
const createdAt = new Date(now); // exactly now
expect(shouldHideNoteByTime(thresholdSeconds, createdAt)).toBe(true);
});
test('ISO 8601 形式の文字列の createdAt に対応できる(文字列でも正しく判定)', () => {
const thresholdSeconds = Math.floor(now / 1000);
const createdAtString = new Date(now - 3600000).toISOString();
expect(shouldHideNoteByTime(thresholdSeconds, createdAtString)).toBe(true);
});
test('異なる閾値タイムスタンプで判定できる2021年設定と現在より1時間前設定の異なる結果', () => {
const thresholdSeconds = Math.floor((now - 86400000) / 1000); // 1 day ago
const createdAtBefore = new Date(now - 172800000); // 2 days ago
const createdAtAfter = new Date(now - 3600000); // 1 hour ago
expect(shouldHideNoteByTime(thresholdSeconds, createdAtBefore)).toBe(true); // 閾値より前→非表示
expect(shouldHideNoteByTime(thresholdSeconds, createdAtAfter)).toBe(false); // 閾値より後→表示
});
});
describe('エッジケース', () => {
test('相対時間モードで非常に古いノートに対応できる(非常に古い→閾値超→非表示)', () => {
const hiddenBefore = -1; // hide notes older than 1 second
const createdAt = new Date(now - 1000000); // very old
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true);
});
test('相対時間モードで非常に新しいノートに対応できる(非常に新しい→閾値未満→表示)', () => {
const hiddenBefore = -86400; // 1 day
const createdAt = new Date(now - 1); // 1ms ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(false);
});
test('大きなタイムスタンプ値に対応できる(未来の日時を指定→現在のノートは全て非表示)', () => {
const thresholdSeconds = Math.floor(now / 1000) + 86400; // 1 day from now
const createdAt = new Date(now); // created now
expect(shouldHideNoteByTime(thresholdSeconds, createdAt)).toBe(true);
});
test('小さな相対時間値に対応できる1秒設定で2秒前→非表示', () => {
const hiddenBefore = -1; // 1 second
const createdAt = new Date(now - 2000); // 2 seconds ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true);
});
});
});