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},
|
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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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?,
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ميغابايت)",
|
"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": "العرض",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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ä",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "表示",
|
||||||
|
|||||||
@@ -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": "보기",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Вид",
|
||||||
|
|||||||
@@ -1539,7 +1539,12 @@
|
|||||||
"medium": "Mellan (2–10 MB)",
|
"medium": "Mellan (2–10 MB)",
|
||||||
"large": "Stor (10–20 MB)",
|
"large": "Stor (10–20 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",
|
||||||
|
|||||||
@@ -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": "檢視",
|
||||||
|
|||||||
@@ -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": "视图",
|
||||||
|
|||||||
Reference in New Issue
Block a user