From 863046ba8c528c3b7286bbcb6dbfc66d16eaa5e3 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: Sat, 30 May 2026 19:05:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8A=95=E7=A8=BF=E6=97=A5=E6=99=82?= =?UTF-8?q?=E3=81=AE=E7=AF=84=E5=9B=B2=E3=82=92=E6=9D=A1=E4=BB=B6=E3=81=AB?= =?UTF-8?q?=E5=8A=A0=E3=81=88=E3=81=A6=E3=83=8E=E3=83=BC=E3=83=88=E6=A4=9C?= =?UTF-8?q?=E7=B4=A2=E5=87=BA=E6=9D=A5=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= =?UTF-8?q?=E3=81=99=E3=82=8B=20(#16119)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 投稿日時の範囲を条件に加えてノート検索出来るようにする * simplify * fix ui * fix CHANGELOG.md * fix * fix * add test --------- Co-authored-by: Sayamame-beans <61457993+Sayamame-beans@users.noreply.github.com> --- CHANGELOG.md | 1 + locales/ja-JP.yml | 2 + packages/backend/src/core/SearchService.ts | 22 +++ .../src/server/api/endpoints/notes/search.ts | 4 + packages/backend/test/unit/SearchService.ts | 125 ++++++++++++++++++ packages/frontend/src/pages/search.note.vue | 24 ++++ packages/i18n/src/autogen/locale.ts | 8 ++ packages/misskey-js/src/autogen/types.ts | 2 + 8 files changed, 188 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index db5ed32f2b..bf24d010f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### General - Feat: ジョブキュー管理画面からキューの一時停止/再開ができるように - Feat: アンテナのタイムラインから個別のノートを削除できるように +- Feat: ノート検索で投稿日時の期間を条件に加えられるように(#16035) ### Client - Fix: URLプレビューのプレイヤーをウィンドウで開いたとき、プレイヤーが読み込まれるまでの間 `Invalid URL` と表示される問題を修正 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index e30c30af98..45d933f2ab 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -3358,6 +3358,8 @@ _search: pleaseEnterServerHost: "サーバーのホストを入力してください" pleaseSelectUser: "ユーザーを選択してください" serverHostPlaceholder: "例: misskey.example.com" + postFrom: "投稿日時from" + postTo: "投稿日時to" _serverSetupWizard: installCompleted: "Misskeyのインストールが完了しました!" diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index 91cc90db34..79e81dbe4b 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -38,6 +38,8 @@ export type SearchOpts = { userId?: MiNote['userId'] | null; channelId?: MiNote['channelId'] | null; host?: string | null; + rangeStartAt?: number | null; + rangeEndAt?: number | null; }; export type SearchPagination = { @@ -232,6 +234,16 @@ export class SearchService { } } + if (opts.rangeStartAt != null) { + const date = this.idService.gen(opts.rangeStartAt - 1); + query.andWhere('note.id > :rangeStartAt', { rangeStartAt: date }); + } + + if (opts.rangeEndAt != null) { + const date = this.idService.gen(opts.rangeEndAt + 1); + query.andWhere('note.id < :rangeEndAt', { rangeEndAt: date }); + } + this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBaseNoteFilteringQuery(query, me); @@ -258,11 +270,21 @@ export class SearchService { k: 'createdAt', v: this.idService.parse(pagination.untilId).date.getTime(), }); + if (opts.rangeEndAt) filter.qs.push({ + op: '<=', + k: 'createdAt', + v: opts.rangeEndAt, + }); if (pagination.sinceId) filter.qs.push({ op: '>', k: 'createdAt', v: this.idService.parse(pagination.sinceId).date.getTime(), }); + if (opts.rangeStartAt) filter.qs.push({ + op: '>=', + k: 'createdAt', + v: opts.rangeStartAt, + }); if (opts.userId) filter.qs.push({ op: '=', k: 'userId', v: opts.userId }); if (opts.channelId) filter.qs.push({ op: '=', k: 'channelId', v: opts.channelId }); if (opts.host) { diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts index ab1bd934fb..0c923293d9 100644 --- a/packages/backend/src/server/api/endpoints/notes/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -39,6 +39,8 @@ export const paramDef = { type: 'object', properties: { query: { type: 'string' }, + rangeStartAt: { type: 'integer', nullable: true }, + rangeEndAt: { type: 'integer', nullable: true }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, sinceDate: { type: 'integer' }, @@ -78,6 +80,8 @@ export default class extends Endpoint { // eslint- userId: ps.userId, channelId: ps.channelId, host: ps.host, + rangeStartAt: ps.rangeStartAt, + rangeEndAt: ps.rangeEndAt, }, { untilId: untilId, sinceId: sinceId, diff --git a/packages/backend/test/unit/SearchService.ts b/packages/backend/test/unit/SearchService.ts index 867d1a424f..6cb3ce83e8 100644 --- a/packages/backend/test/unit/SearchService.ts +++ b/packages/backend/test/unit/SearchService.ts @@ -284,6 +284,131 @@ describe('SearchService', () => { expect(remoteResult.map(note => note.id)).toEqual([remoteNote.id]); }); + describe('date range', () => { + test('filters notes after rangeStartAt', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + + const t1 = Date.now() - 3000; + const t2 = Date.now() - 2000; + const t3 = Date.now() - 1000; + + await createNote(ctx, author, { text: 'hello old' }, t1); + const note2 = await createNote(ctx, author, { text: 'hello middle' }, t2); + const note3 = await createNote(ctx, author, { text: 'hello new' }, t3); + + const result = await ctx.service.searchNote('hello', me, { rangeStartAt: t2 }, { limit: 10 }); + expect(result.map(note => note.id)).toEqual([note3.id, note2.id]); + }); + + test('filters notes before rangeEndAt', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + + const t1 = Date.now() - 3000; + const t2 = Date.now() - 2000; + const t3 = Date.now() - 1000; + + const note1 = await createNote(ctx, author, { text: 'hello old' }, t1); + const note2 = await createNote(ctx, author, { text: 'hello middle' }, t2); + await createNote(ctx, author, { text: 'hello new' }, t3); + + const result = await ctx.service.searchNote('hello', me, { rangeEndAt: t2 }, { limit: 10 }); + expect(result.map(note => note.id)).toEqual([note2.id, note1.id]); + }); + + test('filters notes between rangeStartAt and rangeEndAt', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + + const t1 = Date.now() - 4000; + const t2 = Date.now() - 3000; + const t3 = Date.now() - 2000; + const t4 = Date.now() - 1000; + + await createNote(ctx, author, { text: 'hello old' }, t1); + const note2 = await createNote(ctx, author, { text: 'hello middle' }, t2); + const note3 = await createNote(ctx, author, { text: 'hello newer' }, t3); + await createNote(ctx, author, { text: 'hello new' }, t4); + + const result = await ctx.service.searchNote('hello', me, { rangeStartAt: t2, rangeEndAt: t3 }, { limit: 10 }); + expect(result.map(note => note.id)).toEqual([note3.id, note2.id]); + }); + + test('keeps pagination within date range when sinceId and untilId are outside range', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + + const t1 = Date.now() - 8000; + const t2 = Date.now() - 7000; + const t3 = Date.now() - 6000; + const t4 = Date.now() - 5000; + const t5 = Date.now() - 4000; + const t6 = Date.now() - 3000; + const t7 = Date.now() - 2000; + const t8 = Date.now() - 1000; + + await createNote(ctx, author, { text: 'hello outside oldest' }, t1); + const sinceCursor = await createNote(ctx, author, { text: 'hello since cursor before range' }, t2); + const beforeRange = await createNote(ctx, author, { text: 'hello before range but after since' }, t3); + const note4 = await createNote(ctx, author, { text: 'hello range old' }, t4); + const note5 = await createNote(ctx, author, { text: 'hello range middle' }, t5); + const note6 = await createNote(ctx, author, { text: 'hello range new' }, t6); + const afterRange = await createNote(ctx, author, { text: 'hello after range but before until' }, t7); + const untilCursor = await createNote(ctx, author, { text: 'hello until cursor after range' }, t8); + + const result = await ctx.service.searchNote( + 'hello', + me, + { rangeStartAt: t4, rangeEndAt: t6 }, + { limit: 10, sinceId: sinceCursor.id, untilId: untilCursor.id }, + ); + const resultIds = result.map(note => note.id); + expect(resultIds).toEqual([note6.id, note5.id, note4.id]); + expect(resultIds).not.toContain(beforeRange.id); + expect(resultIds).not.toContain(afterRange.id); + }); + + test('uses sinceId and untilId as pagination boundaries inside date range', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + + const t1 = Date.now() - 8000; + const t2 = Date.now() - 7000; + const t3 = Date.now() - 6000; + const t4 = Date.now() - 5000; + const t5 = Date.now() - 4000; + const t6 = Date.now() - 3000; + const t7 = Date.now() - 2000; + const t8 = Date.now() - 1000; + + await createNote(ctx, author, { text: 'hello before range' }, t1); + const rangeOldest = await createNote(ctx, author, { text: 'hello range oldest' }, t2); + const sinceCursor = await createNote(ctx, author, { text: 'hello since cursor in range' }, t3); + const note4 = await createNote(ctx, author, { text: 'hello page old' }, t4); + const note5 = await createNote(ctx, author, { text: 'hello page new' }, t5); + const untilCursor = await createNote(ctx, author, { text: 'hello until cursor in range' }, t6); + const rangeNewest = await createNote(ctx, author, { text: 'hello range newest' }, t7); + await createNote(ctx, author, { text: 'hello after range' }, t8); + + const result = await ctx.service.searchNote( + 'hello', + me, + { rangeStartAt: t2, rangeEndAt: t7 }, + { limit: 10, sinceId: sinceCursor.id, untilId: untilCursor.id }, + ); + const resultIds = result.map(note => note.id); + expect(resultIds).toEqual([note5.id, note4.id]); + expect(resultIds).not.toContain(rangeOldest.id); + expect(resultIds).not.toContain(rangeNewest.id); + }); + }); + describe('muting and blocking', () => { test('filters out muted users', async () => { const ctx = getCtx(); diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue index ab36f2e6c5..c1b96f89b9 100644 --- a/packages/frontend/src/pages/search.note.vue +++ b/packages/frontend/src/pages/search.note.vue @@ -19,6 +19,15 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+ + + + + + +
+ | null>(null); const searchQuery = ref(toRef(props, 'query').value); const hostInput = ref(toRef(props, 'host').value); +const rangeStartAt = ref(null); +const rangeEndAt = ref(null); const user = shallowRef(null); @@ -205,6 +216,8 @@ type SearchParams = { readonly query: string; readonly host?: string; readonly userId?: string; + readonly rangeStartAt?: number | null; + readonly rangeEndAt?: number | null; }; const fixHostIfLocal = (target: string | null | undefined) => { @@ -212,6 +225,13 @@ const fixHostIfLocal = (target: string | null | undefined) => { return target; }; +const searchRange = () => { + return { + rangeStartAt: rangeStartAt.value ? new Date(rangeStartAt.value).getTime() : null, + rangeEndAt: rangeEndAt.value ? new Date(rangeEndAt.value).getTime() : null, + }; +}; + const searchParams = computed(() => { const trimmedQuery = searchQuery.value.trim(); if (!trimmedQuery) return null; @@ -222,6 +242,7 @@ const searchParams = computed(() => { query: trimmedQuery, host: fixHostIfLocal(user.value.host), userId: user.value.id, + ...searchRange(), }; } @@ -236,6 +257,7 @@ const searchParams = computed(() => { return { query: trimmedQuery, host: fixHostIfLocal(trimmedHost), + ...searchRange(), }; } @@ -243,11 +265,13 @@ const searchParams = computed(() => { return { query: trimmedQuery, host: '.', + ...searchRange(), }; } return { query: trimmedQuery, + ...searchRange(), }; }); diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 3badd68629..a9738c8208 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -12553,6 +12553,14 @@ export interface Locale extends ILocale { * 例: misskey.example.com */ "serverHostPlaceholder": string; + /** + * 投稿日時from + */ + "postFrom": string; + /** + * 投稿日時to + */ + "postTo": string; }; "_serverSetupWizard": { /** diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index dbdc29233d..52ea1b8366 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -31088,6 +31088,8 @@ export interface operations { content: { 'application/json': { query: string; + rangeStartAt?: number | null; + rangeEndAt?: number | null; /** Format: misskey:id */ sinceId?: string; /** Format: misskey:id */