feat: implement keyword migration functionality and update related components
This commit is contained in:
@@ -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<string | null>(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 (
|
||||
<SettingsSection title={t("title")} description={t("description")}>
|
||||
<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) =>
|
||||
editingId === keyword.id ? (
|
||||
<KeywordEditForm
|
||||
|
||||
@@ -216,6 +216,18 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
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> {
|
||||
this.data.emails = this.data.emails.filter(e => e.id !== emailId);
|
||||
this.recalcMailboxCounts();
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface IJMAPClient {
|
||||
batchMarkAsRead(emailIds: string[], read?: boolean): Promise<void>;
|
||||
toggleStar(emailId: string, starred: boolean): Promise<void>;
|
||||
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
|
||||
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
|
||||
deleteEmail(emailId: string): Promise<void>;
|
||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
|
||||
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
||||
|
||||
+68
-17
@@ -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> {
|
||||
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<string, unknown> = {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -157,6 +157,7 @@ interface SettingsState {
|
||||
// Keywords
|
||||
addKeyword: (keyword: KeywordDefinition) => void;
|
||||
updateKeyword: (id: string, updates: Partial<Omit<KeywordDefinition, 'id'>>) => 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<SettingsState>()(
|
||||
});
|
||||
},
|
||||
|
||||
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) });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user