Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhance: リアクション検索 #496

Merged
merged 9 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .config/docker_example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,16 @@ redis:

# You can use OpenSearch when you enabled AdvancedSearch.

# reactionSearchLocalOnly: ローカルユーザーが付けたリアクションのみインデックスするか

#opensearch:
# host: opensearch
# port: 9200
# user: 'admin'
# pass: 'opensearch-adminpassword' #強めのパスワードじゃないと怒られる
# ssl: false
# index: 'instancename' #なんでもいい
# reactionSearchLocalOnly: false

# ┌───────────────┐
#───┘ ID generation └───────────────────────────────────────────
Expand Down
3 changes: 3 additions & 0 deletions .config/example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,13 +205,16 @@ redis:

# You can use OpenSearch when you enabled AdvancedSearch.

# reactionSearchScope: ローカルユーザーが付けたリアクションのみインデックスするか

#opensearch:
# host: localhost
# port: 9200
# user: 'admin'
# pass: 'opensearch-adminpassword' #強めのパスワードじゃないと怒られる
# ssl: false
# index: 'instancename' #なんでもいい
# reactionSearchLocalOnly: false

# ┌───────────────┐
#───┘ ID generation └───────────────────────────────────────────
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG_YOJO.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ Cherrypick 4.11.1
- 返信
- 投票
- コントロールパネル→その他で(クリップ、お気に入り、投票が)再インデックスできるようになりました
- Enhance: ノートにつけられたリアクションを対象にした検索ができるように
- Opensearchのみ対応
- Opensearchの設定で` reactionSearchLocalOnly: true`にすることでリモートのカスタム絵文字リアクションをインデックス対象外にできます

### Client
- Fix: リアクションが閲覧できる状態でも見れない問題を修正 [#429](https://github.com/yojo-art/cherrypick/pull/429)
- Enhance: チャートの連合グラフで割合を表示
Expand All @@ -32,6 +36,7 @@ Cherrypick 4.11.1

### Server
- Enhance: リモートユーザーの`/api/clips/show`と`/api/users/clips`の応答にemojisを追加 [#466](https://github.com/yojo-art/cherrypick/pull/466)
- Change: `notes/advanced-search`で`query`が必須ではなくなりました

### Misc

Expand Down
14 changes: 14 additions & 0 deletions locales/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11895,6 +11895,20 @@ export interface Locale extends ILocale {
*/
"sensitiveOnly": string;
};
"_reactionSearch": {
/**
* リアクション検索
*/
"title": string;
/**
* リアクションピッカー
*/
"include": string;
/**
* 除外リアクションピッカー
*/
"exclude": string;
};
};
"_searchOrApShow": {
/**
Expand Down
5 changes: 5 additions & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3171,6 +3171,11 @@ _advancedSearch:
withOutSensitive: "除外"
includeSensitive: "含むもの"
sensitiveOnly: "全てセンシティブ"
_reactionSearch:
title: "リアクション検索"
include: "リアクションピッカー"
exclude: "除外リアクションピッカー"


_searchOrApShow:
question: "照会を行いますか?"
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ type Source = {
ssl?: boolean;
rejectUnauthorized?: boolean;
index: string;
reactionSearchLocalOnly?: boolean;
} | undefined;
sentryForBackend?: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; };
sentryForFrontend?: { options: Partial<Sentry.NodeOptions> };
Expand Down Expand Up @@ -158,6 +159,7 @@ export type Config = {
ssl?: boolean;
rejectUnauthorized?: boolean;
index: string;
reactionSearchLocalOnly?: boolean;
} | undefined;
proxy: string | undefined;
proxySmtp: string | undefined;
Expand Down
130 changes: 122 additions & 8 deletions packages/backend/src/core/AdvancedSearchService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ 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 { IdentifiableError } from '@/misc/identifiable-error.js';
import type Logger from '@/logger.js';
import { DriveService } from './DriveService.js';

Expand Down Expand Up @@ -111,6 +112,13 @@ const noteIndexBody = {
referenceUserId: { type: 'keyword' },
sensitiveFileCount: { type: 'byte' },
nonSensitiveFileCount: { type: 'byte' },
reactions: {
type: 'nested',
properties: {
emoji: { type: 'keyword' },
count: { type: 'short' },
},
},
},
},
settings: {
Expand Down Expand Up @@ -315,6 +323,16 @@ export class AdvancedSearchService {
const IsQuote = isRenote(note) && isQuote(note);
const sensitiveCount = await this.driveService.getSensitiveFileCount(note.fileIds);
const nonSensitiveCount = note.fileIds.length - sensitiveCount;
let reactions: {
emoji: string;
count: number;
}[];

if (this.config.opensearch?.reactionSearchLocalOnly ?? false) {
reactions = Object.entries(note.reactions).map(([emoji, count]) => ({ emoji, count })).filter((x) => x.emoji.includes('@') === false);
} else {
reactions = Object.entries(note.reactions).map(([emoji, count]) => ({ emoji, count }));
}

const body = {
text: note.text,
Expand All @@ -332,6 +350,7 @@ export class AdvancedSearchService {
referenceUserId: note.replyId ? note.replyUserId : IsQuote ? note.renoteUserId : null,
sensitiveFileCount: sensitiveCount,
nonSensitiveFileCount: nonSensitiveCount,
reactions: reactions,
};
this.index(this.opensearchNoteIndex as string, note.id, body);
}
Expand All @@ -357,6 +376,7 @@ export class AdvancedSearchService {
userId: string,
reaction: string,
remote: boolean,
reactionIncrement?: boolean,
}) {
if (!opts.remote) {
await this.index(this.reactionIndex, opts.id, {
Expand All @@ -366,6 +386,29 @@ export class AdvancedSearchService {
createdAt: this.idService.parse(opts.id).date.getTime(),
});
}
if (opts.reactionIncrement === false) return;
if ((this.config.opensearch?.reactionSearchLocalOnly ?? false) && opts.remote && opts.reaction.includes('@')) return;
await this.opensearch?.update({
id: opts.noteId,
index: this.opensearchNoteIndex as string,
body: {
script: {
lang: 'painless',
source: 'if (ctx._source.containsKey("reactions")) {' +
'if (ctx._source.reactions.stream().anyMatch(r -> r.emoji == params.emoji))' +
' { ctx._source.reactions.stream().filter(r -> r.emoji == params.emoji && r.count < 32700).forEach(r -> r.count += 1); }' +
' else { ctx._source.reactions.add(params.record); }' +
'} else { ctx._source.reactions = new ArrayList(); ctx._source.reactions.add(params.record);}',
params: {
emoji: opts.reaction,
record: {
emoji: opts.reaction,
count: 1,
},
},
},
},
}).catch((err) => this.logger.error(err));
}

@bindThis
Expand Down Expand Up @@ -476,6 +519,7 @@ export class AdvancedSearchService {
userId: reac.userId,
reaction: reac.reaction,
remote: reac.user === null ? false : true, //user.host===nullなら userがnullになる
reactionIncrement: false,
});
latestid = reac.id;
});
Expand Down Expand Up @@ -625,8 +669,29 @@ export class AdvancedSearchService {
}

@bindThis
public async unindexReaction(id: string, remote: boolean): Promise<void> {
public async unindexReaction(id: string, remote: boolean, noteId: string, emoji:string): Promise<void> {
if (!remote) this.unindexById(this.reactionIndex, id);
if ((this.config.opensearch?.reactionSearchLocalOnly ?? false) && remote && emoji.includes('@')) return;
await this.opensearch?.update({
id: noteId,
index: this.opensearchNoteIndex as string,
body: {
script: {
lang: 'painless',
source: 'if (ctx._source.containsKey("reactions")) {' +
'for (int i = 0; i < ctx._source.reactions.length; i++) {' +
' if (ctx._source.reactions[i].emoji == params.emoji) { ctx._source.reactions[i].count -= 1;' +
//DBに格納されるノートのリアクションデータは数が0でも保持されるのでそれに合わせてデータを消さない
//' if (ctx._source.reactions[i].count <= 0) { ctx._source.reactions.remove(i) }' +
'break; }' +
'}' +
'}',
params: {
emoji: emoji,
},
},
},
}).catch((err) => this.logger.error(err));
}
/**
* Favoriteだけどクリップもここ
Expand Down Expand Up @@ -693,7 +758,9 @@ export class AdvancedSearchService {
* エンドポイントから呼ばれるところ
*/
@bindThis
public async searchNote(q: string, me: MiUser | null, opts: {
public async searchNote(me: MiUser | null, opts: {
reactions?: string[] | null;
reactionsExclude?: string[] | null;
userId?: MiNote['userId'] | null;
host?: string | null;
origin?: string | null;
Expand All @@ -708,7 +775,8 @@ export class AdvancedSearchService {
untilId?: MiNote['id'];
sinceId?: MiNote['id'];
limit?: number;
}): Promise<MiNote[]> {
},
q?: string): Promise<MiNote[]> {
if (this.opensearch) {
const osFilter: any = {
bool: {
Expand All @@ -719,6 +787,45 @@ export class AdvancedSearchService {

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.reactions && 0 < opts.reactions.length ) {
const reactionsQuery = {
nested: {
path: 'reactions',
query: {
bool: {
should: [
{ range: { 'reactions.count': { gte: 1 } } },
],
minimum_should_match: 2,
},
},
},
} as any;
opts.reactions.forEach( (reaction) => {
reactionsQuery.nested.query.bool.should.push({ wildcard: { 'reactions.emoji': { value: reaction } } });
});
osFilter.bool.must.push(reactionsQuery);
}
if (opts.reactionsExclude && 0 < opts.reactionsExclude.length) {
const reactionsExcludeQuery = {
nested: {
path: 'reactions',
query: {
bool: {
should: [
{ range: { 'reactions.count': { gte: 1 } } },
],
minimum_should_match: 2,
},
},
},
} as any;
opts.reactionsExclude.forEach( (reaction) => {
reactionsExcludeQuery.nested.query.bool.should.push({ wildcard: { 'reactions.emoji': { value: reaction } } });
});
osFilter.bool.must_not.push(reactionsExcludeQuery);
}

if (opts.userId) {
osFilter.bool.must.push({ term: { userId: opts.userId } });
const user = await this.usersRepository.findOneBy({ id: opts.userId });
Expand Down Expand Up @@ -766,7 +873,7 @@ export class AdvancedSearchService {
}
}

if (q !== '') {
if (q && q !== '') {
if (opts.excludeCW) {
osFilter.bool.must.push({
bool: {
Expand Down Expand Up @@ -829,6 +936,11 @@ export class AdvancedSearchService {
id: In(noteIds),
})).sort((a, b) => a.id > b.id ? -1 : 1);
} else {
if (opts.reactions) {
throw new IdentifiableError('084b2eec-7b60-4382-ae49-3da182d27a9a', 'Unimplemented');
} else if (opts.reactionsExclude) {
throw new IdentifiableError('084b2eec-7b60-4382-ae49-3da182d27a9a', 'Unimplemented');
}
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId);

if (opts.userId) {
Expand All @@ -841,10 +953,12 @@ export class AdvancedSearchService {
query.andWhere('note.userHost IS NOT NULL');
}

if (this.config.pgroonga) {
query.andWhere('note.text &@~ :q', { q: `%${sqlLikeEscape(q)}%` });
} else {
query.andWhere('note.text ILIKE :q', { q: `%${sqlLikeEscape(q)}%` });
if (q && q !== '') {
if (this.config.pgroonga) {
query.andWhere('note.text &@~ :q', { q: `%${sqlLikeEscape(q)}%` });
} else {
query.andWhere('note.text ILIKE :q', { q: `%${sqlLikeEscape(q)}%` });
}
}

query
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/core/ReactionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ export class ReactionService {

// Delete reaction
const result = await this.noteReactionsRepository.delete(exist.id);
await this.advancedSearchService.unindexReaction(exist.id, user.host === null ? false : true);
await this.advancedSearchService.unindexReaction(exist.id, user.host === null ? false : true, note.id, exist.reaction);

if (result.affected !== 1) {
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
Expand Down
Loading
Loading