Customizable renaming rules #1413 (#2297)

This commit is contained in:
Tom Najdek 2023-07-20 12:50:34 +02:00 committed by GitHub
parent 96e2510165
commit 0ba766f2e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 766 additions and 125 deletions

View file

@ -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}'`);
}
}
};

View file

@ -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(' ')} }}`;
});
};
};

View file

@ -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) {

View file

@ -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);

View file

@ -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 <i>Bar</i> Foo<br><br/><br />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 <i>Bar</i> Foo<br><br/><br />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 () {

View file

@ -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');
});
});
})
});