mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat(search): add advanced attachment filters for extension, category and mime type
This commit is contained in:
@@ -37,6 +37,7 @@ use crate::{
|
||||
manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
|
||||
},
|
||||
message::{
|
||||
attachment::AttachmentMetadata,
|
||||
content::AttachmentInfo,
|
||||
search::{SearchFilter, SortBy},
|
||||
tags::TagCount,
|
||||
@@ -246,9 +247,9 @@ impl DuckDBManager {
|
||||
att.filename,
|
||||
att.get_extension(),
|
||||
att.get_category(),
|
||||
att.file_type,
|
||||
att.file_type.to_ascii_lowercase(),
|
||||
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,
|
||||
])
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
@@ -550,6 +551,55 @@ impl DuckDBManager {
|
||||
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(
|
||||
&self,
|
||||
deletes: HashMap<u64, Vec<String>>,
|
||||
@@ -1122,7 +1172,10 @@ impl DuckDBManager {
|
||||
|
||||
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 {
|
||||
base_sql.push_str(
|
||||
@@ -1290,6 +1343,21 @@ impl DuckDBManager {
|
||||
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 {
|
||||
format!("SELECT COUNT(DISTINCT e.id) {}", base_sql)
|
||||
} else {
|
||||
|
||||
@@ -160,7 +160,7 @@ fn extract_envelope_core(
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
//注意:有些附件是没有名字的,这样extension也就不存在,那么在获取附件的时候,就不能通过name定位
|
||||
Some(AttachmentInfo {
|
||||
filename: attachment
|
||||
.attachment_name()
|
||||
|
||||
@@ -25,7 +25,9 @@ use std::{
|
||||
|
||||
use crate::modules::{
|
||||
duckdb::init::duckdb,
|
||||
message::{content::AttachmentInfo, search::SortBy, tags::TagCount},
|
||||
message::{
|
||||
attachment::AttachmentMetadata, content::AttachmentInfo, search::SortBy, tags::TagCount,
|
||||
},
|
||||
settings::cli::SETTINGS,
|
||||
};
|
||||
use crate::{
|
||||
@@ -192,6 +194,15 @@ impl EnvelopeIndexManager {
|
||||
.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(
|
||||
&self,
|
||||
deletes: HashMap<u64, Vec<String>>, // HashMap<account_id, envelope_ids>
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -51,7 +51,7 @@ impl AttachmentInfo {
|
||||
std::path::Path::new(&self.filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_lowercase())
|
||||
.map(|ext| ext.to_ascii_lowercase())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
pub mod append;
|
||||
pub mod attachment;
|
||||
pub mod contacts;
|
||||
pub mod content;
|
||||
pub mod delete;
|
||||
|
||||
@@ -50,6 +50,9 @@ pub struct SearchFilter {
|
||||
pub has_attachment: Option<bool>,
|
||||
pub attachment_name: Option<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)]
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::modules::indexer::manager::EML_INDEX_MANAGER;
|
||||
use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER;
|
||||
use crate::modules::message::append::restore_emails;
|
||||
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::FullNestedMessageContent;
|
||||
use crate::modules::message::content::{retrieve_email_content, FullMessageContent};
|
||||
@@ -411,4 +412,29 @@ impl MessageApi {
|
||||
.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?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,3 +106,31 @@ export const restore_message = async (accountId: number, envelopeIds: string[])
|
||||
});
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export function MailFilterPopover() {
|
||||
{fields.map((field) => (
|
||||
<ContactSelectorField
|
||||
key={field}
|
||||
label={field}
|
||||
label={t(`search.${field}`)}
|
||||
value={filter[field] as string | undefined}
|
||||
onSelect={(email) => updateFilter(field, email)}
|
||||
onReset={() => updateFilter(field, undefined)}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAttachmentMetadata } from "@/hooks/use-attachment-metadata"
|
||||
import { MetadataSelectorField } from "./attachment-metadata-selector"
|
||||
|
||||
const SIZES = {
|
||||
tiny: { min: undefined, max: 15 * 1024 },
|
||||
@@ -34,8 +36,13 @@ export function MoreFiltersPopover() {
|
||||
const { filter, setFilter } = useSearchContext();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const { data: meta, isLoading: metaLoading } = useAttachmentMetadata();
|
||||
|
||||
const [localState, setLocalState] = React.useState({
|
||||
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 || '',
|
||||
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
|
||||
has_attachment: filter?.has_attachment || false
|
||||
@@ -45,6 +52,9 @@ export function MoreFiltersPopover() {
|
||||
if (open) {
|
||||
setLocalState({
|
||||
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 || '',
|
||||
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
|
||||
has_attachment: filter?.has_attachment || false
|
||||
@@ -59,6 +69,15 @@ export function MoreFiltersPopover() {
|
||||
if (localState.attachment_name) next.attachment_name = localState.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;
|
||||
else delete next.message_id;
|
||||
|
||||
@@ -79,7 +98,10 @@ export function MoreFiltersPopover() {
|
||||
filter?.min_size,
|
||||
filter?.max_size,
|
||||
filter?.message_id,
|
||||
filter?.has_attachment
|
||||
filter?.has_attachment,
|
||||
filter?.attachment_extension,
|
||||
filter?.attachment_category,
|
||||
filter?.attachment_content_type
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
@@ -118,6 +140,9 @@ export function MoreFiltersPopover() {
|
||||
delete next.max_size;
|
||||
delete next.message_id;
|
||||
delete next.has_attachment;
|
||||
delete next.attachment_extension;
|
||||
delete next.attachment_category;
|
||||
delete next.attachment_content_type;
|
||||
return next;
|
||||
});
|
||||
setOpen(false);
|
||||
@@ -132,9 +157,19 @@ export function MoreFiltersPopover() {
|
||||
<Checkbox
|
||||
id="has_attachment"
|
||||
checked={localState.has_attachment}
|
||||
onCheckedChange={(checked) =>
|
||||
setLocalState(prev => ({ ...prev, has_attachment: checked as boolean }))
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
const isChecked = checked as boolean;
|
||||
setLocalState(prev => ({
|
||||
...prev,
|
||||
has_attachment: isChecked,
|
||||
...(isChecked ? {} : {
|
||||
attachment_name: '',
|
||||
attachment_extension: '',
|
||||
attachment_category: '',
|
||||
attachment_content_type: ''
|
||||
})
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="has_attachment"
|
||||
@@ -144,7 +179,36 @@ export function MoreFiltersPopover() {
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{localState.has_attachment && (
|
||||
<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">
|
||||
<MetadataSelectorField
|
||||
label={t('search_more.extension')}
|
||||
value={localState.attachment_extension}
|
||||
options={meta?.extensions || []}
|
||||
isLoading={metaLoading}
|
||||
onSelect={(v) => setLocalState(p => ({ ...p, attachment_extension: v, attachment_category: '', attachment_content_type: '' }))}
|
||||
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"
|
||||
@@ -153,6 +217,8 @@ export function MoreFiltersPopover() {
|
||||
placeholder={t('search_more.attachment_name_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">{t('search_more.message_size_label')}</Label>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "متوسط (2 – 10 ميغابايت)",
|
||||
"large": "كبير (10 – 20 ميغابايت)",
|
||||
"huge": "ضخم (> 20 ميغابايت)"
|
||||
}
|
||||
},
|
||||
"any": "الكل",
|
||||
"search_placeholder": "بحث {{field}}...",
|
||||
"extension": "امتداد الملف",
|
||||
"category": "فئة المرفق",
|
||||
"content_types": "نوع محدد"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "العرض",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Mellem (2 – 10 MB)",
|
||||
"large": "Stor (10 – 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": {
|
||||
"button_label": "Visning",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Mittel (2 – 10 MB)",
|
||||
"large": "Groß (10 – 20 MB)",
|
||||
"huge": "Sehr groß (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "Beliebig",
|
||||
"search_placeholder": "{{field}} suchen...",
|
||||
"extension": "Dateiendung",
|
||||
"category": "Anhangskategorie",
|
||||
"content_types": "Spezifischer Typ"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Ansicht",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Medium (2 – 10 MB)",
|
||||
"large": "Large (10 – 20 MB)",
|
||||
"huge": "Huge (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "Any",
|
||||
"search_placeholder": "Search {{field}}...",
|
||||
"extension": "Extension",
|
||||
"category": "Category",
|
||||
"content_types": "Specific Type"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "View",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Mediano (2 – 10 MB)",
|
||||
"large": "Grande (10 – 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": {
|
||||
"button_label": "Vista",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Keskikokoinen (2 – 10 MB)",
|
||||
"large": "Suuri (10 – 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": {
|
||||
"button_label": "Näkymä",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Moyenne (2 – 10 Mo)",
|
||||
"large": "Grande (10 – 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": {
|
||||
"button_label": "Affichage",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Media (2 – 10 MB)",
|
||||
"large": "Grande (10 – 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": {
|
||||
"button_label": "Vista",
|
||||
|
||||
@@ -456,8 +456,8 @@
|
||||
"before": "以前:",
|
||||
"selectDate": "日付を選択",
|
||||
"pleaseSelectAtLeastOne": "少なくとも1つの検索条件を選択してください",
|
||||
"cc": "CC",
|
||||
"bcc": "BCC",
|
||||
"cc": "CC(写し)",
|
||||
"bcc": "BCC(ブラインド写し)",
|
||||
"attachmentName": "添付ファイル名",
|
||||
"messageId": "メッセージID",
|
||||
"minSize": "最小サイズ",
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "中 (2 – 10 MB)",
|
||||
"large": "大 (10 – 20 MB)",
|
||||
"huge": "特大 (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "制限なし",
|
||||
"search_placeholder": "{{field}}を検索...",
|
||||
"extension": "拡張子",
|
||||
"category": "添付ファイルカテゴリ",
|
||||
"content_types": "具体的な形式"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "表示",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "보통 (2 – 10 MB)",
|
||||
"large": "큼 (10 – 20 MB)",
|
||||
"huge": "매우 큼 (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "제한 없음",
|
||||
"search_placeholder": "{{field}} 검색...",
|
||||
"extension": "파일 확장자",
|
||||
"category": "첨부 파일 카테고리",
|
||||
"content_types": "구체적인 형식"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "보기",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Gemiddeld (2 – 10 MB)",
|
||||
"large": "Groot (10 – 20 MB)",
|
||||
"huge": "Zeer groot (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "Alle",
|
||||
"search_placeholder": "Zoek {{field}}...",
|
||||
"extension": "Bestandsextensie",
|
||||
"category": "Bijlagecategorie",
|
||||
"content_types": "Specifiek type"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Weergave",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Middels (2 – 10 MB)",
|
||||
"large": "Stor (10 – 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": {
|
||||
"button_label": "Visning",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Średni (2 – 10 MB)",
|
||||
"large": "Duży (10 – 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": {
|
||||
"button_label": "Widok",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Médio (2 – 10 MB)",
|
||||
"large": "Grande (10 – 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": {
|
||||
"button_label": "Visualização",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Средний (2–10 МБ)",
|
||||
"large": "Большой (10–20 МБ)",
|
||||
"huge": "Очень большой (> 20 МБ)"
|
||||
}
|
||||
},
|
||||
"any": "Любой",
|
||||
"search_placeholder": "Поиск {{field}}...",
|
||||
"extension": "Расширение файла",
|
||||
"category": "Категория вложения",
|
||||
"content_types": "Конкретный тип"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Вид",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Mellan (2–10 MB)",
|
||||
"large": "Stor (10–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": {
|
||||
"button_label": "Vy",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "一般(2–10 MB)",
|
||||
"large": "較大(10–20 MB)",
|
||||
"huge": "極大(> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "不限",
|
||||
"search_placeholder": "搜尋{{field}}...",
|
||||
"extension": "檔案副檔名",
|
||||
"category": "附件分類",
|
||||
"content_types": "具體類型"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "檢視",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "普通 (2 - 10 MB)",
|
||||
"large": "较大 (10 - 20 MB)",
|
||||
"huge": "极大 (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "不限",
|
||||
"search_placeholder": "搜索{{field}}...",
|
||||
"extension": "文件后缀",
|
||||
"category": "附件分类",
|
||||
"content_types": "具体类型"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "视图",
|
||||
|
||||
Reference in New Issue
Block a user