From ad2a43aa3583b61c9e4f429db249f5260d52055f Mon Sep 17 00:00:00 2001 From: ktdd <13239117+ktdd@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:48:55 +0200 Subject: [PATCH] Search improvements --- src/modules/indexer/manager.rs | 56 ++++++++++------- src/modules/message/search.rs | 4 +- web/src/api/mailbox/api.ts | 1 + web/src/components/virtualized-select.tsx | 4 +- web/src/features/search/search-form.tsx | 75 ++++++++++++----------- 5 files changed, 78 insertions(+), 62 deletions(-) diff --git a/src/modules/indexer/manager.rs b/src/modules/indexer/manager.rs index 6692515..42445dd 100644 --- a/src/modules/indexer/manager.rs +++ b/src/modules/indexer/manager.rs @@ -57,7 +57,7 @@ use tantivy::{ AggregationCollector, Key, }, collector::{Count, FacetCollector, TopDocs}, - query::{AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, TermQuery}, + query::{AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, RegexQuery, TermQuery}, schema::{Facet, IndexRecordOption, Value}, store::{Compressor, ZstdCompressor}, DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order, @@ -306,11 +306,12 @@ impl EnvelopeIndexManager { (f.f_bcc, &filter.bcc), ] { if let Some(ref v) = opt_value { - let term = Term::from_field_text(field, v); - subqueries.push(( - Occur::Must, - Box::new(TermQuery::new(term, IndexRecordOption::Basic)), - )); + if let Ok(query) = RegexQuery::from_pattern(v.as_str(), field) { + subqueries.push(( + Occur::Must, + Box::new(query), + )); + } } } @@ -327,11 +328,12 @@ impl EnvelopeIndexManager { } if let Some(ref name) = filter.attachment_name { - let term = Term::from_field_text(f.f_attachments, name); - subqueries.push(( - Occur::Must, - Box::new(TermQuery::new(term, IndexRecordOption::Basic)), - )); + if let Ok(query) = RegexQuery::from_pattern(name.as_str(), f.f_attachments) { + subqueries.push(( + Occur::Must, + Box::new(query), + )); + } } let start_bound = if let Some(from) = filter.since { @@ -351,20 +353,28 @@ impl EnvelopeIndexManager { subqueries.push((Occur::Must, Box::new(q))); } - if let Some(account_id) = filter.account_id { - let term = Term::from_field_u64(f.f_account_id, account_id); - subqueries.push(( - Occur::Must, - Box::new(TermQuery::new(term, IndexRecordOption::Basic)), - )); + if let Some(account_ids) = filter.account_ids { + let mut should_queries: Vec<(Occur, Box)> = Vec::new(); + for id in account_ids { + let term = Term::from_field_u64(f.f_account_id, id); + should_queries.push(( + Occur::Should, + Box::new(TermQuery::new(term, IndexRecordOption::Basic)), + )); + } + subqueries.push((Occur::Must, Box::new(BooleanQuery::new(should_queries)))); } - if let Some(mailbox_id) = filter.mailbox_id { - let term = Term::from_field_u64(f.f_mailbox_id, mailbox_id); - subqueries.push(( - Occur::Must, - Box::new(TermQuery::new(term, IndexRecordOption::Basic)), - )); + if let Some(mailbox_ids) = filter.mailbox_ids { + let mut should_queries: Vec<(Occur, Box)> = Vec::new(); + for id in mailbox_ids { + let term = Term::from_field_u64(f.f_mailbox_id, id); + should_queries.push(( + Occur::Should, + Box::new(TermQuery::new(term, IndexRecordOption::Basic)), + )); + } + subqueries.push((Occur::Must, Box::new(BooleanQuery::new(should_queries)))); } let start_bound = if let Some(from) = filter.min_size { diff --git a/src/modules/message/search.rs b/src/modules/message/search.rs index e6c2400..ccac1d5 100644 --- a/src/modules/message/search.rs +++ b/src/modules/message/search.rs @@ -39,8 +39,8 @@ pub struct SearchFilter { pub bcc: Option, pub since: Option, pub before: Option, - pub account_id: Option, - pub mailbox_id: Option, + pub account_ids: Option>, + pub mailbox_ids: Option>, pub min_size: Option, pub max_size: Option, pub message_id: Option, diff --git a/web/src/api/mailbox/api.ts b/web/src/api/mailbox/api.ts index 39bc3eb..ec7ff40 100644 --- a/web/src/api/mailbox/api.ts +++ b/web/src/api/mailbox/api.ts @@ -21,6 +21,7 @@ import axiosInstance from "@/api/axiosInstance"; export interface MailboxData { + account_id: number; attributes: { attr: string; extension: string | null }[]; delimiter: string | null; exists: number; diff --git a/web/src/components/virtualized-select.tsx b/web/src/components/virtualized-select.tsx index 20651cf..701c846 100644 --- a/web/src/components/virtualized-select.tsx +++ b/web/src/components/virtualized-select.tsx @@ -273,7 +273,7 @@ export function VirtualizedSelect({ .filter(Boolean); if (selectedLabels.length === 0) return placeholder; - return `${selectedLabels[0]} +${selectedLabels.length - 1} more`; + return selectedLabels.join(", "); }; return ( @@ -287,7 +287,7 @@ export function VirtualizedSelect({ className={cn('justify-between', className)} disabled={isLoading || disabled} > - {getDisplayText()} + {getDisplayText()} diff --git a/web/src/features/search/search-form.tsx b/web/src/features/search/search-form.tsx index a38f2ab..7e3569f 100644 --- a/web/src/features/search/search-form.tsx +++ b/web/src/features/search/search-form.tsx @@ -32,46 +32,42 @@ import { VirtualizedSelect } from "@/components/virtualized-select"; import useMinimalAccountList from "@/hooks/use-minimal-account-list"; import { useNavigate } from "@tanstack/react-router"; import { list_mailboxes, MailboxData } from "@/api/mailbox/api"; -import { useQuery } from "@tanstack/react-query"; +import { useQueries } from "@tanstack/react-query"; import { useSearchContext } from "./context"; import { toast } from "@/hooks/use-toast"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { useTranslation } from "react-i18next"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -const getSearchFilterSchema = (t: (key: string) => string) => z.object({ +const searchFilterSchema = z.object({ text: z.string().optional().or(z.literal("")), from: z .string() - .email({ message: t('validation.invalidEmail') }) .optional() .or(z.literal("")), to: z .string() - .email({ message: t('validation.invalidEmail') }) .optional() .or(z.literal("")), cc: z .string() - .email({ message: t('validation.invalidEmail') }) .optional() .or(z.literal("")), bcc: z .string() - .email({ message: t('validation.invalidEmail') }) .optional() .or(z.literal("")), has_attachment: z.boolean().optional(), attachment_name: z.string().optional().or(z.literal("")), since: z.date().optional(), before: z.date().optional(), - account_id: z.number().optional().or(z.literal("")), - mailbox_id: z.number().optional().or(z.literal("")), + account_ids: z.array(z.number()), + mailbox_ids: z.array(z.number()), size_preset: z.enum(['any', 'tiny', 'small', 'medium', 'large', 'huge']).optional(), message_id: z.string().optional().or(z.literal("")), }); -type SearchFilterForm = z.infer>; +type SearchFilterForm = z.infer; interface Props { @@ -89,6 +85,7 @@ const isEmptyValue = (value: any): boolean => { if (typeof value === 'number' && isNaN(value)) return true; if (value === false) return true; if (value === 0) return true; + if (Array.isArray(value) && value.length === 0) return true; return false; }; @@ -120,11 +117,10 @@ function withSizePreset(values: Record) { export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChange }: Props) { const { t } = useTranslation() const [showAdvanced, setShowAdvanced] = useState(false); - const [selectedAccountId, setSelectedAccountId] = useState(undefined); + const [selectedAccountIds, setSelectedAccountIds] = useState([]); const { accountsOptions, isLoading: accountsIsLoading } = useMinimalAccountList(); const { selectedTags } = useSearchContext(); - const searchFilterSchema = getSearchFilterSchema(t) const form = useForm({ resolver: zodResolver(searchFilterSchema), defaultValues: { @@ -139,23 +135,29 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang has_attachment: false, since: undefined, before: undefined, - account_id: undefined, - mailbox_id: undefined, + account_ids: [], + mailbox_ids: [], }, mode: "onChange", }); const navigate = useNavigate(); - const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({ - queryKey: ['search-account-mailboxes', `${selectedAccountId}`], - queryFn: () => list_mailboxes(selectedAccountId!, false), - enabled: !!selectedAccountId, + + const { mailboxes, isMailboxesLoading } = useQueries({ + queries: selectedAccountIds.map((id) => ({ + queryKey: ['search-account-mailboxes', id], + queryFn: () => list_mailboxes(id!, false), + })), + combine: (results) => ({ + mailboxes: results.flatMap((result) => result.data?.sort((a, b) => a.name.localeCompare(b.name))), + isMailboxesLoading: results.some((result) => result.isLoading), + }), }) - - const mailboxesOptions = mailboxes?.map((mailbox: MailboxData) => ({ + const mailboxesOptions = mailboxes?.filter((item) => !!item).map((mailbox: MailboxData) => ({ value: mailbox.id.toString(), label: mailbox.name, + description: accountsOptions.find((item) => Number(item.value) === mailbox.account_id)!.label })) || []; @@ -163,7 +165,6 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang let cleaned = cleanEmpty(values); const payload = withSizePreset(cleaned); - const finalPayload = selectedTags.length > 0 ? { ...payload, tags: selectedTags } @@ -189,12 +190,12 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang attachment_name: "", since: undefined, before: undefined, - account_id: undefined, - mailbox_id: undefined, + account_ids: [], + mailbox_ids: [], size_preset: 'any', message_id: "", }); - setSelectedAccountId(undefined); + setSelectedAccountIds([]); } return ( ( -
- {t('search.account')}: +
+ {t('search.account')} { - const account_id = parseInt(values[0], 10); - setSelectedAccountId(account_id); - field.onChange(account_id); + const ids = values.map((id) => parseInt(id, 10)).sort() + setSelectedAccountIds(ids) + field.onChange(ids) }} - value={field.value?.toString() ?? ""} + value={field.value.map(String)} placeholder={t('search.selectAccount')} className="h-10 w-full" noItemsComponent={ @@ -247,6 +248,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
} + multiple />
@@ -256,17 +258,19 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang /> ( -
- {t('search.mailbox')}: +
+ {t('search.mailbox')} field.onChange(parseInt(values[0], 10))} - value={field.value?.toString() ?? ""} + onSelectOption={(values) => { + field.onChange(values.map((id) => parseInt(id, 10))) + }} + value={field.value.map(String)} placeholder={t('search.selectMailbox')} className="h-10 w-full" noItemsComponent={ @@ -276,6 +280,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang

} + multiple />