refactor progress
This commit is contained in:
35
packages/meilisearch_search/src/MeilisearchClient.tsx
Normal file
35
packages/meilisearch_search/src/MeilisearchClient.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {MeiliSearch} from 'meilisearch';
|
||||
|
||||
export interface MeilisearchClientConfig {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export function createMeilisearchClient(config: MeilisearchClientConfig): MeiliSearch {
|
||||
const options = {
|
||||
host: config.url,
|
||||
apiKey: config.apiKey,
|
||||
timeout: config.timeoutMs,
|
||||
};
|
||||
return new MeiliSearch(options);
|
||||
}
|
||||
87
packages/meilisearch_search/src/MeilisearchFilterUtils.tsx
Normal file
87
packages/meilisearch_search/src/MeilisearchFilterUtils.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export type MeilisearchFilter = string;
|
||||
|
||||
function quoteString(value: string): string {
|
||||
// Meilisearch filter syntax expects double-quoted strings.
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function formatScalar(value: string | number | boolean): string {
|
||||
if (typeof value === 'string') {
|
||||
return quoteString(value);
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function meiliEquals(field: string, value: string | number | boolean): MeilisearchFilter {
|
||||
return `${field} = ${formatScalar(value)}`;
|
||||
}
|
||||
|
||||
export function meiliNotEquals(field: string, value: string | number | boolean): MeilisearchFilter {
|
||||
return `${field} != ${formatScalar(value)}`;
|
||||
}
|
||||
|
||||
export function meiliGte(field: string, value: number): MeilisearchFilter {
|
||||
return `${field} >= ${value}`;
|
||||
}
|
||||
|
||||
export function meiliLte(field: string, value: number): MeilisearchFilter {
|
||||
return `${field} <= ${value}`;
|
||||
}
|
||||
|
||||
export function meiliIsNull(field: string): MeilisearchFilter {
|
||||
return `${field} IS NULL`;
|
||||
}
|
||||
|
||||
export function meiliIsNotNull(field: string): MeilisearchFilter {
|
||||
return `${field} IS NOT NULL`;
|
||||
}
|
||||
|
||||
export function meiliOrEquals(field: string, values: Array<string | number | boolean>): MeilisearchFilter | undefined {
|
||||
if (values.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (values.length === 1) {
|
||||
const value = values[0];
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return meiliEquals(field, value);
|
||||
}
|
||||
return `(${values.map((v) => meiliEquals(field, v)).join(' OR ')})`;
|
||||
}
|
||||
|
||||
export function meiliAndEquals(field: string, values: Array<string | number | boolean>): Array<MeilisearchFilter> {
|
||||
// For array fields, "field = value" checks membership. Joining with AND gives "contains all".
|
||||
return values.map((v) => meiliEquals(field, v));
|
||||
}
|
||||
|
||||
export function meiliExcludeAny(field: string, values: Array<string | number | boolean>): Array<MeilisearchFilter> {
|
||||
// For array fields, "NOT field = value" excludes documents whose array contains value.
|
||||
return values.map((v) => `NOT (${meiliEquals(field, v)})`);
|
||||
}
|
||||
|
||||
export function compactFilters(filters: Array<MeilisearchFilter | undefined>): Array<MeilisearchFilter> {
|
||||
return filters.filter((f): f is MeilisearchFilter => f !== undefined && f !== '');
|
||||
}
|
||||
147
packages/meilisearch_search/src/MeilisearchIndexDefinitions.tsx
Normal file
147
packages/meilisearch_search/src/MeilisearchIndexDefinitions.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export type FluxerSearchIndexName = 'messages' | 'guilds' | 'users' | 'reports' | 'audit_logs' | 'guild_members';
|
||||
|
||||
export interface MeilisearchIndexDefinition {
|
||||
indexName: FluxerSearchIndexName;
|
||||
primaryKey: 'id';
|
||||
filterableAttributes: Array<string>;
|
||||
sortableAttributes: Array<string>;
|
||||
searchableAttributes: Array<string>;
|
||||
}
|
||||
|
||||
export const MEILISEARCH_INDEX_DEFINITIONS: Record<FluxerSearchIndexName, MeilisearchIndexDefinition> = {
|
||||
messages: {
|
||||
indexName: 'messages',
|
||||
primaryKey: 'id',
|
||||
filterableAttributes: [
|
||||
'id',
|
||||
'channelId',
|
||||
'guildId',
|
||||
'authorId',
|
||||
'authorType',
|
||||
'createdAt',
|
||||
'editedAt',
|
||||
'isPinned',
|
||||
'mentionedUserIds',
|
||||
'mentionEveryone',
|
||||
'hasLink',
|
||||
'hasEmbed',
|
||||
'hasPoll',
|
||||
'hasFile',
|
||||
'hasVideo',
|
||||
'hasImage',
|
||||
'hasSound',
|
||||
'hasSticker',
|
||||
'hasForward',
|
||||
'embedTypes',
|
||||
'embedProviders',
|
||||
'linkHostnames',
|
||||
'attachmentFilenames',
|
||||
'attachmentExtensions',
|
||||
],
|
||||
sortableAttributes: ['createdAt'],
|
||||
searchableAttributes: ['content'],
|
||||
},
|
||||
guilds: {
|
||||
indexName: 'guilds',
|
||||
primaryKey: 'id',
|
||||
filterableAttributes: [
|
||||
'id',
|
||||
'ownerId',
|
||||
'memberCount',
|
||||
'verificationLevel',
|
||||
'mfaLevel',
|
||||
'nsfwLevel',
|
||||
'features',
|
||||
'createdAt',
|
||||
'isDiscoverable',
|
||||
'discoveryCategory',
|
||||
'onlineCount',
|
||||
],
|
||||
sortableAttributes: ['createdAt', 'memberCount', 'onlineCount'],
|
||||
searchableAttributes: ['name', 'vanityUrlCode', 'discoveryDescription'],
|
||||
},
|
||||
users: {
|
||||
indexName: 'users',
|
||||
primaryKey: 'id',
|
||||
filterableAttributes: [
|
||||
'id',
|
||||
'isBot',
|
||||
'isSystem',
|
||||
'emailVerified',
|
||||
'emailBounced',
|
||||
'premiumType',
|
||||
'tempBannedUntil',
|
||||
'pendingDeletionAt',
|
||||
'acls',
|
||||
'suspiciousActivityFlags',
|
||||
'createdAt',
|
||||
'lastActiveAt',
|
||||
],
|
||||
sortableAttributes: ['createdAt', 'lastActiveAt'],
|
||||
searchableAttributes: ['username', 'email', 'phone', 'id'],
|
||||
},
|
||||
reports: {
|
||||
indexName: 'reports',
|
||||
primaryKey: 'id',
|
||||
filterableAttributes: [
|
||||
'id',
|
||||
'reporterId',
|
||||
'reportedAt',
|
||||
'status',
|
||||
'reportType',
|
||||
'category',
|
||||
'reportedUserId',
|
||||
'reportedGuildId',
|
||||
'reportedMessageId',
|
||||
'guildContextId',
|
||||
'resolvedByAdminId',
|
||||
'resolvedAt',
|
||||
'createdAt',
|
||||
],
|
||||
sortableAttributes: ['createdAt', 'reportedAt', 'resolvedAt'],
|
||||
searchableAttributes: ['category', 'additionalInfo', 'reportedGuildName', 'reportedChannelName'],
|
||||
},
|
||||
audit_logs: {
|
||||
indexName: 'audit_logs',
|
||||
primaryKey: 'id',
|
||||
filterableAttributes: ['id', 'adminUserId', 'targetType', 'targetId', 'action', 'createdAt'],
|
||||
sortableAttributes: ['createdAt'],
|
||||
searchableAttributes: ['action', 'targetType', 'targetId', 'auditLogReason'],
|
||||
},
|
||||
guild_members: {
|
||||
indexName: 'guild_members',
|
||||
primaryKey: 'id',
|
||||
filterableAttributes: [
|
||||
'id',
|
||||
'guildId',
|
||||
'userId',
|
||||
'roleIds',
|
||||
'joinedAt',
|
||||
'joinSourceType',
|
||||
'sourceInviteCode',
|
||||
'userCreatedAt',
|
||||
'isBot',
|
||||
],
|
||||
sortableAttributes: ['joinedAt', 'userCreatedAt'],
|
||||
searchableAttributes: ['username', 'discriminator', 'globalName', 'nickname', 'userId'],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {MeilisearchIndexAdapter} from '@fluxer/meilisearch_search/src/adapters/MeilisearchIndexAdapter';
|
||||
import {
|
||||
compactFilters,
|
||||
type MeilisearchFilter,
|
||||
meiliEquals,
|
||||
} from '@fluxer/meilisearch_search/src/MeilisearchFilterUtils';
|
||||
import {MEILISEARCH_INDEX_DEFINITIONS} from '@fluxer/meilisearch_search/src/MeilisearchIndexDefinitions';
|
||||
import type {AuditLogSearchFilters, SearchableAuditLog} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {MeiliSearch} from 'meilisearch';
|
||||
|
||||
function buildAuditLogFilters(filters: AuditLogSearchFilters): Array<MeilisearchFilter | undefined> {
|
||||
const clauses: Array<MeilisearchFilter | undefined> = [];
|
||||
|
||||
if (filters.adminUserId) clauses.push(meiliEquals('adminUserId', filters.adminUserId));
|
||||
if (filters.targetType) clauses.push(meiliEquals('targetType', filters.targetType));
|
||||
if (filters.targetId) clauses.push(meiliEquals('targetId', filters.targetId));
|
||||
if (filters.action) clauses.push(meiliEquals('action', filters.action));
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildAuditLogSort(filters: AuditLogSearchFilters): Array<string> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'createdAt';
|
||||
if (sortBy === 'relevance') return undefined;
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [`createdAt:${sortOrder}`];
|
||||
}
|
||||
|
||||
export interface MeilisearchAuditLogAdapterOptions {
|
||||
client: MeiliSearch;
|
||||
waitForTasks: {
|
||||
enabled: boolean;
|
||||
timeoutMs: number;
|
||||
intervalMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class MeilisearchAuditLogAdapter extends MeilisearchIndexAdapter<AuditLogSearchFilters, SearchableAuditLog> {
|
||||
constructor(options: MeilisearchAuditLogAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: MEILISEARCH_INDEX_DEFINITIONS.audit_logs,
|
||||
buildFilters: buildAuditLogFilters,
|
||||
buildSort: buildAuditLogSort,
|
||||
waitForTasks: options.waitForTasks,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {MeilisearchIndexAdapter} from '@fluxer/meilisearch_search/src/adapters/MeilisearchIndexAdapter';
|
||||
import {
|
||||
compactFilters,
|
||||
type MeilisearchFilter,
|
||||
meiliAndEquals,
|
||||
meiliEquals,
|
||||
meiliGte,
|
||||
meiliLte,
|
||||
} from '@fluxer/meilisearch_search/src/MeilisearchFilterUtils';
|
||||
import {MEILISEARCH_INDEX_DEFINITIONS} from '@fluxer/meilisearch_search/src/MeilisearchIndexDefinitions';
|
||||
import type {GuildSearchFilters, SearchableGuild} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {MeiliSearch} from 'meilisearch';
|
||||
|
||||
function buildGuildFilters(filters: GuildSearchFilters): Array<MeilisearchFilter | undefined> {
|
||||
const clauses: Array<MeilisearchFilter | undefined> = [];
|
||||
|
||||
if (filters.ownerId) clauses.push(meiliEquals('ownerId', filters.ownerId));
|
||||
if (filters.verificationLevel !== undefined)
|
||||
clauses.push(meiliEquals('verificationLevel', filters.verificationLevel));
|
||||
if (filters.mfaLevel !== undefined) clauses.push(meiliEquals('mfaLevel', filters.mfaLevel));
|
||||
if (filters.nsfwLevel !== undefined) clauses.push(meiliEquals('nsfwLevel', filters.nsfwLevel));
|
||||
if (filters.minMembers !== undefined) clauses.push(meiliGte('memberCount', filters.minMembers));
|
||||
if (filters.maxMembers !== undefined) clauses.push(meiliLte('memberCount', filters.maxMembers));
|
||||
|
||||
if (filters.hasFeature && filters.hasFeature.length > 0) {
|
||||
clauses.push(...meiliAndEquals('features', filters.hasFeature));
|
||||
}
|
||||
|
||||
if (filters.isDiscoverable !== undefined) clauses.push(meiliEquals('isDiscoverable', filters.isDiscoverable));
|
||||
if (filters.discoveryCategory !== undefined)
|
||||
clauses.push(meiliEquals('discoveryCategory', filters.discoveryCategory));
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildGuildSort(filters: GuildSearchFilters): Array<string> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'createdAt';
|
||||
if (sortBy === 'relevance') return undefined;
|
||||
if (sortBy === 'onlineCount') return [`onlineCount:${filters.sortOrder ?? 'desc'}`];
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [`${sortBy}:${sortOrder}`];
|
||||
}
|
||||
|
||||
export interface MeilisearchGuildAdapterOptions {
|
||||
client: MeiliSearch;
|
||||
waitForTasks: {
|
||||
enabled: boolean;
|
||||
timeoutMs: number;
|
||||
intervalMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class MeilisearchGuildAdapter extends MeilisearchIndexAdapter<GuildSearchFilters, SearchableGuild> {
|
||||
constructor(options: MeilisearchGuildAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: MEILISEARCH_INDEX_DEFINITIONS.guilds,
|
||||
buildFilters: buildGuildFilters,
|
||||
buildSort: buildGuildSort,
|
||||
waitForTasks: options.waitForTasks,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {MeilisearchIndexAdapter} from '@fluxer/meilisearch_search/src/adapters/MeilisearchIndexAdapter';
|
||||
import {
|
||||
compactFilters,
|
||||
type MeilisearchFilter,
|
||||
meiliAndEquals,
|
||||
meiliEquals,
|
||||
meiliGte,
|
||||
meiliLte,
|
||||
meiliOrEquals,
|
||||
} from '@fluxer/meilisearch_search/src/MeilisearchFilterUtils';
|
||||
import {MEILISEARCH_INDEX_DEFINITIONS} from '@fluxer/meilisearch_search/src/MeilisearchIndexDefinitions';
|
||||
import type {
|
||||
GuildMemberSearchFilters,
|
||||
SearchableGuildMember,
|
||||
} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {MeiliSearch} from 'meilisearch';
|
||||
|
||||
function buildGuildMemberFilters(filters: GuildMemberSearchFilters): Array<MeilisearchFilter | undefined> {
|
||||
const clauses: Array<MeilisearchFilter | undefined> = [];
|
||||
|
||||
clauses.push(meiliEquals('guildId', filters.guildId));
|
||||
|
||||
if (filters.roleIds && filters.roleIds.length > 0) {
|
||||
// "All roles" semantics: every role id must be present.
|
||||
clauses.push(...meiliAndEquals('roleIds', filters.roleIds));
|
||||
}
|
||||
|
||||
if (filters.joinedAtGte !== undefined) clauses.push(meiliGte('joinedAt', filters.joinedAtGte));
|
||||
if (filters.joinedAtLte !== undefined) clauses.push(meiliLte('joinedAt', filters.joinedAtLte));
|
||||
|
||||
if (filters.joinSourceType && filters.joinSourceType.length > 0) {
|
||||
clauses.push(meiliOrEquals('joinSourceType', filters.joinSourceType));
|
||||
}
|
||||
|
||||
if (filters.sourceInviteCode && filters.sourceInviteCode.length > 0) {
|
||||
clauses.push(meiliOrEquals('sourceInviteCode', filters.sourceInviteCode));
|
||||
}
|
||||
|
||||
if (filters.userCreatedAtGte !== undefined) clauses.push(meiliGte('userCreatedAt', filters.userCreatedAtGte));
|
||||
if (filters.userCreatedAtLte !== undefined) clauses.push(meiliLte('userCreatedAt', filters.userCreatedAtLte));
|
||||
|
||||
if (filters.isBot !== undefined) clauses.push(meiliEquals('isBot', filters.isBot));
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildGuildMemberSort(filters: GuildMemberSearchFilters): Array<string> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'joinedAt';
|
||||
if (sortBy === 'relevance') return undefined;
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [`${sortBy}:${sortOrder}`];
|
||||
}
|
||||
|
||||
export interface MeilisearchGuildMemberAdapterOptions {
|
||||
client: MeiliSearch;
|
||||
waitForTasks: {
|
||||
enabled: boolean;
|
||||
timeoutMs: number;
|
||||
intervalMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class MeilisearchGuildMemberAdapter extends MeilisearchIndexAdapter<
|
||||
GuildMemberSearchFilters,
|
||||
SearchableGuildMember
|
||||
> {
|
||||
constructor(options: MeilisearchGuildMemberAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: MEILISEARCH_INDEX_DEFINITIONS.guild_members,
|
||||
buildFilters: buildGuildMemberFilters,
|
||||
buildSort: buildGuildMemberSort,
|
||||
waitForTasks: options.waitForTasks,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {MeilisearchFilter} from '@fluxer/meilisearch_search/src/MeilisearchFilterUtils';
|
||||
import {compactFilters} from '@fluxer/meilisearch_search/src/MeilisearchFilterUtils';
|
||||
import type {MeilisearchIndexDefinition} from '@fluxer/meilisearch_search/src/MeilisearchIndexDefinitions';
|
||||
import type {ISearchAdapter, SearchOptions, SearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {Index, MeiliSearch, Settings} from 'meilisearch';
|
||||
|
||||
export interface MeilisearchIndexAdapterOptions<TFilters> {
|
||||
client: MeiliSearch;
|
||||
index: MeilisearchIndexDefinition;
|
||||
buildFilters: (filters: TFilters) => Array<MeilisearchFilter | undefined>;
|
||||
buildSort?: (filters: TFilters) => Array<string> | undefined;
|
||||
waitForTasks: {
|
||||
enabled: boolean;
|
||||
timeoutMs: number;
|
||||
intervalMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSettingsValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return [...value].sort();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function areSettingsEquivalent(a: Settings, b: Settings): boolean {
|
||||
// Only compare the settings we actively manage.
|
||||
const keys: Array<keyof Settings> = ['filterableAttributes', 'sortableAttributes', 'searchableAttributes'];
|
||||
for (const key of keys) {
|
||||
const left = normalizeSettingsValue(a[key]);
|
||||
const right = normalizeSettingsValue(b[key]);
|
||||
if (JSON.stringify(left) !== JSON.stringify(right)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getTaskUid(result: unknown): number | null {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const obj = result as Record<string, unknown>;
|
||||
const uid = obj.taskUid ?? obj.uid;
|
||||
return typeof uid === 'number' ? uid : null;
|
||||
}
|
||||
|
||||
export class MeilisearchIndexAdapter<TFilters, TResult extends {id: string}>
|
||||
implements ISearchAdapter<TFilters, TResult>
|
||||
{
|
||||
private readonly client: MeiliSearch;
|
||||
private readonly indexDefinition: MeilisearchIndexDefinition;
|
||||
private readonly buildFilters: (filters: TFilters) => Array<MeilisearchFilter | undefined>;
|
||||
private readonly buildSort: ((filters: TFilters) => Array<string> | undefined) | undefined;
|
||||
private readonly waitForTasks: MeilisearchIndexAdapterOptions<TFilters>['waitForTasks'];
|
||||
|
||||
private index: Index<TResult> | null = null;
|
||||
private initialized = false;
|
||||
|
||||
constructor(options: MeilisearchIndexAdapterOptions<TFilters>) {
|
||||
this.client = options.client;
|
||||
this.indexDefinition = options.index;
|
||||
this.buildFilters = options.buildFilters;
|
||||
this.buildSort = options.buildSort;
|
||||
this.waitForTasks = options.waitForTasks;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
const indexName = this.indexDefinition.indexName;
|
||||
const primaryKey = this.indexDefinition.primaryKey;
|
||||
|
||||
const index = this.client.index<TResult>(indexName);
|
||||
try {
|
||||
await index.getRawInfo();
|
||||
} catch (error) {
|
||||
// meilisearch-js throws a MeiliSearchApiError with the following shape:
|
||||
// - error.response.status (HTTP status)
|
||||
// - error.cause.code (Meilisearch error code)
|
||||
const err = error as {
|
||||
response?: {status?: unknown};
|
||||
cause?: {code?: unknown; errorCode?: unknown};
|
||||
code?: unknown;
|
||||
errorCode?: unknown;
|
||||
status?: unknown;
|
||||
statusCode?: unknown;
|
||||
};
|
||||
const maybeStatus =
|
||||
err.response?.status ??
|
||||
err.status ??
|
||||
err.statusCode ??
|
||||
(err as {response?: {statusCode?: unknown}}).response?.statusCode;
|
||||
const maybeCode = err.cause?.code ?? err.code;
|
||||
const maybeErrorCode = err.cause?.errorCode ?? err.errorCode;
|
||||
|
||||
const isNotFound =
|
||||
maybeStatus === 404 ||
|
||||
maybeStatus === '404' ||
|
||||
maybeCode === 'index_not_found' ||
|
||||
maybeErrorCode === 'index_not_found';
|
||||
if (!isNotFound) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const createResult = await this.client.createIndex(indexName, {primaryKey});
|
||||
await this.waitForTaskIfEnabled(createResult);
|
||||
} catch (createError) {
|
||||
// If multiple processes race to create the index, ignore "already exists" errors.
|
||||
const createErr = createError as {
|
||||
response?: {status?: unknown};
|
||||
cause?: {code?: unknown; errorCode?: unknown};
|
||||
code?: unknown;
|
||||
errorCode?: unknown;
|
||||
};
|
||||
const createCode = createErr.cause?.code ?? createErr.code;
|
||||
const createErrorCode = createErr.cause?.errorCode ?? createErr.errorCode;
|
||||
const isAlreadyExists = createCode === 'index_already_exists' || createErrorCode === 'index_already_exists';
|
||||
if (!isAlreadyExists) {
|
||||
throw createError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.index = this.client.index<TResult>(indexName);
|
||||
|
||||
const desiredSettings: Settings = {
|
||||
filterableAttributes: this.indexDefinition.filterableAttributes,
|
||||
sortableAttributes: this.indexDefinition.sortableAttributes,
|
||||
searchableAttributes: this.indexDefinition.searchableAttributes,
|
||||
};
|
||||
|
||||
const currentSettings = await this.index.getSettings();
|
||||
if (!areSettingsEquivalent(currentSettings, desiredSettings)) {
|
||||
const updateResult = await this.index.updateSettings(desiredSettings);
|
||||
await this.waitForTaskIfEnabled(updateResult);
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.initialized = false;
|
||||
this.index = null;
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return this.initialized && this.index !== null;
|
||||
}
|
||||
|
||||
async indexDocument(doc: TResult): Promise<void> {
|
||||
await this.indexDocuments([doc]);
|
||||
}
|
||||
|
||||
async indexDocuments(docs: Array<TResult>): Promise<void> {
|
||||
if (docs.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!this.index) {
|
||||
throw new Error('Meilisearch adapter not initialised');
|
||||
}
|
||||
const result = await this.index.addDocuments(docs);
|
||||
await this.waitForTaskIfEnabled(result);
|
||||
}
|
||||
|
||||
async updateDocument(doc: TResult): Promise<void> {
|
||||
// Meilisearch uses addDocuments as an upsert.
|
||||
await this.indexDocuments([doc]);
|
||||
}
|
||||
|
||||
async deleteDocument(id: string): Promise<void> {
|
||||
await this.deleteDocuments([id]);
|
||||
}
|
||||
|
||||
async deleteDocuments(ids: Array<string>): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!this.index) {
|
||||
throw new Error('Meilisearch adapter not initialised');
|
||||
}
|
||||
const result = await this.index.deleteDocuments(ids);
|
||||
await this.waitForTaskIfEnabled(result);
|
||||
}
|
||||
|
||||
async deleteAllDocuments(): Promise<void> {
|
||||
if (!this.index) {
|
||||
throw new Error('Meilisearch adapter not initialised');
|
||||
}
|
||||
const result = await this.index.deleteAllDocuments();
|
||||
await this.waitForTaskIfEnabled(result);
|
||||
}
|
||||
|
||||
async search(query: string, filters: TFilters, options?: SearchOptions): Promise<SearchResult<TResult>> {
|
||||
if (!this.index) {
|
||||
throw new Error('Meilisearch adapter not initialised');
|
||||
}
|
||||
|
||||
const limit = options?.limit ?? options?.hitsPerPage ?? 25;
|
||||
const offset = options?.offset ?? (options?.page ? (options.page - 1) * (options.hitsPerPage ?? 25) : 0);
|
||||
|
||||
const filter = compactFilters(this.buildFilters(filters));
|
||||
const sort = this.buildSort?.(filters);
|
||||
|
||||
const result = await this.index.search<TResult>(query, {
|
||||
limit,
|
||||
offset,
|
||||
filter: filter.length > 0 ? filter : undefined,
|
||||
sort: sort && sort.length > 0 ? sort : undefined,
|
||||
});
|
||||
|
||||
const total = result.estimatedTotalHits ?? result.hits.length;
|
||||
|
||||
return {hits: result.hits, total};
|
||||
}
|
||||
|
||||
protected async waitForTaskIfEnabled(result: unknown): Promise<void> {
|
||||
if (!this.waitForTasks.enabled) {
|
||||
return;
|
||||
}
|
||||
const taskUid = getTaskUid(result);
|
||||
if (taskUid == null) {
|
||||
return;
|
||||
}
|
||||
const task = await this.client.tasks.waitForTask(taskUid, {
|
||||
timeout: this.waitForTasks.timeoutMs,
|
||||
interval: this.waitForTasks.intervalMs,
|
||||
});
|
||||
|
||||
// meilisearch-js resolves even when the task failed; it returns a Task
|
||||
// object with status "failed" and error details.
|
||||
const status = (task as {status?: unknown}).status;
|
||||
if (status === 'failed' || status === 'canceled') {
|
||||
const error = (task as {error?: {message?: unknown; code?: unknown}}).error;
|
||||
const message =
|
||||
typeof error?.message === 'string'
|
||||
? error.message
|
||||
: `Meilisearch task ${taskUid} finished with status ${String(status)}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {MeilisearchIndexAdapter} from '@fluxer/meilisearch_search/src/adapters/MeilisearchIndexAdapter';
|
||||
import {
|
||||
compactFilters,
|
||||
type MeilisearchFilter,
|
||||
meiliAndEquals,
|
||||
meiliEquals,
|
||||
meiliExcludeAny,
|
||||
meiliOrEquals,
|
||||
} from '@fluxer/meilisearch_search/src/MeilisearchFilterUtils';
|
||||
import {MEILISEARCH_INDEX_DEFINITIONS} from '@fluxer/meilisearch_search/src/MeilisearchIndexDefinitions';
|
||||
import type {SearchOptions, SearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {MessageSearchFilters, SearchableMessage} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {MeiliSearch} from 'meilisearch';
|
||||
|
||||
const DEFAULT_HITS_PER_PAGE = 25;
|
||||
const FETCH_MULTIPLIER = 3;
|
||||
|
||||
const HAS_FIELD_MAP: Record<string, string> = {
|
||||
image: 'hasImage',
|
||||
sound: 'hasSound',
|
||||
video: 'hasVideo',
|
||||
file: 'hasFile',
|
||||
sticker: 'hasSticker',
|
||||
embed: 'hasEmbed',
|
||||
link: 'hasLink',
|
||||
poll: 'hasPoll',
|
||||
snapshot: 'hasForward',
|
||||
};
|
||||
|
||||
function buildMessageFilters(filters: MessageSearchFilters): Array<MeilisearchFilter | undefined> {
|
||||
const clauses: Array<MeilisearchFilter | undefined> = [];
|
||||
|
||||
if (filters.guildId) {
|
||||
clauses.push(meiliEquals('guildId', filters.guildId));
|
||||
}
|
||||
|
||||
if (filters.channelId) {
|
||||
clauses.push(meiliEquals('channelId', filters.channelId));
|
||||
}
|
||||
|
||||
if (filters.channelIds && filters.channelIds.length > 0) {
|
||||
clauses.push(meiliOrEquals('channelId', filters.channelIds));
|
||||
}
|
||||
|
||||
if (filters.excludeChannelIds && filters.excludeChannelIds.length > 0) {
|
||||
clauses.push(...meiliExcludeAny('channelId', filters.excludeChannelIds));
|
||||
}
|
||||
|
||||
if (filters.authorId && filters.authorId.length > 0) {
|
||||
clauses.push(meiliOrEquals('authorId', filters.authorId));
|
||||
}
|
||||
|
||||
if (filters.excludeAuthorIds && filters.excludeAuthorIds.length > 0) {
|
||||
clauses.push(...meiliExcludeAny('authorId', filters.excludeAuthorIds));
|
||||
}
|
||||
|
||||
if (filters.authorType && filters.authorType.length > 0) {
|
||||
clauses.push(meiliOrEquals('authorType', filters.authorType));
|
||||
}
|
||||
|
||||
if (filters.excludeAuthorType && filters.excludeAuthorType.length > 0) {
|
||||
clauses.push(...meiliExcludeAny('authorType', filters.excludeAuthorType));
|
||||
}
|
||||
|
||||
if (filters.mentions && filters.mentions.length > 0) {
|
||||
clauses.push(...meiliAndEquals('mentionedUserIds', filters.mentions));
|
||||
}
|
||||
|
||||
if (filters.excludeMentions && filters.excludeMentions.length > 0) {
|
||||
clauses.push(...meiliExcludeAny('mentionedUserIds', filters.excludeMentions));
|
||||
}
|
||||
|
||||
if (filters.mentionEveryone !== undefined) {
|
||||
clauses.push(meiliEquals('mentionEveryone', filters.mentionEveryone));
|
||||
}
|
||||
|
||||
if (filters.pinned !== undefined) {
|
||||
clauses.push(meiliEquals('isPinned', filters.pinned));
|
||||
}
|
||||
|
||||
if (filters.has && filters.has.length > 0) {
|
||||
for (const hasType of filters.has) {
|
||||
const field = HAS_FIELD_MAP[hasType];
|
||||
if (field) {
|
||||
clauses.push(meiliEquals(field, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.excludeHas && filters.excludeHas.length > 0) {
|
||||
for (const hasType of filters.excludeHas) {
|
||||
const field = HAS_FIELD_MAP[hasType];
|
||||
if (field) {
|
||||
clauses.push(meiliEquals(field, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.embedType && filters.embedType.length > 0) {
|
||||
clauses.push(...meiliAndEquals('embedTypes', filters.embedType));
|
||||
}
|
||||
|
||||
if (filters.excludeEmbedTypes && filters.excludeEmbedTypes.length > 0) {
|
||||
clauses.push(...meiliExcludeAny('embedTypes', filters.excludeEmbedTypes));
|
||||
}
|
||||
|
||||
if (filters.embedProvider && filters.embedProvider.length > 0) {
|
||||
clauses.push(...meiliAndEquals('embedProviders', filters.embedProvider));
|
||||
}
|
||||
|
||||
if (filters.excludeEmbedProviders && filters.excludeEmbedProviders.length > 0) {
|
||||
clauses.push(...meiliExcludeAny('embedProviders', filters.excludeEmbedProviders));
|
||||
}
|
||||
|
||||
if (filters.linkHostname && filters.linkHostname.length > 0) {
|
||||
clauses.push(...meiliAndEquals('linkHostnames', filters.linkHostname));
|
||||
}
|
||||
|
||||
if (filters.excludeLinkHostnames && filters.excludeLinkHostnames.length > 0) {
|
||||
clauses.push(...meiliExcludeAny('linkHostnames', filters.excludeLinkHostnames));
|
||||
}
|
||||
|
||||
if (filters.attachmentFilename && filters.attachmentFilename.length > 0) {
|
||||
clauses.push(...meiliAndEquals('attachmentFilenames', filters.attachmentFilename));
|
||||
}
|
||||
|
||||
if (filters.excludeAttachmentFilenames && filters.excludeAttachmentFilenames.length > 0) {
|
||||
clauses.push(...meiliExcludeAny('attachmentFilenames', filters.excludeAttachmentFilenames));
|
||||
}
|
||||
|
||||
if (filters.attachmentExtension && filters.attachmentExtension.length > 0) {
|
||||
clauses.push(...meiliAndEquals('attachmentExtensions', filters.attachmentExtension));
|
||||
}
|
||||
|
||||
if (filters.excludeAttachmentExtensions && filters.excludeAttachmentExtensions.length > 0) {
|
||||
clauses.push(...meiliExcludeAny('attachmentExtensions', filters.excludeAttachmentExtensions));
|
||||
}
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildMessageSort(filters: MessageSearchFilters): Array<string> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'timestamp';
|
||||
if (sortBy === 'relevance') {
|
||||
return undefined;
|
||||
}
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [`createdAt:${sortOrder}`];
|
||||
}
|
||||
|
||||
function getLimit(options?: SearchOptions): number {
|
||||
return options?.limit ?? options?.hitsPerPage ?? DEFAULT_HITS_PER_PAGE;
|
||||
}
|
||||
|
||||
function getOffset(options?: SearchOptions): number {
|
||||
return options?.offset ?? (options?.page ? (options.page - 1) * (options.hitsPerPage ?? DEFAULT_HITS_PER_PAGE) : 0);
|
||||
}
|
||||
|
||||
function applyMaxMinIdFilters(hits: Array<SearchableMessage>, filters: MessageSearchFilters): Array<SearchableMessage> {
|
||||
let filtered = hits;
|
||||
if (filters.maxId != null) {
|
||||
const maxId = BigInt(filters.maxId);
|
||||
filtered = filtered.filter((message) => BigInt(message.id) < maxId);
|
||||
}
|
||||
if (filters.minId != null) {
|
||||
const minId = BigInt(filters.minId);
|
||||
filtered = filtered.filter((message) => BigInt(message.id) > minId);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function applyExactPhraseFilter(hits: Array<SearchableMessage>, phrases: Array<string>): Array<SearchableMessage> {
|
||||
return hits.filter((hit) => {
|
||||
if (!hit.content) return false;
|
||||
return phrases.every((phrase) => hit.content!.includes(phrase));
|
||||
});
|
||||
}
|
||||
|
||||
function applySortByIdTiebreaker(
|
||||
hits: Array<SearchableMessage>,
|
||||
filters: MessageSearchFilters,
|
||||
): Array<SearchableMessage> {
|
||||
const sortBy = filters.sortBy ?? 'timestamp';
|
||||
if (sortBy === 'relevance') {
|
||||
return hits;
|
||||
}
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [...hits].sort((messageA, messageB) => {
|
||||
if (messageA.createdAt !== messageB.createdAt) {
|
||||
return sortOrder === 'asc' ? messageA.createdAt - messageB.createdAt : messageB.createdAt - messageA.createdAt;
|
||||
}
|
||||
const messageAId = BigInt(messageA.id);
|
||||
const messageBId = BigInt(messageB.id);
|
||||
if (sortOrder === 'asc') {
|
||||
return messageAId < messageBId ? -1 : messageAId > messageBId ? 1 : 0;
|
||||
}
|
||||
return messageBId < messageAId ? -1 : messageBId > messageAId ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
export interface MeilisearchMessageAdapterOptions {
|
||||
client: MeiliSearch;
|
||||
waitForTasks: {
|
||||
enabled: boolean;
|
||||
timeoutMs: number;
|
||||
intervalMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class MeilisearchMessageAdapter extends MeilisearchIndexAdapter<MessageSearchFilters, SearchableMessage> {
|
||||
constructor(options: MeilisearchMessageAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: MEILISEARCH_INDEX_DEFINITIONS.messages,
|
||||
buildFilters: buildMessageFilters,
|
||||
buildSort: buildMessageSort,
|
||||
waitForTasks: options.waitForTasks,
|
||||
});
|
||||
}
|
||||
|
||||
override async search(
|
||||
query: string,
|
||||
filters: MessageSearchFilters,
|
||||
options?: SearchOptions,
|
||||
): Promise<SearchResult<SearchableMessage>> {
|
||||
const limit = getLimit(options);
|
||||
const offset = getOffset(options);
|
||||
|
||||
// We often post-filter (exact phrases, min/max snowflake id), so over-fetch candidates.
|
||||
const fetchLimit = Math.max((limit + offset) * FETCH_MULTIPLIER, limit);
|
||||
|
||||
const exactPhrases = filters.exactPhrases ?? [];
|
||||
const contents = filters.contents ?? [];
|
||||
|
||||
if (contents.length > 0) {
|
||||
const resultMap = new Map<string, SearchableMessage>();
|
||||
const searchResults = await Promise.all(
|
||||
contents.map((term) =>
|
||||
super.search(
|
||||
term,
|
||||
{...filters, contents: undefined, exactPhrases: undefined},
|
||||
{...options, limit: fetchLimit, offset: 0},
|
||||
),
|
||||
),
|
||||
);
|
||||
for (const result of searchResults) {
|
||||
for (const hit of result.hits) {
|
||||
if (!resultMap.has(hit.id)) {
|
||||
resultMap.set(hit.id, hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mergedHits = Array.from(resultMap.values());
|
||||
mergedHits = applyMaxMinIdFilters(mergedHits, filters);
|
||||
if (exactPhrases.length > 0) {
|
||||
mergedHits = applyExactPhraseFilter(mergedHits, exactPhrases);
|
||||
}
|
||||
const sorted = applySortByIdTiebreaker(mergedHits, filters);
|
||||
return {
|
||||
hits: sorted.slice(offset, offset + limit),
|
||||
total: mergedHits.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (exactPhrases.length > 0) {
|
||||
const phraseTerms = exactPhrases.join(' ');
|
||||
const searchTerm = query ? `${query} ${phraseTerms}` : phraseTerms;
|
||||
const result = await super.search(
|
||||
searchTerm,
|
||||
{...filters, exactPhrases: undefined},
|
||||
{...options, limit: fetchLimit, offset: 0},
|
||||
);
|
||||
let filteredHits = applyExactPhraseFilter(result.hits, exactPhrases);
|
||||
filteredHits = applyMaxMinIdFilters(filteredHits, filters);
|
||||
const sorted = applySortByIdTiebreaker(filteredHits, filters);
|
||||
return {
|
||||
hits: sorted.slice(offset, offset + limit),
|
||||
total: filteredHits.length,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await super.search(query, filters, {...options, limit: fetchLimit, offset: 0});
|
||||
const filtered = applyMaxMinIdFilters(result.hits, filters);
|
||||
const sorted = applySortByIdTiebreaker(filtered, filters);
|
||||
return {
|
||||
hits: sorted.slice(offset, offset + limit),
|
||||
total: filtered.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {MeilisearchIndexAdapter} from '@fluxer/meilisearch_search/src/adapters/MeilisearchIndexAdapter';
|
||||
import {
|
||||
compactFilters,
|
||||
type MeilisearchFilter,
|
||||
meiliEquals,
|
||||
meiliIsNotNull,
|
||||
meiliIsNull,
|
||||
} from '@fluxer/meilisearch_search/src/MeilisearchFilterUtils';
|
||||
import {MEILISEARCH_INDEX_DEFINITIONS} from '@fluxer/meilisearch_search/src/MeilisearchIndexDefinitions';
|
||||
import type {ReportSearchFilters, SearchableReport} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {MeiliSearch} from 'meilisearch';
|
||||
|
||||
function buildReportFilters(filters: ReportSearchFilters): Array<MeilisearchFilter | undefined> {
|
||||
const clauses: Array<MeilisearchFilter | undefined> = [];
|
||||
|
||||
if (filters.reporterId) clauses.push(meiliEquals('reporterId', filters.reporterId));
|
||||
if (filters.status !== undefined) clauses.push(meiliEquals('status', filters.status));
|
||||
if (filters.reportType !== undefined) clauses.push(meiliEquals('reportType', filters.reportType));
|
||||
if (filters.category) clauses.push(meiliEquals('category', filters.category));
|
||||
if (filters.reportedUserId) clauses.push(meiliEquals('reportedUserId', filters.reportedUserId));
|
||||
if (filters.reportedGuildId) clauses.push(meiliEquals('reportedGuildId', filters.reportedGuildId));
|
||||
if (filters.reportedMessageId) clauses.push(meiliEquals('reportedMessageId', filters.reportedMessageId));
|
||||
if (filters.guildContextId) clauses.push(meiliEquals('guildContextId', filters.guildContextId));
|
||||
if (filters.resolvedByAdminId) clauses.push(meiliEquals('resolvedByAdminId', filters.resolvedByAdminId));
|
||||
|
||||
if (filters.isResolved !== undefined) {
|
||||
clauses.push(filters.isResolved ? meiliIsNotNull('resolvedAt') : meiliIsNull('resolvedAt'));
|
||||
}
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildReportSort(filters: ReportSearchFilters): Array<string> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'reportedAt';
|
||||
if (sortBy === 'relevance') return undefined;
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [`${sortBy}:${sortOrder}`];
|
||||
}
|
||||
|
||||
export interface MeilisearchReportAdapterOptions {
|
||||
client: MeiliSearch;
|
||||
waitForTasks: {
|
||||
enabled: boolean;
|
||||
timeoutMs: number;
|
||||
intervalMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class MeilisearchReportAdapter extends MeilisearchIndexAdapter<ReportSearchFilters, SearchableReport> {
|
||||
constructor(options: MeilisearchReportAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: MEILISEARCH_INDEX_DEFINITIONS.reports,
|
||||
buildFilters: buildReportFilters,
|
||||
buildSort: buildReportSort,
|
||||
waitForTasks: options.waitForTasks,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {MeilisearchIndexAdapter} from '@fluxer/meilisearch_search/src/adapters/MeilisearchIndexAdapter';
|
||||
import {
|
||||
compactFilters,
|
||||
type MeilisearchFilter,
|
||||
meiliAndEquals,
|
||||
meiliEquals,
|
||||
meiliGte,
|
||||
meiliIsNotNull,
|
||||
meiliIsNull,
|
||||
meiliLte,
|
||||
} from '@fluxer/meilisearch_search/src/MeilisearchFilterUtils';
|
||||
import {MEILISEARCH_INDEX_DEFINITIONS} from '@fluxer/meilisearch_search/src/MeilisearchIndexDefinitions';
|
||||
import type {SearchableUser, UserSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {MeiliSearch} from 'meilisearch';
|
||||
|
||||
function buildUserFilters(filters: UserSearchFilters): Array<MeilisearchFilter | undefined> {
|
||||
const clauses: Array<MeilisearchFilter | undefined> = [];
|
||||
|
||||
if (filters.isBot !== undefined) clauses.push(meiliEquals('isBot', filters.isBot));
|
||||
if (filters.isSystem !== undefined) clauses.push(meiliEquals('isSystem', filters.isSystem));
|
||||
if (filters.emailVerified !== undefined) clauses.push(meiliEquals('emailVerified', filters.emailVerified));
|
||||
if (filters.emailBounced !== undefined) clauses.push(meiliEquals('emailBounced', filters.emailBounced));
|
||||
|
||||
if (filters.hasPremium !== undefined) {
|
||||
clauses.push(filters.hasPremium ? meiliIsNotNull('premiumType') : meiliIsNull('premiumType'));
|
||||
}
|
||||
if (filters.isTempBanned !== undefined) {
|
||||
clauses.push(filters.isTempBanned ? meiliIsNotNull('tempBannedUntil') : meiliIsNull('tempBannedUntil'));
|
||||
}
|
||||
if (filters.isPendingDeletion !== undefined) {
|
||||
clauses.push(filters.isPendingDeletion ? meiliIsNotNull('pendingDeletionAt') : meiliIsNull('pendingDeletionAt'));
|
||||
}
|
||||
|
||||
if (filters.hasAcl && filters.hasAcl.length > 0) {
|
||||
clauses.push(...meiliAndEquals('acls', filters.hasAcl));
|
||||
}
|
||||
|
||||
if (filters.minSuspiciousActivityFlags !== undefined) {
|
||||
clauses.push(meiliGte('suspiciousActivityFlags', filters.minSuspiciousActivityFlags));
|
||||
}
|
||||
|
||||
if (filters.createdAtGreaterThanOrEqual !== undefined) {
|
||||
clauses.push(meiliGte('createdAt', filters.createdAtGreaterThanOrEqual));
|
||||
}
|
||||
if (filters.createdAtLessThanOrEqual !== undefined) {
|
||||
clauses.push(meiliLte('createdAt', filters.createdAtLessThanOrEqual));
|
||||
}
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildUserSort(filters: UserSearchFilters): Array<string> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'createdAt';
|
||||
if (sortBy === 'relevance') return undefined;
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [`${sortBy}:${sortOrder}`];
|
||||
}
|
||||
|
||||
export interface MeilisearchUserAdapterOptions {
|
||||
client: MeiliSearch;
|
||||
waitForTasks: {
|
||||
enabled: boolean;
|
||||
timeoutMs: number;
|
||||
intervalMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class MeilisearchUserAdapter extends MeilisearchIndexAdapter<UserSearchFilters, SearchableUser> {
|
||||
constructor(options: MeilisearchUserAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: MEILISEARCH_INDEX_DEFINITIONS.users,
|
||||
buildFilters: buildUserFilters,
|
||||
buildSort: buildUserSort,
|
||||
waitForTasks: options.waitForTasks,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user