feat(search): add advanced attachment filters for extension, category and mime type

This commit is contained in:
rustmailer
2026-03-19 20:23:20 +08:00
parent 884fdeba10
commit c19f3977ba
31 changed files with 466 additions and 40 deletions
+71 -3
View File
@@ -37,6 +37,7 @@ use crate::{
manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER}, manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
}, },
message::{ message::{
attachment::AttachmentMetadata,
content::AttachmentInfo, content::AttachmentInfo,
search::{SearchFilter, SortBy}, search::{SearchFilter, SortBy},
tags::TagCount, tags::TagCount,
@@ -246,9 +247,9 @@ impl DuckDBManager {
att.filename, att.filename,
att.get_extension(), att.get_extension(),
att.get_category(), att.get_category(),
att.file_type, att.file_type.to_ascii_lowercase(),
att.size as u64, att.size as u64,
env.content_hash.clone(), //这里是错误的,应该保存附件的content_hash env.content_hash.clone(), // It's the hash of the attachment content itself, not the hash of the full email.
0, 0,
]) ])
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
@@ -550,6 +551,55 @@ impl DuckDBManager {
Ok(()) Ok(())
} }
pub fn get_attachment_metadata(
&self,
accounts: Option<HashSet<u64>>,
) -> BichonResult<AttachmentMetadata> {
let conn = self.conn()?;
let mut sql = r#"
SELECT
CAST(array_agg(DISTINCT extension) AS JSON) AS extensions,
CAST(array_agg(DISTINCT ext_category) AS JSON) AS categories,
CAST(array_agg(DISTINCT content_type) AS JSON) AS content_types
FROM envelope_attachments
"#
.to_string();
let mut params_vec: Vec<duckdb::types::Value> = Vec::new();
if let Some(ref acc_set) = accounts {
if !acc_set.is_empty() {
let placeholders = vec!["?"; acc_set.len()].join(", ");
sql.push_str(&format!(" WHERE account_id IN ({})", placeholders));
for &id in acc_set {
params_vec.push(id.into());
}
}
}
let mut stmt = conn
.prepare(&sql)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let result = stmt
.query_row(duckdb::params_from_iter(params_vec), |row| {
let exts_raw: String = row.get(0)?;
let cats_raw: String = row.get(1)?;
let ctypes_raw: String = row.get(2)?;
let exts: Vec<String> = serde_json::from_str(&exts_raw).unwrap_or_default();
let cats: Vec<String> = serde_json::from_str(&cats_raw).unwrap_or_default();
let ctypes: Vec<String> = serde_json::from_str(&ctypes_raw).unwrap_or_default();
Ok(AttachmentMetadata {
extensions: exts.into_iter().collect(),
categories: cats.into_iter().collect(),
content_types: ctypes.into_iter().collect(),
})
})
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(result)
}
pub fn delete_envelopes_multi_account( pub fn delete_envelopes_multi_account(
&self, &self,
deletes: HashMap<u64, Vec<String>>, deletes: HashMap<u64, Vec<String>>,
@@ -1122,7 +1172,10 @@ impl DuckDBManager {
let mut base_sql = String::from(" FROM envelopes e "); let mut base_sql = String::from(" FROM envelopes e ");
let need_join_attachment = filter.attachment_name.is_some(); let need_join_attachment = filter.attachment_name.is_some()
|| filter.attachment_extension.is_some()
|| filter.attachment_category.is_some()
|| filter.attachment_content_type.is_some();
if need_join_attachment { if need_join_attachment {
base_sql.push_str( base_sql.push_str(
@@ -1290,6 +1343,21 @@ impl DuckDBManager {
args.push(format!("%{}%", name).into()); args.push(format!("%{}%", name).into());
} }
if let Some(ext) = filter.attachment_extension {
base_sql.push_str(" AND a.extension ILIKE ? ");
args.push(format!("%{}%", ext).into());
}
if let Some(cat) = filter.attachment_category {
base_sql.push_str(" AND a.ext_category ILIKE ? ");
args.push(format!("%{}%", cat).into());
}
if let Some(ctype) = filter.attachment_content_type {
base_sql.push_str(" AND a.content_type ILIKE ? ");
args.push(format!("%{}%", ctype).into());
}
let count_sql = if need_join_attachment { let count_sql = if need_join_attachment {
format!("SELECT COUNT(DISTINCT e.id) {}", base_sql) format!("SELECT COUNT(DISTINCT e.id) {}", base_sql)
} else { } else {
+1 -1
View File
@@ -160,7 +160,7 @@ fn extract_envelope_core(
) )
}) })
.unwrap_or_else(|| "application/octet-stream".to_string()); .unwrap_or_else(|| "application/octet-stream".to_string());
//注意:有些附件是没有名字的,这样extension也就不存在,那么在获取附件的时候,就不能通过name定位
Some(AttachmentInfo { Some(AttachmentInfo {
filename: attachment filename: attachment
.attachment_name() .attachment_name()
+12 -1
View File
@@ -25,7 +25,9 @@ use std::{
use crate::modules::{ use crate::modules::{
duckdb::init::duckdb, duckdb::init::duckdb,
message::{content::AttachmentInfo, search::SortBy, tags::TagCount}, message::{
attachment::AttachmentMetadata, content::AttachmentInfo, search::SortBy, tags::TagCount,
},
settings::cli::SETTINGS, settings::cli::SETTINGS,
}; };
use crate::{ use crate::{
@@ -192,6 +194,15 @@ impl EnvelopeIndexManager {
.map_err(|e| raise_error!(format!("{:?}", e), ErrorCode::InternalError))? .map_err(|e| raise_error!(format!("{:?}", e), ErrorCode::InternalError))?
} }
pub async fn get_attachment_metadata(
&self,
accounts: Option<HashSet<u64>>,
) -> BichonResult<AttachmentMetadata> {
tokio::task::spawn_blocking(move || duckdb()?.get_attachment_metadata(accounts))
.await
.map_err(|e| raise_error!(format!("{:?}", e), ErrorCode::InternalError))?
}
pub async fn delete_envelopes_multi_account( pub async fn delete_envelopes_multi_account(
&self, &self,
deletes: HashMap<u64, Vec<String>>, // HashMap<account_id, envelope_ids> deletes: HashMap<u64, Vec<String>>, // HashMap<account_id, envelope_ids>
+19
View File
@@ -0,0 +1,19 @@
use std::collections::HashSet;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct AttachmentMetadata {
/// A collection of unique file extensions found in attachments.
/// Example: ["pdf", "docx", "png"]
pub extensions: HashSet<String>,
/// A collection of high-level attachment categories.
/// Example: ["document", "image", "archive"]
pub categories: HashSet<String>,
/// A collection of unique MIME types (Content-Type) for the attachments.
/// Example: ["application/pdf", "image/jpeg"]
pub content_types: HashSet<String>,
}
+1 -1
View File
@@ -51,7 +51,7 @@ impl AttachmentInfo {
std::path::Path::new(&self.filename) std::path::Path::new(&self.filename)
.extension() .extension()
.and_then(|ext| ext.to_str()) .and_then(|ext| ext.to_str())
.map(|ext| ext.to_lowercase()) .map(|ext| ext.to_ascii_lowercase())
.unwrap_or_default() .unwrap_or_default()
} }
+1
View File
@@ -17,6 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod append; pub mod append;
pub mod attachment;
pub mod contacts; pub mod contacts;
pub mod content; pub mod content;
pub mod delete; pub mod delete;
+3
View File
@@ -50,6 +50,9 @@ pub struct SearchFilter {
pub has_attachment: Option<bool>, pub has_attachment: Option<bool>,
pub attachment_name: Option<String>, pub attachment_name: Option<String>,
pub tags: Option<HashSet<String>>, pub tags: Option<HashSet<String>>,
pub attachment_extension: Option<String>,
pub attachment_category: Option<String>,
pub attachment_content_type: Option<String>,
} }
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Enum)] #[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Enum)]
+26
View File
@@ -23,6 +23,7 @@ use crate::modules::indexer::manager::EML_INDEX_MANAGER;
use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER; use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER;
use crate::modules::message::append::restore_emails; use crate::modules::message::append::restore_emails;
use crate::modules::message::append::RestoreMessagesRequest; use crate::modules::message::append::RestoreMessagesRequest;
use crate::modules::message::attachment::AttachmentMetadata;
use crate::modules::message::content::retrieve_nested_eml_content; use crate::modules::message::content::retrieve_nested_eml_content;
use crate::modules::message::content::FullNestedMessageContent; use crate::modules::message::content::FullNestedMessageContent;
use crate::modules::message::content::{retrieve_email_content, FullMessageContent}; use crate::modules::message::content::{retrieve_email_content, FullMessageContent};
@@ -411,4 +412,29 @@ impl MessageApi {
.await?, .await?,
)) ))
} }
/// Retrieves unique metadata for all attachments across authorized accounts.
#[oai(
path = "/attachment_metadata",
method = "get",
operation_id = "get_attachment_metadata"
)]
async fn get_attachment_metadata(
&self,
context: ClientContext,
) -> ApiResult<Json<AttachmentMetadata>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(
ENVELOPE_INDEX_MANAGER
.get_attachment_metadata(authorized_ids)
.await?,
))
}
} }
+28
View File
@@ -105,4 +105,32 @@ export const restore_message = async (accountId: number, envelopeIds: string[])
envelope_ids: envelopeIds, envelope_ids: envelopeIds,
}); });
return response.data; return response.data;
};
export interface AttachmentMetadata {
/**
* A collection of unique file extensions found in attachments.
* @example ["pdf", "docx", "png"]
*/
extensions: string[];
/**
* A collection of high-level attachment categories.
* @example ["document", "image", "archive"]
*/
categories: string[];
/**
* A collection of unique MIME types (Content-Type) for the attachments.
* @example ["application/pdf", "image/jpeg"]
*/
content_types: string[];
}
export const get_attachment_meta = async () => {
const response = await axiosInstance.get<AttachmentMetadata>("api/v1/attachment_metadata");
return response.data;
}; };
@@ -0,0 +1,100 @@
import * as React from "react"
import { Check, X } from "lucide-react"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { cn } from "@/lib/utils"
import { useTranslation } from "react-i18next"
interface MetadataSelectorFieldProps {
label: string
value?: string
options: string[]
isLoading: boolean
onSelect: (val: string | undefined) => void
onReset: () => void
}
export function MetadataSelectorField({
label,
value,
options,
isLoading,
onSelect,
onReset
}: MetadataSelectorFieldProps) {
const { t } = useTranslation()
const [searchTerm, setSearchTerm] = React.useState("")
const filteredOptions = React.useMemo(() => {
return options.filter(opt =>
opt.toLowerCase().includes(searchTerm.toLowerCase())
)
}, [options, searchTerm])
return (
<Popover>
<PopoverTrigger asChild>
<button
className={cn(
"group flex items-center justify-between w-full px-4 py-2 hover:bg-accent/50 transition-all text-left relative border rounded-md",
"min-h-[48px]",
value && "bg-accent/30 border-primary/50"
)}
>
<div className="flex flex-col items-start pr-6 overflow-hidden">
<span className="text-[10px] font-bold uppercase opacity-50 tracking-tight leading-none">
{label}
</span>
<span className={cn(
"mt-1 truncate w-full text-xs",
value ? "font-semibold text-primary" : "text-muted-foreground/70"
)}>
{value || t('search_more.any')}
</span>
</div>
<div className="flex items-center gap-1.5">
{value && (
<div
onClick={(e) => { e.stopPropagation(); onReset(); }}
className="p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<X className="h-3 w-3" />
</div>
)}
</div>
{value && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="p-0 w-64 shadow-xl">
<Command shouldFilter={false}>
<CommandInput
placeholder={t('search_more.search_placeholder', { field: label })}
value={searchTerm}
onValueChange={setSearchTerm}
className="h-8"
/>
<CommandList className="max-h-[240px]">
{isLoading && <div className="p-4 text-[10px] text-center opacity-50">{t('common.loading')}</div>}
<CommandEmpty className="text-[10px] p-2 text-center">{t('common.noData')}</CommandEmpty>
<CommandGroup>
{filteredOptions.map((opt) => (
<CommandItem
key={opt}
onSelect={() => {
value === opt ? onReset() : onSelect(opt);
}}
className="flex items-center justify-between py-2 px-3 cursor-pointer text-xs"
>
<span className="truncate">{opt}</span>
{value === opt && <Check className="h-3 w-3 text-primary shrink-0" />}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
+1 -1
View File
@@ -66,7 +66,7 @@ export function MailFilterPopover() {
{fields.map((field) => ( {fields.map((field) => (
<ContactSelectorField <ContactSelectorField
key={field} key={field}
label={field} label={t(`search.${field}`)}
value={filter[field] as string | undefined} value={filter[field] as string | undefined}
onSelect={(email) => updateFilter(field, email)} onSelect={(email) => updateFilter(field, email)}
onReset={() => updateFilter(field, undefined)} onReset={() => updateFilter(field, undefined)}
@@ -11,6 +11,8 @@ import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useAttachmentMetadata } from "@/hooks/use-attachment-metadata"
import { MetadataSelectorField } from "./attachment-metadata-selector"
const SIZES = { const SIZES = {
tiny: { min: undefined, max: 15 * 1024 }, tiny: { min: undefined, max: 15 * 1024 },
@@ -34,8 +36,13 @@ export function MoreFiltersPopover() {
const { filter, setFilter } = useSearchContext(); const { filter, setFilter } = useSearchContext();
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const { data: meta, isLoading: metaLoading } = useAttachmentMetadata();
const [localState, setLocalState] = React.useState({ const [localState, setLocalState] = React.useState({
attachment_name: filter?.attachment_name || '', attachment_name: filter?.attachment_name || '',
attachment_extension: filter?.attachment_extension || '',
attachment_category: filter?.attachment_category || '',
attachment_content_type: filter?.attachment_content_type || '',
message_id: filter?.message_id || '', message_id: filter?.message_id || '',
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size), size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
has_attachment: filter?.has_attachment || false has_attachment: filter?.has_attachment || false
@@ -45,6 +52,9 @@ export function MoreFiltersPopover() {
if (open) { if (open) {
setLocalState({ setLocalState({
attachment_name: filter?.attachment_name || '', attachment_name: filter?.attachment_name || '',
attachment_extension: filter?.attachment_extension || '',
attachment_category: filter?.attachment_category || '',
attachment_content_type: filter?.attachment_content_type || '',
message_id: filter?.message_id || '', message_id: filter?.message_id || '',
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size), size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
has_attachment: filter?.has_attachment || false has_attachment: filter?.has_attachment || false
@@ -59,6 +69,15 @@ export function MoreFiltersPopover() {
if (localState.attachment_name) next.attachment_name = localState.attachment_name; if (localState.attachment_name) next.attachment_name = localState.attachment_name;
else delete next.attachment_name; else delete next.attachment_name;
if (localState.attachment_extension) next.attachment_extension = localState.attachment_extension;
else delete next.attachment_extension;
if (localState.attachment_category) next.attachment_category = localState.attachment_category;
else delete next.attachment_category;
if (localState.attachment_content_type) next.attachment_content_type = localState.attachment_content_type;
else delete next.attachment_content_type;
if (localState.message_id) next.message_id = localState.message_id; if (localState.message_id) next.message_id = localState.message_id;
else delete next.message_id; else delete next.message_id;
@@ -79,7 +98,10 @@ export function MoreFiltersPopover() {
filter?.min_size, filter?.min_size,
filter?.max_size, filter?.max_size,
filter?.message_id, filter?.message_id,
filter?.has_attachment filter?.has_attachment,
filter?.attachment_extension,
filter?.attachment_category,
filter?.attachment_content_type
].filter(Boolean).length; ].filter(Boolean).length;
return ( return (
@@ -118,6 +140,9 @@ export function MoreFiltersPopover() {
delete next.max_size; delete next.max_size;
delete next.message_id; delete next.message_id;
delete next.has_attachment; delete next.has_attachment;
delete next.attachment_extension;
delete next.attachment_category;
delete next.attachment_content_type;
return next; return next;
}); });
setOpen(false); setOpen(false);
@@ -132,9 +157,19 @@ export function MoreFiltersPopover() {
<Checkbox <Checkbox
id="has_attachment" id="has_attachment"
checked={localState.has_attachment} checked={localState.has_attachment}
onCheckedChange={(checked) => onCheckedChange={(checked) => {
setLocalState(prev => ({ ...prev, has_attachment: checked as boolean })) const isChecked = checked as boolean;
} setLocalState(prev => ({
...prev,
has_attachment: isChecked,
...(isChecked ? {} : {
attachment_name: '',
attachment_extension: '',
attachment_category: '',
attachment_content_type: ''
})
}));
}}
/> />
<Label <Label
htmlFor="has_attachment" htmlFor="has_attachment"
@@ -144,15 +179,46 @@ export function MoreFiltersPopover() {
</Label> </Label>
</div> </div>
<div className="space-y-2"> {localState.has_attachment && (
<Label className="text-xs text-muted-foreground">{t('search_more.attachment_name_label')}</Label> <div className="space-y-3 p-2 bg-muted/30 rounded-lg border border-dashed border-border animate-in fade-in slide-in-from-top-1">
<Input <MetadataSelectorField
className="h-8 text-xs" label={t('search_more.extension')}
value={localState.attachment_name} value={localState.attachment_extension}
onChange={(e) => setLocalState(prev => ({ ...prev, attachment_name: e.target.value }))} options={meta?.extensions || []}
placeholder={t('search_more.attachment_name_placeholder')} isLoading={metaLoading}
/> onSelect={(v) => setLocalState(p => ({ ...p, attachment_extension: v, attachment_category: '', attachment_content_type: '' }))}
</div> onReset={() => setLocalState(p => ({ ...p, attachment_extension: '' }))}
/>
<MetadataSelectorField
label={t('search_more.category')}
value={localState.attachment_category}
options={meta?.categories || []}
isLoading={metaLoading}
onSelect={(v) => setLocalState(p => ({ ...p, attachment_category: v, attachment_extension: '', attachment_content_type: '' }))}
onReset={() => setLocalState(p => ({ ...p, attachment_category: '' }))}
/>
<MetadataSelectorField
label={t('search_more.content_types')}
value={localState.attachment_content_type}
options={meta?.content_types || []}
isLoading={metaLoading}
onSelect={(v) => setLocalState(p => ({ ...p, attachment_content_type: v, attachment_extension: '', attachment_category: '' }))}
onReset={() => setLocalState(p => ({ ...p, attachment_content_type: '' }))}
/>
<div className="space-y-1 px-1">
<Label className="text-xs text-muted-foreground">{t('search_more.attachment_name_label')}</Label>
<Input
className="h-8 text-xs"
value={localState.attachment_name}
onChange={(e) => setLocalState(prev => ({ ...prev, attachment_name: e.target.value }))}
placeholder={t('search_more.attachment_name_placeholder')}
/>
</div>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('search_more.message_size_label')}</Label> <Label className="text-xs text-muted-foreground">{t('search_more.message_size_label')}</Label>
+14
View File
@@ -0,0 +1,14 @@
import { AttachmentMetadata, get_attachment_meta } from '@/api/mailbox/envelope/api';
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
export const ATTACHMENT_METADATA_KEY = ['attachment_metadata'] as const;
export const useAttachmentMetadata = (options?: Partial<UseQueryOptions<AttachmentMetadata>>) => {
return useQuery({
queryKey: ATTACHMENT_METADATA_KEY,
queryFn: get_attachment_meta,
staleTime: 1000 * 60 * 10,
gcTime: 1000 * 60 * 30,
...options,
});
};
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "متوسط (2 10 ميغابايت)", "medium": "متوسط (2 10 ميغابايت)",
"large": "كبير (10 20 ميغابايت)", "large": "كبير (10 20 ميغابايت)",
"huge": "ضخم (> 20 ميغابايت)" "huge": "ضخم (> 20 ميغابايت)"
} },
"any": "الكل",
"search_placeholder": "بحث {{field}}...",
"extension": "امتداد الملف",
"category": "فئة المرفق",
"content_types": "نوع محدد"
}, },
"search_view": { "search_view": {
"button_label": "العرض", "button_label": "العرض",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Mellem (2 10 MB)", "medium": "Mellem (2 10 MB)",
"large": "Stor (10 20 MB)", "large": "Stor (10 20 MB)",
"huge": "Meget stor (> 20 MB)" "huge": "Meget stor (> 20 MB)"
} },
"any": "Alle",
"search_placeholder": "Søg {{field}}...",
"extension": "Filendelse",
"category": "Vedhæftningskategori",
"content_types": "Specifik type"
}, },
"search_view": { "search_view": {
"button_label": "Visning", "button_label": "Visning",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Mittel (2 10 MB)", "medium": "Mittel (2 10 MB)",
"large": "Groß (10 20 MB)", "large": "Groß (10 20 MB)",
"huge": "Sehr groß (> 20 MB)" "huge": "Sehr groß (> 20 MB)"
} },
"any": "Beliebig",
"search_placeholder": "{{field}} suchen...",
"extension": "Dateiendung",
"category": "Anhangskategorie",
"content_types": "Spezifischer Typ"
}, },
"search_view": { "search_view": {
"button_label": "Ansicht", "button_label": "Ansicht",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Medium (2 10 MB)", "medium": "Medium (2 10 MB)",
"large": "Large (10 20 MB)", "large": "Large (10 20 MB)",
"huge": "Huge (> 20 MB)" "huge": "Huge (> 20 MB)"
} },
"any": "Any",
"search_placeholder": "Search {{field}}...",
"extension": "Extension",
"category": "Category",
"content_types": "Specific Type"
}, },
"search_view": { "search_view": {
"button_label": "View", "button_label": "View",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Mediano (2 10 MB)", "medium": "Mediano (2 10 MB)",
"large": "Grande (10 20 MB)", "large": "Grande (10 20 MB)",
"huge": "Muy grande (> 20 MB)" "huge": "Muy grande (> 20 MB)"
} },
"any": "Cualquiera",
"search_placeholder": "Buscar {{field}}...",
"extension": "Extensión de archivo",
"category": "Categoría de adjunto",
"content_types": "Tipo específico"
}, },
"search_view": { "search_view": {
"button_label": "Vista", "button_label": "Vista",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Keskikokoinen (2 10 MB)", "medium": "Keskikokoinen (2 10 MB)",
"large": "Suuri (10 20 MB)", "large": "Suuri (10 20 MB)",
"huge": "Erittäin suuri (> 20 MB)" "huge": "Erittäin suuri (> 20 MB)"
} },
"any": "Mikä tahansa",
"search_placeholder": "Etsi {{field}}...",
"extension": "Tiedostopääte",
"category": "Liitekategoria",
"content_types": "Tarkka tyyppi"
}, },
"search_view": { "search_view": {
"button_label": "Näkymä", "button_label": "Näkymä",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Moyenne (2 10 Mo)", "medium": "Moyenne (2 10 Mo)",
"large": "Grande (10 20 Mo)", "large": "Grande (10 20 Mo)",
"huge": "Très grande (> 20 Mo)" "huge": "Très grande (> 20 Mo)"
} },
"any": "Tous",
"search_placeholder": "Rechercher {{field}}...",
"extension": "Extension de fichier",
"category": "Catégorie de pièce jointe",
"content_types": "Type spécifique"
}, },
"search_view": { "search_view": {
"button_label": "Affichage", "button_label": "Affichage",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Media (2 10 MB)", "medium": "Media (2 10 MB)",
"large": "Grande (10 20 MB)", "large": "Grande (10 20 MB)",
"huge": "Molto grande (> 20 MB)" "huge": "Molto grande (> 20 MB)"
} },
"any": "Qualsiasi",
"search_placeholder": "Cerca {{field}}...",
"extension": "Estensione file",
"category": "Categoria allegato",
"content_types": "Tipo specifico"
}, },
"search_view": { "search_view": {
"button_label": "Vista", "button_label": "Vista",
+8 -3
View File
@@ -456,8 +456,8 @@
"before": "以前:", "before": "以前:",
"selectDate": "日付を選択", "selectDate": "日付を選択",
"pleaseSelectAtLeastOne": "少なくとも1つの検索条件を選択してください", "pleaseSelectAtLeastOne": "少なくとも1つの検索条件を選択してください",
"cc": "CC", "cc": "CC(写し)",
"bcc": "BCC", "bcc": "BCC(ブラインド写し)",
"attachmentName": "添付ファイル名", "attachmentName": "添付ファイル名",
"messageId": "メッセージID", "messageId": "メッセージID",
"minSize": "最小サイズ", "minSize": "最小サイズ",
@@ -1539,7 +1539,12 @@
"medium": "中 (2 10 MB)", "medium": "中 (2 10 MB)",
"large": "大 (10 20 MB)", "large": "大 (10 20 MB)",
"huge": "特大 (> 20 MB)" "huge": "特大 (> 20 MB)"
} },
"any": "制限なし",
"search_placeholder": "{{field}}を検索...",
"extension": "拡張子",
"category": "添付ファイルカテゴリ",
"content_types": "具体的な形式"
}, },
"search_view": { "search_view": {
"button_label": "表示", "button_label": "表示",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "보통 (2 10 MB)", "medium": "보통 (2 10 MB)",
"large": "큼 (10 20 MB)", "large": "큼 (10 20 MB)",
"huge": "매우 큼 (> 20 MB)" "huge": "매우 큼 (> 20 MB)"
} },
"any": "제한 없음",
"search_placeholder": "{{field}} 검색...",
"extension": "파일 확장자",
"category": "첨부 파일 카테고리",
"content_types": "구체적인 형식"
}, },
"search_view": { "search_view": {
"button_label": "보기", "button_label": "보기",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Gemiddeld (2 10 MB)", "medium": "Gemiddeld (2 10 MB)",
"large": "Groot (10 20 MB)", "large": "Groot (10 20 MB)",
"huge": "Zeer groot (> 20 MB)" "huge": "Zeer groot (> 20 MB)"
} },
"any": "Alle",
"search_placeholder": "Zoek {{field}}...",
"extension": "Bestandsextensie",
"category": "Bijlagecategorie",
"content_types": "Specifiek type"
}, },
"search_view": { "search_view": {
"button_label": "Weergave", "button_label": "Weergave",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Middels (2 10 MB)", "medium": "Middels (2 10 MB)",
"large": "Stor (10 20 MB)", "large": "Stor (10 20 MB)",
"huge": "Svært stor (> 20 MB)" "huge": "Svært stor (> 20 MB)"
} },
"any": "Alle",
"search_placeholder": "Søk {{field}}...",
"extension": "Filendelse",
"category": "Vedleggskategori",
"content_types": "Spesifikk type"
}, },
"search_view": { "search_view": {
"button_label": "Visning", "button_label": "Visning",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Średni (2 10 MB)", "medium": "Średni (2 10 MB)",
"large": "Duży (10 20 MB)", "large": "Duży (10 20 MB)",
"huge": "Bardzo duży (> 20 MB)" "huge": "Bardzo duży (> 20 MB)"
} },
"any": "Dowolne",
"search_placeholder": "Szukaj {{field}}...",
"extension": "Rozszerzenie pliku",
"category": "Kategoria załącznika",
"content_types": "Konkretny typ"
}, },
"search_view": { "search_view": {
"button_label": "Widok", "button_label": "Widok",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Médio (2 10 MB)", "medium": "Médio (2 10 MB)",
"large": "Grande (10 20 MB)", "large": "Grande (10 20 MB)",
"huge": "Muito grande (> 20 MB)" "huge": "Muito grande (> 20 MB)"
} },
"any": "Qualquer",
"search_placeholder": "Buscar {{field}}...",
"extension": "Extensão de arquivo",
"category": "Categoria de anexo",
"content_types": "Tipo específico"
}, },
"search_view": { "search_view": {
"button_label": "Visualização", "button_label": "Visualização",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Средний (210 МБ)", "medium": "Средний (210 МБ)",
"large": "Большой (1020 МБ)", "large": "Большой (1020 МБ)",
"huge": "Очень большой (> 20 МБ)" "huge": "Очень большой (> 20 МБ)"
} },
"any": "Любой",
"search_placeholder": "Поиск {{field}}...",
"extension": "Расширение файла",
"category": "Категория вложения",
"content_types": "Конкретный тип"
}, },
"search_view": { "search_view": {
"button_label": "Вид", "button_label": "Вид",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Mellan (210 MB)", "medium": "Mellan (210 MB)",
"large": "Stor (1020 MB)", "large": "Stor (1020 MB)",
"huge": "Mycket stor (> 20 MB)" "huge": "Mycket stor (> 20 MB)"
} },
"any": "Alla",
"search_placeholder": "Sök {{field}}...",
"extension": "Filändelse",
"category": "Bilagskategori",
"content_types": "Specifik typ"
}, },
"search_view": { "search_view": {
"button_label": "Vy", "button_label": "Vy",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "一般(210 MB", "medium": "一般(210 MB",
"large": "較大(1020 MB", "large": "較大(1020 MB",
"huge": "極大(> 20 MB" "huge": "極大(> 20 MB"
} },
"any": "不限",
"search_placeholder": "搜尋{{field}}...",
"extension": "檔案副檔名",
"category": "附件分類",
"content_types": "具體類型"
}, },
"search_view": { "search_view": {
"button_label": "檢視", "button_label": "檢視",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "普通 (2 - 10 MB)", "medium": "普通 (2 - 10 MB)",
"large": "较大 (10 - 20 MB)", "large": "较大 (10 - 20 MB)",
"huge": "极大 (> 20 MB)" "huge": "极大 (> 20 MB)"
} },
"any": "不限",
"search_placeholder": "搜索{{field}}...",
"extension": "文件后缀",
"category": "附件分类",
"content_types": "具体类型"
}, },
"search_view": { "search_view": {
"button_label": "视图", "button_label": "视图",