feat: implement keyword migration functionality and update related components

This commit is contained in:
Linus Rath
2026-03-21 01:58:48 +01:00
parent 2547c10060
commit 2d56cc9be9
6 changed files with 139 additions and 31 deletions
+46 -13
View File
@@ -3,8 +3,10 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store"; 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 { 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"; import { cn } from "@/lib/utils";
const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE); const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE);
@@ -91,16 +93,14 @@ function KeywordEditForm({
const [color, setColor] = useState(initial?.color || "blue"); const [color, setColor] = useState(initial?.color || "blue");
const isEditing = !!initial; const isEditing = !!initial;
const normalizedId = isEditing const normalizedId = label
? initial.id .trim()
: label .toLowerCase()
.trim() .replace(/[^a-z0-9_-]/g, "-")
.toLowerCase() .replace(/-+/g, "-")
.replace(/[^a-z0-9_-]/g, "-") .replace(/^-|-$/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 isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
const handleSave = () => { const handleSave = () => {
@@ -159,10 +159,13 @@ function KeywordEditForm({
export function KeywordSettings() { export function KeywordSettings() {
const t = useTranslations("settings.keywords"); const t = useTranslations("settings.keywords");
const { emailKeywords, addKeyword, updateKeyword, removeKeyword, reorderKeywords } = const { emailKeywords, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords } =
useSettingsStore(); useSettingsStore();
const { client } = useAuthStore();
const { fetchTagCounts } = useEmailStore();
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [isMigrating, setIsMigrating] = useState(false);
const existingIds = emailKeywords.map((k) => k.id); const existingIds = emailKeywords.map((k) => k.id);
@@ -171,8 +174,32 @@ export function KeywordSettings() {
setIsAdding(false); setIsAdding(false);
}; };
const handleEdit = (keyword: KeywordDefinition) => { const handleEdit = async (keyword: KeywordDefinition) => {
updateKeyword(keyword.id, { label: keyword.label, color: keyword.color }); 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); setEditingId(null);
}; };
@@ -187,6 +214,12 @@ export function KeywordSettings() {
return ( return (
<SettingsSection title={t("title")} description={t("description")}> <SettingsSection title={t("title")} description={t("description")}>
<div className="space-y-2"> <div className="space-y-2">
{isMigrating && (
<div className="flex items-center gap-2 p-2 text-xs text-muted-foreground bg-accent/50 rounded-md">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t("migrating")}
</div>
)}
{emailKeywords.map((keyword) => {emailKeywords.map((keyword) =>
editingId === keyword.id ? ( editingId === keyword.id ? (
<KeywordEditForm <KeywordEditForm
+12
View File
@@ -216,6 +216,18 @@ export class DemoJMAPClient implements IJMAPClient {
if (email) email.keywords = { ...email.keywords, ...keywords }; if (email) email.keywords = { ...email.keywords, ...keywords };
} }
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
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<void> { async deleteEmail(emailId: string): Promise<void> {
this.data.emails = this.data.emails.filter(e => e.id !== emailId); this.data.emails = this.data.emails.filter(e => e.id !== emailId);
this.recalcMailboxCounts(); this.recalcMailboxCounts();
+1
View File
@@ -72,6 +72,7 @@ export interface IJMAPClient {
batchMarkAsRead(emailIds: string[], read?: boolean): Promise<void>; batchMarkAsRead(emailIds: string[], read?: boolean): Promise<void>;
toggleStar(emailId: string, starred: boolean): Promise<void>; toggleStar(emailId: string, starred: boolean): Promise<void>;
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>; updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
deleteEmail(emailId: string): Promise<void>; deleteEmail(emailId: string): Promise<void>;
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>; moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
batchDeleteEmails(emailIds: string[]): Promise<void>; batchDeleteEmails(emailIds: string[]): Promise<void>;
+68 -17
View File
@@ -763,6 +763,56 @@ export class JMAPClient implements IJMAPClient {
]); ]);
} }
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
// 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<string, Record<string, boolean | null>> = {};
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<void> { async deleteEmail(emailId: string): Promise<void> {
await this.request([ await this.request([
["Email/set", { ["Email/set", {
@@ -1359,34 +1409,35 @@ export class JMAPClient implements IJMAPClient {
})); }));
} }
// Destroy old draft before creating replacement to avoid duplicates // Use a single Email/set call with both destroy and create for atomicity
const methodCalls: JMAPMethodCall[] = []; const setArgs: Record<string, unknown> = {
accountId: this.accountId,
create: { [emailId]: emailData },
};
if (draftId) { if (draftId) {
methodCalls.push(["Email/set", { setArgs.destroy = [draftId];
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"]);
} }
const methodCalls: JMAPMethodCall[] = [
["Email/set", setArgs, "0"],
];
const response = await this.request(methodCalls); const response = await this.request(methodCalls);
const responseIndex = draftId ? 1 : 0;
if (response.methodResponses?.[responseIndex]?.[0] === "Email/set") { if (response.methodResponses?.[0]?.[0] === "Email/set") {
const result = response.methodResponses[responseIndex][1]; const result = response.methodResponses[0][1];
if (result.notCreated || result.notUpdated) { if (result.notCreated) {
const errors = result.notCreated || result.notUpdated; const errors = result.notCreated;
const firstError = Object.values(errors)[0] as { description?: string; type?: string }; const firstError = Object.values(errors)[0] as { description?: string; type?: string };
console.error('Draft save error:', firstError); console.error('Draft save error:', firstError);
throw new Error(firstError?.description || firstError?.type || 'Failed to save draft'); 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]) { if (result.created?.[emailId]) {
return result.created[emailId].id; return result.created[emailId].id;
} }
+3 -1
View File
@@ -691,7 +691,9 @@
"delete": "Delete keyword", "delete": "Delete keyword",
"save": "Save", "save": "Save",
"add": "Add", "add": "Add",
"cancel": "Cancel" "cancel": "Cancel",
"migrating": "Updating keyword on existing emails…",
"migration_error": "Failed to update keyword on existing emails"
}, },
"language_region": { "language_region": {
"title": "Language & Region", "title": "Language & Region",
+9
View File
@@ -157,6 +157,7 @@ interface SettingsState {
// Keywords // Keywords
addKeyword: (keyword: KeywordDefinition) => void; addKeyword: (keyword: KeywordDefinition) => void;
updateKeyword: (id: string, updates: Partial<Omit<KeywordDefinition, 'id'>>) => void; updateKeyword: (id: string, updates: Partial<Omit<KeywordDefinition, 'id'>>) => void;
renameKeyword: (oldId: string, newKeyword: KeywordDefinition) => void;
removeKeyword: (id: string) => void; removeKeyword: (id: string) => void;
reorderKeywords: (keywords: KeywordDefinition[]) => void; reorderKeywords: (keywords: KeywordDefinition[]) => void;
getKeywordById: (id: string) => KeywordDefinition | undefined; getKeywordById: (id: string) => KeywordDefinition | undefined;
@@ -389,6 +390,14 @@ export const useSettingsStore = create<SettingsState>()(
}); });
}, },
renameKeyword: (oldId: string, newKeyword: KeywordDefinition) => {
set({
emailKeywords: get().emailKeywords.map(k =>
k.id === oldId ? newKeyword : k
),
});
},
removeKeyword: (id: string) => { removeKeyword: (id: string) => {
set({ emailKeywords: get().emailKeywords.filter(k => k.id !== id) }); set({ emailKeywords: get().emailKeywords.filter(k => k.id !== id) });
}, },