diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx index e9b27827..5c11b989 100644 --- a/components/settings/keyword-settings.tsx +++ b/components/settings/keyword-settings.tsx @@ -3,8 +3,10 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; import { SettingsSection } from "./settings-section"; -import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw } from "lucide-react"; +import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE); @@ -91,16 +93,14 @@ function KeywordEditForm({ const [color, setColor] = useState(initial?.color || "blue"); const isEditing = !!initial; - const normalizedId = isEditing - ? initial.id - : label - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, ""); + const normalizedId = label + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); - const isDuplicate = !isEditing && normalizedId.length > 0 && existingIds.includes(normalizedId); + const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId); const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate; const handleSave = () => { @@ -159,10 +159,13 @@ function KeywordEditForm({ export function KeywordSettings() { const t = useTranslations("settings.keywords"); - const { emailKeywords, addKeyword, updateKeyword, removeKeyword, reorderKeywords } = + const { emailKeywords, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords } = useSettingsStore(); + const { client } = useAuthStore(); + const { fetchTagCounts } = useEmailStore(); const [editingId, setEditingId] = useState(null); const [isAdding, setIsAdding] = useState(false); + const [isMigrating, setIsMigrating] = useState(false); const existingIds = emailKeywords.map((k) => k.id); @@ -171,8 +174,32 @@ export function KeywordSettings() { setIsAdding(false); }; - const handleEdit = (keyword: KeywordDefinition) => { - updateKeyword(keyword.id, { label: keyword.label, color: keyword.color }); + const handleEdit = async (keyword: KeywordDefinition) => { + const oldId = editingId; + if (!oldId) return; + + const idChanged = oldId !== keyword.id; + + if (idChanged && client) { + setIsMigrating(true); + try { + const oldJmapKeyword = `$label:${oldId}`; + const newJmapKeyword = `$label:${keyword.id}`; + await client.migrateKeyword(oldJmapKeyword, newJmapKeyword); + renameKeyword(oldId, keyword); + fetchTagCounts(client); + } catch (error) { + console.error("Failed to migrate keyword:", error); + const toastModule = await import('sonner'); + toastModule.toast.error(t("migration_error")); + setIsMigrating(false); + return; + } + setIsMigrating(false); + } else { + updateKeyword(oldId, { label: keyword.label, color: keyword.color }); + } + setEditingId(null); }; @@ -187,6 +214,12 @@ export function KeywordSettings() { return (
+ {isMigrating && ( +
+ + {t("migrating")} +
+ )} {emailKeywords.map((keyword) => editingId === keyword.id ? ( { + let count = 0; + for (const email of this.data.emails) { + if (email.keywords[oldKeyword]) { + delete email.keywords[oldKeyword]; + email.keywords[newKeyword] = true; + count++; + } + } + return count; + } + async deleteEmail(emailId: string): Promise { this.data.emails = this.data.emails.filter(e => e.id !== emailId); this.recalcMailboxCounts(); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 3717284e..ac529f27 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -72,6 +72,7 @@ export interface IJMAPClient { batchMarkAsRead(emailIds: string[], read?: boolean): Promise; toggleStar(emailId: string, starred: boolean): Promise; updateEmailKeywords(emailId: string, keywords: Record): Promise; + migrateKeyword(oldKeyword: string, newKeyword: string): Promise; deleteEmail(emailId: string): Promise; moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise; batchDeleteEmails(emailIds: string[]): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 0df69d63..cbef02cd 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -763,6 +763,56 @@ export class JMAPClient implements IJMAPClient { ]); } + async migrateKeyword(oldKeyword: string, newKeyword: string): Promise { + // Query all email IDs that have the old keyword + const allIds: string[] = []; + let position = 0; + const batchSize = 100; + + // eslint-disable-next-line no-constant-condition + while (true) { + const response = await this.request([ + ["Email/query", { + accountId: this.accountId, + filter: { hasKeyword: oldKeyword }, + limit: batchSize, + position, + }, "0"], + ]); + + const queryResult = response.methodResponses?.[0]?.[1]; + const ids: string[] = queryResult?.ids || []; + allIds.push(...ids); + + if (ids.length < batchSize) break; + position += ids.length; + } + + if (allIds.length === 0) return 0; + + // Batch update: remove old keyword, add new keyword using per-property patches + const updateBatchSize = 50; + for (let i = 0; i < allIds.length; i += updateBatchSize) { + const batch = allIds.slice(i, i + updateBatchSize); + const update: Record> = {}; + for (const id of batch) { + update[id] = { + [`keywords/${oldKeyword}`]: null, + [`keywords/${newKeyword}`]: true, + }; + } + + await this.request([ + ["Email/set", { + accountId: this.accountId, + update, + }, "0"], + ]); + } + + return allIds.length; + } + async deleteEmail(emailId: string): Promise { await this.request([ ["Email/set", { @@ -1359,34 +1409,35 @@ export class JMAPClient implements IJMAPClient { })); } - // Destroy old draft before creating replacement to avoid duplicates - const methodCalls: JMAPMethodCall[] = []; + // Use a single Email/set call with both destroy and create for atomicity + const setArgs: Record = { + accountId: this.accountId, + create: { [emailId]: emailData }, + }; if (draftId) { - methodCalls.push(["Email/set", { - accountId: this.accountId, destroy: [draftId], - }, "0"]); - methodCalls.push(["Email/set", { - accountId: this.accountId, create: { [emailId]: emailData }, - }, "1"]); - } else { - methodCalls.push(["Email/set", { - accountId: this.accountId, create: { [emailId]: emailData }, - }, "0"]); + setArgs.destroy = [draftId]; } + const methodCalls: JMAPMethodCall[] = [ + ["Email/set", setArgs, "0"], + ]; + const response = await this.request(methodCalls); - const responseIndex = draftId ? 1 : 0; - if (response.methodResponses?.[responseIndex]?.[0] === "Email/set") { - const result = response.methodResponses[responseIndex][1]; + if (response.methodResponses?.[0]?.[0] === "Email/set") { + const result = response.methodResponses[0][1]; - if (result.notCreated || result.notUpdated) { - const errors = result.notCreated || result.notUpdated; + if (result.notCreated) { + const errors = result.notCreated; const firstError = Object.values(errors)[0] as { description?: string; type?: string }; console.error('Draft save error:', firstError); throw new Error(firstError?.description || firstError?.type || 'Failed to save draft'); } + if (draftId && result.notDestroyed) { + console.warn('Failed to destroy old draft:', result.notDestroyed); + } + if (result.created?.[emailId]) { return result.created[emailId].id; } diff --git a/locales/en/common.json b/locales/en/common.json index 3c9698bd..0dc3243b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -691,7 +691,9 @@ "delete": "Delete keyword", "save": "Save", "add": "Add", - "cancel": "Cancel" + "cancel": "Cancel", + "migrating": "Updating keyword on existing emails…", + "migration_error": "Failed to update keyword on existing emails" }, "language_region": { "title": "Language & Region", diff --git a/stores/settings-store.ts b/stores/settings-store.ts index ab4092a9..dfa4c802 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -157,6 +157,7 @@ interface SettingsState { // Keywords addKeyword: (keyword: KeywordDefinition) => void; updateKeyword: (id: string, updates: Partial>) => void; + renameKeyword: (oldId: string, newKeyword: KeywordDefinition) => void; removeKeyword: (id: string) => void; reorderKeywords: (keywords: KeywordDefinition[]) => void; getKeywordById: (id: string) => KeywordDefinition | undefined; @@ -389,6 +390,14 @@ export const useSettingsStore = create()( }); }, + renameKeyword: (oldId: string, newKeyword: KeywordDefinition) => { + set({ + emailKeywords: get().emailKeywords.map(k => + k.id === oldId ? newKeyword : k + ), + }); + }, + removeKeyword: (id: string) => { set({ emailKeywords: get().emailKeywords.filter(k => k.id !== id) }); },