diff --git a/chrome/content/zotero/xpcom/attachments.js b/chrome/content/zotero/xpcom/attachments.js
index fe4b38f67a..4034bb8366 100644
--- a/chrome/content/zotero/xpcom/attachments.js
+++ b/chrome/content/zotero/xpcom/attachments.js
@@ -23,7 +23,7 @@
***** END LICENSE BLOCK *****
*/
-Zotero.Attachments = new function(){
+Zotero.Attachments = new function () {
const { HiddenBrowser } = ChromeUtils.import("chrome://zotero/content/HiddenBrowser.jsm");
// Keep in sync with Zotero.Schema.integrityCheck() and this.linkModeToName()
@@ -2241,15 +2241,6 @@ Zotero.Attachments = new function(){
* (Optional) |formatString| specifies the format string -- otherwise
* the 'attachmentRenameFormatString' pref is used
*
- * Valid substitution markers:
- *
- * %c -- firstCreator
- * %y -- year (extracted from Date field)
- * %t -- title
- *
- * Fields can be truncated to a certain length by appending an integer
- * within curly brackets -- e.g. %t{50} truncates the title to 50 characters
- *
* @param {Zotero.Item} item
* @param {String} formatString
*/
@@ -2257,79 +2248,160 @@ Zotero.Attachments = new function(){
if (!(item instanceof Zotero.Item)) {
throw new Error("'item' must be a Zotero.Item");
}
-
+
if (!formatString) {
formatString = Zotero.Prefs.get('attachmentRenameFormatString');
}
-
- // Replaces the substitution marker with the field value,
- // truncating based on the {[0-9]+} modifier if applicable
- function rpl(field, str) {
- if (!str) {
- str = formatString;
- }
-
- switch (field) {
- case 'creator':
- field = 'firstCreator';
- var rpl = '%c';
+
+ const getSlicedCreatorsOfType = (creatorType, slice) => {
+ let creatorTypeIDs;
+ switch (creatorType) {
+ case 'author':
+ case 'authors':
+ creatorTypeIDs = [Zotero.CreatorTypes.getPrimaryIDForType(item.itemTypeID)];
break;
-
- case 'year':
- var rpl = '%y';
+ case 'editor':
+ case 'editors':
+ creatorTypeIDs = [Zotero.CreatorTypes.getID('editor'), Zotero.CreatorTypes.getID('seriesEditor')];
break;
-
- case 'title':
- var rpl = '%t';
- break;
- }
-
- var value;
- switch (field) {
- case 'title':
- value = item.getField('title', false, true);
- break;
-
- case 'year':
- value = item.getField('date', true, true);
- if (value) {
- value = Zotero.Date.multipartToSQL(value).substr(0, 4);
- if (value == '0000') {
- value = '';
- }
- }
- break;
-
default:
- value = '' + item.getField(field, false, true);
+ case 'creator':
+ case 'creators':
+ creatorTypeIDs = null;
+ break;
}
- var re = new RegExp("\{?([^%\{\}]*)" + rpl + "(\{[0-9]+\})?" + "([^%\{\}]*)\}?");
-
- // If no value for this field, strip entire conditional block
- // (within curly braces)
- if (!value) {
- if (str.match(re)) {
- return str.replace(re, '')
+ if (slice === 0) {
+ return [];
+ }
+ const matchingCreators = creatorTypeIDs === null
+ ? item.getCreators()
+ : item.getCreators().filter(c => creatorTypeIDs.includes(c.creatorTypeID));
+ const slicedCreators = slice > 0
+ ? matchingCreators.slice(0, slice)
+ : matchingCreators.slice(slice);
+
+ if (slice < 0) {
+ slicedCreators.reverse();
+ }
+ return slicedCreators;
+ };
+
+
+ const common = (value, { truncate = false, prefix = '', suffix = '', case: textCase = '' } = {}) => {
+ if (value === '' || value === null || typeof value === 'undefined') {
+ return '';
+ }
+ if (truncate) {
+ value = value.substr(0, truncate);
+ }
+ if (prefix) {
+ value = prefix + value;
+ }
+ if (suffix) {
+ value += suffix;
+ }
+ switch (textCase) {
+ case 'upper':
+ value = value.toUpperCase();
+ break;
+ case 'lower':
+ value = value.toLowerCase();
+ break;
+ case 'sentence':
+ value = value.slice(0, 1).toUpperCase() + value.slice(1);
+ break;
+ case 'title':
+ value = Zotero.Utilities.capitalizeTitle(value, true);
+ break;
+ case 'hyphen':
+ value = value.toLowerCase().replace(/\s+/g, '-');
+ break;
+ case 'snake':
+ value = value.toLowerCase().replace(/\s+/g, '_');
+ break;
+ case 'camel':
+ value = value.toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, (m, chr) => chr.toUpperCase());
+ break;
+ }
+ return value;
+ };
+
+ const initializeFn = (name, shouldInitialize, initializeWith) => (shouldInitialize ? name.slice(0, 1).toUpperCase() + initializeWith : name);
+
+ const transformName = (creator, { name, namePartSeparator, initialize, initializeWith } = {}) => {
+ if (creator.name) {
+ return initializeFn(creator.name, ['full', 'name'].includes(initialize), initializeWith);
+ }
+
+ const firstLast = ['full', 'given-family', 'first-last'];
+ const lastFirst = ['full-reversed', 'family-given', 'last-first'];
+ const first = ['given', 'first'];
+ const last = ['family', 'last'];
+
+ if (firstLast.includes(name)) {
+ return initializeFn(creator.firstName, ['full', ...first].includes(initialize), initializeWith) + namePartSeparator + initializeFn(creator.lastName, ['full', ...last].includes(initialize), initializeWith);
+ }
+ else if (lastFirst.includes(name)) {
+ return initializeFn(creator.lastName, ['full', ...last].includes(initialize), initializeWith) + namePartSeparator + initializeFn(creator.firstName, ['full', ...first].includes(initialize), initializeWith);
+ }
+ else if (first.includes(name)) {
+ return initializeFn(creator.firstName, ['full', ...first].includes(initialize), initializeWith);
+ }
+
+ return initializeFn(creator.lastName, ['full', ...last].includes(initialize), initializeWith);
+ };
+
+ const commonCreators = (value, { max = Infinity, name = 'family', namePartSeparator = ' ', join = ', ', initialize = '', initializeWith = '.' } = {}) => {
+ return getSlicedCreatorsOfType(value, max)
+ .map(c => transformName(c, { name, namePartSeparator, initialize, initializeWith }))
+ .join(join);
+ };
+
+ const fields = Zotero.ItemFields.getAll()
+ .map(f => f.name)
+ .reduce((obj, name) => {
+ obj[name] = (args) => {
+ return common(item.getField(name, false, true), args);
+ };
+ return obj;
+ }, {});
+
+ const year = (args) => {
+ let value = item.getField('date', true, true);
+ if (value) {
+ value = Zotero.Date.multipartToSQL(value).substr(0, 4);
+ if (value == '0000') {
+ value = '';
}
}
-
- var f = function(match, p1, p2, p3) {
- var maxChars = p2 ? p2.replace(/[^0-9]+/g, '') : false;
- return p1 + (maxChars ? value.substr(0, maxChars) : value) + p3;
- }
-
- return str.replace(re, f);
- }
-
- formatString = rpl('creator');
- formatString = rpl('year');
- formatString = rpl('title');
-
+ return common(value, args);
+ };
+
+ const itemType = ({ localize = false, ...rest }) => common(
+ localize ? Zotero.ItemTypes.getLocalizedString(item.itemType) : item.itemType, rest
+ );
+
+ const creatorFields = ['authors', 'editors', 'creators'].reduce((obj, name) => {
+ obj[name] = (args) => {
+ return common(commonCreators(name, args), args);
+ };
+ return obj;
+ }, {});
+
+ const firstCreator = args => common(
+ // 74492e40 adds \u2068 and \u2069 around names in the `firstCreator` field, which we don't want in the filename
+ // We might actually want to move this replacement to getValidFileName
+ item.getField('firstCreator', false, true).replaceAll('\u2068', '').replaceAll('\u2069', ''), args
+ );
+
+ const vars = { ...fields, ...creatorFields, firstCreator, itemType, year };
+
+ formatString = Zotero.Utilities.Internal.generateHTMLFromTemplate(formatString, vars);
formatString = Zotero.Utilities.cleanTags(formatString);
formatString = Zotero.File.getValidFileName(formatString);
return formatString;
- }
+ };
this.shouldAutoRenameFile = function (isLink) {
@@ -3029,4 +3101,4 @@ Zotero.Attachments = new function(){
}
throw new Error(`Invalid link mode name '${linkModeName}'`);
}
-}
+};
diff --git a/chrome/content/zotero/xpcom/prefs.js b/chrome/content/zotero/xpcom/prefs.js
index 6a0efc0898..fcb747d735 100644
--- a/chrome/content/zotero/xpcom/prefs.js
+++ b/chrome/content/zotero/xpcom/prefs.js
@@ -22,7 +22,7 @@
***** END LICENSE BLOCK *****
*/
-Zotero.Prefs = new function(){
+Zotero.Prefs = new function() {
// Privileged methods
this.get = get;
this.set = set;
@@ -49,7 +49,7 @@ Zotero.Prefs = new function(){
if (!fromVersion) {
fromVersion = 0;
}
- var toVersion = 7;
+ var toVersion = 8;
if (fromVersion < toVersion) {
for (var i = fromVersion + 1; i <= toVersion; i++) {
switch (i) {
@@ -109,6 +109,14 @@ Zotero.Prefs = new function(){
case 7:
this.clear('layers.acceleration.disabled', true);
break;
+ // Convert "attachment rename format string" from old format (e.g. {%c - }{%y - }{%t{50}})
+ // to a new format that uses the template engine
+ case 8:
+ if (this.prefHasUserValue('attachmentRenameFormatString')) {
+ this.set('attachmentRenameFormatString', this.convertLegacyAttachmentRenameFormatString(
+ this.get('attachmentRenameFormatString') || ''
+ ));
+ }
}
}
this.set('prefVersion', toVersion);
@@ -119,7 +127,7 @@ Zotero.Prefs = new function(){
/**
* Retrieve a preference
**/
- function get(pref, global){
+ function get(pref, global) {
try {
pref = global ? pref : ZOTERO_CONFIG.PREF_BRANCH + pref;
let branch = this.rootBranch;
@@ -554,4 +562,38 @@ Zotero.Prefs = new function(){
}
Zotero.Prefs.set(prefKey, JSON.stringify(libraries));
};
-}
+
+ /**
+ * Converts a value of a `attachmentRenameFormatString` pref from a legacy format string
+ * with % markers to a new format that uses the template engine
+ *
+ * @param {string} formatString - The legacy format string to convert.
+ * @returns {string} The new format string.
+ */
+ this.convertLegacyAttachmentRenameFormatString = function (formatString) {
+ const markers = {
+ c: 'firstCreator',
+ y: 'year',
+ t: 'title'
+ };
+
+ // Regexp contains 4 capture groups all wrapped in {}:
+ // * Prefix before the wildcard, can be empty string
+ // * Any recognized marker. % sign marks a wildcard and is required for a match but is
+ // not part of the capture group. Recognized markers are specified in a `markers`
+ // lookup.
+ // * Optionally a maximum number of characters to truncate the value to
+ // * Suffix after the wildcard, can be empty string
+ const re = new RegExp(`{([^%{}]*)%(${Object.keys(markers).join('|')})({[0-9]+})?([^%{}]*)}`, 'ig');
+
+ return formatString.replace(re, (match, prefix, marker, truncate, suffix) => {
+ const field = markers[marker];
+ truncate = truncate ? truncate.replace(/[^0-9]+/g, '') : false;
+ prefix = prefix ? `prefix="${prefix}"` : null;
+ suffix = suffix ? `suffix="${suffix}"` : null;
+ truncate = truncate ? `truncate="${truncate}"` : null;
+
+ return `{{ ${[field, truncate, prefix, suffix].filter(f => f !== null).join(' ')} }}`;
+ });
+ };
+};
diff --git a/chrome/content/zotero/xpcom/utilities_internal.js b/chrome/content/zotero/xpcom/utilities_internal.js
index 5873fd6627..ac7a57c5dc 100644
--- a/chrome/content/zotero/xpcom/utilities_internal.js
+++ b/chrome/content/zotero/xpcom/utilities_internal.js
@@ -2091,11 +2091,47 @@ Zotero.Utilities.Internal = {
};
},
+
+ /**
+ * Splits a string by outer-most brackets (`{{` and '}}' by default, configurable).
+ *
+ * @param {string} input - The input string to split.
+ * @returns {string[]} An array of strings split by outer-most brackets.
+ */
+ splitByOuterBrackets: function(input, left = '{{', right = '}}') {
+ const result = [];
+ let startIndex = 0;
+ let depth = 0;
+
+ for (let i = 0; i < input.length; i++) {
+ if (input.slice(i, i + 2) === left) {
+ if (depth === 0) {
+ result.push(input.slice(startIndex, i));
+ startIndex = i;
+ }
+ depth++;
+ }
+ else if (input.slice(i, i + 2) === right) {
+ depth--;
+ if (depth === 0) {
+ result.push(input.slice(startIndex, i + 2));
+ startIndex = i + 2;
+ }
+ }
+ }
+
+ if (startIndex < input.length) {
+ result.push(input.slice(startIndex));
+ }
+
+ return result;
+ },
+
/**
* A basic templating engine
*
* - 'if' statement does case-insensitive string comparison
- * - Spaces around '==' are necessary in 'if' statement
+ * - functions can be called from if statements but must be wrapped in {{}} if arguments are passed (e.g. {{myFunction arg1="foo" arg2="bar"}})
*
* Vars example:
* {
@@ -2121,44 +2157,108 @@ Zotero.Utilities.Internal = {
* @returns {String} HTML
*/
generateHTMLFromTemplate: function (template, vars) {
- let levels = [{ condition: true }];
+ const hyphenToCamel = varName => varName.replace(/-(.)/g, (_, g1) => g1.toUpperCase());
+
+ const getAttributes = (part) => {
+ let attrsRegexp = new RegExp(/(([\w-]*) *=+ *(['"])((\\\3|[^\3])*?)\3)/g);
+ let attrs = {};
+ let match;
+ while ((match = attrsRegexp.exec(part))) {
+ if (match[1]) { // if first alternative (i.e. argument with value wrapped in " or ') matched, even if value is empty
+ attrs[hyphenToCamel(match[2])] = match[4];
+ }
+ }
+ return attrs;
+ };
+
+
+ const evaluateIdentifier = (ident, args = {}) => {
+ ident = hyphenToCamel(ident);
+
+ if (!(ident in vars)) {
+ return '';
+ }
+
+ const identValue = typeof vars[ident] === 'function' ? vars[ident](args) : vars[ident];
+
+ if (Array.isArray(identValue)) {
+ return identValue.length ? identValue.join(',') : '';
+ }
+
+ if (typeof identValue !== 'string') {
+ throw new Error(`Identifier "${ident}" does not evaluate to a string`);
+ }
+
+ return identValue;
+ };
+
+ // evaluates extracted (i.e. without brackets) statement (e.g. `sum a="1" b="2"`) into a string value
+ const evaluateStatement = (statement) => {
+ statement = statement.trim();
+ const operator = statement.split(' ', 1)[0].trim();
+ const args = statement.slice(operator.length).trim();
+
+ return evaluateIdentifier(operator, getAttributes(args));
+ };
+
+ // splits raw (i.e. bracketed) statement (e.g. `{{ sum a="1" b="2" }}) into operator and arguments (e.g. ['sum', 'a="1" b="2"'])
+ const splitStatement = (statement) => {
+ statement = statement.slice(2, -2).trim();
+ const operator = statement.split(' ', 1)[0].trim();
+ const args = statement.slice(operator.length).trim();
+ return [operator, args];
+ };
+
+ // evaluates a condition (e.g. `a == "b"`) into a boolean value
+ const evaluateCondition = (condition) => {
+ 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)
+ const match = condition.match(new RegExp(String.raw`(?:{{(.*?)}}|(?:(['"])(.*?)\2)|([^ ]+)) *(${comparators.join('|')}) *(?:{{(.*?)}}|(?:(['"])(.*?)\7)|([^ ]+))`));
+
+ if (!match) {
+ // condition is a statement or identifier without a comparator
+ if (condition.startsWith('{{')) {
+ const [operator, args] = splitStatement(condition);
+ return !!evaluateIdentifier(operator, getAttributes(args));
+ }
+ return !!evaluateIdentifier(condition);
+ }
+
+ const left = match[1] ? evaluateStatement(match[1]) : match[3] ?? evaluateIdentifier(match[4]) ?? '';
+ const comparator = match[5];
+ const right = match[6] ? evaluateStatement(match[6]) : match[8] ?? evaluateIdentifier(match[9]) ?? '';
+
+ switch (comparator) {
+ default:
+ case '==':
+ return left.toLowerCase() == right.toLowerCase();
+ case '!=':
+ return left.toLowerCase() != right.toLowerCase();
+ }
+ };
+
let html = '';
- let parts = template.split(/{{|}}/);
+ const levels = [{ condition: true }];
+ const parts = this.splitByOuterBrackets(template);
+
for (let i = 0; i < parts.length; i++) {
let part = parts[i];
let level = levels[levels.length - 1];
- if (i % 2 === 1) {
- let operator = part.split(' ').filter(x => x)[0];
- // Get arguments that are used for 'if'
- let args = [];
- let match = part.match(/(["'][^"|^']+["']|[^\s"']+)/g);
- if (match) {
- args = match.map(x => x.replace(/['"](.*)['"]/, '$1')).slice(1);
- }
+
+ if (part.startsWith('{{')) {
+ const [operator, args] = splitStatement(part);
+
if (operator === 'if') {
- level = { condition: false, executed: false, parentCondition: levels[levels.length-1].condition };
+ level = { condition: false, executed: false, parentCondition: levels[levels.length - 1].condition };
levels.push(level);
}
if (['if', 'elseif'].includes(operator)) {
if (!level.executed) {
- level.condition = level.parentCondition && (
- args[2]
- // If string variable is equal to the provided string
- ? vars[args[0]].toLowerCase() == args[2].toLowerCase()
- : (
- Array.isArray(vars[args[0]])
- // Is array non empty
- ? !!vars[args[0]].length
- : (
- typeof vars[args[0]] === 'function'
- // If function returns a value (only string is supported)
- // Note: To keep things simple, this doesn't support function attributes
- ? !!vars[args[0]]({})
- // If string variable exists
- : !!vars[args[0]]
- )
- )
- );
+ level.condition = level.parentCondition && evaluateCondition(args);
level.executed = level.condition;
}
else {
@@ -2176,18 +2276,8 @@ Zotero.Utilities.Internal = {
continue;
}
if (level.condition) {
- // Get attributes i.e. join=" #"
- let attrsRegexp = new RegExp(/((\w*) *=+ *(['"])((\\\3|[^\3])*?)\3)|((\w*) *=+ *(\w*))/g);
- let attrs = {};
- while ((match = attrsRegexp.exec(part))) {
- if (match[4]) {
- attrs[match[2]] = match[4];
- }
- else {
- attrs[match[7]] = match[8];
- }
- }
- html += (typeof vars[operator] === 'function' ? vars[operator](attrs) : vars[operator]) || '';
+ const attrs = getAttributes(part);
+ html += evaluateIdentifier(operator, attrs);
}
}
else if (level.condition) {
diff --git a/defaults/preferences/zotero.js b/defaults/preferences/zotero.js
index 59793b91ea..9a649f2900 100644
--- a/defaults/preferences/zotero.js
+++ b/defaults/preferences/zotero.js
@@ -36,7 +36,7 @@ pref("extensions.zotero.autoRecognizeFiles", true);
pref("extensions.zotero.autoRenameFiles", true);
pref("extensions.zotero.autoRenameFiles.linked", false);
pref("extensions.zotero.autoRenameFiles.fileTypes", "application/pdf");
-pref("extensions.zotero.attachmentRenameFormatString", "{%c - }{%y - }{%t{50}}");
+pref("extensions.zotero.attachmentRenameFormatString", "{{ firstCreator suffix=\" - \" }}{{ year suffix=\" - \" }}{{ title truncate=\"50\" }}");
pref("extensions.zotero.capitalizeTitles", false);
pref("extensions.zotero.launchNonNativeFiles", false);
pref("extensions.zotero.sortNotesChronologically", false);
diff --git a/test/tests/attachmentsTest.js b/test/tests/attachmentsTest.js
index bd71d2be93..df4a45d4a3 100644
--- a/test/tests/attachmentsTest.js
+++ b/test/tests/attachmentsTest.js
@@ -1293,11 +1293,297 @@ describe("Zotero.Attachments", function() {
});
describe("#getFileBaseNameFromItem()", function () {
- it("should strip HTML tags from title", async function () {
- var item = createUnsavedDataObject('item', { title: 'Foo Bar Foo
Bar' });
- var str = Zotero.Attachments.getFileBaseNameFromItem(item);
+ var item, itemManyAuthors, itemPatent, itemIncomplete, itemBookSection;
+
+ before(() => {
+ item = createUnsavedDataObject('item', { title: 'Lorem Ipsum', itemType: 'journalArticle' });
+ item.setCreators([
+ { firstName: 'Foocius', lastName: 'Barius', creatorType: 'author' },
+ { firstName: 'Bazius', lastName: 'Pixelus', creatorType: 'author' }
+ ]);
+ item.setField('date', "1975-10-15");
+ item.setField('publicationTitle', 'Best Publications Place');
+ item.setField('journalAbbreviation', 'BPP');
+ item.setField('issue', '42');
+ item.setField('pages', '321');
+
+
+ itemManyAuthors = createUnsavedDataObject('item', { title: 'Has Many Authors', itemType: 'book' });
+ itemManyAuthors.setCreators([
+ { firstName: 'First', lastName: 'Author', creatorType: 'author' },
+ { firstName: 'Second', lastName: 'Creator', creatorType: 'author' },
+ { firstName: 'Third', lastName: 'Person', creatorType: 'author' },
+ { firstName: 'Final', lastName: 'Writer', creatorType: 'author' },
+ { firstName: 'Some', lastName: 'Editor1', creatorType: 'editor' },
+ { firstName: 'Other', lastName: 'ProEditor2', creatorType: 'editor' },
+ { firstName: 'Last', lastName: 'SuperbEditor3', creatorType: 'editor' },
+ ]);
+ itemManyAuthors.setField('date', "2000-01-02");
+ itemManyAuthors.setField('publisher', 'Awesome House');
+ itemManyAuthors.setField('volume', '3');
+
+ itemPatent = createUnsavedDataObject('item', { title: 'Retroencabulator', itemType: 'patent' });
+ itemPatent.setCreators([
+ { name: 'AcmeCorp', creatorType: 'inventor' },
+ { firstName: 'Wile', lastName: 'E', creatorType: 'contributor' },
+ { firstName: 'Road', lastName: 'R', creatorType: 'contributor' },
+ ]);
+ itemPatent.setField('date', '1952-05-10');
+ itemPatent.setField('number', 'HBK-8539b');
+ itemPatent.setField('assignee', 'Fast FooBar');
+ itemIncomplete = createUnsavedDataObject('item', { title: 'Incomplete', itemType: 'preprint' });
+ itemBookSection = createUnsavedDataObject('item', { title: 'Book Section', itemType: 'bookSection' });
+ itemBookSection.setField('bookTitle', 'Book Title');
+ });
+
+
+ it('should strip HTML tags from title', function () {
+ var htmlItem = createUnsavedDataObject('item', { title: 'Foo Bar Foo
Bar' });
+ var str = Zotero.Attachments.getFileBaseNameFromItem(htmlItem, '{{ title }}');
assert.equal(str, 'Foo Bar Foo Bar');
});
+
+ it('should accept basic formating options', function () {
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, 'FOO{{year}}BAR'),
+ 'FOO1975BAR'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{firstCreator suffix=" - "}}{{year suffix=" - "}}{{title truncate="50" }}'),
+ 'Barius and Pixelus - 1975 - Lorem Ipsum'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{year suffix="-"}}{{firstCreator truncate="10" suffix="-"}}{{title truncate="5" }}'),
+ '1975-Barius and-Lorem'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, 'foo {{year}} bar {{year prefix="++" truncate="2" suffix="++"}}'),
+ 'foo 1975 bar ++19++'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{firstCreator suffix=" - "}}{{year suffix=" - "}}{{title}}'),
+ 'Author et al. - 2000 - Has Many Authors'
+ );
+ });
+
+ it('should offer a range of options for composing creators', function () {
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ authors max="1" }}'),
+ 'Barius'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ authors max="1" truncate="3" }}'),
+ 'Bar'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ authors max="5" join=" " }}'),
+ 'Barius Pixelus'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ authors max="3" join=" " }}'),
+ 'Author Creator Person'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemPatent, '{{ authors }}'),
+ 'AcmeCorp'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ authors max="2" name="family" initialize="family" join=" " initialize-with="" }}'),
+ 'A C'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemPatent, '{{ authors max="2" name="family" initialize="family" initialize-with="" }}'),
+ 'A'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ authors max="1" name="full" initialize="full" name-part-separator="" initialize-with="" }}'),
+ 'FB'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ authors max="3" name="full" initialize="full" name-part-separator="" join=" " initialize-with="" }}'),
+ 'FA SC TP'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ authors max="1" name="family-given" initialize="given" name-part-separator="" initialize-with="" }}'),
+ 'BariusF'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ authors max="2" name="family-given" initialize="given" join=" " name-part-separator="" initialize-with="" }}'),
+ 'AuthorF CreatorS'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ editors }}test'),
+ 'test'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ editors max="1" }}'),
+ 'Editor1'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ editors max="5" join=" " }}'),
+ 'Editor1 ProEditor2 SuperbEditor3'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ editors max="2" name="family" initialize="family" join=" " initialize-with="" }}'),
+ 'E P'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ editors max="1" name="full" initialize="full" name-part-separator="" initialize-with="" }}'),
+ 'SE'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ editors max="1" name="family-given" initialize="given" name-part-separator="" initialize-with="" }}'),
+ 'Editor1S'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ authors max="3" name="full" initialize="given" }}'),
+ 'F. Barius, B. Pixelus'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ creators case="upper" }}'),
+ 'BARIUS, PIXELUS'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ authors max="2" }}'),
+ 'Author, Creator'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ creators max="3" join=" " name="given" }}'),
+ 'First Second Third'
+ );
+ });
+
+ it('should accept case parameter', async function () {
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ publicationTitle case="upper" }}'),
+ 'BEST PUBLICATIONS PLACE'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ publicationTitle case="lower" }}'),
+ 'best publications place'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ publicationTitle case="title" }}'),
+ 'Best Publications Place'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ publicationTitle case="hyphen" }}'),
+ 'best-publications-place'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ publicationTitle case="camel" }}'),
+ 'bestPublicationsPlace'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ publicationTitle case="snake" }}'),
+ 'best_publications_place'
+ );
+ });
+
+ it('should accept itemType or any other field', function () {
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ itemType localize="true" }}'),
+ 'Journal Article'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ publicationTitle }}'),
+ 'Best Publications Place'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ journalAbbreviation }}'),
+ 'BPP'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ publisher }}'),
+ 'Awesome House'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, '{{ volume }}'),
+ '3'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ issue }}'),
+ '42'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, '{{ pages }}'),
+ '321'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemPatent, '{{ number }}'),
+ 'HBK-8539b'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemPatent, '{{ assignee }}'),
+ 'Fast FooBar'
+ );
+ });
+
+ it("should support simple logic in template syntax", function () {
+ const template = '{{ if itemType == "journalArticle" }}j-{{ publicationTitle case="hyphen" }}{{ elseif itemType == "patent" }}p-{{ number case="hyphen" }}{{ else }}o-{{ title case="hyphen" }}{{ endif }}';
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(item, template), 'j-best-publications-place'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemPatent, template), 'p-hbk-8539b'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemManyAuthors, template), 'o-has-many-authors'
+ );
+ });
+
+ it("should skip missing fields", function () {
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemIncomplete, '{{ authors prefix = "a" suffix="-" }}{{ publicationTitle case="hyphen" suffix="-" }}{{ title }}'),
+ 'Incomplete'
+ );
+ });
+
+ it("should recognized base-mapped fields", function () {
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemBookSection, '{{ bookTitle case="snake" }}'),
+ 'book_title'
+ );
+ assert.equal(
+ Zotero.Attachments.getFileBaseNameFromItem(itemBookSection, '{{ publicationTitle case="snake" }}'),
+ 'book_title'
+ );
+ });
+
+ it("should convert formatString attachmentRenameFormatString to use template syntax", function () {
+ assert.equal(
+ Zotero.Prefs.convertLegacyAttachmentRenameFormatString('{%c - }{%y - }{%t{50}}'),
+ '{{ firstCreator suffix=" - " }}{{ year suffix=" - " }}{{ title truncate="50" }}'
+ );
+ assert.equal(
+ Zotero.Prefs.convertLegacyAttachmentRenameFormatString('{ - %y - }'),
+ '{{ year prefix=" - " suffix=" - " }}'
+ );
+ assert.equal(
+ Zotero.Prefs.convertLegacyAttachmentRenameFormatString('{%y{2}00}'),
+ '{{ year truncate="2" suffix="00" }}'
+ );
+ assert.equal(
+ Zotero.Prefs.convertLegacyAttachmentRenameFormatString('{%c5 - }'),
+ '{{ firstCreator suffix="5 - " }}'
+ );
+ assert.equal(
+ Zotero.Prefs.convertLegacyAttachmentRenameFormatString('{%c-2 - }'),
+ '{{ firstCreator suffix="-2 - " }}'
+ );
+ assert.equal(
+ Zotero.Prefs.convertLegacyAttachmentRenameFormatString('{%t5 - }'),
+ '{{ title suffix="5 - " }}'
+ );
+ assert.equal(
+ Zotero.Prefs.convertLegacyAttachmentRenameFormatString('{++%t{10}--}'),
+ '{{ title truncate="10" prefix="++" suffix="--" }}'
+ );
+ assert.equal(
+ Zotero.Prefs.convertLegacyAttachmentRenameFormatString('foo{%c}-{%t{10}}-{%y{2}00}'),
+ 'foo{{ firstCreator }}-{{ title truncate="10" }}-{{ year truncate="2" suffix="00" }}'
+ );
+ });
});
describe("#getBaseDirectoryRelativePath()", function () {
diff --git a/test/tests/utilities_internalTest.js b/test/tests/utilities_internalTest.js
index 2497fb88c2..f7f0c4d2cd 100644
--- a/test/tests/utilities_internalTest.js
+++ b/test/tests/utilities_internalTest.js
@@ -549,25 +549,176 @@ describe("Zotero.Utilities.Internal", function () {
it("should support variables with attributes", function () {
var vars = {
v1: '1',
- v2: (pars) => pars.a1 + pars.a2 + pars.a3,
+ v2: pars => `${pars.a1 ?? ''}${pars.a2 ?? ''}${pars.a3 ?? ''}`,
v3: () => '',
v5: () => 'something',
ar1: [],
ar2: [1, 2]
};
- var template = `{{ v1}}{{v2 a1= 1 a2 =' 2' a3 = "3 "}}{{v3}}{{v4}}{{if ar1}}ar1{{endif}}{{if ar2}}{{ar2}}{{endif}}{{if v5}}yes{{endif}}{{if v3}}no{{endif}}{{if v2}}no{{endif}}`;
+ var template = `{{ v1}}{{v2 a1= "1" a2 =' 2' a3 = "3 "}}{{v3}}{{v4}}{{if ar1}}ar1{{endif}}{{if ar2}}{{ar2}}{{endif}}{{if v5}}yes{{endif}}{{if v3}}no1{{endif}}{{if v2}}{{v2}}{{endif}}`;
var html = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars);
assert.equal(html, '11 23 1,2yes');
});
+ it("should support empty string as attribute value and correctly render returned false-ish values", function () {
+ const vars = {
+ length: ({ string }) => string.length.toString(),
+ };
+ const template = `"" has a length of {{ length string="" }} and "hello" has a length of {{ length string="hello" }}`;
+ const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars);
+ assert.equal(out, '"" has a length of 0 and "hello" has a length of 5');
+ });
+
+ it("should support functions in comparison statements", function () {
+ const vars = {
+ sum: ({ a, b }) => (parseInt(a) + parseInt(b)).toString(),
+ fooBar: ({ isFoo }) => (isFoo === 'true' ? 'foo' : 'bar'),
+ false: 'false',
+ twoWords: 'two words',
+ onlyOne: 'actually == 1'
+ };
+ const template = `{{if {{ sum a="1" b="2" }} == "3"}}1 + 2 = {{sum a="1" b="2"}}{{else}}no speak math{{endif}}`;
+ const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars);
+ assert.equal(out, '1 + 2 = 3');
+
+ const template2 = '{{if false != "false"}}no{{elseif false == "false"}}yes{{else}}no{{endif}}';
+ const out2 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template2, vars);
+ assert.equal(out2, 'yes');
+
+ const template3 = '{{ if twoWords == "two words" }}yes{{else}}no{{endif}}';
+ const out3 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template3, vars);
+ assert.equal(out3, 'yes');
+
+ const template4 = '{{ if onlyOne == \'actually == 1\' }}yes{{else}}no{{endif}}';
+ const out4 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template4, vars);
+ assert.equal(out4, 'yes');
+
+ const template5 = '{{ if "3" == {{ sum a="1" b="2" }} }}yes{{else}}no{{endif}}';
+ const out5 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template5, vars);
+ assert.equal(out5, 'yes');
+
+ const template6 = '{{ if {{ sum a="1" b="2" }} }}yes{{else}}no{{endif}}';
+ const out6 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template6, vars);
+ assert.equal(out6, 'yes');
+
+ const template7 = '{{ if {{ twoWords }} }}yes{{else}}no{{endif}}';
+ const out7 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template7, vars);
+ assert.equal(out7, 'yes');
+
+ const template8 = '{{ if twoWords }}yes{{else}}no{{endif}}';
+ const out8 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template8, vars);
+ assert.equal(out8, 'yes');
+
+ const template9 = '{{ if missing }}no{{else}}yes{{endif}}';
+ const out9 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template9, vars);
+ assert.equal(out9, 'yes');
+
+ const template10 = '{{ if {{ missing foo="bar" }} }}no{{else}}yes{{endif}}';
+ const out10 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template10, vars);
+ assert.equal(out10, 'yes');
+
+ const template11 = '{{ if {{ missing foo="bar" }} == "" }}yes{{else}}no{{endif}}';
+ const out11 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template11, vars);
+ assert.equal(out11, 'yes');
+
+ const template12 = '{{ if fooBar == "bar" }}yes{{else}}no{{endif}}';
+ const out12 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template12, vars);
+ assert.equal(out12, 'yes');
+
+ const template13 = '{{ if {{ fooBar }} == "bar" }}yes{{else}}no{{endif}}';
+ const out13 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template13, vars);
+ assert.equal(out13, 'yes');
+
+ const template14 = `{{if {{ sum a="1" b="2" }}=="3"}}1 + 2 = {{sum a="1" b="2"}}{{else}}no{{endif}}`;
+ const out14 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template14, vars);
+ assert.equal(out14, '1 + 2 = 3');
+
+ const template15 = `{{if "two words"==twoWords}}yes{{else}}no{{endif}}`;
+ const out15 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template15, vars);
+ assert.equal(out15, 'yes');
+ });
+
+ it("should accept hyphen-case variables and attributes", function () {
+ const vars = {
+ fooBar: ({ isFoo }) => (isFoo === 'true' ? 'foo' : 'bar'),
+ };
+ const template = '{{ foo-bar is-foo="true" }}{{ if {{ foo-bar is-foo="false" }} == "bar" }}{{ foo-bar is-foo="false" }}{{ endif }}';
+ const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars);
+ assert.equal(out, 'foobar');
+ });
+
+ it("should work with a condition in the middle", function () {
+ const vars = {
+ v1: '1',
+ };
+ const template = 'test {{ if v1 == "1" }}yes{{ else }}no{{ endif }} foobar';
+ const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars);
+ assert.equal(out, 'test yes foobar');
+ });
+
+ it("missing identifiers are evaluted as empty string", function () {
+ const vars = {
+ foo: 'foo',
+ };
+ const template = '{{bar}}{{ if foo == "" }}no{{elseif foo}}{{foo}}{{else}}no{{endif}}';
+ const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars);
+ assert.equal(out, 'foo');
+
+ const template2 = 'test: {{ if bar == "" }}yes{{else}}no{{endif}}';
+ const out2 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template2, vars);
+ assert.equal(out2, 'test: yes');
+ });
+
+ it("should preserve whitespace outside of brackets", function () {
+ const template = ' starts }} with {{ whitespace {"test"} == \'foobar\' ';
+ const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, {});
+ assert.equal(out, template);
+ const vars = {
+ space: ' ',
+ spaceFn: () => ' ',
+ };
+
+ const whitespace = ' {{if spaceFn}}{{else}} {{endif}}{{space}} {{space-fn}}';
+ const out2 = Zotero.Utilities.Internal.generateHTMLFromTemplate(whitespace, vars);
+ assert.equal(out2, ' ');
+ });
+
+ it("should accept array values in logic statements", function () {
+ let someTags = ['foo', 'bar'];
+ const vars = {
+ tags: ({ join }) => (join ? someTags.join(join) : someTags),
+ };
+ const template = '{{ if tags }}#{{ tags join=" #" }}{{else}}no tags{{endif}}';
+ const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars);
+ assert.equal(out, '#foo #bar');
+
+ someTags = [];
+ const out2 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars);
+ assert.equal(out2, 'no tags');
+ });
+
+
+ it("should throw if function returns anything else than a string (or an array which is always joined into string)", function () {
+ const vars = {
+ number: () => 1,
+ logic: () => true,
+ array: () => [],
+ fn: () => 1,
+ };
+ assert.throws(() => Zotero.Utilities.Internal.generateHTMLFromTemplate('{{ number }}', vars), /Identifier "number" does not evaluate to a string/);
+ assert.throws(() => Zotero.Utilities.Internal.generateHTMLFromTemplate('{{ logic }}', vars), /Identifier "logic" does not evaluate to a string/);
+ assert.throws(() => Zotero.Utilities.Internal.generateHTMLFromTemplate('{{ if fn }}no{{endif}}', vars), /Identifier "fn" does not evaluate to a string/);
+ assert.throws(() => Zotero.Utilities.Internal.generateHTMLFromTemplate('{{ if {{ fn foo="bar" }} }}no{{endif}}', vars), /Identifier "fn" does not evaluate to a string/);
+ });
+
it("should support nested 'if' statements", function () {
var vars = {
v1: '1',
v2: 'H',
};
- var template = `{{if v1 == '1'}}yes1{{if x}}no{{elseif v2 == h }}yes2{{endif}}{{elseif v2 == 2}}no{{else}}no{{endif}} {{if v2 == 1}}not{{elseif x}}not{{else}}yes3{{ endif}}`;
+ var template = `{{if v1 == '1'}}yes1{{if x}}no{{elseif v2 == "h" }}yes2{{endif}}{{elseif v2 == "2"}}no{{else}}no{{endif}} {{if v2 == "1"}}not{{elseif x}}not{{else}}yes3{{ endif}}`;
var html = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars);
assert.equal(html, 'yes1yes2 yes3');
});
});
-})
+});