From 084c553428da4fbffa49ebde516b144ee1d87d59 Mon Sep 17 00:00:00 2001 From: Esurio Date: Sun, 14 Jul 2024 12:45:02 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20excludeNsfw=E3=81=A7=E3=82=BB?= =?UTF-8?q?=E3=83=B3=E3=82=B7=E3=83=86=E3=82=A3=E3=83=96=E3=81=AA=E3=83=A1?= =?UTF-8?q?=E3=83=87=E3=82=A3=E3=82=A2=E3=82=92=E9=99=A4=E5=A4=96=E3=81=99?= =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/core/AdvancedSearchService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend/src/core/AdvancedSearchService.ts b/packages/backend/src/core/AdvancedSearchService.ts index 0595301568..511ac1a13f 100644 --- a/packages/backend/src/core/AdvancedSearchService.ts +++ b/packages/backend/src/core/AdvancedSearchService.ts @@ -358,6 +358,7 @@ export class AdvancedSearchService { if (opts.excludeNsfw) { query.andWhere('note.cw IS NULL'); + query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); } if (opts.excludeReply) { From df9c48eeb2a83a8c91dc1168823f3f08c926986b Mon Sep 17 00:00:00 2001 From: pen <121443048+penginn-net@users.noreply.github.com> Date: Sun, 14 Jul 2024 03:25:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?Enhance:=E9=AB=98=E5=BA=A6=E3=81=AA?= =?UTF-8?q?=E6=A4=9C=E7=B4=A2=E3=81=AE=E6=A9=9F=E8=83=BD=E5=BC=B7=E5=8C=96?= =?UTF-8?q?(OpenSearch)=20(#175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: kozakura913 <98575220+kozakura913@users.noreply.github.com> --- .config/docker_example.yml | 13 ++ .config/example.yml | 10 +- CHANGELOG_yojo.md | 40 ++++- doc/Advanced-Search.md | 51 ++++++ docker-compose.local-db.yml | 35 ++++ docker-compose_example.yml | 36 ++++ locales/index.d.ts | 28 ++- locales/ja-JP.yml | 9 +- opensearch-dashboards/dockerfile | 3 + .../opensearch-dashboards.yml | 3 + opensearch/dockerfile | 14 ++ .../backend/src/core/AdvancedSearchService.ts | 170 ++++++++++-------- packages/backend/src/core/DriveService.ts | 12 ++ .../api/endpoints/notes/advanced-search.ts | 31 ++-- .../src/autogen/apiClientJSDoc.ts | 2 +- packages/cherrypick-js/src/autogen/types.ts | 27 +-- packages/frontend/src/pages/search.note.vue | 26 ++- 17 files changed, 401 insertions(+), 109 deletions(-) create mode 100644 doc/Advanced-Search.md create mode 100644 opensearch-dashboards/dockerfile create mode 100644 opensearch-dashboards/opensearch-dashboards.yml create mode 100644 opensearch/dockerfile diff --git a/.config/docker_example.yml b/.config/docker_example.yml index ca5b8af9ea..ba6a723a4c 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -117,6 +117,19 @@ redis: # index: '' # scope: local +# ┌───────────────────────────┐ +#───┘ OpenSearch configuration └───────────────────────────── + +# You can use OpenSearch when you enabled AdvancedSearch. + +#opensearch: +# host: opensearch +# port: 9200 +# user: 'admin' +# pass: 'opensearch-adminpassword' #強めのパスワードじゃないと怒られる +# ssl: false +# index: 'instancename' #なんでもいい + # ┌───────────────┐ #───┘ ID generation └─────────────────────────────────────────── diff --git a/.config/example.yml b/.config/example.yml index 403ef33bd2..5c3b80dd9f 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -191,19 +191,17 @@ redis: # ┌───────────────────────────┐ -#───┘ OpenSearch configuration └───────────────────────────── +#───┘ OpenSearch configuration └───────────────────────────── # You can use OpenSearch when you enabled AdvancedSearch. -# THIS DOES NOT WORK BECAUSE OF WIP #opensearch: # host: localhost # port: 9200 -# user: '' -# pass: '' +# user: 'admin' +# pass: 'opensearch-adminpassword' #強めのパスワードじゃないと怒られる # ssl: false -# index: '' -# dictName: '' +# index: 'instancename' #なんでもいい # ┌───────────────┐ #───┘ ID generation └─────────────────────────────────────────── diff --git a/CHANGELOG_yojo.md b/CHANGELOG_yojo.md index 5b59559b98..e6ceb0c121 100644 --- a/CHANGELOG_yojo.md +++ b/CHANGELOG_yojo.md @@ -15,7 +15,45 @@ ### Misc --> -## 0.2.0 (unreleased) +## 0.3.0 (unreleased) + +### Release Date + +### General +- + +### Client +- + +### Server +- Feat: OpenSearchを利用できるように +- Enhance: 高度な検索に新たな条件を追加(OpenSearchが必要です) + - 添付ファイルのセンシティブ条件(なし/含む/除外) + - 引用ノート除外 + - 検索方法の詳細はdoc/Advanced-Search.mdに +- Change:APIのパラメータを変更 + - notes/advanced-search の"excludeNsfw"を"excludeCW"に変更 + - notes/advanced-search の"channelId"を削除 + +## 0.2.2 +Cherrypick 4.9.0-beta.2 + +### General + +### Client + +### Server +- remove: チャンネル機能のAPIを削除 + +## 0.2.1 +Cherrypick 4.9.0-beta.2 + +### Client +- feat: マスコット画像を表示するウィジェットを追加 + +## 0.2.0 +Cherrypick 4.9.0-beta.2 + ### General - enhance: ノートとユーザーの検索時に照会を行うかが選択できるようになりました - @foo@example.com 形式でユーザ検索した場合に照会ができるようになりました diff --git a/doc/Advanced-Search.md b/doc/Advanced-Search.md new file mode 100644 index 0000000000..f9345cba01 --- /dev/null +++ b/doc/Advanced-Search.md @@ -0,0 +1,51 @@ + +### 高度な検索の使い方 + +
OR検索 + +猫かにゃんを含むノートを検索 +``` +猫|にゃん +``` +|(パイプ)をキーワードの間に入れます +
+ +
AND検索 + +猫とにゃんを含むノートを検索 +``` +猫 にゃん +``` +全角または半角のスペースをキーワードの間に入れます +
+ +
NOT検索 + + +猫を含みにゃんを含まないノートを検索 +``` +猫 -にゃん +``` +半角スペースで区切ってから-(マイナス/ハイフン)キーワードの間に入れます +
+ +
一致検索 +表記ゆれやあいまい検索がデフォルトで有効になっているので確実に指定したい場合 + +にゃんで検索するとにゃーんや、にゃーーんも検索で出てきます + +にゃんのみを含むノートを検索 +``` +"にゃん" +``` +"(ダブルコーテーション)でキーワードを囲います +
+ + +
組み合わせ検索 + +にゃんを含み、猫または狐を含み、こやんを含まないノートを検索 +``` +"にゃん" (猫|狐) -こやん +``` +
diff --git a/docker-compose.local-db.yml b/docker-compose.local-db.yml index 16ba4b49e1..dabf3f4780 100644 --- a/docker-compose.local-db.yml +++ b/docker-compose.local-db.yml @@ -40,3 +40,38 @@ services: # volumes: # - ./meili_data:/meili_data +# opensearchとopensearch-dashboardsのdockerfileは実装時の最新版の辞書なので適宜書き換えてください +# opensearch: +# build: ./opensearch +# environment: +# - "server.ssl.enabled:false" +# - "discovery.type=single-node" +# - "OPENSEARCH_INITIAL_ADMIN_PASSWORD=opensearch-adminpassword" #強めのパスワードじゃないと怒られる +# - "plugins.security.disabled=true" +# ulimits: +# memlock: +# soft: -1 # Set memlock to unlimited (no soft or hard limit) +# hard: -1 +# nofile: +# soft: 65536 # Maximum number of open files for the opensearch user - set to at least 65536 +# hard: 65536 +# volumes: +# - ./os-data:/usr/share/opensearch/data +# networks: +# - internal_network +# - external_network + +#OpenSearchのダッシュボードを見る場合に必要 +# opensearch-dashboards: +# build: ./opensearch-dashboards +# ports: +# - 5601:5601 +# links: +# - opensearch +# expose: +# - '5601' +# environment: +# OPENSEARCH_HOSTS: 'http://opensearch:9200' +# networks: +# - internal_network +# - external_network diff --git a/docker-compose_example.yml b/docker-compose_example.yml index 618fd12270..d02a867ef9 100644 --- a/docker-compose_example.yml +++ b/docker-compose_example.yml @@ -79,6 +79,42 @@ services: # interval: 5s # retries: 20 +# opensearchとopensearch-dashboardsのdockerfileは実装時の最新版の辞書なので適宜書き換えてください +# opensearch: +# build: ./opensearch +# environment: +# - "server.ssl.enabled:false" +# - "discovery.type=single-node" +# - "OPENSEARCH_INITIAL_ADMIN_PASSWORD=opensearch-adminpassword" #強めのパスワードじゃないと怒られる +# - "plugins.security.disabled=true" +# ulimits: +# memlock: +# soft: -1 # Set memlock to unlimited (no soft or hard limit) +# hard: -1 +# nofile: +# soft: 65536 # Maximum number of open files for the opensearch user - set to at least 65536 +# hard: 65536 +# volumes: +# - ./os-data:/usr/share/opensearch/data +# networks: +# - internal_network +# - external_network + +#OpenSearchのダッシュボードを見る場合に必要 +# opensearch-dashboards: +# build: ./opensearch-dashboards +# ports: +# - 5601:5601 +# links: +# - opensearch +# expose: +# - '5601' +# environment: +# OPENSEARCH_HOSTS: 'http://opensearch:9200' +# networks: +# - internal_network +# - external_network + # meilisearch: # restart: always # image: getmeili/meilisearch:v1.3.4 diff --git a/locales/index.d.ts b/locales/index.d.ts index 71fe8c9016..162cd182aa 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -11543,7 +11543,7 @@ export interface Locale extends ILocale { /** * CW付きを除外する */ - "toggleNsfw": string; + "toggleCW": string; /** * リプライを除外する */ @@ -11556,6 +11556,10 @@ export interface Locale extends ILocale { * 高度な検索を有効にする */ "toggleAdvancedSearch": string; + /** + * 引用を除外する + */ + "toggleQuote": string; }; "_specifyDate": { /** @@ -11573,6 +11577,28 @@ export interface Locale extends ILocale { */ "other": string; }; + "_fileNsfwOption": { + /** + * 添付ファイルのセンシティブ状態 + */ + "title": string; + /** + * フィルタしない + */ + "combined": string; + /** + * 除外 + */ + "withOutSensitive": string; + /** + * 含むもの + */ + "includeSensitive": string; + /** + * 全てセンシティブ + */ + "sensitiveOnly": string; + }; }; "_searchOrApShow": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 6a4b2c786c..5dab451598 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -3078,15 +3078,22 @@ _advancedSearch: noFile: "なし" combined: "全て" _searchOption: - toggleNsfw: "CW付きを除外する" + toggleCW: "CW付きを除外する" toggleReply: "リプライを除外する" toggleDate: "日時を指定する" toggleAdvancedSearch: "高度な検索を有効にする" + toggleQuote: "引用を除外する" _specifyDate: startDate: "から" endDate: "まで" _description: other: "その他の設定" + _fileNsfwOption: + title: "添付ファイルのセンシティブ状態" + combined: "フィルタしない" + withOutSensitive: "除外" + includeSensitive: "含むもの" + sensitiveOnly: "全てセンシティブ" _searchOrApShow: question: "照会を行いますか?" diff --git a/opensearch-dashboards/dockerfile b/opensearch-dashboards/dockerfile new file mode 100644 index 0000000000..8d70baa354 --- /dev/null +++ b/opensearch-dashboards/dockerfile @@ -0,0 +1,3 @@ +FROM opensearchproject/opensearch-dashboards:2.14.0 +RUN /usr/share/opensearch-dashboards/bin/opensearch-dashboards-plugin remove securityDashboards +COPY --chown=opensearch-dashboards:opensearch-dashboards opensearch_dashboards.yml /usr/share/opensearch-dashboards/config/ diff --git a/opensearch-dashboards/opensearch-dashboards.yml b/opensearch-dashboards/opensearch-dashboards.yml new file mode 100644 index 0000000000..d93c7b7428 --- /dev/null +++ b/opensearch-dashboards/opensearch-dashboards.yml @@ -0,0 +1,3 @@ +server.name: opensearch-dashboards +server.host: "0.0.0.0" +opensearch.hosts: http://localhost:9200 diff --git a/opensearch/dockerfile b/opensearch/dockerfile new file mode 100644 index 0000000000..2a0c2d1401 --- /dev/null +++ b/opensearch/dockerfile @@ -0,0 +1,14 @@ +FROM alpine:latest +RUN apk update && apk add curl unzip +WORKDIR /sudachi-dictionary + +RUN curl -sSL -o sudachi-dictionary-core.zip https://github.com/WorksApplications/SudachiDict/releases/download/v20240409/sudachi-dictionary-20240409-core.zip +RUN unzip sudachi-dictionary-core.zip + +RUN curl -sSL -o sudachi-dictionary.zip https://github.com/WorksApplications/SudachiDict/releases/download/v20240409/sudachi-dictionary-20240409-full.zip +RUN unzip -o sudachi-dictionary.zip + +FROM opensearchproject/opensearch:2.14.0 +RUN /usr/share/opensearch/bin/opensearch-plugin install --batch \ + https://github.com/WorksApplications/elasticsearch-sudachi/releases/download/v3.2.2/opensearch-2.14.0-analysis-sudachi-3.2.2.zip +COPY --from=0 /sudachi-dictionary/sudachi-dictionary-20240409 config/sudachi/ diff --git a/packages/backend/src/core/AdvancedSearchService.ts b/packages/backend/src/core/AdvancedSearchService.ts index 511ac1a13f..8cdabc4587 100644 --- a/packages/backend/src/core/AdvancedSearchService.ts +++ b/packages/backend/src/core/AdvancedSearchService.ts @@ -19,6 +19,8 @@ import { CacheService } from '@/core/CacheService.js'; import { QueryService } from '@/core/QueryService.js'; import { IdService } from '@/core/IdService.js'; import { LoggerService } from '@/core/LoggerService.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; +import { DriveService } from './DriveService.js'; type K = string; type V = string | number | boolean; @@ -81,6 +83,7 @@ export class AdvancedSearchService { private queryService: QueryService, private idService: IdService, private loggerService: LoggerService, + private driveService: DriveService, ) { if (opensearch && config.opensearch && config.opensearch.index) { const indexname = `${config.opensearch.index}---notes`; @@ -91,61 +94,45 @@ export class AdvancedSearchService { }).then((indexExists) => { if (!indexExists) [ this.opensearch?.indices.create({ - index: indexname + `-${new Date().toISOString().slice(0, 7).split(/-/g).join('')}` + `-${randomUUID()}`, + index: indexname, body: { mappings: { properties: { - text: { type: 'text' }, - cw: { type: 'text' }, + text: { + type: 'text', + analyzer: 'sudachi_analyzer' }, + cw: { + type: 'text', + analyzer: 'sudachi_analyzer' }, userId: { type: 'keyword' }, userHost: { type: 'keyword' }, - channelId: { type: 'keyword' }, + createdAt: { type: 'date' }, tags: { type: 'keyword' }, replyId: { type: 'keyword' }, - fileId: { type: 'keyword' }, + fileIds: { type: 'keyword' }, + isQuote: { type: 'bool' }, + sensitiveFileCount: { type: 'byte' }, + nonSensitiveFileCount: { type: 'byte' }, }, }, - settings: { - // TODO: いい感じにする - index: { - analysis: { - tokenizer: { - sudachi_c_tokenizer: { - type: 'sudachi_tokenizer', - additional_settings: '', - split_mode: 'C', - discard_punctuation: true, - }, - sudachi_b_tokenizer: { - type: 'sudachi_tokenizer', - additional_settings: '', - split_mode: 'B', - discard_punctuation: true, - }, - sudachi_a_tokenizer: { - type: 'sudachi_tokenizer', - additional_settings: '', - split_mode: 'A', - discard_punctuation: true, - }, - }, - analyzer: { - c_analyzer: { - filter: [], - tokenizer: 'sudachi_c_tokenizer', - type: 'custom', - }, - b_analyzer: { - filter: [], - tokenizer: 'sudachi_b_tokenizer', - type: 'custom', - }, - a_normalization_analyzer: { - filter: [], - tokenizer: 'sudachi_a_tokenizer', - type: 'custom', - }, - }, + analysis: { + analyzer: { + sudachi_analyzer: { + filter: [ + 'sudachi_base_form', + 'sudachi_readingform', + 'sudachi_normalizedform', + ], + tokenizer: 'sudachi_a_tokenizer', + type: 'custom', + }, + }, + tokenizer: { + sudachi_a_tokenizer: { + type: 'sudachi_tokenizer', + additional_settings: '{"systemDict":"system_full.dic"}', + split_mode: 'A', + discard_punctuation: true, }, }, }, @@ -169,19 +156,30 @@ export class AdvancedSearchService { if (!['home', 'public', 'followers'].includes(note.visibility)) return; if (this.opensearch) { + let sensitiveCount = 0; + let nonSensitiveCount = 0; + if (note.fileIds) { + sensitiveCount = await this.driveService.getSensitiveFileCount(note.fileIds); + nonSensitiveCount = note.fileIds.length - sensitiveCount; + } + const Quote = isRenote(note) && isQuote(note); + const body = { text: note.text, cw: note.cw, userId: note.userId, userHost: note.userHost, - channelId: note.channelId, createdAt: this.idService.parse(note.id).date.getTime(), tags: note.tags, replyId: note.replyId, + fileIds: note.fileIds, + isQuote: Quote, + sensitiveFileCount: sensitiveCount, + nonSensitiveFileCount: nonSensitiveCount, }; await this.opensearch.index({ - index: this.opensearchNoteIndex + `-${new Date().toISOString().slice(0, 7).split(/-/g).join('')}` + `${randomUUID()}` as string, + index: this.opensearchNoteIndex as string, id: note.id, body: body, }).catch((error) => { @@ -217,7 +215,7 @@ export class AdvancedSearchService { if (this.opensearch) { this.opensearch.delete({ - index: this.opensearchNoteIndex + `-${new Date().toISOString().slice(0, 7).split(/-/g).join('')}` + `${randomUUID()}` as string, + index: this.opensearchNoteIndex as string, id: note.id, }).catch((error) => { console.error(error); @@ -228,13 +226,14 @@ export class AdvancedSearchService { @bindThis public async searchNote(q: string, me: MiUser | null, opts: { userId?: MiNote['userId'] | null; - channelId?: MiNote['channelId'] | null; host?: string | null; origin?: string | null; fileOption?: string | null; visibility?: MiNote['visibility'] | null; - excludeNsfw?: boolean; + excludeCW?: boolean; excludeReply?: boolean; + excludeQuote?: boolean; + sensitiveFilter?: string | null; }, pagination: { untilId?: MiNote['id']; sinceId?: MiNote['id']; @@ -244,46 +243,75 @@ export class AdvancedSearchService { const osFilter: any = { bool: { must: [], + must_not: [], }, }; if (pagination.untilId) osFilter.bool.must.push({ range: { createdAt: { lt: this.idService.parse(pagination.untilId).date.getTime() } } }); if (pagination.sinceId) osFilter.bool.must.push({ range: { createdAt: { gt: this.idService.parse(pagination.sinceId).date.getTime() } } }); if (opts.userId) osFilter.bool.must.push({ term: { userId: opts.userId } }); - if (opts.channelId) osFilter.bool.must.push({ term: { channelId: opts.channelId } }); if (opts.host) { if (opts.host === '.') { - osFilter.bool.must.push({ term: { must_not: [{ exists: { field: 'userHost' } }] } }); + osFilter.bool.must_not.push({ exists: { field: 'userHost' } }); } else { osFilter.bool.must.push({ term: { userHost: opts.host } }); } } - if (opts.excludeReply) osFilter.bool.must.push({ term: { must_not: [{ exists: { field: 'replyId' } }] } }); - if (opts.excludeNsfw) osFilter.bool.must.push({ term: { must_not: [{ exists: { field: 'cw' } }] } }); + if (opts.origin) { + if (opts.origin === 'local') { + osFilter.bool.must_not.push({ exists: { field: 'userHost' } }); + } else if (opts.origin === 'remote') { + osFilter.bool.must.push({ exists: { field: 'userHost' } } ); + } + } + if (opts.excludeReply) osFilter.bool.must_not.push({ exists: { field: 'replyId' } }); + if (opts.excludeCW) osFilter.bool.must_not.push({ exists: { field: 'cw' } }); + if (opts.excludeQuote) osFilter.bool.must.push({ term: { isQuote: false } }); if (opts.fileOption) { if (opts.fileOption === 'file-only') { - osFilter.bool.must.push({ term: { must: [{ exists: { field: 'fileId' } }] } }); + osFilter.bool.must.push({ exists: { field: 'fileIds' } }); } else if (opts.fileOption === 'no-file') { - osFilter.bool.must.push({ term: { must_not: [{ exists: { field: 'fileId' } }] } }); + osFilter.bool.must_not.push({ exists: { field: 'fileIds' } }); + } + } + if (opts.sensitiveFilter) { + if (opts.sensitiveFilter === 'includeSensitive') { + osFilter.bool.must.push({ range: { sensitiveFileCount: { gte: 1 } } }); + } else if (opts.sensitiveFilter === 'withOutSensitive') { + osFilter.bool.must.push({ term: { sensitiveFileCount: 0 } } ); + } else if (opts.sensitiveFilter === 'sensitiveOnly') { + osFilter.bool.must.push({ term: { nonSensitiveFileCount: 0 } } ); + osFilter.bool.must.push({ range: { sensitiveFileCount: { gte: 1 } } }); } } if (q !== '') { - osFilter.bool.must.push({ - bool: { - should: [ - { wildcard: { 'text': { value: q } } }, - { simple_query_string: { fields: ['text'], 'query': q, default_operator: 'and' } }, - { wildcard: { 'cw': { value: q } } }, - { simple_query_string: { fields: ['cw'], 'query': q, default_operator: 'and' } }, - ], - minimum_should_match: 1, - }, - }); + if (opts.excludeCW) { + osFilter.bool.must.push({ + bool: { + should: [ + { wildcard: { 'text': { value: q } } }, + { simple_query_string: { fields: ['text'], 'query': q, default_operator: 'and' } }, + ], + minimum_should_match: 1, + }, + }); + } else { + osFilter.bool.must.push({ + bool: { + should: [ + { wildcard: { 'text': { value: q } } }, + { wildcard: { 'cw': { value: q } } }, + { simple_query_string: { fields: ['text', 'cw'], 'query': q, default_operator: 'and' } }, + ], + minimum_should_match: 1, + }, + }); + } } const res = await this.opensearch.search({ - index: this.opensearchNoteIndex + `-${new Date().toISOString().slice(0, 7).split(/-/g).join('')}` + `${randomUUID()}` as string, + index: this.opensearchNoteIndex as string, body: { query: osFilter, sort: [{ createdAt: { order: 'desc' } }], @@ -315,8 +343,6 @@ export class AdvancedSearchService { if (opts.userId) { query.andWhere('note.userId = :userId', { userId: opts.userId }); - } else if (opts.channelId) { - query.andWhere('note.channelId = :channelId', { channelId: opts.channelId }); } if (opts.origin === 'local') { @@ -356,7 +382,7 @@ export class AdvancedSearchService { } } - if (opts.excludeNsfw) { + if (opts.excludeCW) { query.andWhere('note.cw IS NULL'); query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); } diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index e6d0d21cb8..f18bb49421 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -933,4 +933,16 @@ export class DriveService { cleanup(); } } + + @bindThis + public async getSensitiveFileCount(FileIds: string[]): Promise { + let SensitiveCount = 0; + + for (const FileId of FileIds) { + const file = await this.driveFilesRepository.findOneBy({ id: FileId }); + if (file?.isSensitive) SensitiveCount++; + } + + return SensitiveCount; + } } diff --git a/packages/backend/src/server/api/endpoints/notes/advanced-search.ts b/packages/backend/src/server/api/endpoints/notes/advanced-search.ts index 540668ba68..fe446f112b 100644 --- a/packages/backend/src/server/api/endpoints/notes/advanced-search.ts +++ b/packages/backend/src/server/api/endpoints/notes/advanced-search.ts @@ -13,7 +13,7 @@ import { ApiError } from '../../error.js'; export const meta = { description: '高度な検索ができます', tags: ['notes'], - requireCredential: false, + requireCredential: true, res: { type: 'array', optional: false, nullable: false, @@ -70,6 +70,12 @@ export const paramDef = { default: 'combined', description: 'ファイルの添付状態', }, + sensitiveFilter: { + type: 'string', + enum: ['includeSensitive', 'withOutSensitive', 'sensitiveOnly', 'combined'], + default: 'combined', + description: '添付ファイルのセンシティブ状態', + }, offset: { type: 'integer', default: 0, @@ -78,15 +84,20 @@ export const paramDef = { type: 'string', description: 'ノートが作成されたインスタンス。ローカルの場合は`.`を指定します', }, - excludeNsfw: { + excludeCW: { type: 'boolean', default: false, - description: 'trueを指定するとCWを含むノートを除外します', + description: 'CWを含むノートを除外するか', }, excludeReply: { type: 'boolean', default: false, - description: 'trueを指定するとリプライのノートを除外します', + description: 'リプライのノートを除外するか', + }, + excludeQuote: { + type: 'boolean', + default: false, + description: '引用のノートを除外するか', }, userId: { type: 'string', @@ -95,13 +106,6 @@ export const paramDef = { default: null, description: 'ノートを作成したユーザーのID', }, - channelId: { - type: 'string', - format: 'misskey:id', - nullable: true, - default: null, - description: '指定されたチャンネル内のノートを返します', - }, }, required: ['query'], } as const; @@ -123,12 +127,13 @@ export default class extends Endpoint { const notes = await this.advancedSearchService.searchNote(ps.query, me, { userId: ps.userId, - channelId: ps.channelId, host: ps.host, origin: ps.origin, fileOption: ps.fileOption, - excludeNsfw: ps.excludeNsfw, + sensitiveFilter: ps.sensitiveFilter, + excludeCW: ps.excludeCW, excludeReply: ps.excludeReply, + excludeQuote: ps.excludeQuote, }, { untilId: ps.untilId, sinceId: ps.sinceId, diff --git a/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts b/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts index 1f41a02b54..aa1b92542f 100644 --- a/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts +++ b/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts @@ -3410,7 +3410,7 @@ declare module '../api.js' { /** * 高度な検索ができます * - * **Credential required**: *No* + * **Credential required**: *Yes* */ request( endpoint: E, diff --git a/packages/cherrypick-js/src/autogen/types.ts b/packages/cherrypick-js/src/autogen/types.ts index 529943ea30..e4f84d7628 100644 --- a/packages/cherrypick-js/src/autogen/types.ts +++ b/packages/cherrypick-js/src/autogen/types.ts @@ -2951,7 +2951,7 @@ export type paths = { * notes/advanced-search * @description 高度な検索ができます * - * **Credential required**: *No* + * **Credential required**: *Yes* */ post: operations['notes___advanced-search']; }; @@ -23752,7 +23752,7 @@ export type operations = { * notes/advanced-search * @description 高度な検索ができます * - * **Credential required**: *No* + * **Credential required**: *Yes* */ 'notes___advanced-search': { requestBody: { @@ -23787,32 +23787,37 @@ export type operations = { * @enum {string} */ fileOption?: 'file-only' | 'no-file' | 'combined'; + /** + * @description 添付ファイルのセンシティブ状態 + * @default combined + * @enum {string} + */ + sensitiveFilter?: 'includeSensitive' | 'withOutSensitive' | 'sensitiveOnly' | 'combined'; /** @default 0 */ offset?: number; /** @description ノートが作成されたインスタンス。ローカルの場合は`.`を指定します */ host?: string; /** - * @description trueを指定するとCWを含むノートを除外します + * @description CWを含むノートを除外するか * @default false */ - excludeNsfw?: boolean; + excludeCW?: boolean; /** - * @description trueを指定するとリプライのノートを除外します + * @description リプライのノートを除外するか * @default false */ excludeReply?: boolean; /** - * Format: misskey:id - * @description ノートを作成したユーザーのID - * @default null + * @description 引用のノートを除外するか + * @default false */ - userId?: string | null; + excludeQuote?: boolean; /** * Format: misskey:id - * @description 指定されたチャンネル内のノートを返します + * @description ノートを作成したユーザーのID * @default null */ - channelId?: string | null; + userId?: string | null; }; }; }; diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue index abc5e2ba74..de6d825939 100644 --- a/packages/frontend/src/pages/search.note.vue +++ b/packages/frontend/src/pages/search.note.vue @@ -45,6 +45,18 @@ SPDX-License-Identifier: AGPL-3.0-only + + + +
+ + + + + + +
+
@@ -52,7 +64,8 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts._advancedSearch._searchOption.toggleReply }} - {{ i18n.ts._advancedSearch._searchOption.toggleNsfw }} + {{ i18n.ts._advancedSearch._searchOption.toggleCW }} + {{ i18n.ts._advancedSearch._searchOption.toggleQuote }}
@@ -96,8 +109,10 @@ const user = ref(null); const isLocalOnly = ref(false); const isfileOnly = ref('combined'); const advancedSearch = ref(false); -const excludeNsfw = ref(false); +const excludeCW = ref(false); const excludeReply = ref(false); +const excludeQuote = ref(false); +const sensitiveFilter = ref('combined'); const isAdvancedSearchAvailable = ($i != null && instance.policies.canAdvancedSearchNotes ) || ($i != null && $i.policies.canAdvancedSearchNotes ); @@ -148,8 +163,10 @@ async function search() { userId: user.value ? user.value.id : null, origin: searchOrigin.value, fileOption: isfileOnly.value, - excludeNsfw: excludeNsfw.value, + excludeCW: excludeCW.value, excludeReply: excludeReply.value, + excludeQuote: excludeQuote.value, + sensitiveFilter: sensitiveFilter.value, }, }; } else { @@ -169,3 +186,6 @@ async function search() { key.value++; } +