feat(search): expand default search scope and support specific field filtering

This commit is contained in:
rustmailer
2026-03-19 15:44:20 +08:00
parent 2228e98410
commit 884fdeba10
24 changed files with 236 additions and 150 deletions
+16
View File
@@ -1168,6 +1168,18 @@ impl DuckDBManager {
} }
} }
if let Some(subject) = filter.subject {
let pattern = format!("(?i){}", subject);
base_sql.push_str(" AND regexp_matches(coalesce(e.subject, ''), ?)");
args.push(pattern.into());
}
if let Some(body) = filter.body {
let pattern = format!("(?i){}", body);
base_sql.push_str(" AND regexp_matches(coalesce(e.body, ''), ?)");
args.push(pattern.into());
}
if let Some(text) = filter.text { if let Some(text) = filter.text {
let pattern = format!("(?i){}", text); let pattern = format!("(?i){}", text);
base_sql.push_str( base_sql.push_str(
@@ -1175,10 +1187,14 @@ impl DuckDBManager {
AND ( AND (
regexp_matches(coalesce(e.subject, ''), ?) regexp_matches(coalesce(e.subject, ''), ?)
OR regexp_matches(coalesce(e.body, ''), ?) OR regexp_matches(coalesce(e.body, ''), ?)
OR regexp_matches(coalesce(e.sender, ''), ?)
OR regexp_matches(array_to_string(e.recipients, ','), ?)
) )
", ",
); );
args.push(pattern.clone().into()); args.push(pattern.clone().into());
args.push(pattern.clone().into());
args.push(pattern.clone().into());
args.push(pattern.into()); args.push(pattern.into());
} }
-2
View File
@@ -16,8 +16,6 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::io::Write;
use mail_parser::{parsers::MessageStream, MessageParser, MimeHeaders}; use mail_parser::{parsers::MessageStream, MessageParser, MimeHeaders};
use crate::{ use crate::{
+18 -8
View File
@@ -34,6 +34,8 @@ use crate::{
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)] #[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct SearchFilter { pub struct SearchFilter {
pub text: Option<String>, pub text: Option<String>,
pub subject: Option<String>,
pub body: Option<String>,
pub from: Option<String>, pub from: Option<String>,
pub to: Option<String>, pub to: Option<String>,
pub cc: Option<String>, pub cc: Option<String>,
@@ -80,15 +82,23 @@ impl SearchRequest {
)); ));
} }
if let Some(ref pattern) = self.filter.text { let validate = |pattern: &Option<String>| -> BichonResult<()> {
if let Err(_) = duckdb()?.validate_regex(pattern) { if let Some(ref p) = pattern {
return Err(raise_error!( if duckdb()?.validate_regex(p).is_err() {
"Invalid search pattern: The regular expression is not supported by DuckDB." return Err(raise_error!(
.into(), "Invalid search pattern: The regular expression is not supported by DuckDB.".into(),
ErrorCode::InvalidParameter ErrorCode::InvalidParameter
)); ));
}
} }
} Ok(())
};
validate(&self.filter.text)?;
validate(&self.filter.subject)?;
validate(&self.filter.body)?;
validate(&self.filter.from)?;
validate(&self.filter.to)?;
Ok(()) Ok(())
} }
+2 -2
View File
@@ -8,7 +8,7 @@ export function FilterResetButton() {
const { filter, setFilter } = useSearchContext(); const { filter, setFilter } = useSearchContext();
const { t } = useTranslation() const { t } = useTranslation()
const { q, ...restFilters } = filter; const { q, ...restFilters } = filter;
const activeFiltersCount = Object.keys(restFilters).filter(key => { const activeFiltersCount = Object.keys(restFilters).filter(key => {
const value = restFilters[key]; const value = restFilters[key];
if (Array.isArray(value)) return value.length > 0; if (Array.isArray(value)) return value.length > 0;
@@ -19,7 +19,7 @@ export function FilterResetButton() {
return ( return (
<Button <Button
variant="ghost" variant="default"
size="sm" size="sm"
onClick={() => setFilter(q ? { q } : {})} onClick={() => setFilter(q ? { q } : {})}
className={cn( className={cn(
+2 -2
View File
@@ -32,10 +32,10 @@ export function DataTableToolbar<TData>({
<TagFilterPopover /> <TagFilterPopover />
<TimePopover /> <TimePopover />
<MoreFiltersPopover /> <MoreFiltersPopover />
<FilterResetButton /> <DataTableViewOptions table={table} />
</div> </div>
<div className="flex-shrink-0 ml-auto lg:ml-0"> <div className="flex-shrink-0 ml-auto lg:ml-0">
<DataTableViewOptions table={table} /> <FilterResetButton />
</div> </div>
</div> </div>
</div> </div>
+126 -118
View File
@@ -1,63 +1,61 @@
import React, { useState, useEffect, useRef } from "react" import React, { useState, useEffect, useRef } from "react"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Search, X, Clock, Trash2, Info } from "lucide-react" import { Search, X, Clock, Trash2 } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useSearchContext } from "./context" import { useSearchContext } from "./context"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const STORAGE_KEY = "mail_search_history" const STORAGE_KEY = "bichon_mail_search_history"
const MAX_HISTORY = 20 const MAX_HISTORY = 20
type SearchField = "text" | "subject" | "body"
const SEARCH_FIELDS: SearchField[] = ["text", "subject", "body"]
export function TextSearchInput() { export function TextSearchInput() {
const { t } = useTranslation() const { t } = useTranslation()
const { filter, setFilter } = useSearchContext() const { filter, setFilter } = useSearchContext()
const [value, setValue] = useState(filter.text || "")
const [value, setValue] = useState("")
const [field, setField] = useState<SearchField>("text")
const [history, setHistory] = useState<string[]>([]) const [history, setHistory] = useState<string[]>([])
const [showHistory, setShowHistory] = useState(false) const [showHistory, setShowHistory] = useState(false)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const activeField = SEARCH_FIELDS.find(key => !!filter[key]) || "text"
const activeValue = filter[activeField] as string || ""
setField(activeField)
setValue(activeValue)
}, [filter])
useEffect(() => { useEffect(() => {
try { try {
const saved = localStorage.getItem(STORAGE_KEY) const saved = localStorage.getItem(STORAGE_KEY)
if (saved) { if (saved) setHistory(JSON.parse(saved))
setHistory(JSON.parse(saved))
}
} catch (err) { } catch (err) {
console.warn("Failed to load search history", err) console.warn("Failed to load search history", err)
} }
}, []) }, [])
useEffect(() => { const applyFilter = (currentField: SearchField, searchTerm: string) => {
setValue(filter.text || "") const trimmed = searchTerm.trim()
}, [filter.text])
setFilter((prev) => {
const saveToHistory = (term: string) => { const next = { ...prev }
if (!term.trim()) return SEARCH_FIELDS.forEach(f => {
delete next[f]
setHistory(prev => { })
const trimmed = term.trim() if (trimmed) {
const withoutCurrent = prev.filter(item => item !== trimmed) next[currentField] = trimmed
const newHistory = [trimmed, ...withoutCurrent].slice(0, MAX_HISTORY)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory))
} catch (err) {
console.warn("Failed to save search history", err)
} }
return next
return newHistory
}) })
}
const handleSearch = () => {
const trimmed = value.trim()
setFilter(prev => ({
...prev,
text: trimmed || undefined
}))
if (trimmed) { if (trimmed) {
saveToHistory(trimmed) saveToHistory(trimmed)
} }
@@ -65,37 +63,31 @@ export function TextSearchInput() {
inputRef.current?.blur() inputRef.current?.blur()
} }
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const saveToHistory = (term: string) => {
if (e.key === "Enter") { setHistory((prev) => {
e.preventDefault() const trimmed = term.trim()
handleSearch() const newHistory = [trimmed, ...prev.filter((item) => item !== trimmed)].slice(0, MAX_HISTORY)
} localStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory))
return newHistory
})
} }
const handleSearch = () => applyFilter(field, value)
const handleClear = () => { const handleClear = () => {
setValue("") setValue("")
setFilter(prev => { applyFilter(field, "")
const next = { ...prev }
delete next.text
return next
})
setShowHistory(false)
} }
const handleSelectHistory = (term: string) => { const handleSelectHistory = (term: string) => {
setValue(term) setValue(term)
setShowHistory(false) applyFilter(field, term)
// 如果需要点击历史立即搜索,可以在这里调用 handleSearch()
} }
const handleClearHistory = () => { const handleClearHistory = (e: React.MouseEvent) => {
e.stopPropagation()
setHistory([]) setHistory([])
try { localStorage.removeItem(STORAGE_KEY)
localStorage.removeItem(STORAGE_KEY)
} catch (err) {
console.warn("Failed to clear search history", err)
}
setShowHistory(false)
} }
useEffect(() => { useEffect(() => {
@@ -108,91 +100,107 @@ export function TextSearchInput() {
return () => document.removeEventListener("mousedown", handleClickOutside) return () => document.removeEventListener("mousedown", handleClickOutside)
}, []) }, [])
const isActive = !!filter.text?.trim()
return ( return (
<div ref={containerRef} className="relative w-full max-w-[550px] min-w-[280px]"> <div ref={containerRef} className="relative w-full max-w-[620px] min-w-[320px]">
<div className="flex flex-col gap-1.5"> <div className="flex items-center rounded-md border bg-background focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/30 transition-all">
<div className="relative flex items-center gap-1.5"> <Select
<div className="relative flex-1"> value={field}
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> onValueChange={(val) => {
<Input const newField = val as SearchField
ref={inputRef} setField(newField)
value={value} if (value.trim()) applyFilter(newField, value)
onChange={(e) => setValue(e.target.value)} }}
onFocus={() => setShowHistory(true)} >
onKeyDown={handleKeyDown} <SelectTrigger
placeholder={t('search_input.placeholder')} className={cn(
className={cn( "h-9 w-[110px] md:w-[130px] border-r border-border rounded-r-none",
"h-9 pl-9 pr-9 text-sm", "text-xs md:text-sm bg-transparent focus:ring-0 focus:ring-offset-0 shadow-none border-y-0 border-l-0"
isActive && "border-primary/50 focus-visible:ring-primary/30"
)}
/>
{value && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={handleClear}
>
<X className="h-4 w-4" />
</Button>
)} )}
</div>
<Button
size="sm"
className="h-9 px-5"
onClick={handleSearch}
disabled={!value.trim()}
> >
{t('search_input.button')} <SelectValue />
</Button> </SelectTrigger>
</div> <SelectContent className="min-w-[240px]">
<SelectItem value="text" className="font-medium cursor-pointer text-xs">
{/* 搜索范围提示 */} {t("search_input.all")}
<div className="flex items-center gap-1 px-1 opacity-60"> <p className="text-[11px] text-muted-foreground/60 leading-relaxed">
<Info className="h-3 w-3 text-muted-foreground" /> {t("search_input.all_fields_desc")}
<span className="text-[10px] text-muted-foreground"> </p>
{t('search_input.hint')} </SelectItem>
</span> <SelectItem value="subject" className="cursor-pointer text-xs">
{t("search_input.subject")}
</SelectItem>
<SelectItem value="body" className="cursor-pointer text-xs">
{t("search_input.body")}
</SelectItem>
</SelectContent>
</Select>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onFocus={() => setShowHistory(true)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder={t("search_input.placeholder")}
className="h-9 border-none shadow-none focus-visible:ring-0 pl-9 pr-10 text-sm bg-transparent w-full"
/>
{value && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={handleClear}
>
<X className="h-4 w-4" />
</Button>
)}
</div> </div>
<Button
size="sm"
className="h-7 mr-1.5 px-3 text-xs md:px-5 md:text-sm"
onClick={handleSearch}
disabled={!value.trim()}
>
{t("search_input.button")}
</Button>
</div> </div>
{showHistory && ( {showHistory && (
<div className="absolute top-10 left-0 w-full mt-1 bg-popover border rounded-md shadow-md z-50 max-h-[280px] overflow-auto"> <div className="absolute top-full left-0 w-full mt-1 bg-popover border rounded-md shadow-lg z-50 max-h-[300px] overflow-hidden flex flex-col">
<div className="py-1.5 px-3 text-xs text-muted-foreground font-medium border-b flex items-center justify-between sticky top-0 bg-popover z-10"> <div className="py-2 px-3 text-[10px] uppercase tracking-wider text-muted-foreground font-semibold border-b flex items-center justify-between bg-muted/30">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
{t('search_input.recent_title')} {t("search_input.recent_title")}
</div> </div>
{history.length > 0 && ( {history.length > 0 && (
<button <button
onClick={handleClearHistory} onClick={handleClearHistory}
className="text-xs text-destructive hover:text-destructive/80 flex items-center gap-1 hover:underline" className="text-destructive hover:underline flex items-center gap-1"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
{t('search_input.clear_history')} {t("search_input.clear_history")}
</button> </button>
)} )}
</div> </div>
{history.length > 0 ? ( <div className="overflow-auto py-1">
history.map((term, idx) => ( {history.length > 0 ? (
<button history.map((term, idx) => (
key={idx} <button
className="w-full text-left px-3 py-2 text-xs hover:bg-accent transition-colors flex items-center gap-2" key={idx}
onClick={() => handleSelectHistory(term)} className="w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex items-center gap-2 group"
> onClick={() => handleSelectHistory(term)}
<Search className="h-3.5 w-3.5 text-muted-foreground" /> >
{term} <Search className="h-3.5 w-3.5 text-muted-foreground group-hover:text-primary" />
</button> <span className="truncate flex-1">{term}</span>
)) </button>
) : ( ))
<div className="px-3 py-4 text-xs text-center text-muted-foreground"> ) : (
{t('search_input.no_history')} <div className="px-3 py-6 text-sm text-center text-muted-foreground">
</div> {t("search_input.no_history")}
)} </div>
)}
</div>
</div> </div>
)} )}
</div> </div>
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "عمليات البحث الأخيرة", "recent_title": "عمليات البحث الأخيرة",
"clear_history": "مسح السجل", "clear_history": "مسح السجل",
"no_history": "لا يوجد سجل بحث", "no_history": "لا يوجد سجل بحث",
"hint": "نطاق البحث الافتراضي: العنوان، المحتوى، وأسماء المرفقات" "all": "الكل",
"all_fields_desc": "يطابق موضوع الرسالة، النص، المرسل والمستلمين",
"subject": "موضوع الرسالة",
"body": "نص الرسالة"
} }
} }
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Seneste søgninger", "recent_title": "Seneste søgninger",
"clear_history": "Ryd historik", "clear_history": "Ryd historik",
"no_history": "Ingen søgehistorik", "no_history": "Ingen søgehistorik",
"hint": "Standard søgeområde: emne, indhold og vedhæftningsnavne" "all": "Alle",
"all_fields_desc": "Matcher emne, indhold, afsender og modtagere",
"subject": "Emne",
"body": "Indhold"
} }
} }
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Letzte Suchanfragen", "recent_title": "Letzte Suchanfragen",
"clear_history": "Verlauf löschen", "clear_history": "Verlauf löschen",
"no_history": "Kein Suchverlauf", "no_history": "Kein Suchverlauf",
"hint": "Standard-Suchbereich: Betreff, Inhalt und Anhangnamen" "all": "Alle Felder",
"all_fields_desc": "Matcht Betreff, Text, Absender und Empfänger",
"subject": "Betreff",
"body": "Inhalt"
} }
} }
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Recent searches", "recent_title": "Recent searches",
"clear_history": "Clear history", "clear_history": "Clear history",
"no_history": "No search history", "no_history": "No search history",
"hint": "Default scope: subject, body and attachment names" "all": "All Fields",
"all_fields_desc": "Matches subject, body, sender, and recipients",
"subject": "Subject",
"body": "Body"
} }
} }
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Búsquedas recientes", "recent_title": "Búsquedas recientes",
"clear_history": "Borrar historial", "clear_history": "Borrar historial",
"no_history": "Sin historial de búsqueda", "no_history": "Sin historial de búsqueda",
"hint": "Ámbito de búsqueda predeterminado: asunto, contenido y nombres de archivos adjuntos" "all": "Todo",
"all_fields_desc": "Coincide con asunto, cuerpo, remitente y destinatarios",
"subject": "Asunto",
"body": "Cuerpo del mensaje"
} }
} }
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Viimeisimmät haut", "recent_title": "Viimeisimmät haut",
"clear_history": "Tyhjennä historia", "clear_history": "Tyhjennä historia",
"no_history": "Ei hakuhistoriaa", "no_history": "Ei hakuhistoriaa",
"hint": "Oletushakualue: otsikko, sisältö ja liitteiden nimet" "all": "Kaikki",
"all_fields_desc": "Hakee aiheesta, tekstistä, lähettäjästä ja vastaanottajista",
"subject": "Aihe",
"body": "Viestiosa"
} }
} }
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Recherches récentes", "recent_title": "Recherches récentes",
"clear_history": "Effacer lhistorique", "clear_history": "Effacer lhistorique",
"no_history": "Aucun historique de recherche", "no_history": "Aucun historique de recherche",
"hint": "Portée par défaut : objet, contenu et noms des pièces jointes" "all": "Tous les champs",
"all_fields_desc": "Recherche dans l'objet, le corps, l'expéditeur et les destinataires",
"subject": "Objet",
"body": "Corps du message"
} }
} }
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Ricerche recenti", "recent_title": "Ricerche recenti",
"clear_history": "Cancella cronologia", "clear_history": "Cancella cronologia",
"no_history": "Nessuna cronologia di ricerca", "no_history": "Nessuna cronologia di ricerca",
"hint": "Ambito predefinito: oggetto, contenuto e nomi degli allegati" "all": "Tutti i campi",
"all_fields_desc": "Cerca in oggetto, testo, mittente e destinatari",
"subject": "Oggetto",
"body": "Corpo del messaggio"
} }
} }
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "最近の検索", "recent_title": "最近の検索",
"clear_history": "履歴をクリア", "clear_history": "履歴をクリア",
"no_history": "履歴なし", "no_history": "履歴なし",
"hint": "検索対象:件名・本文・添付ファイル名" "all": "すべて",
"all_fields_desc": "件名、本文、送信者、受信者に一致します",
"subject": "件名",
"body": "本文"
} }
} }
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "최근 검색", "recent_title": "최근 검색",
"clear_history": "기록 지우기", "clear_history": "기록 지우기",
"no_history": "검색 기록 없음", "no_history": "검색 기록 없음",
"hint": "기본 검색 범위: 제목, 본문 및 첨부파일 이름" "all": "전체",
"all_fields_desc": "제목, 본문, 발신자 및 수신자를 검색합니다",
"subject": "제목",
"body": "본문"
} }
} }
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Recente zoekopdrachten", "recent_title": "Recente zoekopdrachten",
"clear_history": "Geschiedenis wissen", "clear_history": "Geschiedenis wissen",
"no_history": "Geen zoekgeschiedenis", "no_history": "Geen zoekgeschiedenis",
"hint": "Standaard zoekbereik: onderwerp, inhoud en bijlagenamen" "all": "Alle velden",
"all_fields_desc": "Zoekt in onderwerp, tekst, afzender en ontvangers",
"subject": "Onderwerp",
"body": "Inhoud"
} }
} }
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Nylige søk", "recent_title": "Nylige søk",
"clear_history": "Tøm historikk", "clear_history": "Tøm historikk",
"no_history": "Ingen søkehistorikk", "no_history": "Ingen søkehistorikk",
"hint": "Standard søkeområde: emne, innhold og vedleggsnavn" "all": "Alle",
"all_fields_desc": "Søker i emne, tekst, avsender og mottakere",
"subject": "Emne",
"body": "Innhold"
} }
} }
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Ostatnie wyszukiwania", "recent_title": "Ostatnie wyszukiwania",
"clear_history": "Wyczyść historię", "clear_history": "Wyczyść historię",
"no_history": "Brak historii wyszukiwania", "no_history": "Brak historii wyszukiwania",
"hint": "Domyślny zakres wyszukiwania: temat, treść i nazwy załączników" "all": "Wszystkie",
"all_fields_desc": "Dopasowuje temat, treść, nadawcę i odbiorców",
"subject": "Temat",
"body": "Treść"
} }
} }
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Pesquisas recentes", "recent_title": "Pesquisas recentes",
"clear_history": "Limpar histórico", "clear_history": "Limpar histórico",
"no_history": "Nenhum histórico de pesquisa", "no_history": "Nenhum histórico de pesquisa",
"hint": "Escopo padrão: assunto, conteúdo e nomes dos anexos" "all": "Todos os campos",
"all_fields_desc": "Corresponde ao assunto, corpo, remetente e destinatários",
"subject": "Assunto",
"body": "Corpo da mensagem"
} }
} }
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Недавние поиски", "recent_title": "Недавние поиски",
"clear_history": "Очистить историю", "clear_history": "Очистить историю",
"no_history": "История поиска пуста", "no_history": "История поиска пуста",
"hint": "По умолчанию поиск выполняется по теме, содержимому и именам вложений" "all": "Все поля",
"all_fields_desc": "Поиск по теме, тексту, отправителю и получателям",
"subject": "Тема",
"body": "Текст письма"
} }
} }
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Senaste sökningar", "recent_title": "Senaste sökningar",
"clear_history": "Rensa historik", "clear_history": "Rensa historik",
"no_history": "Ingen sökhistorik", "no_history": "Ingen sökhistorik",
"hint": "Standardomfattning: ämne, innehåll och bilagenamn" "all": "Alla",
"all_fields_desc": "Matchar ämne, innehåll, avsändare och mottagare",
"subject": "Ämne",
"body": "Innehåll"
} }
} }
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "最近搜尋", "recent_title": "最近搜尋",
"clear_history": "清除紀錄", "clear_history": "清除紀錄",
"no_history": "尚無搜尋紀錄", "no_history": "尚無搜尋紀錄",
"hint": "預設搜尋範圍:郵件標題、內容與附件名稱" "all": "全部",
"all_fields_desc": "匹配郵件主題、正文、發件人及收件人",
"subject": "郵件主題",
"body": "郵件正文"
} }
} }
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "最近搜索", "recent_title": "最近搜索",
"clear_history": "清空记录", "clear_history": "清空记录",
"no_history": "暂无搜索记录", "no_history": "暂无搜索记录",
"hint": "默认搜索范围:邮件标题、正文及附件名称" "all": "全部",
"all_fields_desc": "匹配邮件主题、正文、发件人及收件人",
"subject": "邮件主题",
"body": "邮件正文"
} }
} }