mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: Ability to add/remove tags from any list of messages #189
This commit is contained in:
+24
-12
@@ -40,7 +40,7 @@ use crate::{
|
||||
attachment::AttachmentMetadata,
|
||||
content::{AttachmentDetail, AttachmentInfo},
|
||||
search::{SearchFilter, SortBy},
|
||||
tags::TagCount,
|
||||
tags::{TagAction, TagCount, TagsRequest},
|
||||
},
|
||||
rest::response::DataPage,
|
||||
settings::{cli::SETTINGS, dir::DATA_DIR_MANAGER},
|
||||
@@ -611,20 +611,24 @@ impl DuckDBManager {
|
||||
Ok(contacts)
|
||||
}
|
||||
|
||||
pub fn update_envelope_tags(
|
||||
&self,
|
||||
updates: HashMap<u64, Vec<String>>,
|
||||
tags: Vec<String>,
|
||||
) -> BichonResult<()> {
|
||||
pub fn update_envelope_tags(&self, request: TagsRequest) -> BichonResult<()> {
|
||||
let mut conn = self.conn()?;
|
||||
let tags_json = serde_json::to_string(&tags)
|
||||
let tags_json = serde_json::to_string(&request.tags)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
let set_clause = match &request.action {
|
||||
TagAction::Overwrite => "SET tags = ?::JSON::VARCHAR[]",
|
||||
TagAction::Add => "SET tags = list_distinct(list_concat(tags, ?::JSON::VARCHAR[]))",
|
||||
TagAction::Remove => {
|
||||
"SET tags = list_filter(tags, x -> NOT list_contains(?::JSON::VARCHAR[], x))"
|
||||
}
|
||||
};
|
||||
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
for (account_id, ids) in updates {
|
||||
for (account_id, ids) in request.updates {
|
||||
if ids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -633,23 +637,31 @@ impl DuckDBManager {
|
||||
let placeholders = vec!["?"; chunk.len()].join(", ");
|
||||
let query = format!(
|
||||
"UPDATE envelopes
|
||||
SET tags = CAST(json(?) AS VARCHAR[])
|
||||
{}
|
||||
WHERE account_id = ?
|
||||
AND id IN ({})",
|
||||
placeholders
|
||||
set_clause, placeholders
|
||||
);
|
||||
|
||||
let mut params: Vec<Box<dyn duckdb::ToSql>> = Vec::new();
|
||||
let mut params: Vec<Box<dyn duckdb::ToSql>> = Vec::with_capacity(chunk.len() + 2);
|
||||
|
||||
params.push(Box::new(tags_json.clone()));
|
||||
params.push(Box::new(account_id));
|
||||
|
||||
for id in chunk {
|
||||
params.push(Box::new(id.clone()));
|
||||
}
|
||||
|
||||
let param_refs: Vec<&dyn duckdb::ToSql> =
|
||||
params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
tx.execute(&query, duckdb::params_from_iter(param_refs))
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
.map_err(|e| {
|
||||
raise_error!(
|
||||
format!("Update failed. Account: {}, Error: {}", account_id, e),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::modules::{
|
||||
attachment::AttachmentMetadata,
|
||||
content::{AttachmentDetail, AttachmentInfo},
|
||||
search::SortBy,
|
||||
tags::TagCount,
|
||||
tags::{TagCount, TagsRequest},
|
||||
},
|
||||
};
|
||||
use crate::{
|
||||
@@ -204,16 +204,12 @@ impl EnvelopeIndexManager {
|
||||
.map_err(|e| raise_error!(format!("{:?}", e), ErrorCode::InternalError))?
|
||||
}
|
||||
|
||||
pub async fn update_envelope_tags(
|
||||
&self,
|
||||
updates: HashMap<u64, Vec<String>>, // HashMap<account_id, envelope_ids>
|
||||
tags: Vec<String>,
|
||||
) -> BichonResult<()> {
|
||||
if updates.is_empty() {
|
||||
tracing::warn!("update_envelope_tags: updates is empty, nothing to update");
|
||||
pub async fn update_envelope_tags(&self, request: TagsRequest) -> BichonResult<()> {
|
||||
if request.updates.is_empty() {
|
||||
tracing::warn!("update_envelope_tags: request is empty, nothing to update");
|
||||
return Ok(());
|
||||
}
|
||||
tokio::task::spawn_blocking(move || duckdb()?.update_envelope_tags(updates, tags))
|
||||
tokio::task::spawn_blocking(move || duckdb()?.update_envelope_tags(request))
|
||||
.await
|
||||
.map_err(|e| raise_error!(format!("{:?}", e), ErrorCode::InternalError))?
|
||||
}
|
||||
|
||||
@@ -16,16 +16,16 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use poem_openapi::Object;
|
||||
use poem_openapi::{Enum, Object};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct UpdateTagsRequest {
|
||||
pub struct TagsRequest {
|
||||
pub updates: HashMap<u64, Vec<String>>, // account_id -> envelope_ids
|
||||
pub tags: Vec<String>,
|
||||
pub action: TagAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
@@ -33,3 +33,11 @@ pub struct TagCount {
|
||||
pub tag: String,
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
#[derive(Enum, Default, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub enum TagAction {
|
||||
Add,
|
||||
Remove,
|
||||
#[default]
|
||||
Overwrite,
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ use crate::modules::message::delete::delete_messages_impl;
|
||||
use crate::modules::message::list::{get_thread_messages, list_messages_impl};
|
||||
use crate::modules::message::search::{search_messages_impl, SearchRequest};
|
||||
use crate::modules::message::tags::TagCount;
|
||||
use crate::modules::message::tags::UpdateTagsRequest;
|
||||
use crate::modules::message::tags::TagsRequest;
|
||||
use crate::modules::rest::api::ApiTags;
|
||||
use crate::modules::rest::response::DataPage;
|
||||
use crate::modules::rest::ApiResult;
|
||||
@@ -376,7 +376,7 @@ impl MessageApi {
|
||||
)]
|
||||
async fn update_envelope_tags(
|
||||
&self,
|
||||
req: Json<UpdateTagsRequest>,
|
||||
req: Json<TagsRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
let req = req.0;
|
||||
@@ -392,7 +392,7 @@ impl MessageApi {
|
||||
}
|
||||
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.update_envelope_tags(req.updates, req.tags)
|
||||
.update_envelope_tags(req)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { X, Trash2, Upload } from 'lucide-react'
|
||||
import { X, Trash2, Upload, TagIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -66,6 +66,11 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
|
||||
setOpen('restore')
|
||||
}
|
||||
|
||||
|
||||
const handleUpdateTags = () => {
|
||||
setOpen('update-tags')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const buttons = toolbarRef.current?.querySelectorAll('button')
|
||||
if (!buttons || buttons.length === 0) return
|
||||
@@ -174,7 +179,22 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
|
||||
{t('restore_message.restore_to_imap', 'Restore Mail')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleUpdateTags}
|
||||
className="gap-1"
|
||||
>
|
||||
<TagIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('search.bulkActions.manageTags')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Plus, Tag as TagIcon, X, Loader2, Check, AlertTriangle } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useAvailableTags } from '@/hooks/use-available-tags';
|
||||
import { TagAction, useUpdateTags } from '@/hooks/use-update-tags';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { validateTag } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchContext } from './context';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function UpdateTagsDialog({ open, onOpenChange }: Props) {
|
||||
const { tags: availableTags } = useAvailableTags();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate, isPending } = useUpdateTags();
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
const [action, setAction] = useState<TagAction>('Overwrite');
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { selected } = useSearchContext()
|
||||
|
||||
const handleAddTag = (tag: string) => {
|
||||
const normalized = tag.toLowerCase().trim();
|
||||
const result = validateTag(normalized);
|
||||
if (!result.valid) {
|
||||
toast({
|
||||
title: t('search.updateTags.invalidTitle'),
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (normalized && !selectedTags.includes(normalized)) {
|
||||
setSelectedTags(prev => [...prev, normalized]);
|
||||
}
|
||||
setInputValue('');
|
||||
setCommandOpen(false);
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tag: string) => {
|
||||
setSelectedTags(prev => prev.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (inputValue.trim()) {
|
||||
const normalized = inputValue.toLowerCase().trim();
|
||||
const result = validateTag(normalized);
|
||||
|
||||
if (!result.valid) {
|
||||
toast({
|
||||
title: t('search.updateTags.invalidTitle'),
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedTags.includes(normalized)) {
|
||||
setSelectedTags(prev => [...prev, normalized]);
|
||||
}
|
||||
|
||||
setInputValue('');
|
||||
}
|
||||
|
||||
const updates: Record<number, string[]> = {};
|
||||
|
||||
selected.forEach((tagSet, accountId) => {
|
||||
updates[accountId] = Array.from(tagSet);
|
||||
});
|
||||
|
||||
let finalTags = inputValue.trim()
|
||||
? [...selectedTags, inputValue.toLowerCase().trim()]
|
||||
: selectedTags;
|
||||
|
||||
if (finalTags.length === 0 && action !== 'Overwrite') {
|
||||
return;
|
||||
}
|
||||
|
||||
mutate(
|
||||
{
|
||||
updates,
|
||||
tags: finalTags,
|
||||
action
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t('search.updateTags.updatedTitle'),
|
||||
description: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>{t('search.updateTags.updatedDesc')}</span>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['all-tags'] });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('search.updateTags.updateFailedTitle'),
|
||||
description: error?.message || t('search.updateTags.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const filteredSuggestions = availableTags.filter(
|
||||
tag => !selectedTags.includes(tag) && tag.includes(inputValue.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md min-h-[50vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TagIcon className="h-5 w-5" />
|
||||
{t('search.updateTags.title')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs value={action} onValueChange={(v) => setAction(v as TagAction)} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="Add" className="text-xs">{t('search.updateTags.actionAdd', "Add")}</TabsTrigger>
|
||||
<TabsTrigger value="Remove" className="text-xs">{t('search.updateTags.actionRemove', "remove")}</TabsTrigger>
|
||||
<TabsTrigger value="Overwrite" className="text-xs">{t('search.updateTags.actionOverwrite', "overwrite")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="space-y-5 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTags.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('search.updateTags.none')}</p>
|
||||
) : (
|
||||
selectedTags.map(tag => (
|
||||
<Badge key={tag} variant="secondary" className="gap-1 pr-1 h-7">
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="rounded-sm hover:bg-destructive/20 hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Command shouldFilter={false} onKeyDown={(e) => e.stopPropagation()} >
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<CommandInput
|
||||
placeholder={t('search.updateTags.searchPlaceholder')}
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
onFocus={() => setCommandOpen(true)}
|
||||
className="h-9 pr-10"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && inputValue.trim()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleAddTag(inputValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{inputValue.trim() && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="absolute right-1 top-1 h-7 w-7 p-0"
|
||||
onClick={() => handleAddTag(inputValue)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{inputValue.trim() && filteredSuggestions.length === 0 && (
|
||||
<div className="px-1 text-xs text-muted-foreground animate-in fade-in duration-200">
|
||||
{t('search.updateTags.createHint', { tag: inputValue })}
|
||||
</div>
|
||||
)}
|
||||
{commandOpen && inputValue && filteredSuggestions.length > 0 && (
|
||||
<CommandList className="max-h-64 overflow-auto rounded-md border bg-popover shadow-md">
|
||||
<CommandGroup>
|
||||
{filteredSuggestions.map(tag => (
|
||||
<CommandItem
|
||||
key={tag}
|
||||
onSelect={() => handleAddTag(tag)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4 opacity-0" />
|
||||
{tag}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('search.updateTags.selectedCount', { count: selectedTags.length })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('search.addTags.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isPending} variant={action === 'Remove' ? 'destructive' : 'default'}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t(`search.updateTags.saving${action}`)}
|
||||
</>
|
||||
) : (
|
||||
t(`search.updateTags.submit${action}`)
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{action == "Overwrite" && <div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-500">
|
||||
<AlertTriangle className="h-5 w-5 shrink-0" />
|
||||
<p className="text-xs leading-relaxed">
|
||||
{t('search.updateTags.overwriteWarning')}
|
||||
</p>
|
||||
</div>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import React from 'react'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
import { SortingState } from '@tanstack/react-table'
|
||||
|
||||
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'restore' | 'delete-mailbox'
|
||||
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'update-tags' | 'restore' | 'delete-mailbox'
|
||||
|
||||
interface SearchContextType {
|
||||
open: SearchDialogType | null
|
||||
|
||||
+1
@@ -107,6 +107,7 @@ export function EditTagsDialog({ open, onOpenChange }: Props) {
|
||||
tags: inputValue.trim()
|
||||
? [...selectedTags, inputValue.toLowerCase().trim()]
|
||||
: selectedTags,
|
||||
action: "Overwrite"
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -28,12 +28,13 @@ import { MailDisplayDrawer } from './mail-display-dialog';
|
||||
import { EnvelopeDeleteDialog } from './delete-dialog';
|
||||
import SearchProvider, { SearchDialogType } from './context';
|
||||
import useDialogState from '@/hooks/use-dialog-state';
|
||||
import { EditTagsDialog } from './add-tag-dialog';
|
||||
import { EditTagsDialog } from './edit-tag-dialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RestoreMessageDialog } from './restore-message-dialog';
|
||||
import { MailListTable } from './mail-list-table';
|
||||
import { SortingState } from '@tanstack/react-table';
|
||||
import { MailBoxDeleteDialog } from './delete-mailbox-dialog';
|
||||
import { UpdateTagsDialog } from './bulk-add-tag-dialog';
|
||||
|
||||
export default function Search() {
|
||||
const { t } = useTranslation()
|
||||
@@ -154,6 +155,12 @@ export default function Search() {
|
||||
onOpenChange={() => setOpen('edit-tags')}
|
||||
/>
|
||||
|
||||
<UpdateTagsDialog
|
||||
key='edit-tags-dialog'
|
||||
open={open === 'update-tags'}
|
||||
onOpenChange={() => setOpen('update-tags')}
|
||||
/>
|
||||
|
||||
<RestoreMessageDialog
|
||||
key='restore-mail-dialog'
|
||||
open={open === 'restore'}
|
||||
|
||||
@@ -146,12 +146,18 @@ export function MailListTable({
|
||||
<span className="text-[11px] truncate font-medium leading-none">
|
||||
{mailbox_name}
|
||||
</span>
|
||||
{safeTags.length > 0 && (
|
||||
<span className="text-[9px] text-primary/70 truncate leading-none mt-0.5">
|
||||
{visibleTags.join(' · ')}
|
||||
{safeTags.length > 2 && ` · +${safeTags.length - 2}`}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{visibleTags.map((tag, i) => (
|
||||
<span key={i} className="px-1.5 py-0.5 rounded-sm bg-primary/10 text-primary text-[9px] font-medium leading-none border border-primary/20 whitespace-nowrap">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{safeTags.length > 2 && (
|
||||
<span className="px-1.5 py-0.5 rounded-sm bg-gray-100 text-gray-500 text-[9px] font-medium leading-none border border-gray-200">
|
||||
+{safeTags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -242,7 +248,7 @@ export function MailListTable({
|
||||
header: t('search.subject'),
|
||||
cell: ({ row }) => <LongText className='text-xs'>{row.original.subject}</LongText>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
maxSize: 600,
|
||||
maxSize: 450,
|
||||
},
|
||||
{
|
||||
id: "text_preview",
|
||||
@@ -317,14 +323,14 @@ export function MailListTable({
|
||||
)
|
||||
},
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 100,
|
||||
maxSize: 100,
|
||||
minSize: 130,
|
||||
maxSize: 130,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('users.columns.actions'),
|
||||
cell: DataTableRowActions,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
meta: { className: 'text-right text-xs' },
|
||||
minSize: 50,
|
||||
maxSize: 60,
|
||||
},
|
||||
|
||||
@@ -98,7 +98,7 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
{children && (<>{children(table)}</>)}
|
||||
<ScrollArea className='h-[calc(100vh-15rem)] rounded-md border' orientation='both'>
|
||||
<ScrollArea className='h-[calc(100vh-13rem)] rounded-md border' orientation='both'>
|
||||
<ShadcnTable>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
||||
@@ -21,9 +21,12 @@ import { update_tags } from '@/api/search/api';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from './use-toast';
|
||||
|
||||
|
||||
export type TagAction = 'Add' | 'Remove' | 'Overwrite';
|
||||
export interface UpdateTagsParams {
|
||||
updates: Record<number, string[]>;
|
||||
tags: string[];
|
||||
action: TagAction;
|
||||
}
|
||||
|
||||
export function useUpdateTags() {
|
||||
@@ -31,12 +34,7 @@ export function useUpdateTags() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (params: UpdateTagsParams) => {
|
||||
const { updates, tags } = params;
|
||||
const payload = {
|
||||
updates,
|
||||
tags
|
||||
};
|
||||
return update_tags(payload);
|
||||
return update_tags(params);
|
||||
},
|
||||
|
||||
onSuccess: () => {
|
||||
|
||||
+25
-1
@@ -482,6 +482,29 @@
|
||||
"save": "حفظ",
|
||||
"saving": "جارٍ الحفظ..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "تحذير: سيؤدي هذا إلى الكتابة فوق جميع العلامات الموجودة في العناصر المختارة.",
|
||||
"title": "تحديث العلامات",
|
||||
"none": "لم يتم اختيار أي علامات",
|
||||
"searchPlaceholder": "ابحث أو أنشئ علامة جديدة...",
|
||||
"createHint": "اضغط Enter لإنشاء علامة جديدة: \"{{tag}}\"",
|
||||
"selectedCount": "سيتم تطبيق {{count}} من العلامات",
|
||||
"cancel": "إلغاء",
|
||||
"invalidTitle": "علامة غير صالحة",
|
||||
"updatedTitle": "تم تحديث العلامات",
|
||||
"updatedDesc": "تم تحديث العلامات بنجاح.",
|
||||
"updateFailedTitle": "فشل التحديث",
|
||||
"tryAgain": "يرجى المحاولة مرة أخرى لاحقاً.",
|
||||
"actionAdd": "إضافة",
|
||||
"actionRemove": "إزالة",
|
||||
"actionOverwrite": "استبدال",
|
||||
"submitAdd": "إضافة علامات",
|
||||
"submitRemove": "إزالة علامات",
|
||||
"submitOverwrite": "استبدال العلامات",
|
||||
"savingAdd": "جاري الإضافة...",
|
||||
"savingRemove": "جاري الإزالة...",
|
||||
"savingOverwrite": "جاري الاستبدال..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "المحادثة · {{count}} رسالة",
|
||||
"error": "فشل تحميل المحادثة",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "حذف",
|
||||
"deleteDesc": "حذف الرسائل المحددة",
|
||||
"selected": "تم تحديد {{count}}",
|
||||
"clear": "مسح التحديد"
|
||||
"clear": "مسح التحديد",
|
||||
"manageTags": "إدارة العلامات"
|
||||
},
|
||||
"delete": {
|
||||
"title": "حذف الرسائل",
|
||||
|
||||
+25
-1
@@ -482,6 +482,29 @@
|
||||
"save": "Gem",
|
||||
"saving": "Gemmer..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "Advarsel: Dette vil overskrive alle eksisterende tags på de valgte elementer.",
|
||||
"title": "Opdater tags",
|
||||
"none": "Ingen tags valgt",
|
||||
"searchPlaceholder": "Søg eller opret nyt tag...",
|
||||
"createHint": "Tryk på Enter for at oprette nyt tag: \"{{tag}}\"",
|
||||
"selectedCount": "{{count}} tag(s) vil blive anvendt",
|
||||
"cancel": "Annuller",
|
||||
"invalidTitle": "Ugyldigt tag",
|
||||
"updatedTitle": "Tags opdateret",
|
||||
"updatedDesc": "Tags er blevet opdateret korrekt.",
|
||||
"updateFailedTitle": "Opdatering fejlede",
|
||||
"tryAgain": "Prøv venligst igen senere.",
|
||||
"actionAdd": "Tilføj",
|
||||
"actionRemove": "Fjern",
|
||||
"actionOverwrite": "Overskriv",
|
||||
"submitAdd": "Tilføj tags",
|
||||
"submitRemove": "Fjern tags",
|
||||
"submitOverwrite": "Overskriv tags",
|
||||
"savingAdd": "Tilføjer...",
|
||||
"savingRemove": "Fjerner...",
|
||||
"savingOverwrite": "Overskriver..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "Tråd · {{count}} meddelelser",
|
||||
"error": "Kunne ikke indlæse tråd",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "Slet",
|
||||
"deleteDesc": "Slet valgte meddelelser",
|
||||
"selected": "{{count}} valgt",
|
||||
"clear": "Ryd valg"
|
||||
"clear": "Ryd valg",
|
||||
"manageTags": "Administrer tags"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Slet meddelelser",
|
||||
|
||||
+25
-1
@@ -482,6 +482,29 @@
|
||||
"save": "Speichern",
|
||||
"saving": "Wird gespeichert..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "Warnung: Dies überschreibt alle vorhandenen Tags der ausgewählten Elemente.",
|
||||
"title": "Tags aktualisieren",
|
||||
"none": "Keine Tags ausgewählt",
|
||||
"searchPlaceholder": "Tag suchen oder erstellen...",
|
||||
"createHint": "Eingabetaste drücken, um neuen Tag zu erstellen: \"{{tag}}\"",
|
||||
"selectedCount": "{{count}} Tag(s) werden angewendet",
|
||||
"cancel": "Abbrechen",
|
||||
"invalidTitle": "Ungültiger Tag",
|
||||
"updatedTitle": "Tags aktualisiert",
|
||||
"updatedDesc": "Tags wurden erfolgreich aktualisiert.",
|
||||
"updateFailedTitle": "Aktualisierung fehlgeschlagen",
|
||||
"tryAgain": "Bitte versuchen Sie es später erneut.",
|
||||
"actionAdd": "Hinzufügen",
|
||||
"actionRemove": "Entfernen",
|
||||
"actionOverwrite": "Überschreiben",
|
||||
"submitAdd": "Tags hinzufügen",
|
||||
"submitRemove": "Tags entfernen",
|
||||
"submitOverwrite": "Tags überschreiben",
|
||||
"savingAdd": "Wird hinzugefügt...",
|
||||
"savingRemove": "Wird entfernt...",
|
||||
"savingOverwrite": "Wird überschrieben..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "Konversations-Thread · {{count}} Nachrichten",
|
||||
"error": "Fehler beim Laden des Threads",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "Löschen",
|
||||
"deleteDesc": "Ausgewählte Nachrichten löschen",
|
||||
"selected": "{{count}} ausgewählt",
|
||||
"clear": "Auswahl aufheben"
|
||||
"clear": "Auswahl aufheben",
|
||||
"manageTags": "Tags verwalten"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Nachrichten löschen",
|
||||
|
||||
+25
-1
@@ -482,6 +482,29 @@
|
||||
"save": "Save",
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "Warning: This will overwrite all existing tags on the selected items.",
|
||||
"title": "Update Tags",
|
||||
"none": "No tags selected",
|
||||
"searchPlaceholder": "Search or create new tag...",
|
||||
"createHint": "Press Enter to create new tag: \"{{tag}}\"",
|
||||
"selectedCount": "{{count}} tag(s) to be applied",
|
||||
"cancel": "Cancel",
|
||||
"invalidTitle": "Invalid Tag",
|
||||
"updatedTitle": "Tags Updated",
|
||||
"updatedDesc": "Tags have been successfully updated.",
|
||||
"updateFailedTitle": "Update Failed",
|
||||
"tryAgain": "Please try again later.",
|
||||
"actionAdd": "Add",
|
||||
"actionRemove": "Remove",
|
||||
"actionOverwrite": "Overwrite",
|
||||
"submitAdd": "Add Tags",
|
||||
"submitRemove": "Remove Tags",
|
||||
"submitOverwrite": "Overwrite Tags",
|
||||
"savingAdd": "Adding...",
|
||||
"savingRemove": "Removing...",
|
||||
"savingOverwrite": "Overwriting..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "Thread · {{count}} messages",
|
||||
"error": "Failed to load thread",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "Delete",
|
||||
"deleteDesc": "Delete selected messages",
|
||||
"selected": "{{count}} selected",
|
||||
"clear": "Clear selection"
|
||||
"clear": "Clear selection",
|
||||
"manageTags": "Manage Tags"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete messages",
|
||||
|
||||
+25
-1
@@ -482,6 +482,29 @@
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "Advertencia: esto sobrescribirá todas las etiquetas existentes en los elementos seleccionados.",
|
||||
"title": "Actualizar etiquetas",
|
||||
"none": "No hay etiquetas seleccionadas",
|
||||
"searchPlaceholder": "Buscar o crear nueva etiqueta...",
|
||||
"createHint": "Presione Enter para crear una nueva etiqueta: \"{{tag}}\"",
|
||||
"selectedCount": "{{count}} etiqueta(s) a aplicar",
|
||||
"cancel": "Cancelar",
|
||||
"invalidTitle": "Etiqueta inválida",
|
||||
"updatedTitle": "Etiquetas actualizadas",
|
||||
"updatedDesc": "Las etiquetas se han actualizado con éxito.",
|
||||
"updateFailedTitle": "Error al actualizar",
|
||||
"tryAgain": "Por favor, inténtelo de nuevo más tarde.",
|
||||
"actionAdd": "Añadir",
|
||||
"actionRemove": "Eliminar",
|
||||
"actionOverwrite": "Sobrescribir",
|
||||
"submitAdd": "Añadir etiquetas",
|
||||
"submitRemove": "Eliminar etiquetas",
|
||||
"submitOverwrite": "Sobrescribir etiquetas",
|
||||
"savingAdd": "Añadiendo...",
|
||||
"savingRemove": "Eliminando...",
|
||||
"savingOverwrite": "Sobrescribiendo..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "Hilo de conversación · {{count}} mensajes",
|
||||
"error": "Error al cargar el hilo",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "Eliminar",
|
||||
"deleteDesc": "Eliminar seleccionados",
|
||||
"selected": "{{count}} seleccionado(s)",
|
||||
"clear": "Limpiar selección"
|
||||
"clear": "Limpiar selección",
|
||||
"manageTags": "Administrar etiquetas"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Eliminar mensajes",
|
||||
|
||||
+25
-1
@@ -482,6 +482,29 @@
|
||||
"save": "Tallenna",
|
||||
"saving": "Tallennetaan..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "Varoitus: Tämä korvaa kaikki valittujen kohteiden olemassa olevat tunnisteet.",
|
||||
"title": "Päivitä tunnisteet",
|
||||
"none": "Ei valittuja tunnisteita",
|
||||
"searchPlaceholder": "Hae tai luo uusi tunniste...",
|
||||
"createHint": "Paina Enter luodaksesi uuden tunnisteen: \"{{tag}}\"",
|
||||
"selectedCount": "{{count}} tunniste(tta) otetaan käyttöön",
|
||||
"cancel": "Peruuta",
|
||||
"invalidTitle": "Virheellinen tunniste",
|
||||
"updatedTitle": "Tunnisteet päivitetty",
|
||||
"updatedDesc": "Tunnisteet on päivitetty onnistuneesti.",
|
||||
"updateFailedTitle": "Päivitys epäonnistui",
|
||||
"tryAgain": "Yritä myöhemmin uudelleen.",
|
||||
"actionAdd": "Lisää",
|
||||
"actionRemove": "Poista",
|
||||
"actionOverwrite": "Korvaa",
|
||||
"submitAdd": "Lisää tunnisteet",
|
||||
"submitRemove": "Poista tunnisteet",
|
||||
"submitOverwrite": "Korvaa tunnisteet",
|
||||
"savingAdd": "Lisätään...",
|
||||
"savingRemove": "Poistetaan...",
|
||||
"savingOverwrite": "Korvataan..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "Keskusteluketju · {{count}} viestiä",
|
||||
"error": "Keskusteluketjun lataus epäonnistui",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "Poista",
|
||||
"deleteDesc": "Poista valitut viestit",
|
||||
"selected": "{{count}} valittu",
|
||||
"clear": "Tyhjennä valinnat"
|
||||
"clear": "Tyhjennä valinnat",
|
||||
"manageTags": "Hallitse tunnisteita"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Poista viestit",
|
||||
|
||||
+25
-1
@@ -482,6 +482,29 @@
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement en cours..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "Avertissement : cela écrasera tous les tags existants sur les éléments sélectionnés.",
|
||||
"title": "Modifier les tags",
|
||||
"none": "Aucun tag sélectionné",
|
||||
"searchPlaceholder": "Rechercher ou créer un tag...",
|
||||
"createHint": "Appuyez sur Entrée pour créer le tag : \"{{tag}}\"",
|
||||
"selectedCount": "{{count}} tag(s) à appliquer",
|
||||
"cancel": "Annuler",
|
||||
"invalidTitle": "Tag invalide",
|
||||
"updatedTitle": "Tags mis à jour",
|
||||
"updatedDesc": "Les tags ont été mis à jour avec succès.",
|
||||
"updateFailedTitle": "Échec de la mise à jour",
|
||||
"tryAgain": "Veuillez réessayer plus tard.",
|
||||
"actionAdd": "Ajouter",
|
||||
"actionRemove": "Retirer",
|
||||
"actionOverwrite": "Écraser",
|
||||
"submitAdd": "Ajouter les tags",
|
||||
"submitRemove": "Retirer les tags",
|
||||
"submitOverwrite": "Écraser les tags",
|
||||
"savingAdd": "Ajout...",
|
||||
"savingRemove": "Retrait...",
|
||||
"savingOverwrite": "Écrasement..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "Fil de discussion · {{count}} messages",
|
||||
"error": "Échec du chargement du fil de discussion",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "Supprimer",
|
||||
"deleteDesc": "Supprimer les messages sélectionnés",
|
||||
"selected": "{{count}} sélectionné(s)",
|
||||
"clear": "Effacer la sélection"
|
||||
"clear": "Effacer la sélection",
|
||||
"manageTags": "Gérer les tags"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Supprimer les messages",
|
||||
|
||||
+25
-1
@@ -482,6 +482,29 @@
|
||||
"save": "Salva",
|
||||
"saving": "Salvataggio in corso..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "Avviso: questo sovrascriverà tutti i tag esistenti sugli elementi selezionati.",
|
||||
"title": "Aggiorna tag",
|
||||
"none": "Nessun tag selezionato",
|
||||
"searchPlaceholder": "Cerca o crea un nuovo tag...",
|
||||
"createHint": "Premi Invio per creare un nuovo tag: \"{{tag}}\"",
|
||||
"selectedCount": "{{count}} tag da applicare",
|
||||
"cancel": "Annulla",
|
||||
"invalidTitle": "Tag non valido",
|
||||
"updatedTitle": "Tag aggiornati",
|
||||
"updatedDesc": "I tag sono stati aggiornati con successo.",
|
||||
"updateFailedTitle": "Aggiornamento fallito",
|
||||
"tryAgain": "Riprova più tardi.",
|
||||
"actionAdd": "Aggiungi",
|
||||
"actionRemove": "Rimuovi",
|
||||
"actionOverwrite": "Sovrascrivi",
|
||||
"submitAdd": "Aggiungi tag",
|
||||
"submitRemove": "Rimuovi tag",
|
||||
"submitOverwrite": "Sovrascrivi tag",
|
||||
"savingAdd": "Aggiunta...",
|
||||
"savingRemove": "Rimozione...",
|
||||
"savingOverwrite": "Sovrascrittura..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "Thread · {{count}} messaggi",
|
||||
"error": "Caricamento thread fallito",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "Elimina",
|
||||
"deleteDesc": "Elimina messaggi selezionati",
|
||||
"selected": "{{count}} selezionati",
|
||||
"clear": "Annulla selezione"
|
||||
"clear": "Annulla selezione",
|
||||
"manageTags": "Gestisci tag"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Elimina messaggi",
|
||||
|
||||
+25
-1
@@ -482,6 +482,29 @@
|
||||
"save": "保存",
|
||||
"saving": "保存中..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "警告:この操作により、選択した項目の既存のタグがすべて上書きされます。",
|
||||
"title": "タグを更新",
|
||||
"none": "タグが選択されていません",
|
||||
"searchPlaceholder": "タグを検索または作成...",
|
||||
"createHint": "Enterキーを押して新しいタグを作成:\"{{tag}}\"",
|
||||
"selectedCount": "{{count}} 個のタグを適用します",
|
||||
"cancel": "キャンセル",
|
||||
"invalidTitle": "無効なタグ",
|
||||
"updatedTitle": "タグを更新しました",
|
||||
"updatedDesc": "タグが正常に更新されました。",
|
||||
"updateFailedTitle": "更新に失敗しました",
|
||||
"tryAgain": "後でもう一度お試しください。",
|
||||
"actionAdd": "追加",
|
||||
"actionRemove": "削除",
|
||||
"actionOverwrite": "上書き",
|
||||
"submitAdd": "タグを追加",
|
||||
"submitRemove": "タグを削除",
|
||||
"submitOverwrite": "タグを上書き",
|
||||
"savingAdd": "追加中...",
|
||||
"savingRemove": "削除中...",
|
||||
"savingOverwrite": "上書き中..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "スレッド · {{count}}件のメッセージ",
|
||||
"error": "スレッドの読み込みに失敗しました",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "削除",
|
||||
"deleteDesc": "選択したメッセージを削除",
|
||||
"selected": "{{count}} 件選択済み",
|
||||
"clear": "選択を解除"
|
||||
"clear": "選択を解除",
|
||||
"manageTags": "タグを管理"
|
||||
},
|
||||
"delete": {
|
||||
"title": "メッセージを削除",
|
||||
|
||||
+25
-1
@@ -482,6 +482,29 @@
|
||||
"save": "저장",
|
||||
"saving": "저장 중..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "경고: 이 작업은 선택한 항목의 모든 기존 태그를 덮어씁니다.",
|
||||
"title": "태그 업데이트",
|
||||
"none": "선택된 태그 없음",
|
||||
"searchPlaceholder": "태그 검색 또는 생성...",
|
||||
"createHint": "Enter를 눌러 새 태그 생성: \"{{tag}}\"",
|
||||
"selectedCount": "{{count}}개의 태그가 적용됩니다",
|
||||
"cancel": "취소",
|
||||
"invalidTitle": "유효하지 않은 태그",
|
||||
"updatedTitle": "태그 업데이트됨",
|
||||
"updatedDesc": "태그가 성공적으로 업데이트되었습니다.",
|
||||
"updateFailedTitle": "업데이트 실패",
|
||||
"tryAgain": "나중에 다시 시도해 주세요.",
|
||||
"actionAdd": "추가",
|
||||
"actionRemove": "제거",
|
||||
"actionOverwrite": "덮어쓰기",
|
||||
"submitAdd": "태그 추가",
|
||||
"submitRemove": "태그 제거",
|
||||
"submitOverwrite": "태그 덮어쓰기",
|
||||
"savingAdd": "추가 중...",
|
||||
"savingRemove": "제거 중...",
|
||||
"savingOverwrite": "덮어쓰는 중..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "스레드 · {{count}}개 메시지",
|
||||
"error": "스레드 로드 실패",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "삭제",
|
||||
"deleteDesc": "선택한 메시지 삭제",
|
||||
"selected": "{{count}}개 선택됨",
|
||||
"clear": "선택 해제"
|
||||
"clear": "선택 해제",
|
||||
"manageTags": "태그 관리"
|
||||
},
|
||||
"delete": {
|
||||
"title": "메시지 삭제",
|
||||
|
||||
+25
-1
@@ -482,6 +482,29 @@
|
||||
"save": "Opslaan",
|
||||
"saving": "Bezig met opslaan..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "Waarschuwing: dit overschrijft alle bestaande tags op de geselecteerde items.",
|
||||
"title": "Tags bijwerken",
|
||||
"none": "Geen tags geselecteerd",
|
||||
"searchPlaceholder": "Zoek of maak nieuwe tag...",
|
||||
"createHint": "Druk op Enter om nieuwe tag te maken: \"{{tag}}\"",
|
||||
"selectedCount": "{{count}} tag(s) worden toegepast",
|
||||
"cancel": "Annuleren",
|
||||
"invalidTitle": "Ongeldige tag",
|
||||
"updatedTitle": "Tags bijgewerkt",
|
||||
"updatedDesc": "Tags zijn succesvol bijgewerkt.",
|
||||
"updateFailedTitle": "Bijwerken mislukt",
|
||||
"tryAgain": "Probeer het later opnieuw.",
|
||||
"actionAdd": "Toevoegen",
|
||||
"actionRemove": "Verwijderen",
|
||||
"actionOverwrite": "Overschrijven",
|
||||
"submitAdd": "Tags toevoegen",
|
||||
"submitRemove": "Tags verwijderen",
|
||||
"submitOverwrite": "Tags overschrijven",
|
||||
"savingAdd": "Toevoegen...",
|
||||
"savingRemove": "Verwijderen...",
|
||||
"savingOverwrite": "Overschrijven..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "Draad · {{count}} berichten",
|
||||
"error": "Laden van draad mislukt",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "Verwijderen",
|
||||
"deleteDesc": "Geselecteerde berichten verwijderen",
|
||||
"selected": "{{count}} geselecteerd",
|
||||
"clear": "Selectie wissen"
|
||||
"clear": "Selectie wissen",
|
||||
"manageTags": "Tags beheren"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Berichten verwijderen",
|
||||
|
||||
+25
-1
@@ -482,6 +482,29 @@
|
||||
"save": "Lagre",
|
||||
"saving": "Lagrer..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "Advarsel: Dette vil overskrive alle eksisterende tagger på de valgte elementene.",
|
||||
"title": "Oppdater tagger",
|
||||
"none": "Ingen tagger valgt",
|
||||
"searchPlaceholder": "Søk eller opprett ny tagg...",
|
||||
"createHint": "Trykk Enter for å opprette ny tagg: \"{{tag}}\"",
|
||||
"selectedCount": "{{count}} tag(g/er) vil bli brukt",
|
||||
"cancel": "Avbryt",
|
||||
"invalidTitle": "Ugyldig tagg",
|
||||
"updatedTitle": "Tagger oppdatert",
|
||||
"updatedDesc": "Tagger har blitt oppdatert.",
|
||||
"updateFailedTitle": "Oppdatering mislyktes",
|
||||
"tryAgain": "Vennligst prøv igjen senere.",
|
||||
"actionAdd": "Legg til",
|
||||
"actionRemove": "Fjern",
|
||||
"actionOverwrite": "Overskriv",
|
||||
"submitAdd": "Legg til tagger",
|
||||
"submitRemove": "Fjern tagger",
|
||||
"submitOverwrite": "Overskriv tagger",
|
||||
"savingAdd": "Legger til...",
|
||||
"savingRemove": "Fjerner...",
|
||||
"savingOverwrite": "Overskriver..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "Tråd · {{count}} meldinger",
|
||||
"error": "Kunne ikke laste tråd",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "Slett",
|
||||
"deleteDesc": "Slett valgte meldinger",
|
||||
"selected": "{{count}} valgt",
|
||||
"clear": "Fjern valg"
|
||||
"clear": "Fjern valg",
|
||||
"manageTags": "Administrer tagger"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Slett meldinger",
|
||||
|
||||
+25
-1
@@ -482,6 +482,29 @@
|
||||
"save": "Zapisz",
|
||||
"saving": "Zapisywanie..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "Ostrzeżenie: to nadpisze wszystkie istniejące tagi w wybranych elementach.",
|
||||
"title": "Aktualizuj tagi",
|
||||
"none": "Nie wybrano żadnych tagów",
|
||||
"searchPlaceholder": "Szukaj lub utwórz nowy tag...",
|
||||
"createHint": "Naciśnij Enter, aby utworzyć nowy tag: \"{{tag}}\"",
|
||||
"selectedCount": "Zostanie zastosowanych tagów: {{count}}",
|
||||
"cancel": "Anuluj",
|
||||
"invalidTitle": "Nieprawidłowy tag",
|
||||
"updatedTitle": "Tagi zaktualizowane",
|
||||
"updatedDesc": "Tagi zostały pomyślnie zaktualizowane.",
|
||||
"updateFailedTitle": "Aktualizacja nie powiodła się",
|
||||
"tryAgain": "Spróbuj ponownie później.",
|
||||
"actionAdd": "Dodaj",
|
||||
"actionRemove": "Usuń",
|
||||
"actionOverwrite": "Nadpisz",
|
||||
"submitAdd": "Dodaj tagi",
|
||||
"submitRemove": "Usuń tagi",
|
||||
"submitOverwrite": "Nadpisz tagi",
|
||||
"savingAdd": "Dodawanie...",
|
||||
"savingRemove": "Usuwanie...",
|
||||
"savingOverwrite": "Nadpisywanie..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "Wątek · {{count}} wiadomości",
|
||||
"error": "Nie udało się załadować wątku",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "Usuń",
|
||||
"deleteDesc": "Usuń wybrane wiadomości",
|
||||
"selected": "Wybrano {{count}}",
|
||||
"clear": "Wyczyść zaznaczenie"
|
||||
"clear": "Wyczyść zaznaczenie",
|
||||
"manageTags": "Zarządzaj tagami"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Usuń wiadomości",
|
||||
|
||||
+25
-1
@@ -482,6 +482,29 @@
|
||||
"save": "Salvar",
|
||||
"saving": "Salvando..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "Aviso: isto substituirá todas as etiquetas existentes nos itens selecionados.",
|
||||
"title": "Atualizar etiquetas",
|
||||
"none": "Nenhuma etiqueta selecionada",
|
||||
"searchPlaceholder": "Procurar ou criar nova etiqueta...",
|
||||
"createHint": "Pressione Enter para criar nova etiqueta: \"{{tag}}\"",
|
||||
"selectedCount": "{{count}} etiqueta(s) a aplicar",
|
||||
"cancel": "Cancelar",
|
||||
"invalidTitle": "Etiqueta inválida",
|
||||
"updatedTitle": "Etiquetas atualizadas",
|
||||
"updatedDesc": "Etiquetas atualizadas com sucesso.",
|
||||
"updateFailedTitle": "Falha na atualização",
|
||||
"tryAgain": "Por favor, tente novamente mais tarde.",
|
||||
"actionAdd": "Adicionar",
|
||||
"actionRemove": "Remover",
|
||||
"actionOverwrite": "Substituir",
|
||||
"submitAdd": "Adicionar etiquetas",
|
||||
"submitRemove": "Remover etiquetas",
|
||||
"submitOverwrite": "Substituir etiquetas",
|
||||
"savingAdd": "Adicionando...",
|
||||
"savingRemove": "Removendo...",
|
||||
"savingOverwrite": "Substituindo..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "Tópico · {{count}} Mensagens",
|
||||
"error": "Falha ao carregar o tópico",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "Excluir",
|
||||
"deleteDesc": "Excluir mensagens selecionadas",
|
||||
"selected": "{{count}} selecionadas",
|
||||
"clear": "Limpar seleção"
|
||||
"clear": "Limpar seleção",
|
||||
"manageTags": "Gerenciar etiquetas"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Excluir mensagens",
|
||||
|
||||
+25
-1
@@ -482,6 +482,29 @@
|
||||
"save": "Сохранить",
|
||||
"saving": "Сохранение..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "Предупреждение: это перезапишет все существующие теги в выбранных элементах.",
|
||||
"title": "Обновить теги",
|
||||
"none": "Теги не выбраны",
|
||||
"searchPlaceholder": "Поиск или создание тега...",
|
||||
"createHint": "Нажмите Enter, чтобы создать тег: \"{{tag}}\"",
|
||||
"selectedCount": "Будет применено тегов: {{count}}",
|
||||
"cancel": "Отмена",
|
||||
"invalidTitle": "Недопустимый тег",
|
||||
"updatedTitle": "Теги обновлены",
|
||||
"updatedDesc": "Теги были успешно обновлены.",
|
||||
"updateFailedTitle": "Ошибка обновления",
|
||||
"tryAgain": "Пожалуйста, попробуйте позже.",
|
||||
"actionAdd": "Добавить",
|
||||
"actionRemove": "Удалить",
|
||||
"actionOverwrite": "Перезаписать",
|
||||
"submitAdd": "Добавить теги",
|
||||
"submitRemove": "Удалить теги",
|
||||
"submitOverwrite": "Перезаписать теги",
|
||||
"savingAdd": "Добавление...",
|
||||
"savingRemove": "Удаление...",
|
||||
"savingOverwrite": "Перезапись..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "Цепочка · {{count}} сообщений",
|
||||
"error": "Не удалось загрузить цепочку",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "Удалить",
|
||||
"deleteDesc": "Удалить выбранные",
|
||||
"selected": "Выбрано: {{count}}",
|
||||
"clear": "Очистить выбор"
|
||||
"clear": "Очистить выбор",
|
||||
"manageTags": "Управление тегами"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Удалить сообщения",
|
||||
|
||||
+25
-1
@@ -482,6 +482,29 @@
|
||||
"save": "Spara",
|
||||
"saving": "Sparar..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "Varning: Detta kommer att skriva över alla befintliga taggar på de valda objekten.",
|
||||
"title": "Uppdatera taggar",
|
||||
"none": "Inga taggar valda",
|
||||
"searchPlaceholder": "Sök eller skapa ny tagg...",
|
||||
"createHint": "Tryck på Enter för att skapa ny tagg: \"{{tag}}\"",
|
||||
"selectedCount": "{{count}} tagg(ar) kommer att tillämpas",
|
||||
"cancel": "Avbryt",
|
||||
"invalidTitle": "Ogiltig tagg",
|
||||
"updatedTitle": "Taggar uppdaterade",
|
||||
"updatedDesc": "Taggarna har uppdaterats.",
|
||||
"updateFailedTitle": "Uppdateringen misslyckades",
|
||||
"tryAgain": "Försök igen senare.",
|
||||
"actionAdd": "Lägg till",
|
||||
"actionRemove": "Ta bort",
|
||||
"actionOverwrite": "Skriv över",
|
||||
"submitAdd": "Lägg till taggar",
|
||||
"submitRemove": "Ta bort taggar",
|
||||
"submitOverwrite": "Skriv över taggar",
|
||||
"savingAdd": "Lägger till...",
|
||||
"savingRemove": "Tar bort...",
|
||||
"savingOverwrite": "Skriver över..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "Tråd · {{count}} meddelanden",
|
||||
"error": "Kunde inte ladda tråd",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "Ta bort",
|
||||
"deleteDesc": "Ta bort valda meddelanden",
|
||||
"selected": "{{count}} valda",
|
||||
"clear": "Rensa val"
|
||||
"clear": "Rensa val",
|
||||
"manageTags": "Hantera taggar"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Ta bort meddelanden",
|
||||
|
||||
@@ -482,6 +482,29 @@
|
||||
"save": "儲存",
|
||||
"saving": "正在儲存..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "警告:此操作將覆蓋所選項目的所有現有標籤。",
|
||||
"title": "更新標籤",
|
||||
"none": "未選擇標籤",
|
||||
"searchPlaceholder": "搜尋或建立新標籤...",
|
||||
"createHint": "按 Enter 鍵建立新標籤:\"{{tag}}\"",
|
||||
"selectedCount": "將套用 {{count}} 個標籤",
|
||||
"cancel": "取消",
|
||||
"invalidTitle": "標籤無效",
|
||||
"updatedTitle": "標籤已更新",
|
||||
"updatedDesc": "標籤已成功更新。",
|
||||
"updateFailedTitle": "更新失敗",
|
||||
"tryAgain": "請稍後再試。",
|
||||
"actionAdd": "新增",
|
||||
"actionRemove": "移除",
|
||||
"actionOverwrite": "覆蓋",
|
||||
"submitAdd": "新增標籤",
|
||||
"submitRemove": "移除標籤",
|
||||
"submitOverwrite": "覆蓋標籤",
|
||||
"savingAdd": "正在新增...",
|
||||
"savingRemove": "正在移除...",
|
||||
"savingOverwrite": "正在覆蓋..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "串流 · {{count}} 則訊息",
|
||||
"error": "載入串流失敗",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "刪除",
|
||||
"deleteDesc": "刪除選中的訊息",
|
||||
"selected": "已選擇 {{count}} 項",
|
||||
"clear": "清除選取"
|
||||
"clear": "清除選取",
|
||||
"manageTags": "管理標籤"
|
||||
},
|
||||
"delete": {
|
||||
"title": "刪除訊息",
|
||||
|
||||
+25
-1
@@ -482,6 +482,29 @@
|
||||
"save": "保存",
|
||||
"saving": "保存中..."
|
||||
},
|
||||
"updateTags": {
|
||||
"overwriteWarning": "警告:此操作将覆盖所选项目的所有现有标签。",
|
||||
"title": "更新标签",
|
||||
"none": "未选择标签",
|
||||
"searchPlaceholder": "搜索或创建新标签...",
|
||||
"createHint": "按回车键创建新标签:\"{{tag}}\"",
|
||||
"selectedCount": "将应用 {{count}} 个标签",
|
||||
"saving": "正在更新...",
|
||||
"invalidTitle": "标签无效",
|
||||
"updatedTitle": "标签已更新",
|
||||
"updatedDesc": "标签已成功更新。",
|
||||
"updateFailedTitle": "更新失败",
|
||||
"tryAgain": "请稍后再试。",
|
||||
"actionAdd": "追加",
|
||||
"actionRemove": "移除",
|
||||
"actionOverwrite": "覆盖",
|
||||
"submitAdd": "追加标签",
|
||||
"submitRemove": "移除标签",
|
||||
"submitOverwrite": "覆盖标签",
|
||||
"savingAdd": "正在追加...",
|
||||
"savingRemove": "正在移除...",
|
||||
"savingOverwrite": "正在覆盖..."
|
||||
},
|
||||
"thread": {
|
||||
"title": "会话 · 共 {{count}} 封邮件",
|
||||
"error": "加载会话失败",
|
||||
@@ -496,7 +519,8 @@
|
||||
"delete": "删除",
|
||||
"deleteDesc": "删除选中的消息",
|
||||
"selected": "已选择 {{count}} 项",
|
||||
"clear": "清除选择"
|
||||
"clear": "清除选择",
|
||||
"manageTags": "管理标签"
|
||||
},
|
||||
"delete": {
|
||||
"title": "删除消息",
|
||||
|
||||
Reference in New Issue
Block a user