File renaming: add support for counting creators (#5175)

Also extends the templating engine to support inequality comparisons.
This commit is contained in:
Tom Najdek 2025-04-02 11:31:50 +02:00 committed by Dan Stillman
parent 9fb961fd63
commit 730d3b1f34
4 changed files with 141 additions and 9 deletions

View file

@ -2488,6 +2488,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;
}, {});

View file

@ -2182,14 +2182,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) {
@ -2201,16 +2219,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);
}
};

View file

@ -1756,6 +1756,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" }}' }),

View file

@ -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'),