mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: keep if/elsif/else chains whole when filters are saved on the phone
This commit is contained in:
@@ -253,6 +253,58 @@ describe('external rule preservation (issue #201)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// elsif and else were taken for statements of their own and cut at the
|
||||||
|
// first ";" in their body: a save wrote the chain back without its closing
|
||||||
|
// brace and without the comments after it, and the server refused it.
|
||||||
|
describe('if blocks with elsif and else', () => {
|
||||||
|
const read = (file: string) =>
|
||||||
|
readFileSync(join(__dirname, 'fixtures', file), 'utf-8').replace(/\r\n/g, '\n');
|
||||||
|
const resave = (script: string) => {
|
||||||
|
const parsed = parseScript(script);
|
||||||
|
return generateScript(parsed.rules, parsed.vacation, {
|
||||||
|
externalRequires: parsed.externalRequires,
|
||||||
|
extensions: ['fileinto', 'copy', 'imap4flags', 'mailbox', 'mailboxid'],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it('keeps a hand-written if/elsif/else chain whole', () => {
|
||||||
|
const fixture = read('hand-written-elsif.sieve');
|
||||||
|
const parsed = parseScript(fixture);
|
||||||
|
expect(parsed.isOpaque).toBe(false);
|
||||||
|
expect(parsed.rules).toHaveLength(1);
|
||||||
|
expect(parsed.rules[0].origin).toBe('opaque');
|
||||||
|
expect(parsed.rules[0].name).toBe('Sorted by hand');
|
||||||
|
|
||||||
|
const saved = resave(fixture);
|
||||||
|
expect(saved).toContain(fixture.slice(fixture.indexOf('# Sorted by hand')).trim());
|
||||||
|
expect(resave(saved)).toBe(saved);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the category tabs section with its END marker', () => {
|
||||||
|
const fixture = read('category-tabs.sieve');
|
||||||
|
const parsed = parseScript(fixture);
|
||||||
|
expect(parsed.rules.filter((r) => !r.origin)).toHaveLength(4);
|
||||||
|
const external = parsed.rules.filter((r) => r.origin);
|
||||||
|
expect(external).toHaveLength(1);
|
||||||
|
expect(external[0].origin).toBe('opaque');
|
||||||
|
|
||||||
|
const section = fixture.slice(fixture.indexOf('# BEGIN category-tabs')).trim();
|
||||||
|
expect(external[0].rawBlock!.trim()).toBe(section);
|
||||||
|
const saved = resave(fixture);
|
||||||
|
expect(saved).toContain(section);
|
||||||
|
expect(saved.trimEnd().endsWith('# END category-tabs')).toBe(true);
|
||||||
|
expect(resave(saved)).toBe(saved);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still reads a lone if as a rule', () => {
|
||||||
|
const parsed = parseScript('require ["fileinto"];\nif header :contains "from" "a@example.org" {\n fileinto "A";\n}\n# trailing note\n');
|
||||||
|
expect(parsed.rules).toHaveLength(1);
|
||||||
|
expect(parsed.rules[0].origin).toBe('external');
|
||||||
|
expect(parsed.rules[0].actions).toEqual([{ type: 'move', value: 'A' }]);
|
||||||
|
expect(parsed.rules[0].rawBlock).toContain('# trailing note');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('round-trip', () => {
|
describe('round-trip', () => {
|
||||||
it('preserves external rules through parse → generate → parse', () => {
|
it('preserves external rules through parse → generate → parse', () => {
|
||||||
const initial = `require ["fileinto", "imap4flags"];\n\n# rule:[VIP]\nif header :contains "From" "boss@company.com" {\n fileinto "VIP";\n addflag "\\\\Flagged";\n}\n\n# Nextcloud Mail - begin\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n# Nextcloud Mail - end\n`;
|
const initial = `require ["fileinto", "imap4flags"];\n\n# rule:[VIP]\nif header :contains "From" "boss@company.com" {\n fileinto "VIP";\n addflag "\\\\Flagged";\n}\n\n# Nextcloud Mail - begin\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n# Nextcloud Mail - end\n`;
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/* @metadata:begin
|
||||||
|
{"version":1,"rules":[{"id":"invoices","name":"Invoices","enabled":true,"matchType":"all","conditions":[{"field":"subject","comparator":"contains","value":["invoice","Rechnung"]}],"actions":[{"type":"move","value":"Finance/Invoices","mailboxId":"m12"},{"type":"star"},{"type":"add_label","value":"finance"}],"stopProcessing":false},{"id":"backup","name":"Backup","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"contains","value":"news@example.com"}],"actions":[{"type":"copy","value":"Archive","mailboxId":"m7"}],"stopProcessing":false},{"id":"legacy","name":"Legacy path","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"contains","value":"news@example.com"}],"actions":[{"type":"move","value":"Old/Path"}],"stopProcessing":false},{"id":"fwd","name":"Forward","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"contains","value":"news@example.com"}],"actions":[{"type":"forward","value":"me@elsewhere.example","keepCopy":true}],"stopProcessing":false}]}
|
||||||
|
@metadata:end */
|
||||||
|
|
||||||
|
require ["copy", "fileinto", "imap4flags", "mailbox", "mailboxid"];
|
||||||
|
|
||||||
|
# Rule: Invoices
|
||||||
|
if header :contains "Subject" ["invoice", "Rechnung"] {
|
||||||
|
addflag "\\Flagged";
|
||||||
|
addflag "$label:finance";
|
||||||
|
fileinto :mailboxid "m12" "Finance/Invoices";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rule: Backup
|
||||||
|
if header :contains "From" "news@example.com" {
|
||||||
|
fileinto :copy :mailboxid "m7" "Archive";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rule: Legacy path
|
||||||
|
if header :contains "From" "news@example.com" {
|
||||||
|
fileinto "Old/Path";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rule: Forward
|
||||||
|
if header :contains "From" "news@example.com" {
|
||||||
|
redirect :copy "me@elsewhere.example";
|
||||||
|
}
|
||||||
|
|
||||||
|
# BEGIN category-tabs (managed by the Inbox Category Tabs plugin)
|
||||||
|
if header :contains "from" ["friend@example.org"] { }
|
||||||
|
elsif header :contains "from" ["shop@example.com", "deals@example.net"] {
|
||||||
|
setflag "$category-promotions";
|
||||||
|
}
|
||||||
|
elsif anyof (
|
||||||
|
header :contains "subject" ["newsletter"],
|
||||||
|
exists "list-unsubscribe",
|
||||||
|
header :contains "precedence" ["bulk", "list"]
|
||||||
|
) {
|
||||||
|
setflag "$category-updates";
|
||||||
|
}
|
||||||
|
# END category-tabs
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
require ["fileinto", "imap4flags"];
|
||||||
|
|
||||||
|
# Sorted by hand
|
||||||
|
if header :contains "from" "boss@example.org" {
|
||||||
|
addflag "\\Flagged";
|
||||||
|
fileinto "Work";
|
||||||
|
} elsif header :contains "list-id" "lists.example.org" {
|
||||||
|
fileinto "Lists";
|
||||||
|
}
|
||||||
|
# Everything else stays in the inbox
|
||||||
|
else {
|
||||||
|
keep;
|
||||||
|
}
|
||||||
|
|
||||||
|
# end of my rules
|
||||||
+35
-2
@@ -206,9 +206,21 @@ function skipIfStatement(s: string, i: number): number {
|
|||||||
return skipBalanced(s, i, '{', '}');
|
return skipBalanced(s, i, '{', '}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Past whitespace and comments, to the next token.
|
||||||
|
function skipSpaceAndComments(s: string, i: number): number {
|
||||||
|
while (i < s.length) {
|
||||||
|
if (/\s/.test(s[i])) { i++; continue; }
|
||||||
|
if (s[i] === '#') { i = skipHashComment(s, i); continue; }
|
||||||
|
if (s[i] === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
interface TopBlock {
|
interface TopBlock {
|
||||||
kind: 'require' | 'if' | 'vacation' | 'other';
|
kind: 'require' | 'if' | 'vacation' | 'other';
|
||||||
raw: string; // from start-of-leading-text to end of statement
|
raw: string; // from start-of-leading-text to end of statement
|
||||||
|
leading: string; // the comments and whitespace in front of the statement
|
||||||
statement: string; // the statement itself (no leading comments/whitespace)
|
statement: string; // the statement itself (no leading comments/whitespace)
|
||||||
startIdx: number;
|
startIdx: number;
|
||||||
endIdx: number;
|
endIdx: number;
|
||||||
@@ -223,6 +235,7 @@ function scanTopLevel(content: string): TopBlock[] {
|
|||||||
blocks.push({
|
blocks.push({
|
||||||
kind,
|
kind,
|
||||||
raw: content.slice(segmentStart, stmtEnd),
|
raw: content.slice(segmentStart, stmtEnd),
|
||||||
|
leading: content.slice(segmentStart, stmtStart),
|
||||||
statement: content.slice(stmtStart, stmtEnd),
|
statement: content.slice(stmtStart, stmtEnd),
|
||||||
startIdx: segmentStart,
|
startIdx: segmentStart,
|
||||||
endIdx: stmtEnd,
|
endIdx: stmtEnd,
|
||||||
@@ -254,6 +267,15 @@ function scanTopLevel(content: string): TopBlock[] {
|
|||||||
consume('require', stmtStart, i);
|
consume('require', stmtStart, i);
|
||||||
} else if (ident === 'if') {
|
} else if (ident === 'if') {
|
||||||
i = skipIfStatement(content, i);
|
i = skipIfStatement(content, i);
|
||||||
|
// elsif and else belong to the if in front of them. Taken as
|
||||||
|
// statements of their own they were cut at the first ";" in their
|
||||||
|
// body, and a save wrote back a block without its closing brace.
|
||||||
|
for (;;) {
|
||||||
|
const next = skipSpaceAndComments(content, i);
|
||||||
|
const branch = /^(elsif|else)(?![a-zA-Z0-9_])/.exec(content.slice(next, next + 6));
|
||||||
|
if (!branch) break;
|
||||||
|
i = skipIfStatement(content, next + branch[1].length);
|
||||||
|
}
|
||||||
consume('if', stmtStart, i);
|
consume('if', stmtStart, i);
|
||||||
} else if (ident === 'vacation') {
|
} else if (ident === 'vacation') {
|
||||||
i = skipStatement(content, i);
|
i = skipStatement(content, i);
|
||||||
@@ -264,6 +286,15 @@ function scanTopLevel(content: string): TopBlock[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Comments after the last statement, such as the "# END category-tabs"
|
||||||
|
// marker a plugin closes its section with, go with that statement, or a
|
||||||
|
// save would drop them.
|
||||||
|
const last = blocks[blocks.length - 1];
|
||||||
|
if (last && content.slice(segmentStart).trim() !== '') {
|
||||||
|
last.raw += content.slice(segmentStart);
|
||||||
|
last.endIdx = content.length;
|
||||||
|
}
|
||||||
|
|
||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,6 +663,8 @@ function parseIfBlockToRule(block: TopBlock, idPrefix: string, index: number): F
|
|||||||
// statement always terminates at the matching `}`.
|
// statement always terminates at the matching `}`.
|
||||||
const lastBraceIdx = afterIf.length - 1;
|
const lastBraceIdx = afterIf.length - 1;
|
||||||
if (braceIdx === -1 || afterIf[lastBraceIdx] !== '}' || lastBraceIdx < braceIdx) return null;
|
if (braceIdx === -1 || afterIf[lastBraceIdx] !== '}' || lastBraceIdx < braceIdx) return null;
|
||||||
|
// An if with elsif or else branches stays as written.
|
||||||
|
if (skipBalanced(afterIf, braceIdx, '{', '}') !== afterIf.length) return null;
|
||||||
|
|
||||||
const condStr = afterIf.slice(0, braceIdx).trim();
|
const condStr = afterIf.slice(0, braceIdx).trim();
|
||||||
const bodyStr = afterIf.slice(braceIdx + 1, lastBraceIdx).trim();
|
const bodyStr = afterIf.slice(braceIdx + 1, lastBraceIdx).trim();
|
||||||
@@ -657,7 +690,7 @@ function parseIfBlockToRule(block: TopBlock, idPrefix: string, index: number): F
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const leading = block.raw.slice(0, block.statement ? block.raw.length - block.statement.length : 0);
|
const leading = block.leading;
|
||||||
const originLabel = detectOriginLabel(leading);
|
const originLabel = detectOriginLabel(leading);
|
||||||
const name = extractName(leading, `Rule ${index + 1}`);
|
const name = extractName(leading, `Rule ${index + 1}`);
|
||||||
|
|
||||||
@@ -676,7 +709,7 @@ function parseIfBlockToRule(block: TopBlock, idPrefix: string, index: number): F
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeOpaqueRule(block: TopBlock, idPrefix: string, index: number): FilterRule {
|
function makeOpaqueRule(block: TopBlock, idPrefix: string, index: number): FilterRule {
|
||||||
const leading = block.raw.slice(0, block.raw.length - block.statement.length);
|
const leading = block.leading;
|
||||||
const originLabel = detectOriginLabel(leading);
|
const originLabel = detectOriginLabel(leading);
|
||||||
const name = extractName(leading, `External rule ${index + 1}`);
|
const name = extractName(leading, `External rule ${index + 1}`);
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user