mirror of
https://github.com/zotero/zotero.git
synced 2026-08-28 05:25:31 +00:00
Some checks are pending
- Fix multiple potential scenarios causing a template engine crash
- Add support for specifying string literals in the template engine
- Validate `if/else/elseif` order and matching clause closures
- Validate to ensure every `{{` is properly closed with a matching `}}`
- When a template is invalid, display a warning, do not offer batch-renaming tools, do not update synced setting
- When a template is invalid, prompt the user to fix or reset the template when
Closes #5965
448 lines
19 KiB
JavaScript
448 lines
19 KiB
JavaScript
describe("FileRenamingDialog", function () {
|
|
let win;
|
|
let group;
|
|
|
|
before(async function () {
|
|
group = await getGroup();
|
|
win = await loadWindow("chrome://zotero/content/fileRenamingDialog.xhtml");
|
|
});
|
|
|
|
after(function () {
|
|
if (win) {
|
|
win.close();
|
|
}
|
|
});
|
|
|
|
describe("#handleSettingsChange()", function () {
|
|
// Drives handleSettingsChange for a given library/template with SyncedSettings.set stubbed,
|
|
// returning the values the template setting would have been synced with
|
|
function collectTemplateSyncs(libraryID, template) {
|
|
let dialog = win.FileRenamingDialog;
|
|
dialog.libraryPicker.value = String(libraryID);
|
|
dialog._currentLibraryID = libraryID;
|
|
dialog.loadSettingsForLibrary(libraryID);
|
|
dialog.settingsEl.formatTemplate = template;
|
|
|
|
let sandbox = sinon.createSandbox();
|
|
let setStub = sandbox.stub(Zotero.SyncedSettings, "set").resolves();
|
|
let clearStub = sandbox.stub(Zotero.SyncedSettings, "clear").resolves();
|
|
sandbox.stub(Zotero.Prefs, "set");
|
|
try {
|
|
dialog.handleSettingsChange({
|
|
detail: {
|
|
autoRenameEnabled: true,
|
|
enabledFileTypes: 'application/pdf',
|
|
renameLinkedEnabled: false,
|
|
formatTemplate: template
|
|
}
|
|
});
|
|
return {
|
|
otherSynced: setStub.getCalls().some(c => c.args[1] !== 'attachmentRenameTemplate'),
|
|
templateValues: setStub.getCalls()
|
|
.filter(c => c.args[1] === 'attachmentRenameTemplate')
|
|
.map(c => c.args[2]),
|
|
templateCleared: clearStub.getCalls()
|
|
.some(c => c.args[1] === 'attachmentRenameTemplate')
|
|
};
|
|
}
|
|
finally {
|
|
sandbox.restore();
|
|
}
|
|
}
|
|
|
|
it("should not push an invalid template to a group's synced settings", function () {
|
|
let invalid = collectTemplateSyncs(group.libraryID, '{{ title');
|
|
assert.deepEqual(invalid.templateValues, [], "invalid template must not be synced");
|
|
assert.isTrue(invalid.otherSynced, "other group settings should still be synced");
|
|
|
|
let valid = collectTemplateSyncs(group.libraryID, '{{ title }}');
|
|
assert.deepEqual(valid.templateValues, ['{{ title }}'], "valid template must be synced");
|
|
});
|
|
|
|
it("should clear a group's template setting when the template is emptied", function () {
|
|
for (let empty of ['', ' ']) {
|
|
let result = collectTemplateSyncs(group.libraryID, empty);
|
|
assert.deepEqual(result.templateValues, [], `empty template ${JSON.stringify(empty)} must not be synced`);
|
|
assert.isTrue(result.templateCleared, "empty template must be cleared from a group's synced settings");
|
|
assert.isTrue(result.otherSynced, "other group settings should still be synced");
|
|
}
|
|
});
|
|
|
|
it("should not push an invalid template to the user library's synced settings", function () {
|
|
let invalid = collectTemplateSyncs(Zotero.Libraries.userLibraryID, '{{ title');
|
|
assert.deepEqual(invalid.templateValues, [], "invalid template must not be synced");
|
|
|
|
let valid = collectTemplateSyncs(Zotero.Libraries.userLibraryID, '{{ title }}');
|
|
assert.deepEqual(valid.templateValues, ['{{ title }}'], "valid template must be synced");
|
|
});
|
|
});
|
|
|
|
describe("invalid template prompt", function () {
|
|
const INVALID_TEMPLATE = '{{ title';
|
|
|
|
// Loads the given library and applies a template change through the dialog's change
|
|
// handler (with persistence stubbed by the caller), leaving the dialog dirty
|
|
function makeDirty(libraryID, template) {
|
|
let dialog = win.FileRenamingDialog;
|
|
dialog.libraryPicker.value = String(libraryID);
|
|
dialog._currentLibraryID = libraryID;
|
|
dialog.loadSettingsForLibrary(libraryID);
|
|
dialog.settingsEl.formatTemplate = template;
|
|
dialog.handleSettingsChange({
|
|
detail: {
|
|
autoRenameEnabled: dialog.settingsEl.autoRenameEnabled,
|
|
enabledFileTypes: dialog.settingsEl.enabledFileTypes,
|
|
renameLinkedEnabled: dialog.settingsEl.renameLinkedEnabled,
|
|
formatTemplate: template
|
|
}
|
|
});
|
|
return dialog;
|
|
}
|
|
|
|
function setupStubs(sandbox, confirmIndex) {
|
|
return {
|
|
set: sandbox.stub(Zotero.SyncedSettings, 'set').resolves(),
|
|
clear: sandbox.stub(Zotero.SyncedSettings, 'clear').resolves(),
|
|
prefSet: sandbox.stub(Zotero.Prefs, 'set'),
|
|
confirm: sandbox.stub(Zotero.Prompt, 'confirm').returns(confirmIndex)
|
|
};
|
|
}
|
|
|
|
function resetStubHistory(stubs) {
|
|
stubs.set.resetHistory();
|
|
stubs.clear.resetHistory();
|
|
stubs.prefSet.resetHistory();
|
|
}
|
|
|
|
function templateWrites(stubs) {
|
|
return {
|
|
sets: stubs.set.getCalls().filter(c => c.args[1] === 'attachmentRenameTemplate'),
|
|
clears: stubs.clear.getCalls().filter(c => c.args[1] === 'attachmentRenameTemplate')
|
|
};
|
|
}
|
|
|
|
function restoreDialog() {
|
|
let dialog = win.FileRenamingDialog;
|
|
dialog._forceClose = false;
|
|
dialog.libraryPicker.value = String(Zotero.Libraries.userLibraryID);
|
|
dialog._currentLibraryID = Zotero.Libraries.userLibraryID;
|
|
dialog.loadSettingsForLibrary(Zotero.Libraries.userLibraryID);
|
|
}
|
|
|
|
it("should keep the window open and not touch the template setting on cancel", async function () {
|
|
let sandbox = sinon.createSandbox();
|
|
let stubs = setupStubs(sandbox, 1);
|
|
let origClose = win.close;
|
|
let closeSpy = sinon.spy();
|
|
win.close = closeSpy;
|
|
try {
|
|
let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE);
|
|
resetStubHistory(stubs);
|
|
let event = { preventDefault: sinon.spy() };
|
|
await dialog.handleWindowClose(event);
|
|
assert.isTrue(event.preventDefault.called, 'close must be blocked');
|
|
assert.isFalse(closeSpy.called, 'window must stay open on cancel');
|
|
assert.equal(stubs.confirm.callCount, 1, 'prompt shown once');
|
|
assert.isString(stubs.confirm.firstCall.args[0].title, 'prompt title must resolve to a string');
|
|
assert.isString(stubs.confirm.firstCall.args[0].text, 'prompt body must resolve to a string');
|
|
let { sets, clears } = templateWrites(stubs);
|
|
assert.lengthOf(sets, 0, 'template must not be synced on cancel');
|
|
assert.lengthOf(clears, 0, 'template must not be cleared on cancel');
|
|
}
|
|
finally {
|
|
win.close = origClose;
|
|
sandbox.restore();
|
|
restoreDialog();
|
|
}
|
|
});
|
|
|
|
it("should clear the group's template setting and close on reset", async function () {
|
|
let sandbox = sinon.createSandbox();
|
|
let stubs = setupStubs(sandbox, 0);
|
|
let origClose = win.close;
|
|
let closeSpy = sinon.spy();
|
|
win.close = closeSpy;
|
|
try {
|
|
let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE);
|
|
resetStubHistory(stubs);
|
|
let event = { preventDefault: sinon.spy() };
|
|
await dialog.handleWindowClose(event);
|
|
assert.isString(stubs.confirm.firstCall.args[0].title, 'prompt title must resolve to a string');
|
|
assert.isString(stubs.confirm.firstCall.args[0].text, 'prompt body must resolve to a string');
|
|
assert.equal(stubs.confirm.callCount, 1, 'no rename prompt when the reset lands back on the baseline');
|
|
let { sets, clears } = templateWrites(stubs);
|
|
assert.lengthOf(sets, 0, 'template must not be synced on reset');
|
|
assert.lengthOf(clears, 1, 'template setting must be cleared');
|
|
assert.equal(clears[0].args[0], group.libraryID);
|
|
assert.isFalse(
|
|
stubs.prefSet.calledWith('autoRenameFiles.done', sinon.match.any),
|
|
'user-library done pref must not change for a group reset'
|
|
);
|
|
assert.isTrue(closeSpy.called, 'window must close after reset');
|
|
}
|
|
finally {
|
|
win.close = origClose;
|
|
sandbox.restore();
|
|
restoreDialog();
|
|
}
|
|
});
|
|
|
|
it("should clear the user library's template setting and recompute the done pref on reset", async function () {
|
|
let userLibraryID = Zotero.Libraries.userLibraryID;
|
|
// Make the baseline deterministic: default template stored, no rename pending
|
|
let origTemplate = Zotero.SyncedSettings.get(userLibraryID, 'attachmentRenameTemplate');
|
|
let origDone = Zotero.Prefs.get('autoRenameFiles.done');
|
|
await Zotero.SyncedSettings.clear(userLibraryID, 'attachmentRenameTemplate');
|
|
Zotero.Prefs.set('autoRenameFiles.done', true);
|
|
|
|
let sandbox = sinon.createSandbox();
|
|
let stubs = setupStubs(sandbox, 0);
|
|
let origClose = win.close;
|
|
let closeSpy = sinon.spy();
|
|
win.close = closeSpy;
|
|
try {
|
|
let dialog = makeDirty(userLibraryID, INVALID_TEMPLATE);
|
|
resetStubHistory(stubs);
|
|
let event = { preventDefault: sinon.spy() };
|
|
await dialog.handleWindowClose(event);
|
|
assert.isString(stubs.confirm.firstCall.args[0].title, 'prompt title must resolve to a string');
|
|
assert.isString(stubs.confirm.firstCall.args[0].text, 'prompt body must resolve to a string');
|
|
assert.equal(stubs.confirm.callCount, 1, 'no rename prompt when the reset lands back on the baseline');
|
|
let { sets, clears } = templateWrites(stubs);
|
|
assert.lengthOf(sets, 0, 'template must not be synced on reset');
|
|
assert.lengthOf(clears, 1, 'template setting must be cleared');
|
|
assert.equal(clears[0].args[0], userLibraryID);
|
|
// The reset lands back on the baseline, so no rename is pending
|
|
assert.isTrue(
|
|
stubs.prefSet.calledWith('autoRenameFiles.done', true),
|
|
'done pref must be recomputed against the baseline, not hard-set to false'
|
|
);
|
|
assert.isTrue(closeSpy.called, 'window must close after reset');
|
|
}
|
|
finally {
|
|
win.close = origClose;
|
|
sandbox.restore();
|
|
if (origTemplate) {
|
|
await Zotero.SyncedSettings.set(userLibraryID, 'attachmentRenameTemplate', origTemplate);
|
|
}
|
|
Zotero.Prefs.set('autoRenameFiles.done', origDone);
|
|
restoreDialog();
|
|
}
|
|
});
|
|
|
|
it("should prompt when an invalid template blurs directly to Done", async function () {
|
|
let sandbox = sinon.createSandbox();
|
|
let stubs = setupStubs(sandbox, 1);
|
|
let origClose = win.close;
|
|
let closeSpy = sinon.spy();
|
|
win.close = closeSpy;
|
|
try {
|
|
let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE);
|
|
resetStubHistory(stubs);
|
|
dialog.settingsEl.handleTemplateBlur();
|
|
await dialog._handleDoneClick();
|
|
assert.isFalse(closeSpy.called, 'window must stay open on cancel');
|
|
assert.equal(stubs.confirm.callCount, 1, 'only the invalid-template prompt is shown');
|
|
let { sets, clears } = templateWrites(stubs);
|
|
assert.lengthOf(sets, 0);
|
|
assert.lengthOf(clears, 0);
|
|
}
|
|
finally {
|
|
win.close = origClose;
|
|
sandbox.restore();
|
|
restoreDialog();
|
|
}
|
|
});
|
|
|
|
it("should stay on the current library when switching libraries and the user cancels", async function () {
|
|
let sandbox = sinon.createSandbox();
|
|
let stubs = setupStubs(sandbox, 1);
|
|
try {
|
|
let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE);
|
|
resetStubHistory(stubs);
|
|
dialog.libraryPicker.value = String(Zotero.Libraries.userLibraryID);
|
|
await dialog.handleLibraryChange();
|
|
assert.equal(dialog.libraryPicker.value, String(group.libraryID), 'picker must revert to the previous library');
|
|
assert.equal(dialog._currentLibraryID, group.libraryID, 'current library must not change');
|
|
let { sets, clears } = templateWrites(stubs);
|
|
assert.lengthOf(sets, 0);
|
|
assert.lengthOf(clears, 0);
|
|
}
|
|
finally {
|
|
sandbox.restore();
|
|
restoreDialog();
|
|
}
|
|
});
|
|
|
|
it("should reset the previous library's template and switch libraries on reset", async function () {
|
|
let sandbox = sinon.createSandbox();
|
|
let stubs = setupStubs(sandbox, 0);
|
|
try {
|
|
let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE);
|
|
resetStubHistory(stubs);
|
|
dialog.libraryPicker.value = String(Zotero.Libraries.userLibraryID);
|
|
await dialog.handleLibraryChange();
|
|
let { clears } = templateWrites(stubs);
|
|
assert.lengthOf(clears, 1, 'previous library template setting must be cleared');
|
|
assert.equal(clears[0].args[0], group.libraryID);
|
|
assert.equal(dialog._currentLibraryID, Zotero.Libraries.userLibraryID, 'switch must proceed after reset');
|
|
assert.equal(dialog.libraryPicker.value, String(Zotero.Libraries.userLibraryID));
|
|
}
|
|
finally {
|
|
sandbox.restore();
|
|
restoreDialog();
|
|
}
|
|
});
|
|
|
|
it("should prompt to rename files after resetting the template on Done and skip the preview when declined", async function () {
|
|
await Zotero.SyncedSettings.set(group.libraryID, 'attachmentRenameTemplate', '{{ title }}-baseline');
|
|
let sandbox = sinon.createSandbox();
|
|
let stubs = setupStubs(sandbox, 0);
|
|
stubs.confirm.onSecondCall().returns(1);
|
|
let dialog = win.FileRenamingDialog;
|
|
let previewStub = sandbox.stub(dialog, '_openRenameFilesPreview').returns(true);
|
|
let origClose = win.close;
|
|
let closeSpy = sinon.spy();
|
|
win.close = closeSpy;
|
|
try {
|
|
makeDirty(group.libraryID, INVALID_TEMPLATE);
|
|
resetStubHistory(stubs);
|
|
await dialog._handleDoneClick();
|
|
assert.equal(stubs.confirm.callCount, 2, 'reset prompt and rename prompt must both be shown');
|
|
let { clears } = templateWrites(stubs);
|
|
assert.lengthOf(clears, 1, 'template setting must be cleared');
|
|
assert.isFalse(previewStub.called, 'declining the rename must not open the preview');
|
|
assert.isTrue(closeSpy.called, 'window must close after the prompts');
|
|
}
|
|
finally {
|
|
win.close = origClose;
|
|
sandbox.restore();
|
|
await Zotero.SyncedSettings.clear(group.libraryID, 'attachmentRenameTemplate');
|
|
restoreDialog();
|
|
}
|
|
});
|
|
|
|
it("should prompt to rename files after resetting the template when switching libraries", async function () {
|
|
await Zotero.SyncedSettings.set(group.libraryID, 'attachmentRenameTemplate', '{{ title }}-baseline');
|
|
let sandbox = sinon.createSandbox();
|
|
let stubs = setupStubs(sandbox, 0);
|
|
let dialog = win.FileRenamingDialog;
|
|
let previewStub = sandbox.stub(dialog, '_openRenameFilesPreview').returns(true);
|
|
try {
|
|
makeDirty(group.libraryID, INVALID_TEMPLATE);
|
|
resetStubHistory(stubs);
|
|
dialog.libraryPicker.value = String(Zotero.Libraries.userLibraryID);
|
|
await dialog.handleLibraryChange();
|
|
assert.equal(stubs.confirm.callCount, 2, 'reset prompt and rename prompt must both be shown');
|
|
assert.isTrue(previewStub.calledOnceWith(group.libraryID), 'rename preview must open for the previous library');
|
|
assert.equal(dialog._currentLibraryID, Zotero.Libraries.userLibraryID, 'switch must proceed after the prompts');
|
|
assert.equal(dialog.libraryPicker.value, String(Zotero.Libraries.userLibraryID));
|
|
}
|
|
finally {
|
|
sandbox.restore();
|
|
await Zotero.SyncedSettings.clear(group.libraryID, 'attachmentRenameTemplate');
|
|
restoreDialog();
|
|
}
|
|
});
|
|
|
|
it("should only prompt when settings changed and the template is invalid", function () {
|
|
let sandbox = sinon.createSandbox();
|
|
setupStubs(sandbox, 1);
|
|
try {
|
|
let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE);
|
|
assert.isTrue(dialog._shouldPromptInvalidTemplate(), 'changed + invalid must prompt');
|
|
|
|
makeDirty(group.libraryID, '{{ title }}{{ year }}');
|
|
assert.isFalse(dialog._shouldPromptInvalidTemplate(), 'changed + valid must not prompt');
|
|
|
|
dialog.loadSettingsForLibrary(group.libraryID);
|
|
dialog.settingsEl.formatTemplate = INVALID_TEMPLATE;
|
|
assert.isFalse(dialog._shouldPromptInvalidTemplate(), 'unchanged + invalid must not prompt');
|
|
}
|
|
finally {
|
|
sandbox.restore();
|
|
restoreDialog();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("template preview", function () {
|
|
it("should keep the last valid preview while typing and show an error on blur", function () {
|
|
let dialog = win.FileRenamingDialog;
|
|
let settingsEl = dialog.settingsEl;
|
|
let preview = settingsEl.querySelector('#file-renaming-format-preview');
|
|
let libraryID = dialog._currentLibraryID;
|
|
let sandbox = sinon.createSandbox();
|
|
sandbox.stub(Zotero.SyncedSettings, 'set').resolves();
|
|
sandbox.stub(Zotero.SyncedSettings, 'clear').resolves();
|
|
sandbox.stub(Zotero.Prefs, 'set');
|
|
try {
|
|
settingsEl.formatTemplate = '{{ title }}';
|
|
settingsEl.updatePreview();
|
|
let firstValidPreview = preview.innerText;
|
|
|
|
settingsEl.formatTemplate = '{{ title';
|
|
settingsEl.handleTemplateInput();
|
|
assert.equal(preview.innerText, firstValidPreview, 'last valid preview retained');
|
|
assert.isFalse(settingsEl.previewSection.classList.contains('is-hidden'), 'preview remains visible');
|
|
assert.isTrue(settingsEl.invalidMessage.classList.contains('is-hidden'), 'error remains hidden');
|
|
|
|
settingsEl.formatTemplate = '{{ title }}-updated';
|
|
settingsEl.handleTemplateInput();
|
|
let secondValidPreview = preview.innerText;
|
|
assert.notEqual(secondValidPreview, firstValidPreview, 'preview updates when valid again');
|
|
|
|
settingsEl.formatTemplate = '{{ title';
|
|
settingsEl.handleTemplateInput();
|
|
assert.equal(preview.innerText, secondValidPreview, 'latest valid preview retained');
|
|
settingsEl.handleTemplateBlur();
|
|
assert.isTrue(settingsEl.previewSection.classList.contains('is-hidden'), 'preview hidden on blur');
|
|
assert.isFalse(settingsEl.invalidMessage.classList.contains('is-hidden'), 'error shown on blur');
|
|
}
|
|
finally {
|
|
sandbox.restore();
|
|
dialog.loadSettingsForLibrary(libraryID);
|
|
}
|
|
});
|
|
|
|
it("should clear a stale syntax-error message when the template is cleared to whitespace", function () {
|
|
let settingsEl = win.FileRenamingDialog.settingsEl;
|
|
// Show the invalid state (updatePreview has no change side effects)
|
|
settingsEl.formatTemplate = '{{ title';
|
|
settingsEl.updatePreview();
|
|
assert.isFalse(settingsEl.invalidMessage.classList.contains('is-hidden'), 'error shown while invalid');
|
|
assert.isTrue(settingsEl.previewSection.classList.contains('is-hidden'), 'preview hidden while invalid');
|
|
// Deleting down to whitespace must clear the stale error and restore the preview
|
|
settingsEl.formatTemplate = ' ';
|
|
settingsEl.handleTemplateInput();
|
|
assert.isTrue(settingsEl.invalidMessage.classList.contains('is-hidden'), 'error cleared for whitespace');
|
|
assert.isFalse(settingsEl.previewSection.classList.contains('is-hidden'), 'preview restored for whitespace');
|
|
});
|
|
|
|
it("should validate the template as the engine renders it, ignoring newlines inside a tag", function () {
|
|
let settingsEl = win.FileRenamingDialog.settingsEl;
|
|
// The engine strips newlines before validating/rendering, so the dialog must judge the
|
|
// same normalized string -- a newline splitting `endif` here must not read as invalid
|
|
settingsEl.formatTemplate = '{{if title}}a{{end\nif}}';
|
|
assert.isTrue(settingsEl.templateValid, 'newline inside a tag must not read as invalid');
|
|
});
|
|
|
|
it("should validate the template at most once per keystroke", function () {
|
|
let settingsEl = win.FileRenamingDialog.settingsEl;
|
|
settingsEl.formatTemplate = '{{ title }}{{ year }}';
|
|
let sandbox = sinon.createSandbox();
|
|
// Stub persistence so the change handler runs without touching settings
|
|
sandbox.stub(Zotero.SyncedSettings, 'set').resolves();
|
|
sandbox.stub(Zotero.SyncedSettings, 'clear').resolves();
|
|
sandbox.stub(Zotero.Prefs, 'set');
|
|
let spy = sandbox.spy(settingsEl, '_validateTemplate');
|
|
try {
|
|
settingsEl.handleTemplateInput();
|
|
assert.isAtMost(spy.callCount, 1, 'template should be parsed at most once per keystroke');
|
|
}
|
|
finally {
|
|
sandbox.restore();
|
|
}
|
|
});
|
|
});
|
|
});
|