From 4f03c5d3606bb6a38763960ec2ddb74588743eb2 Mon Sep 17 00:00:00 2001 From: Tom Najdek Date: Wed, 2 Apr 2025 11:31:50 +0200 Subject: [PATCH] File renaming: add support for counting creators (#5175) Also extends the templating engine to support inequality comparisons. --- chrome/content/zotero/xpcom/attachments.js | 3 + .../zotero/xpcom/utilities_internal.js | 44 +++++++++--- test/tests/attachmentsTest.js | 67 +++++++++++++++++++ test/tests/utilities_internalTest.js | 36 ++++++++++ 4 files changed, 141 insertions(+), 9 deletions(-) diff --git a/chrome/content/zotero/xpcom/attachments.js b/chrome/content/zotero/xpcom/attachments.js index a562ad2f84..441d62cf5f 100644 --- a/chrome/content/zotero/xpcom/attachments.js +++ b/chrome/content/zotero/xpcom/attachments.js @@ -2398,6 +2398,9 @@ Zotero.Attachments = new function () { obj[name] = (args) => { return common(commonCreators(name, args), args); }; + obj[`${name}Count`] = (args) => { + return common(getSlicedCreatorsOfType(name, Infinity).length.toString(), args); + }; return obj; }, {}); diff --git a/chrome/content/zotero/xpcom/utilities_internal.js b/chrome/content/zotero/xpcom/utilities_internal.js index 6020a2e755..730e7c09e1 100644 --- a/chrome/content/zotero/xpcom/utilities_internal.js +++ b/chrome/content/zotero/xpcom/utilities_internal.js @@ -2219,14 +2219,32 @@ Zotero.Utilities.Internal = { return [operator, args]; }; + // We allow unquoted numbers in conditions, e.g. `a == 1` or `a == 1.0` but not `a == 1.0.0` or `a == 1st edition` + const asNumber = (string) => { + if (typeof string === 'number') { + return string; + } + const number = parseFloat(string); + if (!Number.isNaN(number) && string?.trim().match(/^[+-]?\d+(\.\d+)?$/)) { + return number; + } + return null; + }; + // evaluates a condition (e.g. `a == "b"`) into a boolean value const evaluateCondition = (condition) => { - const comparators = ['==', '!=']; + const comparators = ['==', '!=', "<=", ">=", '<', '>']; condition = condition.trim(); - - // match[1] if left is statement, match[3] if left is literal, match[4] if left is identifier - // match[6] if right is statement, match[8] if right is literal, match[9] if right is identifier - // match[2] and match[7] are used to match the quotes around the literal (and then check that the other quote is the same) + + // Regular expression breakdown for condition matching: + // - `match[1]`: Left operand if it's a statement enclosed in `{{...}}`. + // - `match[3]`: Left operand if it's a string literal, extracted without quotes. + // - `match[4]`: Left operand if it's a standalone identifier (e.g., a variable) or a number + // - `match[6]`: Right operand if it's a statement enclosed in `{{...}}`. + // - `match[8]`: Right operand if it's a string literal, extracted without quotes. + // - `match[9]`: Right operand if it's a standalone identifier or a number. + // - `match[2]` and `match[7]`: Captured quotes around string literals, used to ensure matching pairs. + // - `match[5]`: The comparator (e.g., `==`, `!=`, `<`, `>`, etc.), extracted from `comparators.join('|')`. const match = condition.match(new RegExp(String.raw`(?:{{(.*?)}}|(?:(['"])(.*?)\2)|([^ ]+)) *(${comparators.join('|')}) *(?:{{(.*?)}}|(?:(['"])(.*?)\7)|([^ ]+))`)); if (!match) { @@ -2238,16 +2256,24 @@ Zotero.Utilities.Internal = { return !!evaluateIdentifier(condition); } - const left = match[1] ? evaluateStatement(match[1]) : match[3] ?? evaluateIdentifier(match[4]) ?? ''; + const left = match[1] ? evaluateStatement(match[1]) : match[3] ?? asNumber(match[4]) ?? evaluateIdentifier(match[4]) ?? ''; const comparator = match[5]; - const right = match[6] ? evaluateStatement(match[6]) : match[8] ?? evaluateIdentifier(match[9]) ?? ''; + const right = match[6] ? evaluateStatement(match[6]) : match[8] ?? asNumber(match[9]) ?? evaluateIdentifier(match[9]) ?? ''; switch (comparator) { default: case '==': - return left.toLowerCase() == right.toLowerCase(); + return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() == right.toLowerCase() : asNumber(left) == asNumber(right); case '!=': - return left.toLowerCase() != right.toLowerCase(); + return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() != right.toLowerCase() : asNumber(left) != asNumber(right); + case ">=": + return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() >= right.toLowerCase() : asNumber(left) >= asNumber(right); + case "<=": + return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() <= right.toLowerCase() : asNumber(left) <= asNumber(right); + case '>': + return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() > right.toLowerCase() : asNumber(left) > asNumber(right); + case '<': + return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() < right.toLowerCase() : asNumber(left) < asNumber(right); } }; diff --git a/test/tests/attachmentsTest.js b/test/tests/attachmentsTest.js index ba5a06d73f..fd807c1170 100644 --- a/test/tests/attachmentsTest.js +++ b/test/tests/attachmentsTest.js @@ -1755,6 +1755,73 @@ describe("Zotero.Attachments", function() { ); }); + it("should be possible to count authors", function () { + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, { formatString: '{{ authorsCount }}' }), + '4' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, { formatString: '{{ editorsCount }}' }), + '3' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, { formatString: '{{ creatorsCount }}' }), + '7' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, { formatString: 'test{{ if authorsCount > 4 }}{{ authorsCount prefix="-" }}{{ endif }}' }), + 'test' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, { formatString: 'test{{ if authorsCount >= 4 }}{{ authorsCount prefix="-" }}{{ endif }}' }), + 'test-4' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, { formatString: 'test{{ if editorsCount <= 4 }}{{ editorsCount prefix="-" }}{{ endif }}' }), + 'test-3' + ); + }); + + it("should be possible to test number of authors using equality operator", function () { + const template = `{{ if {{ authorsCount == "2" }} }}two{{ else }}not two{{ endif }}`; + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, { formatString: template }), + 'not two' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, { formatString: template }), + 'two' + ); + }); + + it("should be possible to test number of authors using relational operators", function () { + const template = `{{ if {{ authorsCount > "2" }} }} +{{ authors max="1" suffix=" et al" }} +{{ else }} +{{ authors join=" & " }} +{{ endif }}`; + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, { formatString: template }), + 'Author et al' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, { formatString: template }), + 'Barius & Pixelus' + ); + }); + + it("should handle zero in relational operators", function () { + const template = '{{ if {{ authorsCount > 0 }} }}more than zero{{ elseif {{ authorsCount <= 0 }} }}zero{{ endif }}'; + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(item, { formatString: template }), + 'more than zero' + ); + assert.equal( + Zotero.Attachments.getFileBaseNameFromItem(itemIncomplete, { formatString: template }), + 'zero' + ); + }); + it("should perform regex in a case-insensitive way, unless configured otherwise", function () { assert.equal( Zotero.Attachments.getFileBaseNameFromItem(item, { formatString: '{{ title match="lorem" }}' }), diff --git a/test/tests/utilities_internalTest.js b/test/tests/utilities_internalTest.js index 6885a71e57..4e91ee72da 100644 --- a/test/tests/utilities_internalTest.js +++ b/test/tests/utilities_internalTest.js @@ -655,6 +655,42 @@ describe("Zotero.Utilities.Internal", function () { assert.equal(out15, 'yes'); }); + it("should support relational operators", function () { + const vars = { + sum: ({ a, b }) => (parseInt(a) + parseInt(b)).toString(), + v1: '1', + v2: 'foo', + v3: '100', + v4: '99', + π: '3.14', + }; + + const template1 = `{{if v1 > π}}more than π{{elseif v1 <= π}}less or equal to π{{endif}}`; + const out1 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template1, vars); + assert.equal(out1, 'less or equal to π'); + + const template2 = `{{if {{ sum a="2" b="3" }} > π}}more than π{{else}}less or equal to π{{endif}}`; + const out2 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template2, vars); + assert.equal(out2, 'more than π'); + + const template3 = `{{if 3.14 >= π}}more than or equal to π{{else}}less than π{{endif}}`; + const out3 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template3, vars); + assert.equal(out3, 'more than or equal to π'); + + const template4 = `{{if v3 > v4}}100 is more than 99{{else}}string "100" would be sorted before "99"{{endif}}`; + const out4 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template4, vars); + assert.equal(out4, '100 is more than 99'); + + // This is undocumented and unsupported behavior, but comparing strings should work + const template5 = `{{if "test" > v2}}"t" is after "f" in the alphabet{{else}}no{{endif}}`; + const out5 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template5, vars); + assert.equal(out5, '"t" is after "f" in the alphabet'); + + const template6 = `{{if "bar" < v2 }}"f" is before "b" in the alphabet{{else}}no{{endif}}`; + const out6 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template6, vars); + assert.equal(out6, '"f" is before "b" in the alphabet'); + }); + it("should accept hyphen-case variables and attributes", function () { const vars = { fooBar: ({ isFoo }) => (isFoo === 'true' ? 'foo' : 'bar'),