Merge pull request #27 from LINUXexpert-org/sieve-rule-order-on-edit

Keep an edited filter rule where it was
This commit is contained in:
LINUXexpert.org
2026-08-25 07:52:32 -07:00
committed by GitHub
5 changed files with 37 additions and 12 deletions
+9 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { newRule, rulesToSieve, sieveToRules, testToSieve, sieveString } from "../sieve"; import { newRule, rulesToSieve, sieveToRules, testToSieve, sieveString, upsertRule } from "../sieve";
describe("sieve codec", () => { describe("sieve codec", () => {
it("escapes strings", () => { it("escapes strings", () => {
@@ -24,6 +24,14 @@ describe("sieve codec", () => {
expect(script).toContain("# (disabled) Big"); expect(script).toContain("# (disabled) Big");
expect(sieveToRules(script)).toEqual(rules); expect(sieveToRules(script)).toEqual(rules);
}); });
it("keeps an edited rule in its place and appends a new one", () => {
const rules = ["r1", "r2", "r3"].map((id) => newRule({ id, name: id }));
const renamed = { ...rules[1]!, name: "Renamed" };
expect(upsertRule(rules, renamed).map((r) => r.id)).toEqual(["r1", "r2", "r3"]);
expect(upsertRule(rules, renamed)[1]!.name).toBe("Renamed");
expect(upsertRule(rules, newRule({ id: "r4" })).map((r) => r.id)).toEqual(["r1", "r2", "r3", "r4"]);
expect(rules.map((r) => r.name)).toEqual(["r1", "r2", "r3"]);
});
it("reports hand-written scripts as raw", () => { it("reports hand-written scripts as raw", () => {
expect(sieveToRules('require ["fileinto"];\nif true { keep; }')).toBeNull(); expect(sieveToRules('require ["fileinto"];\nif true { keep; }')).toBeNull();
expect(sieveToRules("")).toEqual([]); expect(sieveToRules("")).toEqual([]);
+8
View File
@@ -202,6 +202,14 @@ export function newRule(partial: Partial<SieveRule> = {}): SieveRule {
}; };
} }
/**
* Replaces a rule with the same id in place, or appends it when it is new.
* Order is evaluation order in Sieve, so an edited rule has to keep its seat.
*/
export function upsertRule(rules: SieveRule[], rule: SieveRule): SieveRule[] {
return rules.some((x) => x.id === rule.id) ? rules.map((x) => (x.id === rule.id ? rule : x)) : [...rules, rule];
}
export function describeRule(r: SieveRule): string { export function describeRule(r: SieveRule): string {
const tests = r.tests const tests = r.tests
.map((t) => { .map((t) => {
+12 -4
View File
@@ -3,7 +3,7 @@ import type { Email, Id } from "@/jmap/types";
import { useSieve } from "@/store/sieve"; import { useSieve } from "@/store/sieve";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { ruleFromEmail, applyRuleToMailbox } from "@/lib/sieveApply"; import { ruleFromEmail, applyRuleToMailbox } from "@/lib/sieveApply";
import type { SieveRule } from "@/lib/sieve"; import { upsertRule, type SieveRule } from "@/lib/sieve";
import { RuleDialog } from "../settings/RuleDialog"; import { RuleDialog } from "../settings/RuleDialog";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { Spinner } from "@/ui/misc"; import { Spinner } from "@/ui/misc";
@@ -48,6 +48,7 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
title="Filter messages like this" title="Filter messages like this"
saveLabel="Create filter" saveLabel="Create filter"
applyMailbox={mailbox ? { id: mailbox.id, name: mailbox.name } : null} applyMailbox={mailbox ? { id: mailbox.id, name: mailbox.name } : null}
applyByDefault
onClose={onClose} onClose={onClose}
onSave={(r, applyNow) => { onSave={(r, applyNow) => {
onClose(); onClose();
@@ -57,23 +58,30 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
); );
} }
/**
* Saves `r` into `existing` — replacing it in place when it is already there,
* appending it when it is new — and optionally runs it over a folder.
* `existing` is the rule list as it stands *before* the edit.
*/
export async function saveAndApply(r: SieveRule, existing: SieveRule[], applyMailboxId: Id | null) { export async function saveAndApply(r: SieveRule, existing: SieveRule[], applyMailboxId: Id | null) {
const sieve = useSieve.getState(); const sieve = useSieve.getState();
const created = !existing.some((x) => x.id === r.id);
const verb = created ? "created" : "saved";
try { try {
await sieve.saveRules([...existing.filter((x) => x.id !== r.id), r]); await sieve.saveRules(upsertRule(existing, r));
} catch (err) { } catch (err) {
toast.error(`Could not save filter: ${(err as Error).message}`); toast.error(`Could not save filter: ${(err as Error).message}`);
return; return;
} }
if (!applyMailboxId) { if (!applyMailboxId) {
toast.success("Filter created — it will run on new mail"); toast.success(`Filter ${verb} — it will run on new mail`);
return; return;
} }
const tid = toast.show("Applying filter to existing messages…", { duration: 0 }); const tid = toast.show("Applying filter to existing messages…", { duration: 0 });
try { try {
const res = await applyRuleToMailbox(r, applyMailboxId); const res = await applyRuleToMailbox(r, applyMailboxId);
toast.dismiss(tid); toast.dismiss(tid);
toast.success(`Filter created · applied to ${res.matched} of ${res.scanned} message${res.scanned === 1 ? "" : "s"}${res.skippedActions.length ? ` (skipped: ${res.skippedActions.join("; ")})` : ""}`, { duration: 8000 }); toast.success(`Filter ${verb} · applied to ${res.matched} of ${res.scanned} message${res.scanned === 1 ? "" : "s"}${res.skippedActions.length ? ` (skipped: ${res.skippedActions.join("; ")})` : ""}`, { duration: 8000 });
} catch (err) { } catch (err) {
toast.dismiss(tid); toast.dismiss(tid);
toast.error(`Filter saved, but applying it failed: ${(err as Error).message}`); toast.error(`Filter saved, but applying it failed: ${(err as Error).message}`);
+4 -5
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { ArrowDown, ArrowUp, Code, Plus, Trash2, Wand2, Play, AlertTriangle, Power } from "lucide-react"; import { ArrowDown, ArrowUp, Code, Plus, Trash2, Wand2, Play, AlertTriangle, Power } from "lucide-react";
import { useSieve } from "@/store/sieve"; import { useSieve } from "@/store/sieve";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { describeRule, newRule, rulesToSieve, type SieveRule } from "@/lib/sieve"; import { describeRule, newRule, rulesToSieve, upsertRule, type SieveRule } from "@/lib/sieve";
import { RuleDialog } from "./RuleDialog"; import { RuleDialog } from "./RuleDialog";
import { saveAndApply } from "../mail/FilterFromMessage"; import { saveAndApply } from "../mail/FilterFromMessage";
import { confirmDialog, promptDialog } from "@/ui/dialog"; import { confirmDialog, promptDialog } from "@/ui/dialog";
@@ -110,14 +110,13 @@ function RulesEditor() {
onClose={() => setEditing(null)} onClose={() => setEditing(null)}
applyMailbox={inbox ? { id: inbox.id, name: inbox.name } : null} applyMailbox={inbox ? { id: inbox.id, name: inbox.name } : null}
onSave={(r, applyNow) => { onSave={(r, applyNow) => {
const exists = list.some((x) => x.id === r.id);
const next = exists ? list.map((x) => (x.id === r.id ? r : x)) : [...list, r];
setEditing(null); setEditing(null);
if (applyNow && inbox) { if (applyNow && inbox) {
// Save immediately so the rule is live, then apply it to the Inbox. // Save immediately so the rule is live, then apply it to the Inbox.
// saveAndApply takes the list as it stands now: an edited rule keeps its place.
setLocal(null); setLocal(null);
void saveAndApply(r, next.filter((x) => x.id !== r.id), inbox.id); void saveAndApply(r, list, inbox.id);
} else setLocal(next); } else setLocal(upsertRule(list, r));
}} }}
/> />
)} )}
+4 -2
View File
@@ -13,13 +13,15 @@ export interface RuleDialogProps {
onSave: (r: SieveRule, applyNow: boolean) => void; onSave: (r: SieveRule, applyNow: boolean) => void;
/** When set, offers "Also apply to existing messages in <folder>". */ /** When set, offers "Also apply to existing messages in <folder>". */
applyMailbox?: { id: Id; name: string } | null; applyMailbox?: { id: Id; name: string } | null;
/** Ticks that offer by default. Off unless applying is the point of the dialog. */
applyByDefault?: boolean;
title?: string; title?: string;
saveLabel?: string; saveLabel?: string;
} }
export function RuleDialog({ rule, onClose, onSave, applyMailbox, title, saveLabel }: RuleDialogProps) { export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault, title, saveLabel }: RuleDialogProps) {
const [r, setR] = useState<SieveRule>(rule); const [r, setR] = useState<SieveRule>(rule);
const [applyNow, setApplyNow] = useState(Boolean(applyMailbox)); const [applyNow, setApplyNow] = useState(Boolean(applyMailbox && applyByDefault));
const mailboxes = useMail((s) => s.mailboxes); const mailboxes = useMail((s) => s.mailboxes);
const mailboxPath = useMail((s) => s.mailboxPath); const mailboxPath = useMail((s) => s.mailboxPath);
const folders = useMemo(() => Object.values(mailboxes).map((m) => ({ id: m.id, path: mailboxPath(m.id) })).sort((a, b) => a.path.localeCompare(b.path)), [mailboxes, mailboxPath]); const folders = useMemo(() => Object.values(mailboxes).map((m) => ({ id: m.id, path: mailboxPath(m.id) })).sort((a, b) => a.path.localeCompare(b.path)), [mailboxes, mailboxPath]);