Merge pull request #113 from ktdd/search-improvements-v1

Search improvements
This commit is contained in:
rustmailer
2026-01-14 14:32:38 +08:00
committed by GitHub
5 changed files with 78 additions and 62 deletions
+33 -23
View File
@@ -57,7 +57,7 @@ use tantivy::{
AggregationCollector, Key, AggregationCollector, Key,
}, },
collector::{Count, FacetCollector, TopDocs}, 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}, schema::{Facet, IndexRecordOption, Value},
store::{Compressor, ZstdCompressor}, store::{Compressor, ZstdCompressor},
DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order, DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order,
@@ -306,11 +306,12 @@ impl EnvelopeIndexManager {
(f.f_bcc, &filter.bcc), (f.f_bcc, &filter.bcc),
] { ] {
if let Some(ref v) = opt_value { if let Some(ref v) = opt_value {
let term = Term::from_field_text(field, v); if let Ok(query) = RegexQuery::from_pattern(v.as_str(), field) {
subqueries.push(( subqueries.push((
Occur::Must, Occur::Must,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)), Box::new(query),
)); ));
}
} }
} }
@@ -327,11 +328,12 @@ impl EnvelopeIndexManager {
} }
if let Some(ref name) = filter.attachment_name { if let Some(ref name) = filter.attachment_name {
let term = Term::from_field_text(f.f_attachments, name); if let Ok(query) = RegexQuery::from_pattern(name.as_str(), f.f_attachments) {
subqueries.push(( subqueries.push((
Occur::Must, Occur::Must,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)), Box::new(query),
)); ));
}
} }
let start_bound = if let Some(from) = filter.since { let start_bound = if let Some(from) = filter.since {
@@ -351,20 +353,28 @@ impl EnvelopeIndexManager {
subqueries.push((Occur::Must, Box::new(q))); subqueries.push((Occur::Must, Box::new(q)));
} }
if let Some(account_id) = filter.account_id { if let Some(account_ids) = filter.account_ids {
let term = Term::from_field_u64(f.f_account_id, account_id); let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
subqueries.push(( for id in account_ids {
Occur::Must, let term = Term::from_field_u64(f.f_account_id, id);
Box::new(TermQuery::new(term, IndexRecordOption::Basic)), 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 { if let Some(mailbox_ids) = filter.mailbox_ids {
let term = Term::from_field_u64(f.f_mailbox_id, mailbox_id); let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
subqueries.push(( for id in mailbox_ids {
Occur::Must, let term = Term::from_field_u64(f.f_mailbox_id, id);
Box::new(TermQuery::new(term, IndexRecordOption::Basic)), 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 { let start_bound = if let Some(from) = filter.min_size {
+2 -2
View File
@@ -39,8 +39,8 @@ pub struct SearchFilter {
pub bcc: Option<String>, pub bcc: Option<String>,
pub since: Option<i64>, pub since: Option<i64>,
pub before: Option<i64>, pub before: Option<i64>,
pub account_id: Option<u64>, pub account_ids: Option<Vec<u64>>,
pub mailbox_id: Option<u64>, pub mailbox_ids: Option<Vec<u64>>,
pub min_size: Option<u64>, pub min_size: Option<u64>,
pub max_size: Option<u64>, pub max_size: Option<u64>,
pub message_id: Option<String>, pub message_id: Option<String>,
+1
View File
@@ -21,6 +21,7 @@ import axiosInstance from "@/api/axiosInstance";
export interface MailboxData { export interface MailboxData {
account_id: number;
attributes: { attr: string; extension: string | null }[]; attributes: { attr: string; extension: string | null }[];
delimiter: string | null; delimiter: string | null;
exists: number; exists: number;
+2 -2
View File
@@ -273,7 +273,7 @@ export function VirtualizedSelect({
.filter(Boolean); .filter(Boolean);
if (selectedLabels.length === 0) return placeholder; if (selectedLabels.length === 0) return placeholder;
return `${selectedLabels[0]} +${selectedLabels.length - 1} more`; return selectedLabels.join(", ");
}; };
return ( return (
@@ -287,7 +287,7 @@ export function VirtualizedSelect({
className={cn('justify-between', className)} className={cn('justify-between', className)}
disabled={isLoading || disabled} disabled={isLoading || disabled}
> >
{getDisplayText()} <span className='truncate'>{getDisplayText()}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
+40 -35
View File
@@ -32,46 +32,42 @@ import { VirtualizedSelect } from "@/components/virtualized-select";
import useMinimalAccountList from "@/hooks/use-minimal-account-list"; import useMinimalAccountList from "@/hooks/use-minimal-account-list";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { list_mailboxes, MailboxData } from "@/api/mailbox/api"; 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 { useSearchContext } from "./context";
import { toast } from "@/hooks/use-toast"; import { toast } from "@/hooks/use-toast";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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("")), text: z.string().optional().or(z.literal("")),
from: z from: z
.string() .string()
.email({ message: t('validation.invalidEmail') })
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
to: z to: z
.string() .string()
.email({ message: t('validation.invalidEmail') })
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
cc: z cc: z
.string() .string()
.email({ message: t('validation.invalidEmail') })
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
bcc: z bcc: z
.string() .string()
.email({ message: t('validation.invalidEmail') })
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
has_attachment: z.boolean().optional(), has_attachment: z.boolean().optional(),
attachment_name: z.string().optional().or(z.literal("")), attachment_name: z.string().optional().or(z.literal("")),
since: z.date().optional(), since: z.date().optional(),
before: z.date().optional(), before: z.date().optional(),
account_id: z.number().optional().or(z.literal("")), account_ids: z.array(z.number()),
mailbox_id: z.number().optional().or(z.literal("")), mailbox_ids: z.array(z.number()),
size_preset: z.enum(['any', 'tiny', 'small', 'medium', 'large', 'huge']).optional(), size_preset: z.enum(['any', 'tiny', 'small', 'medium', 'large', 'huge']).optional(),
message_id: z.string().optional().or(z.literal("")), message_id: z.string().optional().or(z.literal("")),
}); });
type SearchFilterForm = z.infer<ReturnType<typeof getSearchFilterSchema>>; type SearchFilterForm = z.infer<typeof searchFilterSchema>;
interface Props { interface Props {
@@ -89,6 +85,7 @@ const isEmptyValue = (value: any): boolean => {
if (typeof value === 'number' && isNaN(value)) return true; if (typeof value === 'number' && isNaN(value)) return true;
if (value === false) return true; if (value === false) return true;
if (value === 0) return true; if (value === 0) return true;
if (Array.isArray(value) && value.length === 0) return true;
return false; return false;
}; };
@@ -120,11 +117,10 @@ function withSizePreset(values: Record<string, any>) {
export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChange }: Props) { export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChange }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
const [selectedAccountId, setSelectedAccountId] = useState<number | undefined>(undefined); const [selectedAccountIds, setSelectedAccountIds] = useState<number[]>([]);
const { accountsOptions, isLoading: accountsIsLoading } = useMinimalAccountList(); const { accountsOptions, isLoading: accountsIsLoading } = useMinimalAccountList();
const { selectedTags } = useSearchContext(); const { selectedTags } = useSearchContext();
const searchFilterSchema = getSearchFilterSchema(t)
const form = useForm<SearchFilterForm>({ const form = useForm<SearchFilterForm>({
resolver: zodResolver(searchFilterSchema), resolver: zodResolver(searchFilterSchema),
defaultValues: { defaultValues: {
@@ -139,23 +135,29 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
has_attachment: false, has_attachment: false,
since: undefined, since: undefined,
before: undefined, before: undefined,
account_id: undefined, account_ids: [],
mailbox_id: undefined, mailbox_ids: [],
}, },
mode: "onChange", mode: "onChange",
}); });
const navigate = useNavigate(); const navigate = useNavigate();
const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({
queryKey: ['search-account-mailboxes', `${selectedAccountId}`], const { mailboxes, isMailboxesLoading } = useQueries({
queryFn: () => list_mailboxes(selectedAccountId!, false), queries: selectedAccountIds.map((id) => ({
enabled: !!selectedAccountId, 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?.filter((item) => !!item).map((mailbox: MailboxData) => ({
const mailboxesOptions = mailboxes?.map((mailbox: MailboxData) => ({
value: mailbox.id.toString(), value: mailbox.id.toString(),
label: mailbox.name, 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); let cleaned = cleanEmpty(values);
const payload = withSizePreset(cleaned); const payload = withSizePreset(cleaned);
const finalPayload = const finalPayload =
selectedTags.length > 0 selectedTags.length > 0
? { ...payload, tags: selectedTags } ? { ...payload, tags: selectedTags }
@@ -189,12 +190,12 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
attachment_name: "", attachment_name: "",
since: undefined, since: undefined,
before: undefined, before: undefined,
account_id: undefined, account_ids: [],
mailbox_id: undefined, mailbox_ids: [],
size_preset: 'any', size_preset: 'any',
message_id: "", message_id: "",
}); });
setSelectedAccountId(undefined); setSelectedAccountIds([]);
} }
return (<Sheet return (<Sheet
@@ -218,21 +219,21 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField <FormField
control={form.control} control={form.control}
name="account_id" name="account_ids"
render={({ field }) => ( render={({ field }) => (
<FormItem className="min-w-[180px]"> <FormItem className="min-w-[180px]">
<div className="flex items-center gap-2"> <div className="flex flex-col gap-2">
<FormLabel className="text-xs whitespace-nowrap">{t('search.account')}:</FormLabel> <FormLabel className="text-xs whitespace-nowrap">{t('search.account')}</FormLabel>
<FormControl className="flex-1"> <FormControl className="flex-1">
<VirtualizedSelect <VirtualizedSelect
options={accountsOptions} options={accountsOptions}
isLoading={accountsIsLoading} isLoading={accountsIsLoading}
onSelectOption={(values) => { onSelectOption={(values) => {
const account_id = parseInt(values[0], 10); const ids = values.map((id) => parseInt(id, 10)).sort()
setSelectedAccountId(account_id); setSelectedAccountIds(ids)
field.onChange(account_id); field.onChange(ids)
}} }}
value={field.value?.toString() ?? ""} value={field.value.map(String)}
placeholder={t('search.selectAccount')} placeholder={t('search.selectAccount')}
className="h-10 w-full" className="h-10 w-full"
noItemsComponent={ noItemsComponent={
@@ -247,6 +248,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
</Button> </Button>
</div> </div>
} }
multiple
/> />
</FormControl> </FormControl>
</div> </div>
@@ -256,17 +258,19 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
/> />
<FormField <FormField
control={form.control} control={form.control}
name="mailbox_id" name="mailbox_ids"
render={({ field }) => ( render={({ field }) => (
<FormItem className="min-w-[180px]"> <FormItem className="min-w-[180px]">
<div className="flex items-center gap-2"> <div className="flex flex-col gap-2">
<FormLabel className="text-xs whitespace-nowrap">{t('search.mailbox')}:</FormLabel> <FormLabel className="text-xs whitespace-nowrap">{t('search.mailbox')}</FormLabel>
<FormControl className="flex-1"> <FormControl className="flex-1">
<VirtualizedSelect <VirtualizedSelect
options={mailboxesOptions} options={mailboxesOptions}
isLoading={isMailboxesLoading} isLoading={isMailboxesLoading}
onSelectOption={(values) => field.onChange(parseInt(values[0], 10))} onSelectOption={(values) => {
value={field.value?.toString() ?? ""} field.onChange(values.map((id) => parseInt(id, 10)))
}}
value={field.value.map(String)}
placeholder={t('search.selectMailbox')} placeholder={t('search.selectMailbox')}
className="h-10 w-full" className="h-10 w-full"
noItemsComponent={ noItemsComponent={
@@ -276,6 +280,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
</p> </p>
</div> </div>
} }
multiple
/> />
</FormControl> </FormControl>
</div> </div>