diff --git a/src/modules/duckdb/init.rs b/src/modules/duckdb/init.rs index 1cbd7bc..fba254e 100644 --- a/src/modules/duckdb/init.rs +++ b/src/modules/duckdb/init.rs @@ -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>, - tags: Vec, - ) -> 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> = Vec::new(); + let mut params: Vec> = 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 + ) + })?; } } diff --git a/src/modules/indexer/manager.rs b/src/modules/indexer/manager.rs index f158cc6..b40066d 100644 --- a/src/modules/indexer/manager.rs +++ b/src/modules/indexer/manager.rs @@ -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>, // HashMap - tags: Vec, - ) -> 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))? } diff --git a/src/modules/message/tags.rs b/src/modules/message/tags.rs index 6597b27..d20ac07 100644 --- a/src/modules/message/tags.rs +++ b/src/modules/message/tags.rs @@ -16,16 +16,16 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - 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>, // account_id -> envelope_ids pub tags: Vec, + 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, +} diff --git a/src/modules/rest/api/message.rs b/src/modules/rest/api/message.rs index 80244b1..38889c1 100644 --- a/src/modules/rest/api/message.rs +++ b/src/modules/rest/api/message.rs @@ -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, + req: Json, 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(()) } diff --git a/web/src/features/search/bulk-actions.tsx b/web/src/features/search/bulk-actions.tsx index 0956dfe..d77131a 100644 --- a/web/src/features/search/bulk-actions.tsx +++ b/web/src/features/search/bulk-actions.tsx @@ -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')} - + + + + + + + {t('search.bulkActions.manageTags')} + + diff --git a/web/src/features/search/bulk-add-tag-dialog.tsx b/web/src/features/search/bulk-add-tag-dialog.tsx new file mode 100644 index 0000000..c21114d --- /dev/null +++ b/web/src/features/search/bulk-add-tag-dialog.tsx @@ -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 . + +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([]); + const [inputValue, setInputValue] = useState(''); + const [commandOpen, setCommandOpen] = useState(false); + const [action, setAction] = useState('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 = {}; + + 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: ( +
+ + {t('search.updateTags.updatedDesc')} +
+ ), + }); + 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 ( + + + + + + {t('search.updateTags.title')} + + + setAction(v as TagAction)} className="w-full"> + + {t('search.updateTags.actionAdd', "Add")} + {t('search.updateTags.actionRemove', "remove")} + {t('search.updateTags.actionOverwrite', "overwrite")} + + +
+
+ {selectedTags.length === 0 ? ( +

{t('search.updateTags.none')}

+ ) : ( + selectedTags.map(tag => ( + + {tag} + + + )) + )} +
+ e.stopPropagation()} > +
+
+ setCommandOpen(true)} + className="h-9 pr-10" + onKeyDown={(e) => { + if (e.key === 'Enter' && inputValue.trim()) { + e.preventDefault(); + e.stopPropagation(); + handleAddTag(inputValue); + } + }} + /> + {inputValue.trim() && ( + + )} +
+ {inputValue.trim() && filteredSuggestions.length === 0 && ( +
+ {t('search.updateTags.createHint', { tag: inputValue })} +
+ )} + {commandOpen && inputValue && filteredSuggestions.length > 0 && ( + + + {filteredSuggestions.map(tag => ( + handleAddTag(tag)} + className="cursor-pointer" + > + + {tag} + + ))} + + + )} +
+
+
+ +
+

+ {t('search.updateTags.selectedCount', { count: selectedTags.length })} +

+
+ + +
+
+ {action == "Overwrite" &&
+ +

+ {t('search.updateTags.overwriteWarning')} +

+
} +
+
+ ); +} diff --git a/web/src/features/search/context/index.tsx b/web/src/features/search/context/index.tsx index 49630c9..e2609e3 100644 --- a/web/src/features/search/context/index.tsx +++ b/web/src/features/search/context/index.tsx @@ -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 diff --git a/web/src/features/search/add-tag-dialog.tsx b/web/src/features/search/edit-tag-dialog.tsx similarity index 99% rename from web/src/features/search/add-tag-dialog.tsx rename to web/src/features/search/edit-tag-dialog.tsx index 49afe35..832dfef 100644 --- a/web/src/features/search/add-tag-dialog.tsx +++ b/web/src/features/search/edit-tag-dialog.tsx @@ -107,6 +107,7 @@ export function EditTagsDialog({ open, onOpenChange }: Props) { tags: inputValue.trim() ? [...selectedTags, inputValue.toLowerCase().trim()] : selectedTags, + action: "Overwrite" }, { onSuccess: () => { diff --git a/web/src/features/search/index.tsx b/web/src/features/search/index.tsx index f187cf1..ecd914a 100644 --- a/web/src/features/search/index.tsx +++ b/web/src/features/search/index.tsx @@ -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')} /> + setOpen('update-tags')} + /> + {mailbox_name} - {safeTags.length > 0 && ( - - {visibleTags.join(' · ')} - {safeTags.length > 2 && ` · +${safeTags.length - 2}`} - - )} +
+ {visibleTags.map((tag, i) => ( + + {tag} + + ))} + {safeTags.length > 2 && ( + + +{safeTags.length - 2} + + )} +
); @@ -242,7 +248,7 @@ export function MailListTable({ header: t('search.subject'), cell: ({ row }) => {row.original.subject}, 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, }, diff --git a/web/src/features/search/table/table.tsx b/web/src/features/search/table/table.tsx index 5996fcd..759a56e 100755 --- a/web/src/features/search/table/table.tsx +++ b/web/src/features/search/table/table.tsx @@ -98,7 +98,7 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder return (
{children && (<>{children(table)})} - + {table.getHeaderGroups().map((headerGroup) => ( diff --git a/web/src/hooks/use-update-tags.ts b/web/src/hooks/use-update-tags.ts index 4e7fb02..6b0d3a3 100644 --- a/web/src/hooks/use-update-tags.ts +++ b/web/src/hooks/use-update-tags.ts @@ -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; 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: () => { diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json index 3c87f20..0498387 100644 --- a/web/src/locales/ar.json +++ b/web/src/locales/ar.json @@ -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": "حذف الرسائل", diff --git a/web/src/locales/da.json b/web/src/locales/da.json index fbdc82b..9487533 100644 --- a/web/src/locales/da.json +++ b/web/src/locales/da.json @@ -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", diff --git a/web/src/locales/de.json b/web/src/locales/de.json index fb4ee6e..bc12916 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -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", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index f29dd72..96fd446 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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", diff --git a/web/src/locales/es.json b/web/src/locales/es.json index b8faaa9..5687fd5 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -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", diff --git a/web/src/locales/fi.json b/web/src/locales/fi.json index 3f4b355..bc8d4af 100644 --- a/web/src/locales/fi.json +++ b/web/src/locales/fi.json @@ -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", diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index 4752acd..5d03e24 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -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", diff --git a/web/src/locales/it.json b/web/src/locales/it.json index 3fdbf1c..15e4e87 100644 --- a/web/src/locales/it.json +++ b/web/src/locales/it.json @@ -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", diff --git a/web/src/locales/jp.json b/web/src/locales/jp.json index 56b6cd8..5af57b3 100644 --- a/web/src/locales/jp.json +++ b/web/src/locales/jp.json @@ -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": "メッセージを削除", diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index bf06d57..ef5d940 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -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": "메시지 삭제", diff --git a/web/src/locales/nl.json b/web/src/locales/nl.json index edbc04a..2586902 100644 --- a/web/src/locales/nl.json +++ b/web/src/locales/nl.json @@ -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", diff --git a/web/src/locales/no.json b/web/src/locales/no.json index 0d97c26..8949322 100644 --- a/web/src/locales/no.json +++ b/web/src/locales/no.json @@ -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", diff --git a/web/src/locales/pl.json b/web/src/locales/pl.json index 6d49e93..bbc6394 100644 --- a/web/src/locales/pl.json +++ b/web/src/locales/pl.json @@ -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", diff --git a/web/src/locales/pt.json b/web/src/locales/pt.json index 21ef891..19c1a99 100644 --- a/web/src/locales/pt.json +++ b/web/src/locales/pt.json @@ -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", diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index 71c097c..c7fdac8 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -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": "Удалить сообщения", diff --git a/web/src/locales/sv.json b/web/src/locales/sv.json index 56fdc0f..dd8fd92 100644 --- a/web/src/locales/sv.json +++ b/web/src/locales/sv.json @@ -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", diff --git a/web/src/locales/zh-tw.json b/web/src/locales/zh-tw.json index 669d608..a617bc5 100644 --- a/web/src/locales/zh-tw.json +++ b/web/src/locales/zh-tw.json @@ -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": "刪除訊息", diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index ab7238c..97e0bbb 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -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": "删除消息",