mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat(search): expand default search scope and support specific field filtering
This commit is contained in:
@@ -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 {
|
||||
let pattern = format!("(?i){}", text);
|
||||
base_sql.push_str(
|
||||
@@ -1175,10 +1187,14 @@ impl DuckDBManager {
|
||||
AND (
|
||||
regexp_matches(coalesce(e.subject, ''), ?)
|
||||
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.into());
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
// 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/>.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use mail_parser::{parsers::MessageStream, MessageParser, MimeHeaders};
|
||||
|
||||
use crate::{
|
||||
|
||||
@@ -34,6 +34,8 @@ use crate::{
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct SearchFilter {
|
||||
pub text: Option<String>,
|
||||
pub subject: Option<String>,
|
||||
pub body: Option<String>,
|
||||
pub from: Option<String>,
|
||||
pub to: Option<String>,
|
||||
pub cc: Option<String>,
|
||||
@@ -80,15 +82,23 @@ impl SearchRequest {
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(ref pattern) = self.filter.text {
|
||||
if let Err(_) = duckdb()?.validate_regex(pattern) {
|
||||
return Err(raise_error!(
|
||||
"Invalid search pattern: The regular expression is not supported by DuckDB."
|
||||
.into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
let validate = |pattern: &Option<String>| -> BichonResult<()> {
|
||||
if let Some(ref p) = pattern {
|
||||
if duckdb()?.validate_regex(p).is_err() {
|
||||
return Err(raise_error!(
|
||||
"Invalid search pattern: The regular expression is not supported by DuckDB.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
validate(&self.filter.text)?;
|
||||
validate(&self.filter.subject)?;
|
||||
validate(&self.filter.body)?;
|
||||
validate(&self.filter.from)?;
|
||||
validate(&self.filter.to)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export function FilterResetButton() {
|
||||
const { filter, setFilter } = useSearchContext();
|
||||
const { t } = useTranslation()
|
||||
const { q, ...restFilters } = filter;
|
||||
|
||||
|
||||
const activeFiltersCount = Object.keys(restFilters).filter(key => {
|
||||
const value = restFilters[key];
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
@@ -19,7 +19,7 @@ export function FilterResetButton() {
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setFilter(q ? { q } : {})}
|
||||
className={cn(
|
||||
|
||||
@@ -32,10 +32,10 @@ export function DataTableToolbar<TData>({
|
||||
<TagFilterPopover />
|
||||
<TimePopover />
|
||||
<MoreFiltersPopover />
|
||||
<FilterResetButton />
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-auto lg:ml-0">
|
||||
<DataTableViewOptions table={table} />
|
||||
<FilterResetButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,63 +1,61 @@
|
||||
import React, { useState, useEffect, useRef } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
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 { useSearchContext } from "./context"
|
||||
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
|
||||
|
||||
type SearchField = "text" | "subject" | "body"
|
||||
const SEARCH_FIELDS: SearchField[] = ["text", "subject", "body"]
|
||||
|
||||
export function TextSearchInput() {
|
||||
const { t } = useTranslation()
|
||||
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 [showHistory, setShowHistory] = useState(false)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(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(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
setHistory(JSON.parse(saved))
|
||||
}
|
||||
if (saved) setHistory(JSON.parse(saved))
|
||||
} catch (err) {
|
||||
console.warn("Failed to load search history", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setValue(filter.text || "")
|
||||
}, [filter.text])
|
||||
const applyFilter = (currentField: SearchField, searchTerm: string) => {
|
||||
const trimmed = searchTerm.trim()
|
||||
|
||||
|
||||
const saveToHistory = (term: string) => {
|
||||
if (!term.trim()) return
|
||||
|
||||
setHistory(prev => {
|
||||
const trimmed = term.trim()
|
||||
const withoutCurrent = prev.filter(item => item !== 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)
|
||||
setFilter((prev) => {
|
||||
const next = { ...prev }
|
||||
SEARCH_FIELDS.forEach(f => {
|
||||
delete next[f]
|
||||
})
|
||||
if (trimmed) {
|
||||
next[currentField] = trimmed
|
||||
}
|
||||
|
||||
return newHistory
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
const trimmed = value.trim()
|
||||
setFilter(prev => ({
|
||||
...prev,
|
||||
text: trimmed || undefined
|
||||
}))
|
||||
if (trimmed) {
|
||||
saveToHistory(trimmed)
|
||||
}
|
||||
@@ -65,37 +63,31 @@ export function TextSearchInput() {
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleSearch()
|
||||
}
|
||||
const saveToHistory = (term: string) => {
|
||||
setHistory((prev) => {
|
||||
const trimmed = term.trim()
|
||||
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 = () => {
|
||||
setValue("")
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
delete next.text
|
||||
return next
|
||||
})
|
||||
setShowHistory(false)
|
||||
applyFilter(field, "")
|
||||
}
|
||||
|
||||
const handleSelectHistory = (term: string) => {
|
||||
setValue(term)
|
||||
setShowHistory(false)
|
||||
// 如果需要点击历史立即搜索,可以在这里调用 handleSearch()
|
||||
applyFilter(field, term)
|
||||
}
|
||||
|
||||
const handleClearHistory = () => {
|
||||
const handleClearHistory = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setHistory([])
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
} catch (err) {
|
||||
console.warn("Failed to clear search history", err)
|
||||
}
|
||||
setShowHistory(false)
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -108,91 +100,107 @@ export function TextSearchInput() {
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const isActive = !!filter.text?.trim()
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full max-w-[550px] min-w-[280px]">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative flex items-center gap-1.5">
|
||||
<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={handleKeyDown}
|
||||
placeholder={t('search_input.placeholder')}
|
||||
className={cn(
|
||||
"h-9 pl-9 pr-9 text-sm",
|
||||
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 ref={containerRef} className="relative w-full max-w-[620px] min-w-[320px]">
|
||||
<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">
|
||||
<Select
|
||||
value={field}
|
||||
onValueChange={(val) => {
|
||||
const newField = val as SearchField
|
||||
setField(newField)
|
||||
if (value.trim()) applyFilter(newField, value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"h-9 w-[110px] md:w-[130px] border-r border-border rounded-r-none",
|
||||
"text-xs md:text-sm bg-transparent focus:ring-0 focus:ring-offset-0 shadow-none border-y-0 border-l-0"
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 px-5"
|
||||
onClick={handleSearch}
|
||||
disabled={!value.trim()}
|
||||
>
|
||||
{t('search_input.button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 搜索范围提示 */}
|
||||
<div className="flex items-center gap-1 px-1 opacity-60">
|
||||
<Info className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t('search_input.hint')}
|
||||
</span>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[240px]">
|
||||
<SelectItem value="text" className="font-medium cursor-pointer text-xs">
|
||||
{t("search_input.all")}
|
||||
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
|
||||
{t("search_input.all_fields_desc")}
|
||||
</p>
|
||||
</SelectItem>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{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="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="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-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">
|
||||
<Clock className="h-3 w-3" />
|
||||
{t('search_input.recent_title')}
|
||||
{t("search_input.recent_title")}
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<button
|
||||
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" />
|
||||
{t('search_input.clear_history')}
|
||||
{t("search_input.clear_history")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{history.length > 0 ? (
|
||||
history.map((term, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className="w-full text-left px-3 py-2 text-xs hover:bg-accent transition-colors flex items-center gap-2"
|
||||
onClick={() => handleSelectHistory(term)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{term}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-4 text-xs text-center text-muted-foreground">
|
||||
{t('search_input.no_history')}
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-auto py-1">
|
||||
{history.length > 0 ? (
|
||||
history.map((term, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
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 group-hover:text-primary" />
|
||||
<span className="truncate flex-1">{term}</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-6 text-sm text-center text-muted-foreground">
|
||||
{t("search_input.no_history")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "عمليات البحث الأخيرة",
|
||||
"clear_history": "مسح السجل",
|
||||
"no_history": "لا يوجد سجل بحث",
|
||||
"hint": "نطاق البحث الافتراضي: العنوان، المحتوى، وأسماء المرفقات"
|
||||
"all": "الكل",
|
||||
"all_fields_desc": "يطابق موضوع الرسالة، النص، المرسل والمستلمين",
|
||||
"subject": "موضوع الرسالة",
|
||||
"body": "نص الرسالة"
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "Seneste søgninger",
|
||||
"clear_history": "Ryd historik",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "Letzte Suchanfragen",
|
||||
"clear_history": "Verlauf löschen",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "Recent searches",
|
||||
"clear_history": "Clear 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"
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "Búsquedas recientes",
|
||||
"clear_history": "Borrar historial",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "Viimeisimmät haut",
|
||||
"clear_history": "Tyhjennä historia",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "Recherches récentes",
|
||||
"clear_history": "Effacer l’historique",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "Ricerche recenti",
|
||||
"clear_history": "Cancella cronologia",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "最近の検索",
|
||||
"clear_history": "履歴をクリア",
|
||||
"no_history": "履歴なし",
|
||||
"hint": "検索対象:件名・本文・添付ファイル名"
|
||||
"all": "すべて",
|
||||
"all_fields_desc": "件名、本文、送信者、受信者に一致します",
|
||||
"subject": "件名",
|
||||
"body": "本文"
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "최근 검색",
|
||||
"clear_history": "기록 지우기",
|
||||
"no_history": "검색 기록 없음",
|
||||
"hint": "기본 검색 범위: 제목, 본문 및 첨부파일 이름"
|
||||
"all": "전체",
|
||||
"all_fields_desc": "제목, 본문, 발신자 및 수신자를 검색합니다",
|
||||
"subject": "제목",
|
||||
"body": "본문"
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "Recente zoekopdrachten",
|
||||
"clear_history": "Geschiedenis wissen",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "Nylige søk",
|
||||
"clear_history": "Tøm historikk",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "Ostatnie wyszukiwania",
|
||||
"clear_history": "Wyczyść historię",
|
||||
"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ść"
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "Pesquisas recentes",
|
||||
"clear_history": "Limpar histórico",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "Недавние поиски",
|
||||
"clear_history": "Очистить историю",
|
||||
"no_history": "История поиска пуста",
|
||||
"hint": "По умолчанию поиск выполняется по теме, содержимому и именам вложений"
|
||||
"all": "Все поля",
|
||||
"all_fields_desc": "Поиск по теме, тексту, отправителю и получателям",
|
||||
"subject": "Тема",
|
||||
"body": "Текст письма"
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "Senaste sökningar",
|
||||
"clear_history": "Rensa historik",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "最近搜尋",
|
||||
"clear_history": "清除紀錄",
|
||||
"no_history": "尚無搜尋紀錄",
|
||||
"hint": "預設搜尋範圍:郵件標題、內容與附件名稱"
|
||||
"all": "全部",
|
||||
"all_fields_desc": "匹配郵件主題、正文、發件人及收件人",
|
||||
"subject": "郵件主題",
|
||||
"body": "郵件正文"
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1555,9 @@
|
||||
"recent_title": "最近搜索",
|
||||
"clear_history": "清空记录",
|
||||
"no_history": "暂无搜索记录",
|
||||
"hint": "默认搜索范围:邮件标题、正文及附件名称"
|
||||
"all": "全部",
|
||||
"all_fields_desc": "匹配邮件主题、正文、发件人及收件人",
|
||||
"subject": "邮件主题",
|
||||
"body": "邮件正文"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user