zotero/test/tests/zoteroPaneTest.js
Dan Stillman a0ce7b3256
Show recently used collections in the Add to Collection menu (#6036)
Collections are now tracked as they're selected, added to, or dropped
on, and the five most recent usable targets are listed above the
full collection hierarchy by full path.
2026-09-01 21:59:20 -04:00

2689 lines
104 KiB
JavaScript

"use strict";
describe("ZoteroPane", function () {
var win, doc, zp, userLibraryID;
// Load Zotero pane and select library
before(function* () {
win = yield loadZoteroPane();
doc = win.document;
zp = win.ZoteroPane;
userLibraryID = Zotero.Libraries.userLibraryID;
});
after(function () {
win.close();
});
describe("#_setHighlightedRowsCallback()", function () {
it("should highlight containing collection of selected item on Ctrl/Option", async function () {
var collection = await createDataObject('collection');
var item = await createDataObject('item', { collections: [collection.id] });
await selectLibrary(win);
await zp.itemsView.selectItem(item.id);
var itemTree = doc.getElementById("item-tree-main-default");
itemTree.focus();
assert.equal(doc.activeElement.id, "item-tree-main-default");
var key = Zotero.isMac ? "Alt" : "Control";
itemTree.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
// The handler sets a 225ms timer before applying highlights
await waitForCallback(() => doc.querySelectorAll('.highlighted').length > 0, 50, 5);
var rows = doc.querySelectorAll('.highlighted');
assert.lengthOf(rows, 1);
await zp.collectionsView.setHighlightedRows();
});
it("should highlight parent collection of collection in trash", async function () {
var collection1 = await createDataObject('collection');
var collection2 = await createDataObject('collection', { parentID: collection1.id, deleted: true });
await selectTrash(win);
var row = zp.itemsView.getRowIndexByID(collection2.treeViewID);
zp.itemsView.selection.select(row);
var spy = sinon.spy(zp.collectionsView, 'setHighlightedRows');
await zp._setHighlightedRowsCallback();
assert.sameMembers(spy.getCall(0).args[0], [collection1.treeViewID]);
var rows = win.document.querySelectorAll('.highlighted');
assert.lengthOf(rows, 1);
await zp.collectionsView.setHighlightedRows();
spy.restore();
// Switch back to library to avoid breaking other tests
await selectLibrary(win);
});
});
describe("#newItem", function () {
it("should create an item and focus the title field", async function () {
await zp.newItem(Zotero.ItemTypes.getID('book'), {}, null, true);
assert.equal(doc.activeElement.closest("editable-text").id, "itembox-field-value-title");
doc.activeElement.blur();
await Zotero.Promise.delay(1);
})
it("should save an entered value when New Item is used", async function () {
var value = "Test";
var item = await zp.newItem(Zotero.ItemTypes.getID('book'), {}, null, true);
let header = doc.getElementById('zotero-item-pane-header');
let title = header.querySelector("editable-text");
title.value = value;
await header.save();
item = await Zotero.Items.getAsync(item.id);
assert.equal(item.getField('title'), value);
})
});
describe("#newNote()", function () {
it("should create a child note and select it", async function () {
var item = await createDataObject('item');
var noteID = await zp.newNote(false, item.key, "Test");
var selected = zp.itemsView.getSelectedItems(true);
assert.lengthOf(selected, 1);
assert.equal(selected, noteID);
})
it("should create a standalone note within a collection and select it", async function () {
var collection = await createDataObject('collection');
await select(win, collection);
var noteID = await zp.newNote(false, false, "Test");
assert.equal(zp.collectionsView.getSelectedCollections()[0], collection);
var selected = zp.itemsView.getSelectedItems(true);
assert.lengthOf(selected, 1);
assert.equal(selected, noteID);
})
})
describe("#newCollection()", function () {
it("should create a collection", async function () {
var promise = waitForDialog(
null,
'accept',
'chrome://zotero/content/newCollectionDialog.xhtml'
);
var id = await zp.newCollection();
await promise;
var collection = Zotero.Collections.get(id);
assert.isTrue(collection.name.startsWith(Zotero.getString('pane.collections.untitled')));
});
});
describe("Advanced Search", function () {
it("should create a saved search", async function () {
await selectLibrary(win);
await zp.toggleAdvancedSearchState('open');
var deck = doc.getElementById('zotero-advanced-search-pane-deck');
var searchIDs = (await Zotero.Searches.getAll(userLibraryID)).map(s => s.id);
// Saving prompts for a name; accept the default
var promptService = Services.prompt;
Services.prompt = { prompt: () => true };
try {
await deck.pane.save();
}
finally {
Services.prompt = promptService;
}
var newSearches = (await Zotero.Searches.getAll(userLibraryID))
.filter(s => !searchIDs.includes(s.id));
assert.lengthOf(newSearches, 1);
assert.isTrue(newSearches[0].name.startsWith(Zotero.getString('pane.collections.untitled')));
assert.equal(deck.state, 'closed');
await newSearches[0].eraseTx();
});
});
describe("#itemSelected()", function () {
it.skip("should update the item count", function* () {
var collection = new Zotero.Collection;
collection.name = "Count Test";
var id = yield collection.saveTx();
yield waitForItemsLoad(win);
// Unselected, with no items in view
assert.equal(
doc.getElementById('zotero-item-pane-message-box').textContent,
yield doc.l10n.formatValue('item-pane-message-unselected', { count: 0 })
);
// Unselected, with one item in view
var item = new Zotero.Item('newspaperArticle');
item.setCollections([id]);
var itemID1 = yield item.saveTx({
skipSelect: true
});
assert.equal(
doc.getElementById('zotero-item-pane-message-box').textContent,
yield doc.l10n.formatValue('item-pane-message-unselected', { count: 1 })
);
// Unselected, with multiple items in view
var item = new Zotero.Item('audioRecording');
item.setCollections([id]);
var itemID2 = yield item.saveTx({
skipSelect: true
});
assert.equal(
doc.getElementById('zotero-item-pane-message-box').textContent,
yield doc.l10n.formatValue('item-pane-message-unselected', { count: 2 })
);
// Multiple items selected
var promise = zp.itemsView._getItemSelectedPromise();
zp.itemsView.rememberSelection([itemID1, itemID2]);
yield promise;
assert.equal(
doc.getElementById('zotero-item-pane-message-box').textContent,
Zotero.getString('pane.item.selected.multiple', 2)
);
})
it("should update the item count when filtering with nothing selected", async function () {
var collection = await createDataObject('collection');
await createDataObject('item', { collections: [collection.id], title: 'Perez study' });
await createDataObject('item', { collections: [collection.id], title: 'Unrelated paper' });
await zp.collectionsView.selectByID("C" + collection.id);
await waitForItemsLoad(win);
var messageBox = doc.getElementById('zotero-item-pane-message-box');
var twoInView = await doc.l10n.formatValue('item-pane-message-unselected', { count: 2 });
var oneInView = await doc.l10n.formatValue('item-pane-message-unselected', { count: 1 });
// Nothing selected, both items shown
await waitForCallback(() => messageBox.textContent == twoInView, 100, 5);
// Quick search filters to one item; the count should update even though the
// selection (nothing) hasn't changed
await zp.itemsView.setFilter('search', 'perez');
await waitForCallback(() => messageBox.textContent == oneInView, 100, 5);
await zp.itemsView.setFilter('search', '');
await collection.eraseTx();
})
})
describe("#viewAttachment", function () {
var apiKey = Zotero.Utilities.randomString(24);
var baseURL;
var httpd;
var server;
async function downloadOnDemand() {
var item = new Zotero.Item("attachment");
item.attachmentLinkMode = 'imported_file';
item.attachmentPath = 'storage:test.txt';
// TODO: Test binary data
var text = Zotero.Utilities.randomString();
item.attachmentSyncState = "to_download";
await item.saveTx();
var mtime = "1441252524000";
var md5 = Zotero.Utilities.Internal.md5(text)
var s3Path = `pretend-s3/${item.key}`;
server.respondWith(function (req) {
if (req.method == "GET"
&& req.url == baseURL + `users/1/items/${item.key}/file`) {
req.respond(302, {
"Zotero-File-Modification-Time": mtime,
"Zotero-File-MD5": md5,
"Zotero-File-Compressed": "No",
"Location": baseURL + s3Path,
}, "");
}
});
httpd.registerPathHandler(
"/" + s3Path,
{
handle: function (request, response) {
response.setStatusLine(null, 200, "OK");
response.write(text);
}
}
);
// Disable loadURI() so viewAttachment() doesn't trigger translator loading
var stub = sinon.stub(Zotero, "launchFile");
await zp.viewAttachment(item.id);
assert.ok(stub.calledOnce);
assert.ok(stub.calledWith(item.getFilePath()));
stub.restore();
assert.equal(await item.attachmentHash, md5);
assert.equal(await item.attachmentModificationTime, mtime);
var path = await item.getFilePathAsync();
assert.equal(await Zotero.File.getContentsAsync(path), text);
};
before(function () {
Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
})
beforeEach(async function () {
var port;
({ httpd, port } = await startHTTPServer());
baseURL = `http://localhost:${port}/`;
Zotero.Prefs.set("api.url", baseURL);
server = sinon.fakeServer.create();
server.autoRespond = true;
Zotero.Sync.Runner.apiKey = apiKey;
await Zotero.Users.setCurrentUserID(1);
await Zotero.Users.setCurrentUsername("testuser");
})
afterEach(function* () {
server.restore();
var defer = Zotero.Promise.defer();
httpd.stop(() => defer.resolve());
yield defer.promise;
})
after(function () {
Zotero.HTTP.mock = null;
});
it("should download an attachment on-demand in as-needed mode", async function () {
Zotero.Sync.Storage.Local.downloadAsNeeded(Zotero.Libraries.userLibraryID, true);
await downloadOnDemand();
});
// As noted in viewAttachment(), this is only necessary for files modified before 5.0.85
it("should re-download a remotely modified attachment in as-needed mode", async function () {
Zotero.Sync.Storage.Local.downloadAsNeeded(Zotero.Libraries.userLibraryID, true);
var item = await importFileAttachment('test.txt');
item.attachmentSyncState = "to_download";
await item.saveTx();
var text = Zotero.Utilities.randomString();
var mtime = "1441252524000";
var md5 = Zotero.Utilities.Internal.md5(text)
var s3Path = `pretend-s3/${item.key}`;
server.respondWith(function (req) {
if (req.method == "GET"
&& req.url == baseURL + `users/1/items/${item.key}/file`) {
req.respond(302, {
"Zotero-File-Modification-Time": mtime,
"Zotero-File-MD5": md5,
"Zotero-File-Compressed": "No",
"Location": baseURL + s3Path,
}, "");
}
});
httpd.registerPathHandler(
"/" + s3Path,
{
handle: function (request, response) {
response.setStatusLine(null, 200, "OK");
response.write(text);
}
}
);
// Disable loadURI() so viewAttachment() doesn't trigger translator loading
var downloadSpy = sinon.spy(Zotero.Sync.Runner, "downloadFile");
var launchFileStub = sinon.stub(Zotero, "launchFile");
await zp.viewAttachment(item.id);
assert.ok(downloadSpy.calledOnce);
assert.ok(launchFileStub.calledOnce);
assert.ok(launchFileStub.calledWith(item.getFilePath()));
downloadSpy.restore();
launchFileStub.restore();
assert.equal(await item.attachmentHash, md5);
assert.equal(await item.attachmentModificationTime, mtime);
var path = await item.getFilePathAsync();
assert.equal(await Zotero.File.getContentsAsync(path), text);
});
it("should handle a 404 when re-downloading a remotely modified attachment in as-needed mode", async function () {
Zotero.Sync.Storage.Local.downloadAsNeeded(Zotero.Libraries.userLibraryID, true);
var item = await importFileAttachment('test.txt');
item.attachmentSyncState = "to_download";
await item.saveTx();
var mtime = await item.attachmentModificationTime;
var md5 = await item.attachmentHash;
var text = await Zotero.File.getContentsAsync(item.getFilePath());
httpd.registerPathHandler(
`/users/1/items/${item.key}/file`,
{
handle: function (request, response) {
response.setStatusLine(null, 404, "Not Found");
}
}
);
// Disable loadURI() so viewAttachment() doesn't trigger translator loading
var downloadSpy = sinon.spy(Zotero.Sync.Runner, "downloadFile");
var launchFileStub = sinon.stub(Zotero, "launchFile");
await zp.viewAttachment(item.id);
assert.ok(downloadSpy.calledOnce);
assert.ok(launchFileStub.calledOnce);
assert.ok(launchFileStub.calledWith(item.getFilePath()));
downloadSpy.restore();
launchFileStub.restore();
// File shouldn't have changed
assert.equal(await item.attachmentModificationTime, mtime);
assert.equal(await item.attachmentHash, md5);
var path = await item.getFilePathAsync();
assert.equal(await Zotero.File.getContentsAsync(path), text);
});
it("should download an attachment on-demand in at-sync-time mode", async function () {
Zotero.Sync.Storage.Local.downloadOnSync(Zotero.Libraries.userLibraryID, true);
await downloadOnDemand();
});
it("should update a PDF with a blank MIME type", async function () {
let attachment = await importFileAttachment('test.pdf');
// Can't use contentType argument to importFileAttachment() because blank string is ignored
attachment.attachmentContentType = '';
await attachment.saveTx();
await zp.viewAttachment(attachment.id);
assert.equal(attachment.attachmentContentType, 'application/pdf');
});
it("should update an EPUB with an 'application/epub' MIME type", async function () {
let attachment = await importFileAttachment('stub.epub', { contentType: 'application/epub' });
assert.equal(attachment.attachmentContentType, 'application/epub');
await zp.viewAttachment(attachment.id);
assert.equal(attachment.attachmentContentType, 'application/epub+zip');
});
it("should update an EPUB with an 'application/octet-stream' MIME type", async function () {
let attachment = await importFileAttachment('stub.epub', { contentType: 'application/octet-stream' });
assert.equal(attachment.attachmentContentType, 'application/octet-stream');
await zp.viewAttachment(attachment.id);
assert.equal(attachment.attachmentContentType, 'application/epub+zip');
});
it("should handle Windows paths on macOS/Linux", async function () {
if (!Zotero.isMac && !Zotero.isLinux) {
this.skip();
return;
}
let file = getTestDataDirectory();
file.append('test.pdf');
let attachment = await Zotero.Attachments.linkFromFile({ file });
attachment.attachmentPath = 'C:\\some\\windows\\path';
await attachment.saveTx();
let stub = sinon.stub(zp, 'showAttachmentNotFoundDialog');
await zp.viewAttachment(attachment.id);
assert.ok(stub.calledOnce);
assert.ok(stub.calledWith(attachment));
stub.restore();
});
})
describe("#addNoteFromAnnotationsFromSelected()", function () {
it("should create a single note within a selected regular item for all child attachments", async function () {
var item = await createDataObject('item');
var attachment1 = await importPDFAttachment(item);
var attachment2 = await importPDFAttachment(item);
var annotation1 = await createAnnotation('highlight', attachment1);
var annotation2 = await createAnnotation('highlight', attachment1);
var annotation3 = await createAnnotation('highlight', attachment2);
var annotation4 = await createAnnotation('highlight', attachment2);
await zp.selectItems([item.id]);
await zp.addNoteFromAnnotationsFromSelected();
var newItems = zp.getSelectedItems();
assert.lengthOf(newItems, 1);
var note = newItems[0];
assert.equal(note.itemType, 'note');
assert.equal(note.parentID, item.id);
var dp = new DOMParser();
var doc = dp.parseFromString(note.getNote(), 'text/html');
assert.sameMembers(
[...doc.querySelectorAll('h3')].map(x => x.textContent),
[attachment1.getField('title'), attachment2.getField('title')]
);
assert.lengthOf([...doc.querySelectorAll('h3 + p')], 2);
assert.lengthOf([...doc.querySelectorAll('span.highlight')], 4);
});
it("should create a single note within the parent for all selected sibling attachments", async function () {
var item = await createDataObject('item');
var attachment1 = await importPDFAttachment(item);
var attachment2 = await importPDFAttachment(item);
var annotation1 = await createAnnotation('highlight', attachment1);
var annotation2 = await createAnnotation('highlight', attachment1);
var annotation3 = await createAnnotation('highlight', attachment2);
var annotation4 = await createAnnotation('highlight', attachment2);
await zp.selectItems([attachment1.id, attachment2.id]);
await zp.addNoteFromAnnotationsFromSelected();
var newItems = zp.getSelectedItems();
assert.lengthOf(newItems, 1);
var note = newItems[0];
assert.equal(note.parentID, item.id);
var dp = new DOMParser();
var doc = dp.parseFromString(note.getNote(), 'text/html');
assert.sameMembers(
[...doc.querySelectorAll('h3')].map(x => x.textContent),
[attachment1.getField('title'), attachment2.getField('title')]
);
// No item titles
assert.lengthOf([...doc.querySelectorAll('h2 + p')], 0);
// Just attachment titles
assert.lengthOf([...doc.querySelectorAll('h3 + p')], 2);
assert.lengthOf([...doc.querySelectorAll('span.highlight')], 4);
});
it("should ignore top-level item if child attachment is also selected", async function () {
var item = await createDataObject('item');
var attachment1 = await importPDFAttachment(item);
var attachment2 = await importPDFAttachment(item);
await createAnnotation('highlight', attachment1);
await createAnnotation('highlight', attachment1);
await createAnnotation('highlight', attachment2);
await zp.selectItems([item.id, attachment1.id]);
await zp.addNoteFromAnnotationsFromSelected();
var newItems = zp.getSelectedItems();
assert.lengthOf(newItems, 1);
var note = newItems[0];
var dp = new DOMParser();
var doc = dp.parseFromString(note.getNote(), 'text/html');
// No titles
assert.lengthOf([...doc.querySelectorAll('h2 + p')], 0);
assert.lengthOf([...doc.querySelectorAll('h3 + p')], 0);
assert.lengthOf([...doc.querySelectorAll('span.highlight')], 2);
});
it("shouldn't do anything if parent item and child note is selected", async function () {
var item = await createDataObject('item');
var attachment = await importPDFAttachment(item);
var note = await createDataObject('item', { itemType: 'note', parentID: item.id });
await createAnnotation('highlight', attachment);
await zp.selectItems([item.id, note.id]);
await zp.addNoteFromAnnotationsFromSelected();
var selectedItems = zp.getSelectedItems();
assert.lengthOf(selectedItems, 2);
assert.sameMembers(selectedItems, [item, note]);
});
});
describe("#createStandaloneNoteFromAnnotationsFromSelected()", function () {
it("should create a single standalone note for all child attachments of selected regular items", async function () {
var collection = await createDataObject('collection');
await select(win, collection);
var item1 = await createDataObject('item', { setTitle: true, collections: [collection.id] });
var item2 = await createDataObject('item', { setTitle: true, collections: [collection.id] });
var attachment1 = await importPDFAttachment(item1);
var attachment2 = await importPDFAttachment(item1);
var attachment3 = await importPDFAttachment(item2);
var attachment4 = await importPDFAttachment(item2);
await createAnnotation('highlight', attachment1);
await createAnnotation('highlight', attachment1);
await createAnnotation('highlight', attachment2);
await createAnnotation('highlight', attachment2);
await createAnnotation('highlight', attachment3);
await createAnnotation('highlight', attachment3);
await createAnnotation('highlight', attachment4);
await createAnnotation('highlight', attachment4);
await zp.selectItems([item1.id, item2.id]);
await zp.createStandaloneNoteFromAnnotationsFromSelected();
var newItems = zp.getSelectedItems();
assert.lengthOf(newItems, 1);
var note = newItems[0];
assert.equal(note.itemType, 'note');
assert.isFalse(note.parentID);
assert.isTrue(collection.hasItem(note));
var dp = new DOMParser();
var doc = dp.parseFromString(note.getNote(), 'text/html');
assert.sameMembers(
[...doc.querySelectorAll('h2')].map(x => x.textContent),
[item1.getDisplayTitle(), item2.getDisplayTitle()]
);
assert.sameMembers(
[...doc.querySelectorAll('h3')].map(x => x.textContent),
[
attachment1.getField('title'),
attachment2.getField('title'),
attachment3.getField('title'),
attachment4.getField('title')
]
);
assert.lengthOf([...doc.querySelectorAll('h3 + p')], 4);
assert.lengthOf([...doc.querySelectorAll('span.highlight')], 8);
});
it("should create a single standalone note for all selected attachments", async function () {
var collection = await createDataObject('collection');
await select(win, collection);
var item1 = await createDataObject('item', { setTitle: true, collections: [collection.id] });
var item2 = await createDataObject('item', { setTitle: true, collections: [collection.id] });
var attachment1 = await importPDFAttachment(item1);
var attachment2 = await importPDFAttachment(item1);
var attachment3 = await importPDFAttachment(item2);
var attachment4 = await importPDFAttachment(item2);
await createAnnotation('highlight', attachment1);
await createAnnotation('highlight', attachment1);
await createAnnotation('highlight', attachment2);
await createAnnotation('highlight', attachment2);
await createAnnotation('highlight', attachment3);
await createAnnotation('highlight', attachment3);
await createAnnotation('highlight', attachment4);
await createAnnotation('highlight', attachment4);
await zp.selectItems([attachment1.id, attachment3.id]);
await zp.createStandaloneNoteFromAnnotationsFromSelected();
var newItems = zp.getSelectedItems();
assert.lengthOf(newItems, 1);
var note = newItems[0];
assert.isFalse(note.parentID);
assert.isTrue(collection.hasItem(note));
var dp = new DOMParser();
var doc = dp.parseFromString(note.getNote(), 'text/html');
assert.sameMembers(
[...doc.querySelectorAll('h2')].map(x => x.textContent),
[item1.getDisplayTitle(), item2.getDisplayTitle()]
);
assert.lengthOf([...doc.querySelectorAll('h2 + p')], 2);
assert.lengthOf([...doc.querySelectorAll('h3')], 0);
assert.lengthOf([...doc.querySelectorAll('span.highlight')], 4);
});
it("should ignore top-level item if child attachment is also selected", async function () {
var item1 = await createDataObject('item', { setTitle: true });
var item2 = await createDataObject('item', { setTitle: true });
var attachment1 = await importPDFAttachment(item1);
var attachment2 = await importPDFAttachment(item1);
var attachment3 = await importPDFAttachment(item2);
var attachment4 = await importPDFAttachment(item2);
await createAnnotation('highlight', attachment1);
await createAnnotation('highlight', attachment1);
await createAnnotation('highlight', attachment2);
await createAnnotation('highlight', attachment2);
await createAnnotation('highlight', attachment3);
await createAnnotation('highlight', attachment3);
await createAnnotation('highlight', attachment4);
await createAnnotation('highlight', attachment4);
await zp.selectItems([item1.id, attachment1.id, attachment3.id]);
await zp.createStandaloneNoteFromAnnotationsFromSelected();
var newItems = zp.getSelectedItems();
assert.lengthOf(newItems, 1);
var note = newItems[0];
var dp = new DOMParser();
var doc = dp.parseFromString(note.getNote(), 'text/html');
assert.sameMembers(
[...doc.querySelectorAll('h2')].map(x => x.textContent),
[item1.getDisplayTitle(), item2.getDisplayTitle()]
);
assert.lengthOf([...doc.querySelectorAll('h2 + p')], 2);
assert.lengthOf([...doc.querySelectorAll('h3')], 0);
assert.lengthOf([...doc.querySelectorAll('span.highlight')], 4);
});
});
describe("#duplicateSelectedItem()", function () {
it("should add reverse relations", async function () {
await selectLibrary(win);
var item1 = await createDataObject('item');
var item2 = await createDataObject('item');
item1.addRelatedItem(item2);
await item1.saveTx();
item2.addRelatedItem(item1);
await item2.saveTx();
var item3 = await zp.duplicateSelectedItem();
assert.sameMembers(item3.relatedItems, [item1.key]);
assert.sameMembers(item2.relatedItems, [item1.key]);
assert.sameMembers(item1.relatedItems, [item2.key, item3.key]);
});
});
describe("#duplicateAndConvertSelectedItem()", function () {
describe("book to book section", function () {
it("should not add relations to other book sections for the same book", async function () {
await selectLibrary(win);
var bookItem = await createDataObject('item', { itemType: 'book', title: "Book Title" });
// Relate book to another book section with a different title
var otherBookSection = createUnsavedDataObject('item', { itemType: 'bookSection', setTitle: true })
otherBookSection.setField('bookTitle', "Another Book Title");
await otherBookSection.saveTx();
bookItem.addRelatedItem(otherBookSection);
await bookItem.saveTx();
otherBookSection.addRelatedItem(bookItem);
await otherBookSection.saveTx();
await zp.selectItem(bookItem.id);
var bookSectionItem1 = await zp.duplicateAndConvertSelectedItem();
await zp.selectItem(bookItem.id);
var bookSectionItem2 = await zp.duplicateAndConvertSelectedItem();
// Book sections should only be related to parent
assert.sameMembers(bookSectionItem1.relatedItems, [bookItem.key, otherBookSection.key]);
assert.sameMembers(bookSectionItem2.relatedItems, [bookItem.key, otherBookSection.key]);
});
});
it("should not copy abstracts", async function () {
await selectLibrary(win);
var bookItem = await createDataObject('item', { itemType: 'book', title: "Book Title" });
bookItem.setField('abstractNote', 'An abstract');
bookItem.saveTx();
var bookSectionItem = await zp.duplicateAndConvertSelectedItem();
assert.isEmpty(bookSectionItem.getField('abstractNote'));
});
it("should not copy DOIs", async function () {
await selectLibrary(win);
var bookItem = await createDataObject('item', { itemType: 'book', title: "Book Title" });
bookItem.setField('DOI', '10.1515/9783111453750');
bookItem.saveTx();
var bookSectionItem = await zp.duplicateAndConvertSelectedItem();
assert.isEmpty(bookSectionItem.getField('DOI'));
bookSectionItem.setField('DOI', '10.1515/9783111453750-003');
await bookSectionItem.saveTx();
bookItem = await zp.duplicateAndConvertSelectedItem();
assert.isEmpty(bookItem.getField('DOI'));
});
});
describe("#deleteSelectedItems()", function () {
afterEach(async function () {
await selectLibrary(win);
});
it("should be called on Delete key", async function () {
var item = await createDataObject('item');
var iv = zp.itemsView;
iv.selectItem(item.id);
var tree = doc.getElementById(iv.id);
tree.focus();
var stub = sinon.stub(zp, 'deleteSelectedItems');
// Unmodified Delete -- force should be false
tree.dispatchEvent(new KeyboardEvent(
"keypress",
{
key: 'Delete',
code: 'Delete',
keyCode: 46,
bubbles: true,
cancelable: true,
}
));
assert.isTrue(stub.calledOnce);
assert.isFalse(stub.firstCall.args[0]);
stub.resetHistory();
// Modified Delete -- force should be true
tree.dispatchEvent(new KeyboardEvent(
"keypress",
{
key: 'Delete',
code: 'Delete',
keyCode: 46,
bubbles: true,
cancelable: true,
metaKey: Zotero.isMac,
shiftKey: !Zotero.isMac,
}
));
assert.isTrue(stub.calledOnce);
assert.isTrue(stub.firstCall.args[0]);
stub.restore();
});
it("should remove an item from My Publications", async function () {
var item = createUnsavedDataObject('item');
item.inPublications = true;
await item.saveTx();
await zp.collectionsView.selectByID("P" + userLibraryID);
await waitForItemsLoad(win);
var iv = zp.itemsView;
var selected = iv.selectItem(item.id);
assert.ok(selected);
var promise = waitForDialog();
await zp.deleteSelectedItems();
await promise;
assert.isFalse(item.inPublications);
assert.isFalse(item.deleted);
});
it("should move My Publications item to trash with prompt for modified Delete", async function () {
var item = createUnsavedDataObject('item');
item.inPublications = true;
await item.saveTx();
await zp.collectionsView.selectByID("P" + userLibraryID);
await waitForItemsLoad(win);
var iv = zp.itemsView;
var selected = iv.selectItem(item.id);
assert.ok(selected);
var promise = waitForDialog();
await zp.deleteSelectedItems(true);
await promise;
assert.isTrue(item.inPublications);
assert.isTrue(item.deleted);
});
it("should move saved search item to trash with prompt for unmodified Delete", async function () {
var search = await createDataObject('search');
var title = [...Object.values(search.conditions)]
.filter(x => x.condition == 'title' && x.operator == 'contains')[0].value;
var item = await createDataObject('item', { title });
await select(win, search);
var iv = zp.itemsView;
var selected = iv.selectItem(item.id);
assert.ok(selected);
var promise = waitForDialog();
await zp.deleteSelectedItems();
await promise;
assert.isTrue(item.deleted);
});
it("should move saved search item to trash without prompt for modified Delete", async function () {
var search = await createDataObject('search');
var title = [...Object.values(search.conditions)]
.filter(x => x.condition == 'title' && x.operator == 'contains')[0].value;
var item = await createDataObject('item', { title });
await select(win, search);
var iv = zp.itemsView;
var selected = iv.selectItem(item.id);
assert.ok(selected);
await zp.deleteSelectedItems(true);
assert.isTrue(item.deleted);
});
it("should prompt to remove an item from subcollections when recursiveCollections enabled", async function () {
Zotero.Prefs.set('recursiveCollections', true);
let collection1 = await createDataObject('collection');
let collection2 = await createDataObject('collection', { parentID: collection1.id });
let item = await createDataObject('item', { collections: [collection2.id] });
await select(win, collection1);
let iv = zp.itemsView;
assert.ok(await iv.selectItem(item.id));
await Zotero.Promise.delay(100);
let promise = waitForDialog();
let modifyPromise = waitForItemEvent('modify');
await zp.deleteSelectedItems(false);
let dialog = await promise;
await modifyPromise;
assert.include(dialog.document.documentElement.textContent, Zotero.getString('pane.items.removeRecursive'));
assert.isFalse(item.inCollection(collection2.id));
Zotero.Prefs.clear('recursiveCollections');
});
});
describe("#deleteSelectedCollection()", function () {
it("should move collection to trash but not descendant items by default", async function () {
var collection = await createDataObject('collection');
await select(win, collection);
var item = await createDataObject('item', { collections: [collection.id] });
var promise = waitForDialog();
await zp.deleteSelectedCollection();
assert.isTrue(collection.deleted);
assert.isTrue(Zotero.Items.exists(item.id));
assert.isFalse(item.deleted);
});
it("should move to trash collection and descendant items when deleteItems=true", async function () {
var collection = await createDataObject('collection');
await select(win, collection);
var item = await createDataObject('item', { collections: [collection.id] });
var promise = waitForDialog();
await zp.deleteSelectedCollection(true);
assert.isTrue(collection.deleted);
assert.isTrue(Zotero.Items.exists(item.id));
assert.isTrue(item.deleted);
});
});
describe("#emptyTrash()", function () {
it("should clear the undo/redo history", async function () {
// Record an undo entry
Zotero.UndoHistory.clear();
var collection = await createDataObject('collection', { name: 'Original' });
collection.name = 'Renamed';
await collection.saveTx({ undoAction: 'undo-action-rename-collection' });
assert.isTrue(Zotero.UndoHistory.canUndo());
// Put something in the trash to empty
await createDataObject('item', { deleted: true });
await selectTrash(win);
var promise = waitForDialog();
await zp.emptyTrash();
await promise;
assert.isFalse(Zotero.UndoHistory.canUndo());
assert.isFalse(Zotero.UndoHistory.canRedo());
});
});
describe("#setVirtual()", function () {
var cv;
before(function* () {
cv = zp.collectionsView;
});
beforeEach(function () {
Zotero.Prefs.clear('duplicateLibraries');
Zotero.Prefs.clear('unfiledLibraries');
return selectLibrary(win);
})
it("should show a hidden virtual collection in My Library", async function () {
// Create unfiled, duplicate items
var title = Zotero.Utilities.randomString();
var item1 = await createDataObject('item', { title });
var item2 = await createDataObject('item', { title });
// Start hidden (tested in collectionTreeViewTest)
Zotero.Prefs.set('duplicateLibraries', `{"${userLibraryID}": false}`);
Zotero.Prefs.set('unfiledLibraries', `{"${userLibraryID}": false}`);
await cv.refresh();
// Show Duplicate Items
var id = "D" + userLibraryID;
assert.isFalse(cv.getRowIndexByID(id));
await zp.setVirtual(userLibraryID, 'duplicates', true, true);
// Duplicate Items should be selected
assert.equal(zp.getCollectionTreeRows()[0].id, id);
// Should be missing from pref
assert.isUndefined(JSON.parse(Zotero.Prefs.get('duplicateLibraries'))[userLibraryID])
// Clicking should select both items
var row = cv.getRowIndexByID(id);
assert.ok(row);
assert.equal(cv.selection.pivot, row);
await waitForItemsLoad(win);
var iv = zp.itemsView;
row = iv.getRowIndexByID(item1.id);
assert.isNumber(row);
var promise = iv.waitForSelect();
clickOnItemsRow(win, iv, row);
assert.equal(iv.selection.count, 2);
await promise;
// Show Unfiled Items
id = "U" + userLibraryID;
assert.isFalse(cv.getRowIndexByID(id));
await zp.setVirtual(userLibraryID, 'unfiled', true, true);
// Unfiled Items should be selected
assert.equal(zp.getCollectionTreeRows()[0].id, id);
// Should be missing from pref
assert.isUndefined(JSON.parse(Zotero.Prefs.get('unfiledLibraries'))[userLibraryID])
});
it("should expand library if collapsed when showing virtual collection", async function () {
// Start hidden (tested in collectionTreeViewTest)
Zotero.Prefs.set('duplicateLibraries', `{"${userLibraryID}": false}`);
await cv.refresh();
var libraryRow = cv.getRowIndexByID(Zotero.Libraries.userLibrary.treeViewID);
if (cv.isContainerOpen(libraryRow)) {
await cv.toggleOpenState(libraryRow);
cv._saveOpenStates();
}
// Show Duplicate Items
var id = "D" + userLibraryID;
await zp.setVirtual(userLibraryID, 'duplicates', true, true);
// Library should have been expanded and Duplicate Items selected
assert.ok(cv.getRowIndexByID(id));
assert.equal(zp.getCollectionTreeRows()[0].id, id);
});
it("should hide a virtual collection in My Library", async function () {
await cv.refresh();
// Hide Duplicate Items
var id = "D" + userLibraryID;
assert.ok(await cv.selectByID(id));
await zp.setVirtual(userLibraryID, 'duplicates', false);
assert.isFalse(cv.getRowIndexByID(id));
assert.isFalse(JSON.parse(Zotero.Prefs.get('duplicateLibraries'))[userLibraryID])
// Hide Unfiled Items
id = "U" + userLibraryID;
assert.ok(await cv.selectByID(id));
await zp.setVirtual(userLibraryID, 'unfiled', false);
assert.isFalse(cv.getRowIndexByID(id));
assert.isFalse(JSON.parse(Zotero.Prefs.get('unfiledLibraries'))[userLibraryID])
});
it("should hide a virtual collection in a group", async function () {
await cv.refresh();
var group = await createGroup();
var groupRow = cv.getRowIndexByID(group.treeViewID);
var rowCount = cv._rows.length;
// Make sure group is open
if (!cv.isContainerOpen(groupRow)) {
await cv.toggleOpenState(groupRow);
}
// Make sure Duplicate Items is showing
var id = "D" + group.libraryID;
assert.ok(cv.getRowIndexByID(id));
// Hide Duplicate Items
assert.ok(await cv.selectByID(id));
await zp.setVirtual(group.libraryID, 'duplicates', false);
// Row should have been removed
assert.isFalse(cv.getRowIndexByID(id));
// Pref should have been updated
Zotero.debug(Zotero.Prefs.get('duplicateLibraries'));
assert.isFalse(JSON.parse(Zotero.Prefs.get('duplicateLibraries'))[group.libraryID]);
// Group row shouldn't have changed
assert.equal(cv.getRowIndexByID(group.treeViewID), groupRow);
// Group should remain open
assert.isTrue(cv.isContainerOpen(groupRow));
// Row count should be 1 less
assert.equal(cv._rows.length, --rowCount);
// Hide Unfiled Items
id = "U" + group.libraryID;
assert.ok(await cv.selectByID(id));
// Hide Unfiled Items
await zp.setVirtual(group.libraryID, 'unfiled', false);
// Row should have been removed
assert.isFalse(cv.getRowIndexByID(id));
// Pref should have been updated
assert.isFalse(JSON.parse(Zotero.Prefs.get('unfiledLibraries'))[group.libraryID]);
// Group row shouldn't have changed
assert.equal(cv.getRowIndexByID(group.treeViewID), groupRow);
// Group should remain open
assert.isTrue(cv.isContainerOpen(groupRow));
// Row count should be 1 less
assert.equal(cv._rows.length, --rowCount);
});
});
describe("#editSelectedCollection()", function () {
async function editSearchAddCondition(search) {
await select(win, search);
await zp.editSelectedCollection();
var deck = doc.getElementById('zotero-advanced-search-pane-deck');
assert.equal(deck.state, 'open');
assert.equal(deck.selectedSearchType, 'saved');
var pane = deck.pane;
var searchBox = pane.querySelector('zoterosearch');
var c = searchBox.search.getCondition(
searchBox.search.addCondition("title", "contains", "foo")
);
searchBox.rootGroup.addCondition(c);
await pane.save();
}
it("should edit a saved search", async function () {
var search = await createDataObject('search');
await editSearchAddCondition(search);
var conditions = search.getConditions();
assert.lengthOf(Object.keys(conditions), 3);
});
it("should edit a saved search in a group", async function () {
var group = await getGroup();
var search = await createDataObject('search', { libraryID: group.libraryID });
await editSearchAddCondition(search);
var conditions = search.getConditions();
assert.lengthOf(Object.keys(conditions), 3);
});
});
describe("#buildCollectionContextMenu()", function () {
async function selectCollectionRows(ids) {
let cv = zp.collectionsView;
await cv.selectByID(ids[0]);
await waitForItemsLoad(win);
for (let id of ids.slice(1)) {
cv.selection.toggleSelect(cv.getRowIndexByID(id));
}
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
}
afterEach(async function () {
await selectLibrary(win);
});
it("should hide New Subcollection and Rename for a multiple-collection selection", async function () {
let c1 = await createDataObject('collection');
let c2 = await createDataObject('collection');
// A single collection shows both single-target actions
await selectCollectionRows(["C" + c1.id]);
await zp.buildCollectionContextMenu();
assert.isFalse(win.document.getElementById('newSubcollection').hidden);
assert.isFalse(win.document.getElementById('editSelectedCollection').hidden);
// Two collections hides them
await selectCollectionRows(["C" + c1.id, "C" + c2.id]);
await zp.buildCollectionContextMenu();
assert.isTrue(win.document.getElementById('newSubcollection').hidden);
assert.isTrue(win.document.getElementById('editSelectedCollection').hidden);
});
it("should disable the report for a cross-library collection selection", async function () {
let group = await createGroup();
let c1 = await createDataObject('collection');
let c2 = await createDataObject('collection', { libraryID: group.libraryID });
await createDataObject('item', { collections: [c1.id] });
await createDataObject('item', { libraryID: group.libraryID, collections: [c2.id] });
await zp.collectionsView.expandLibrary(group.libraryID);
await selectCollectionRows(["C" + c1.id, "C" + c2.id]);
await zp.buildCollectionContextMenu();
assert.isTrue(win.document.getElementById('loadReport').disabled);
await group.eraseTx();
});
});
describe("#buildAddItemToCollectionMenu()", function () {
var popup;
before(function () {
popup = doc.getElementById('zotero-add-to-collection-popup');
});
beforeEach(async function () {
// Leave the tree on the library root, so that a collection created later in a test
// can't be recorded by the reselection that follows adding a row
await selectLibrary(win);
});
after(async function () {
// Don't leave a multiple selection behind for later tests
await selectLibrary(win);
});
it("should record the selected collection as recently used", async function () {
var collection = await createDataObject('collection');
await zp.collectionsView.selectByID("C" + collection.id);
await waitForItemsLoad(win);
assert.equal(Zotero.Collections.getRecent()[0], collection);
});
it("should record the collection added to a multiple selection", async function () {
var c1 = await createDataObject('collection', { name: 'AAA recent' });
var c2 = await createDataObject('collection', { name: 'ZZZ recent' });
var cv = zp.collectionsView;
await cv.selectByID("C" + c1.id);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID("C" + c2.id));
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
assert.equal(Zotero.Collections.getRecent()[0], c2);
});
it("should offer recently used collections above the full list", async function () {
var parent = await createDataObject('collection', { name: 'Parent' });
var child = await createDataObject('collection', { name: 'Child', parentID: parent.id });
var item = await createDataObject('item', { collections: [parent.id] });
Zotero.Collections.addToRecent(parent);
Zotero.Collections.addToRecent(child);
zp.buildAddItemToCollectionMenu({ target: popup, currentTarget: popup }, [item]);
// Recent collections follow New Collection and a separator, by full path
var nodes = [...popup.children];
var recent = [];
for (let node of nodes.slice(nodes.findIndex(n => n.tagName == 'menuseparator') + 1)) {
if (node.tagName == 'menuseparator') break;
recent.push(node.getAttribute('label'));
}
assert.include(recent, 'Parent \u203A Child');
// The parent already contains the item, so it isn't offered
assert.notInclude(recent, 'Parent');
});
});
describe("#buildItemContextMenu()", function () {
it("shouldn't show export or bib options for multiple standalone file attachments without notes", async function () {
var item1 = await importFileAttachment('test.png');
var item2 = await importFileAttachment('test.png');
await zp.selectItems([item1.id, item2.id]);
await zp.buildItemContextMenu();
var menu = win.document.getElementById('zotero-itemmenu');
assert.isTrue(menu.querySelector('.zotero-menuitem-export').hidden);
assert.isTrue(menu.querySelector('.zotero-menuitem-create-bibliography').hidden);
});
it("should disable the report when selected items span libraries", async function () {
let group = await createGroup();
let c1 = await createDataObject('collection');
let c2 = await createDataObject('collection', { libraryID: group.libraryID });
let item1 = await createDataObject('item', { collections: [c1.id] });
let item2 = await createDataObject('item', { libraryID: group.libraryID, collections: [c2.id] });
// Cross-library multiple-collection selection -> grouped items view
await zp.collectionsView.expandLibrary(group.libraryID);
let cv = zp.collectionsView;
await cv.selectByID("C" + c1.id);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID("C" + c2.id));
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
let menu = win.document.getElementById('zotero-itemmenu');
let report = menu.querySelector('.zotero-menuitem-create-report');
// Select within the current (grouped) view rather than ZoteroPane.selectItems(),
// which would navigate the collection tree and drop the multi-selection
// Items from a single library: report enabled
await zp.itemsView.selectItems([item1.id]);
await zp.buildItemContextMenu();
assert.isFalse(report.disabled);
// Items spanning libraries: report disabled
await zp.itemsView.selectItems([item1.id, item2.id]);
await zp.buildItemContextMenu();
assert.isTrue(report.disabled);
await selectLibrary(win);
await group.eraseTx();
});
it("shouldn't offer Remove from Collection when a saved search is also selected", async function () {
let collection = await createDataObject('collection');
let search = await createDataObject('search');
let item = await createDataObject('item', { collections: [collection.id] });
let cv = zp.collectionsView;
let menu = win.document.getElementById('zotero-itemmenu');
let removeItems = menu.querySelector('.zotero-menuitem-remove-items');
let moveToTrash = menu.querySelector('.zotero-menuitem-move-to-trash');
await cv.selectByID("C" + collection.id);
await waitForItemsLoad(win);
await zp.itemsView.selectItems([item.id]);
await zp.buildItemContextMenu();
assert.isFalse(removeItems.hidden, "Offered for a collection on its own");
// Items in the saved search needn't be in the collection, so removing
// from the collection isn't meaningful
cv.selection.toggleSelect(cv.getRowIndexByID("S" + search.id));
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
await zp.itemsView.selectItems([item.id]);
await zp.buildItemContextMenu();
assert.isTrue(removeItems.hidden, "Not offered alongside a saved search");
assert.isFalse(moveToTrash.hidden, "Move to Trash is still offered");
await selectLibrary(win);
});
it("shouldn't open the item context menu on a library header row", async function () {
let group = await createGroup();
let c1 = await createDataObject('collection');
let c2 = await createDataObject('collection', { libraryID: group.libraryID });
await createDataObject('item', { collections: [c1.id] });
await createDataObject('item', { libraryID: group.libraryID, collections: [c2.id] });
// Cross-library multiple-collection selection -> grouped view with headers
await zp.collectionsView.expandLibrary(group.libraryID);
let cv = zp.collectionsView;
await cv.selectByID("C" + c1.id);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID("C" + c2.id));
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
let headerRow = zp.itemsView.getRowIndexByID("L" + Zotero.Libraries.userLibraryID);
let headerNode = win.document.getElementById(`${zp.itemsView.id}-row-${headerRow}`);
assert.isTrue(headerNode.classList.contains('library-header-row'));
let spy = sinon.spy(zp, 'buildItemContextMenu');
try {
await zp.onItemsContextMenuOpen({ target: headerNode, screenX: 0, screenY: 0 });
assert.isFalse(spy.called, "Context menu shouldn't be built for a header row");
}
finally {
spy.restore();
}
await selectLibrary(win);
await group.eraseTx();
});
it("shouldn't activate a library header row on double-click", async function () {
let group = await createGroup();
let c1 = await createDataObject('collection');
let c2 = await createDataObject('collection', { libraryID: group.libraryID });
await createDataObject('item', { collections: [c1.id] });
await createDataObject('item', { libraryID: group.libraryID, collections: [c2.id] });
// Cross-library multiple-collection selection -> grouped view with headers
await zp.collectionsView.expandLibrary(group.libraryID);
let cv = zp.collectionsView;
await cv.selectByID("C" + c1.id);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID("C" + c2.id));
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
let headerRow = zp.itemsView.getRowIndexByID("L" + Zotero.Libraries.userLibraryID);
assert.equal(zp.itemsView.getRow(headerRow).type, 'library-header');
let spy = sinon.spy(zp, 'onItemTreeActivate');
try {
zp.itemsView.handleActivate(new MouseEvent('dblclick'), [headerRow]);
assert.isFalse(spy.called, "Header row shouldn't be activated");
}
finally {
spy.restore();
}
await selectLibrary(win);
await group.eraseTx();
});
it("should show “Export Note…” for standalone file attachment with note", async function () {
var item1 = await importFileAttachment('test.png');
item1.setNote('<p>Foo</p>');
await item1.saveTx();
var item2 = await importFileAttachment('test.png');
await zp.selectItems([item1.id, item2.id]);
await zp.buildItemContextMenu();
var menu = win.document.getElementById('zotero-itemmenu');
var exportMenuItem = menu.querySelector('.zotero-menuitem-export');
assert.isFalse(exportMenuItem.hidden);
assert.equal(
exportMenuItem.getAttribute('label'),
Zotero.getString('pane.items.menu.exportNote.multiple')
);
});
it("should enable “Delete Item…” when selected item or an ancestor is in trash", async function () {
var item1 = await createDataObject('item', { deleted: true });
var attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
var userLibraryID = Zotero.Libraries.userLibraryID;
await zp.collectionsView.selectByID('T' + userLibraryID);
// Enable for attachment of item in trash
await zp.selectItems([attachment1.id]);
await zp.buildItemContextMenu();
var menu = win.document.getElementById('zotero-itemmenu');
var deleteMenuItem = menu.querySelector('.zotero-menuitem-delete-from-lib');
assert.isFalse(deleteMenuItem.disabled);
// Enable for parent and attachment
await zp.selectItems([item1.id, attachment1.id]);
await zp.buildItemContextMenu();
assert.isFalse(deleteMenuItem.disabled);
// Disable for non-trashed parent shown as container of trashed attachment
item1.deleted = false;
attachment1.deleted = true;
await item1.saveTx();
await attachment1.saveTx();
await zp.selectItems([item1.id]);
await zp.buildItemContextMenu();
assert.isTrue(deleteMenuItem.disabled);
});
it("should enable “Restore to Library” when at least one selected item is in trash", async function () {
var item1 = await createDataObject('item', { deleted: true });
var attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
var userLibraryID = Zotero.Libraries.userLibraryID;
await zp.collectionsView.selectByID('T' + userLibraryID);
await zp.selectItems([item1.id]);
await zp.buildItemContextMenu();
var menu = win.document.getElementById('zotero-itemmenu');
var restoreMenuItem = menu.querySelector('.zotero-menuitem-restore-to-library');
assert.isFalse(restoreMenuItem.disabled);
await zp.selectItems([item1.id, attachment1.id]);
await zp.buildItemContextMenu();
assert.isFalse(restoreMenuItem.disabled);
});
it("should disable “Restore to Library” when no selected items are in trash", async function () {
var item1 = await createDataObject('item');
var attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
attachment1.deleted = true;
await attachment1.saveTx();
var userLibraryID = Zotero.Libraries.userLibraryID;
await zp.collectionsView.selectByID('T' + userLibraryID);
await zp.selectItems([item1.id]);
await zp.buildItemContextMenu();
var menu = win.document.getElementById('zotero-itemmenu');
var restoreMenuItem = menu.querySelector('.zotero-menuitem-restore-to-library');
assert.isTrue(restoreMenuItem.disabled);
});
});
describe("#restoreSelectedItems()", function () {
it("should restore trashed parent and single trashed child when both are selected", async function () {
let item1 = await createDataObject('item', { deleted: true });
let attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
attachment1.deleted = true;
await attachment1.saveTx();
var userLibraryID = Zotero.Libraries.userLibraryID;
await zp.collectionsView.selectByID('T' + userLibraryID);
await zp.selectItems([item1.id, attachment1.id]);
await zp.restoreSelectedItems();
assert.isFalse(item1.deleted);
assert.isFalse(attachment1.deleted);
});
it("should restore child when parent and trashed child are selected", async function () {
let item1 = await createDataObject('item', { deleted: false });
let attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
attachment1.deleted = true;
await attachment1.saveTx();
var userLibraryID = Zotero.Libraries.userLibraryID;
await zp.collectionsView.selectByID('T' + userLibraryID);
await zp.selectItems([item1.id, attachment1.id]);
await zp.restoreSelectedItems();
assert.isFalse(item1.deleted);
assert.isFalse(attachment1.deleted);
});
it("should restore parent and selected children when parent and some trashed children are selected", async function () {
let item1 = await createDataObject('item', { deleted: false });
let attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
let attachment2 = await importFileAttachment('test.png', { parentItemID: item1.id });
attachment1.deleted = true;
await attachment1.saveTx();
attachment2.deleted = true;
await attachment2.saveTx();
var userLibraryID = Zotero.Libraries.userLibraryID;
await zp.collectionsView.selectByID('T' + userLibraryID);
await zp.selectItems([item1.id, attachment1.id]);
await zp.restoreSelectedItems();
assert.isFalse(item1.deleted);
assert.isFalse(attachment1.deleted);
assert.isTrue(attachment2.deleted);
});
it("should restore parent and all children when trashed parent and no children are selected", async function () {
let item1 = await createDataObject('item', { deleted: true });
let attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
let attachment2 = await importFileAttachment('test.png', { parentItemID: item1.id });
let attachment3 = await importFileAttachment('test.png', { parentItemID: item1.id });
attachment1.deleted = true;
await attachment1.saveTx();
attachment2.deleted = true;
await attachment2.saveTx();
attachment3.deleted = true;
await attachment3.saveTx();
var userLibraryID = Zotero.Libraries.userLibraryID;
await zp.collectionsView.selectByID('T' + userLibraryID);
await zp.selectItems([item1.id]);
await zp.restoreSelectedItems();
assert.isFalse(item1.deleted);
assert.isFalse(attachment1.deleted);
assert.isFalse(attachment2.deleted);
assert.isFalse(attachment3.deleted);
});
it("should restore parent and selected children when trashed parent and some trashed children are selected", async function () {
let item1 = await createDataObject('item', { deleted: true });
let attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
let attachment2 = await importFileAttachment('test.png', { parentItemID: item1.id });
let attachment3 = await importFileAttachment('test.png', { parentItemID: item1.id });
attachment1.deleted = true;
await attachment1.saveTx();
attachment2.deleted = true;
await attachment2.saveTx();
var userLibraryID = Zotero.Libraries.userLibraryID;
await zp.collectionsView.selectByID('T' + userLibraryID);
await zp.selectItems([item1.id, attachment2.id, attachment3.id]);
await zp.restoreSelectedItems();
assert.isFalse(item1.deleted);
assert.isTrue(attachment1.deleted);
assert.isFalse(attachment2.deleted);
assert.isFalse(attachment3.deleted);
});
it("should restore selected children when trashed children and untrashed children are selected", async function () {
let item1 = await createDataObject('item', { deleted: false });
let attachment1 = await importFileAttachment('test.png', { parentItemID: item1.id });
let attachment2 = await importFileAttachment('test.png', { parentItemID: item1.id });
let attachment3 = await importFileAttachment('test.png', { parentItemID: item1.id });
attachment1.deleted = true;
await attachment1.saveTx();
attachment2.deleted = true;
await attachment2.saveTx();
var userLibraryID = Zotero.Libraries.userLibraryID;
await zp.collectionsView.selectByID('T' + userLibraryID);
await zp.selectItems([attachment1.id, attachment2.id, attachment3.id]);
await zp.restoreSelectedItems();
assert.isFalse(item1.deleted);
assert.isFalse(attachment1.deleted);
assert.isFalse(attachment2.deleted);
assert.isFalse(attachment3.deleted);
});
});
describe("#checkForLinkedFilesToRelink()", function () {
let labdDir;
this.beforeEach(async () => {
labdDir = await getTempDirectory();
Zotero.Prefs.set('baseAttachmentPath', labdDir);
Zotero.Prefs.set('saveRelativeAttachmentPath', true);
});
it("should detect and relink a single attachment", async function () {
let item = await createDataObject('item');
let file = getTestDataDirectory();
file.append('test.pdf');
let outsideStorageDir = await getTempDirectory();
let outsideFile = OS.Path.join(outsideStorageDir, 'test.pdf');
let labdFile = OS.Path.join(labdDir, 'test.pdf');
await OS.File.copy(file.path, outsideFile);
let attachment = await Zotero.Attachments.linkFromFile({
file: outsideFile,
parentItemID: item.id
});
assert.isTrue(await attachment.fileExists());
await OS.File.move(outsideFile, labdFile);
assert.isFalse(await attachment.fileExists());
let stub = sinon.stub(zp, 'showLinkedFileFoundAutomaticallyDialog')
.returns('one');
await zp.checkForLinkedFilesToRelink(attachment);
assert.ok(stub.calledOnce);
assert.ok(stub.calledWith(attachment, sinon.match.string, 0));
assert.isTrue(await attachment.fileExists());
assert.equal(attachment.getFilePath(), labdFile);
assert.equal(attachment.attachmentPath, 'attachments:test.pdf');
stub.restore();
});
it("should detect and relink multiple attachments when user chooses", async function () {
for (let choice of ['one', 'all']) {
let file1 = getTestDataDirectory();
file1.append('test.pdf');
let file2 = getTestDataDirectory();
file2.append('empty.pdf');
let outsideStorageDir = await getTempDirectory();
let outsideFile1 = OS.Path.join(outsideStorageDir, 'test.pdf');
let outsideFile2 = OS.Path.join(outsideStorageDir, 'empty.pdf');
let labdFile1 = OS.Path.join(labdDir, 'test.pdf');
let labdFile2 = OS.Path.join(labdDir, 'empty.pdf');
await OS.File.copy(file1.path, outsideFile1);
await OS.File.copy(file2.path, outsideFile2);
let attachment1 = await Zotero.Attachments.linkFromFile({ file: outsideFile1 });
let attachment2 = await Zotero.Attachments.linkFromFile({ file: outsideFile2 });
assert.isTrue(await attachment1.fileExists());
assert.isTrue(await attachment2.fileExists());
await OS.File.move(outsideFile1, labdFile1);
await OS.File.move(outsideFile2, labdFile2);
assert.isFalse(await attachment1.fileExists());
assert.isFalse(await attachment2.fileExists());
let stub = sinon.stub(zp, 'showLinkedFileFoundAutomaticallyDialog')
.returns(choice);
await zp.checkForLinkedFilesToRelink(attachment1);
assert.ok(stub.calledOnce);
assert.ok(stub.calledWith(attachment1, sinon.match.string, 1));
assert.isTrue(await attachment1.fileExists());
assert.equal(await attachment2.fileExists(), choice === 'all');
assert.equal(attachment1.getFilePath(), labdFile1);
assert.equal(attachment1.attachmentPath, 'attachments:test.pdf');
if (choice === 'all') {
assert.equal(attachment2.getFilePath(), labdFile2);
assert.equal(attachment2.attachmentPath, 'attachments:empty.pdf');
}
else {
assert.equal(attachment2.getFilePath(), outsideFile2);
}
stub.restore();
}
});
it("should use subdirectories of original path", async function () {
let file = getTestDataDirectory();
file.append('test.pdf');
let outsideStorageDir = OS.Path.join(await getTempDirectory(), 'subdir');
await OS.File.makeDir(outsideStorageDir);
let outsideFile = OS.Path.join(outsideStorageDir, 'test.pdf');
let labdSubdir = OS.Path.join(labdDir, 'subdir');
await OS.File.makeDir(labdSubdir);
let labdFile = OS.Path.join(labdSubdir, 'test.pdf');
await OS.File.copy(file.path, outsideFile);
let attachment = await Zotero.Attachments.linkFromFile({ file: outsideFile });
assert.isTrue(await attachment.fileExists());
await OS.File.move(outsideFile, labdFile);
assert.isFalse(await attachment.fileExists());
let dialogStub = sinon.stub(zp, 'showLinkedFileFoundAutomaticallyDialog')
.returns('one');
// No longer works with IOUtils
//let existsSpy = sinon.spy(IOUtils, 'exists');
await zp.checkForLinkedFilesToRelink(attachment);
assert.ok(dialogStub.calledOnce);
assert.ok(dialogStub.calledWith(attachment, sinon.match.string, 0));
//Zotero.debug(existsSpy.calledWith(OS.Path.join(labdSubdir, 'test.pdf')));
//assert.ok(existsSpy.calledWith(OS.Path.join(labdSubdir, 'test.pdf')));
//assert.notOk(existsSpy.calledWith(OS.Path.join(labdDir, 'test.pdf'))); // Should never get there
assert.isTrue(await attachment.fileExists());
assert.equal(attachment.getFilePath(), labdFile);
assert.equal(attachment.attachmentPath, 'attachments:subdir/test.pdf');
dialogStub.restore();
//existsSpy.restore();
});
it("should handle Windows paths", async function () {
let filenames = [['test.pdf'], ['empty.pdf'], ['search', 'baz.pdf']];
let labdFiles = [];
let attachments = [];
for (let parts of filenames) {
let file = getTestDataDirectory();
parts.forEach(part => file.append(part));
await OS.File.makeDir(OS.Path.join(labdDir, ...parts.slice(0, -1)));
let labdFile = OS.Path.join(labdDir, ...parts);
await OS.File.copy(file.path, labdFile);
labdFiles.push(labdFile);
let attachment = await Zotero.Attachments.linkFromFile({ file });
attachment.attachmentPath = `C:\\test\\${parts.join('\\')}`;
await attachment.saveTx();
attachments.push(attachment);
assert.isFalse(await attachment.fileExists());
}
let stub = sinon.stub(zp, 'showLinkedFileFoundAutomaticallyDialog')
.returns('all');
await zp.checkForLinkedFilesToRelink(attachments[0]);
assert.ok(stub.calledOnce);
assert.ok(stub.calledWith(attachments[0], sinon.match.string, filenames.length - 1));
for (let i = 0; i < filenames.length; i++) {
let attachment = attachments[i];
assert.isTrue(await attachment.fileExists());
assert.equal(attachment.getFilePath(), labdFiles[i]);
assert.equal(attachment.attachmentPath, 'attachments:' + OS.Path.join(...filenames[i]));
}
stub.restore();
});
});
describe("#focus()", function () {
before(async function () {
var collection = new Zotero.Collection;
collection.name = "Focus Test";
await collection.saveTx();
// Make sure there is a tag
var item = new Zotero.Item('newspaperArticle');
item.setCollections([collection.id]);
await item.setTags(["Tag"]);
await item.saveTx({
skipSelect: true
});
// Make sure there is more than one tab so that the tabs menu is focusable
if (win.Zotero_Tabs.numTabs == 1) {
let attachment = await importFileAttachment('test.pdf');
await attachment.saveTx();
await zp.viewAttachment(attachment.id);
win.Zotero_Tabs.select('zotero-pane');
}
await waitForItemsLoad(win);
await zp.collectionsView.selectLibrary(userLibraryID);
});
var tab = new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey: false,
bubbles: true
});
var shiftTab = new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey: true,
bubbles: true
});
var rightArrow = new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true
});
var leftArrow = new KeyboardEvent('keydown', {
key: 'ArrowLeft',
bubbles: true
});
// Focus sequence for Zotero Pane
let sequence = [
"zotero-tb-search-textbox",
"zotero-tb-search-dropmarker",
"zotero-tb-add",
"tag-selector-actions",
"search-input",
"tag-selector-item",
"collection-tree",
"zotero-collections-search",
"zotero-tb-collection-add",
"zotero-tb-sync",
"zotero-tb-tabs-menu"
];
// The focus traversal relies on focus/blur events, which only fire when the
// window is active. It's normally active, but can lose activation --
// intermittently in CI, or when another app is focused during a local run --
// so restore it if necessary and report whether that worked.
async function activatePaneWindow() {
if (Services.focus.activeWindow !== win) {
win.focus();
await Zotero.Promise.delay(100);
}
return Services.focus.activeWindow === win;
}
beforeEach(async function () {
// Reset collection search field state
let collectionSearchField = doc.getElementById("zotero-collections-search");
let collectionSearchButton = doc.getElementById("zotero-tb-collections-search");
collectionSearchField.classList.remove("visible", "expanding");
collectionSearchField.setAttribute("disabled", true);
collectionSearchField.style.visibility = 'hidden';
collectionSearchField.style.removeProperty('max-width');
collectionSearchField.value = '';
collectionSearchButton.style.display = '';
});
it("should shift-tab across the zotero pane", async function () {
// Without an active window, the traversal waits for events that never come
if (!await activatePaneWindow()) {
Zotero.debug("Skipping test -- pane window isn't active");
this.skip();
}
// Start from the Advanced Search button (the last focusable element in the
// search field) so the first shift-tab exercises advanced button -> search field
let advancedButton = doc.getElementById('zotero-tb-search-advanced-button');
advancedButton.focus();
for (let id of sequence) {
// Set up focus listener before dispatching the event
// to avoid racing against async focus from setTimeout.
// Use "focusin" rather than "focus" because the target is a XUL
// <search-textbox> whose real focus target is an anonymous inner
// <input>; "focus" does not bubble, so a listener on the outer
// element would never fire.
let focusPromise;
if (id === "zotero-collections-search") {
focusPromise = waitForDOMEvent(doc.getElementById(id), "focusin");
}
// Set up hide observer before the shift-tab that blurs collection-search
let hidePromise;
if (id === "zotero-tb-collection-add") {
let field = doc.getElementById("zotero-collections-search");
if (field.style.visibility !== 'hidden') {
hidePromise = waitForDOMAttributes(field, "style",
() => field.style.visibility === 'hidden');
}
}
doc.activeElement.dispatchEvent(shiftTab);
if (focusPromise) {
await focusPromise;
}
if (hidePromise) {
await hidePromise;
}
// Some elements don't have id, so use classes to verify they're focused
if (doc.activeElement.id) {
assert.equal(doc.activeElement.id, id);
}
else {
let clases = [...doc.activeElement.classList];
assert.include(clases, id);
}
}
doc.activeElement.dispatchEvent(shiftTab);
assert.equal(doc.activeElement.className, "tab selected");
doc.activeElement.dispatchEvent(shiftTab);
// Toggle Item/Context Pane button
assert.equal(doc.activeElement.dataset.action, "toggle-pane");
});
it("should tab across the zotero pane", async function () {
// Without an active window, the traversal waits for events that never come
if (!await activatePaneWindow()) {
Zotero.debug("Skipping test -- pane window isn't active");
this.skip();
}
win.Zotero_Tabs.moveFocus("current");
let reversed = [...sequence].reverse();
for (let id of reversed) {
// Set up focus listener before dispatching the event
// to avoid racing against async focus from setTimeout.
// See the shift-tab test above for why this listens on "focusin"
// rather than "focus".
let focusPromise;
if (id === "zotero-collections-search") {
focusPromise = waitForDOMEvent(doc.getElementById(id), "focusin");
}
doc.activeElement.dispatchEvent(tab);
if (focusPromise) {
await focusPromise;
}
// Some elements don't have id, so use classes to verify they're focused
if (doc.activeElement.id) {
assert.equal(doc.activeElement.id, id);
}
else {
let clases = [...doc.activeElement.classList];
assert.include(clases, id);
}
}
// Tab from the search field to the Advanced Search button at the end of the field
doc.activeElement.dispatchEvent(tab);
assert.equal(doc.activeElement.id, "zotero-tb-search-advanced-button");
});
it("should navigate toolbarbuttons with arrows", async function () {
let addItem = doc.getElementById('zotero-tb-add');
addItem.focus();
doc.activeElement.dispatchEvent(rightArrow);
assert.equal(doc.activeElement.id, "zotero-tb-lookup");
doc.activeElement.dispatchEvent(rightArrow);
assert.equal(doc.activeElement.id, "zotero-tb-attachment-add");
doc.activeElement.dispatchEvent(rightArrow);
assert.equal(doc.activeElement.id, "zotero-tb-note-add");
doc.activeElement.dispatchEvent(leftArrow);
assert.equal(doc.activeElement.id, "zotero-tb-attachment-add");
doc.activeElement.dispatchEvent(leftArrow);
assert.equal(doc.activeElement.id, "zotero-tb-lookup");
doc.activeElement.dispatchEvent(leftArrow);
assert.equal(doc.activeElement.id, "zotero-tb-add");
});
});
describe("#addAttachmentFromDialog()", function () {
it("should set an automatic title on the first file attachment of each supported type", async function () {
let parentItem = await createDataObject('item');
// Add a link attachment, which won't affect renaming
await Zotero.Attachments.linkFromURL({
url: 'https://example.com/',
parentItemID: parentItem.id,
});
// Add a PDF attachment, which will get a default title
let file = getTestDataDirectory();
file.append('test.pdf');
let [pdfAttachment1] = await zp.addAttachmentFromDialog(false, parentItem.id, [file.path]);
assert.equal(parentItem.getAttachments().length, 2);
assert.equal(pdfAttachment1.getField('title'), Zotero.getString('file-type-pdf'));
// Add a second, which will get a title based on its filename
let [pdfAttachment2] = await zp.addAttachmentFromDialog(false, parentItem.id, [file.path]);
assert.equal(parentItem.getAttachments().length, 3);
assert.equal(pdfAttachment2.getField('title'), 'test');
// Add an EPUB attachment, which will get a default title
file = getTestDataDirectory();
file.append('stub.epub');
let [epubAttachment] = await zp.addAttachmentFromDialog(false, parentItem.id, [file.path]);
assert.equal(parentItem.getAttachments().length, 4);
assert.equal(epubAttachment.getField('title'), Zotero.getString('file-type-ebook'));
});
it("shouldn't set type-based titles when multiple attachments of the same type are added at once", async function () {
let parentItem = await createDataObject('item');
// Add two PDF attachments at once, which will get titles based on their filenames
let file = getTestDataDirectory();
file.append('test.pdf');
let [pdfAttachment1, pdfAttachment2] = await zp.addAttachmentFromDialog(false, parentItem.id, [file.path, file.path]);
assert.equal(parentItem.getAttachments().length, 2);
assert.equal(pdfAttachment1.getField('title'), 'test');
assert.equal(pdfAttachment2.getField('title'), 'test');
// Add an EPUB attachment, which will get a default title
file = getTestDataDirectory();
file.append('stub.epub');
let [epubAttachment] = await zp.addAttachmentFromDialog(false, parentItem.id, [file.path]);
assert.equal(parentItem.getAttachments().length, 3);
assert.equal(epubAttachment.getField('title'), Zotero.getString('file-type-ebook'));
});
it("should select added file attachment", async function () {
let parentItem = await createDataObject('item');
let file = getTestDataDirectory();
file.append('test.pdf');
let [pdfAttachment1] = await zp.addAttachmentFromDialog(false, parentItem.id, [file.path]);
let parentItemIndex = zp.itemsView.getRowIndexByID(parentItem.id);
assert.equal(zp.itemsView.selection.focused, parentItemIndex + 1);
let selected = zp.itemsView.getSelectedItems()[0];
assert.equal(selected.id, pdfAttachment1.id);
});
describe("Linked file renaming", function () {
before(() => {
Zotero.Prefs.set('autoRenameFiles.linked', true);
});
after(() => {
Zotero.Prefs.clear('autoRenameFiles.linked');
});
it("should only rename and change the title of the first PDF attachment", async function () {
let testFile = getTestDataDirectory();
testFile.append('test.pdf');
let tempDir = await getTempDirectory();
let copy1 = PathUtils.join(tempDir, 'copy1.pdf');
let copy2 = PathUtils.join(tempDir, 'copy2.pdf');
await IOUtils.copy(testFile.path, copy1);
await IOUtils.copy(testFile.path, copy2);
let parentItem = await createDataObject('item', { title: 'Foo' });
let [attachment1] = await zp.addAttachmentFromDialog(false, parentItem.id, [copy1]);
assert.equal(attachment1.getField('title'), Zotero.getString('file-type-pdf'));
assert.equal(attachment1.attachmentFilename, 'Foo.pdf');
let [attachment2] = await zp.addAttachmentFromDialog(false, parentItem.id, [copy2]);
assert.equal(attachment2.getField('title'), 'copy2');
assert.equal(attachment2.attachmentFilename, 'copy2.pdf');
});
});
});
describe("#createParentItemsFromSelected()", function () {
async function createParent() {
let parent;
let dialogPromise = waitForDialog(async (win) => {
parent = await createDataObject('item', { title: 'Book Title' });
win.arguments[0].dataOut = { parent };
win.close();
}, false, 'chrome://zotero/content/createParentDialog.xhtml');
let createParentPromise = zp.createParentItemsFromSelected();
await dialogPromise;
await createParentPromise;
return parent;
}
it("should rename the attachment and set an automatic title", async function () {
let attachment = await importPDFAttachment({
title: 'Attachment title',
});
assert.equal(attachment.attachmentFilename, 'test.pdf');
let parent = await createParent();
assert.equal(attachment.parentItem, parent);
assert.equal(attachment.attachmentFilename, 'Book Title.pdf');
assert.equal(attachment.getField('title'), Zotero.getString('file-type-pdf'));
});
it("shouldn't rename or change the title of an attachment with a disabled type", async function () {
Zotero.Prefs.set('autoRenameFiles.fileTypes', 'x-nonexistent/type');
let file = getTestDataDirectory();
file.append('test.pdf');
let attachment = await Zotero.Attachments.linkFromFile({
file,
title: 'Attachment title'
});
assert.equal(attachment.attachmentFilename, 'test.pdf');
let parent = await createParent();
assert.equal(attachment.parentItem, parent);
assert.equal(attachment.attachmentFilename, 'test.pdf');
assert.equal(attachment.getField('title'), 'Attachment title');
Zotero.Prefs.clear('autoRenameFiles.fileTypes');
});
it("shouldn't rename a linked attachment or set an automatic title when linked file renaming disabled", async function () {
Zotero.Prefs.set('autoRenameFiles.linked', false);
let file = getTestDataDirectory();
file.append('test.pdf');
let attachment = await Zotero.Attachments.linkFromFile({
file,
title: 'Attachment title'
});
assert.equal(attachment.attachmentFilename, 'test.pdf');
let parent = await createParent();
assert.equal(attachment.parentItem, parent);
assert.equal(attachment.attachmentFilename, 'test.pdf');
assert.equal(attachment.getField('title'), 'Attachment title');
Zotero.Prefs.clear('autoRenameFiles.linked');
});
});
describe("#changeParentItem", function () {
it("should update the parent of selected items", async function () {
// One item has 2 children and the other one - none
let oldParent = await createDataObject('item');
let newParent = await createDataObject('item');
var attachment = await importPDFAttachment(oldParent);
var note = await createDataObject('item', { itemType: 'note', parentID: oldParent.id });
// Select child items
await zp.selectItems([attachment.id, note.id]);
// Open the dialog to select new parent and wait for it to load
waitForWindow('chrome://zotero/content/selectItemsDialog.xhtml', async (selectWin) => {
do {
await Zotero.Promise.delay(50);
}
while (!selectWin.loaded);
console.log(selectWin);
await selectWin.itemsView.waitForLoad();
// select item and accept the dialog
await selectWin.itemsView.selectItem(newParent.id);
selectWin.document.querySelector('dialog').acceptDialog();
});
zp.changeParentItem();
await waitForItemEvent('modify');
// Make sure the new parent now has the note and attachment
assert.include(newParent.getNotes(), note.id);
assert.include(newParent.getAttachments(), attachment.id);
// And the old parent does not
assert.notInclude(oldParent.getNotes(), note.id);
assert.notInclude(oldParent.getAttachments(), attachment.id);
});
it("should allow converting attachments to standalone when applicable", async function () {
let collection = await createDataObject('collection');
let parent = await createDataObject('item', { collections: [collection.id] });
var attachment = await importPDFAttachment(parent);
// Select child item
await zp.selectItems([attachment.id]);
// Open the dialog to select new parent and wait for it to load
waitForWindow('chrome://zotero/content/selectItemsDialog.xhtml', async (selectWin) => {
do {
await Zotero.Promise.delay(50);
}
while (!selectWin.loaded);
await selectWin.itemsView.waitForLoad();
// Button to make attachment standalone should be visible
let moveToStandaloneBtn = selectWin.document.querySelector("dialog button[dlgtype=extra1]");
assert.isFalse(moveToStandaloneBtn.hidden);
moveToStandaloneBtn.click();
});
zp.changeParentItem();
await waitForItemEvent('modify');
// The attachment should have no parent item
assert.isFalse(attachment.parentID);
// Attachment should belong to the same collection as parent item
assert.equal(attachment.getCollections()[0], collection.id);
});
});
describe("#copyCollection", function () {
it("should copy collection within the same library", async function () {
let collectionParent = await createDataObject('collection');
let collectionChild = await createDataObject('collection', { parentID: collectionParent.id });
let collectionDestination = await createDataObject('collection');
let itemOne = await createDataObject('item', { collections: [collectionParent.id] });
let itemTwo = await createDataObject('item', { collections: [collectionChild.id] });
await zp.collectionsView.selectByID("C" + collectionParent.id);
await zp.copyCollection(collectionDestination);
await waitForNotifierEvent("add", "collection");
// Newly created collections have the same names as the original ones
let collectionNames = collectionDestination.getDescendents(false, 'collection').map(col => col.name);
assert.sameMembers(collectionNames, [collectionParent.name, collectionChild.name]);
// Newly created collections contain the same items
let items = collectionDestination.getDescendents(false, 'item').map(item => item.id);
assert.sameMembers(items, [itemOne.id, itemTwo.id]);
});
it("should duplicate top-level collection", async function () {
let collection = await createDataObject('collection');
let mylibrary = Zotero.Libraries.get(collection.libraryID);
let itemOne = await createDataObject('item', { collections: [collection.id] });
await zp.collectionsView.selectByID("C" + collection.id);
await zp.copyCollection(mylibrary);
await waitForNotifierEvent("add", "collection");
// Find the duplicated collection and make sure it exists
let topLevelCollections = Zotero.Collections.getByLibrary(mylibrary.id);
let newCollection = topLevelCollections.find(col => col.name == collection.name && collection.id !== col.id);
assert.exists(newCollection);
// Newly created collection contain the same item
let items = newCollection.getDescendents(false, 'item').map(item => item.id);
assert.sameMembers(items, [itemOne.id]);
});
it("should copy collection between libraries", async function () {
let groupDestination = await createGroup();
let groupCollection = await createDataObject('collection', { libraryID: groupDestination.libraryID });
let collectionParent = await createDataObject('collection');
let collectionChild = await createDataObject('collection', { parentID: collectionParent.id });
let itemOne = await createDataObject('item', { collections: [collectionParent.id] });
let itemTwo = await createDataObject('item', { collections: [collectionChild.id] });
await zp.collectionsView.selectByID("C" + collectionParent.id);
await zp.copyCollection(groupCollection);
await waitForNotifierEvent("add", "collection");
// Newly created collections have the same names as the original ones
let collectionNames = groupCollection.getDescendents(false, 'collection').map(col => col.name);
assert.sameMembers(collectionNames, [collectionParent.name, collectionChild.name]);
// Newly created collections also have copies of items
let items = groupCollection.getDescendents(false, 'item').map(item => Zotero.Items.get(item.id).getDisplayTitle());
assert.sameMembers(items, [itemOne.getDisplayTitle(), itemTwo.getDisplayTitle()]);
});
it("should not allow copying between libraries if there is a linked collection", async function () {
let groupDestination = await createGroup();
let groupCollection = await createDataObject('collection', { libraryID: groupDestination.libraryID });
let collection = await createDataObject('collection');
let collectionChild = await createDataObject('collection', { parentID: collection.id });
await zp.collectionsView.selectByID("C" + collectionChild.id);
await zp.copyCollection(groupCollection);
await waitForNotifierEvent("add", "collection");
// Collection has been copies
let groupCollections = groupCollection.getDescendents(false, 'collection');
let newCollectionID = groupCollections.find(col => col.name == collectionChild.name).id;
let newCollection = Zotero.Collections.get(newCollectionID);
assert.exists(newCollection);
// Right click on the selected collection
await zp.collectionsView.selectByID("C" + collectionChild.id);
zp.buildCopyCollectionMenu({});
// Delay for menus to get disabled
await Zotero.Promise.delay();
let groupMenu = doc.querySelector(`#zotero-copy-collection-popup menu[value="L${groupDestination.libraryID}"]`);
// Menu of the library with linked collection should be disabled
assert.equal(groupMenu.disabled, true);
// Right click on the parent of the copies collection
await zp.collectionsView.selectByID("C" + collection.id);
zp.buildCopyCollectionMenu({});
// Delay for menus to get disabled
await Zotero.Promise.delay();
groupMenu = doc.querySelector(`#zotero-copy-collection-popup menu[value="L${groupDestination.libraryID}"]`);
// Menu of the library with linked sub-collection should be disabled
assert.equal(groupMenu.disabled, true);
});
it("should copy subcollection to library root", async function () {
let collectionParent = await createDataObject('collection');
let collectionChild = await createDataObject('collection', { parentID: collectionParent.id });
let libraryDestination = Zotero.Libraries.get(collectionChild.libraryID);
let item = await createDataObject('item', { collections: [collectionChild.id] });
await zp.collectionsView.selectByID("C" + collectionChild.id);
await zp.copyCollection(libraryDestination);
let data = await waitForNotifierEvent("add", "collection");
let collectionID = data.ids[0];
let newCollection = Zotero.Collections.get(collectionID);
// Copied collection has the same name as the original
assert.equal(newCollection.name, collectionChild.name);
// Copied collections contain the same item
let items = newCollection.getDescendents(false, 'item').map(item => item.id);
assert.sameMembers(items, [item.id]);
// Copied collection is a top-level collection
assert.notOk(newCollection.parentID);
});
});
describe("#moveCollection", function () {
it("should move collection into another collection of the same library", async function () {
let collection = await createDataObject('collection');
let collectionDestination = await createDataObject('collection');
await zp.collectionsView.selectByID("C" + collection.id);
let promise = waitForNotifierEvent("modify", "collection");
await zp.moveCollection(collectionDestination);
await promise;
// Collection was moved into destination collection
let collectionChildIDs = collectionDestination.getDescendents(false, 'collection').map(col => col.id);
assert.sameMembers(collectionChildIDs, [collection.id]);
});
it("should make collection a top-level collection", async function () {
let collectionParent = await createDataObject('collection');
let collectionChild = await createDataObject('collection', { parentID: collectionParent.id });
let library = Zotero.Libraries.get(collectionChild.libraryID);
await zp.collectionsView.selectByID("C" + collectionChild.id);
let promise = waitForNotifierEvent("modify", "collection");
await zp.moveCollection(library);
await promise;
// Child collection was pulled from under its parent to become a top-level collection
let topLevelCollections = Zotero.Collections.getByLibrary(library.id);
assert.includeMembers(topLevelCollections, [collectionChild]);
});
});
describe("Multi-Collection Selection", function () {
afterEach(async function () {
Zotero.Prefs.clear('recursiveCollections');
await selectLibrary(win);
});
async function selectMultipleCollections(collections) {
let cv = zp.collectionsView;
// Select the first collection
await cv.selectByID("C" + collections[0].id);
await waitForItemsLoad(win);
// Toggle-select additional collections
for (let i = 1; i < collections.length; i++) {
let row = cv.getRowIndexByID("C" + collections[i].id);
cv.selection.toggleSelect(row);
}
// Wait for the items view to reload with the combined set. The
// selection-triggered call is queued ahead of this one in Zotero.serial,
// and this one no-ops once the view matches the selection.
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
}
describe("Uncombinable selections", function () {
it("should keep only the focused row when Recently Read and a collection are selected together", async function () {
let collection = await createDataObject('collection');
let cv = zp.collectionsView;
// Show and select Recently Read
await zp.setVirtual(Zotero.Libraries.userLibraryID, 'recentlyRead', true, true);
await waitForItemsLoad(win);
// Toggle-select the collection, forming an uncombinable two-row selection
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collection.id));
await zp.onCollectionSelected();
// The selection should have been reduced to the focused (collection) row
assert.equal(cv.selection.count, 1);
let rows = zp.getCollectionTreeRows();
assert.lengthOf(rows, 1);
assert.isTrue(rows[0].isCollection());
await zp.setVirtual(Zotero.Libraries.userLibraryID, 'recentlyRead', false);
});
it("should keep only the focused row when a library and a collection are selected together", async function () {
let collection = await createDataObject('collection');
let cv = zp.collectionsView;
// Select the library root, then toggle-select a collection within it
await cv.selectByID("L" + Zotero.Libraries.userLibraryID);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collection.id));
await zp.onCollectionSelected();
// The selection should have been reduced to the focused (collection) row
assert.equal(cv.selection.count, 1);
let rows = zp.getCollectionTreeRows();
assert.lengthOf(rows, 1);
assert.isTrue(rows[0].isCollection());
});
});
describe("Combinable special views", function () {
it("should combine Recently Read across libraries without reducing the selection", async function () {
let userLibraryID = Zotero.Libraries.userLibraryID;
let groupLibraryID = (await createGroup()).libraryID;
let cv = zp.collectionsView;
// Seed a recently-read item in each library
let userItem = await createDataObject('item', { libraryID: userLibraryID });
let userAttachment = await importPDFAttachment(userItem);
userAttachment.attachmentLastRead = Math.round(Date.now() / 1000);
await userAttachment.saveTx();
let groupItem = await createDataObject('item', { libraryID: groupLibraryID });
let groupAttachment = await importPDFAttachment(groupItem);
groupAttachment.attachmentLastRead = Math.round(Date.now() / 1000);
await groupAttachment.saveTx();
// Show Recently Read in both libraries, then select both rows
await zp.setVirtual(userLibraryID, 'recentlyRead', true, false);
await zp.setVirtual(groupLibraryID, 'recentlyRead', true, false);
await cv.selectByID('Y' + userLibraryID);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID('Y' + groupLibraryID));
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
// Both Recently Read rows should remain selected
assert.equal(cv.selection.count, 2);
let rows = zp.getCollectionTreeRows();
assert.lengthOf(rows, 2);
assert.isTrue(rows.every(r => r.isRecentlyRead()));
// The items list should show the recently-read item from each library
assert.isNumber(zp.itemsView.getRowIndexByID(userItem.id));
assert.isNumber(zp.itemsView.getRowIndexByID(groupItem.id));
await zp.setVirtual(userLibraryID, 'recentlyRead', false);
await zp.setVirtual(groupLibraryID, 'recentlyRead', false);
Zotero.Items._lastReadCutoffs.clear();
});
});
describe("Select All (Cmd/Ctrl-A)", function () {
// Cmd/Ctrl-A is wired to _handleSelectAll() in the tree's keydown handler;
// the tests call it directly so they can await its (sometimes async) work.
it("should select all library roots when a library is selected", async function () {
let group = await createGroup();
let cv = zp.collectionsView;
await cv.selectByID("L" + Zotero.Libraries.userLibraryID);
await waitForItemsLoad(win);
await cv._handleSelectAll();
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
let rows = zp.getCollectionTreeRows();
assert.isAbove(rows.length, 1, "More than one row should be selected");
assert.isTrue(rows.every(r => r.isLibrary(true)), "Every selected row should be a library root");
let libraryIDs = rows.map(r => r.ref.libraryID);
assert.include(libraryIDs, Zotero.Libraries.userLibraryID);
assert.include(libraryIDs, group.libraryID);
await group.eraseTx();
});
it("should select collections sharing a parent with any selected collection", async function () {
let cv = zp.collectionsView;
// Two parents with their own children. Selecting one child from each
// should expand to all children of both parents -- but not deeper
// (a subcollection) and not other libraries.
let parentA = await createDataObject('collection');
let a1 = await createDataObject('collection', { parentID: parentA.id });
let a2 = await createDataObject('collection', { parentID: parentA.id });
let parentB = await createDataObject('collection');
let b1 = await createDataObject('collection', { parentID: parentB.id });
let b2 = await createDataObject('collection', { parentID: parentB.id });
let sub = await createDataObject('collection', { parentID: a1.id });
let group = await createGroup();
let other = await createDataObject('collection', { libraryID: group.libraryID });
// Make the subcollection and the other library's collection visible rows
// so their exclusion is actually exercised, not just an absence of rows
await cv.expandToCollection(sub.id);
await cv.expandToCollection(b1.id);
await cv.expandLibrary(group.libraryID);
await cv.selectByID("C" + a1.id);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID("C" + b1.id));
await zp.onCollectionSelected();
await cv._handleSelectAll();
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
let rows = zp.getCollectionTreeRows();
assert.isTrue(rows.every(r => r.isCollection()), "Every selected row should be a collection");
assert.sameMembers(rows.map(r => r.ref), [a1, a2, b1, b2]);
await group.eraseTx();
});
it("should select Recently Read in all libraries", async function () {
let userLibraryID = Zotero.Libraries.userLibraryID;
let groupLibraryID = (await createGroup()).libraryID;
let cv = zp.collectionsView;
await zp.setVirtual(userLibraryID, 'recentlyRead', true, false);
await zp.setVirtual(groupLibraryID, 'recentlyRead', true, false);
// Collapse the group so its Recently Read row isn't in the tree -- Select
// All must expand it to reach Recently Read in every library. Do this
// before selecting, since collapseLibrary() selects the collapsed library.
cv.collapseLibrary(groupLibraryID);
assert.isFalse(cv.getRowIndexByID('Y' + groupLibraryID),
"Group Recently Read should be hidden before Select All");
await cv.selectByID('Y' + userLibraryID);
await waitForItemsLoad(win);
await cv._handleSelectAll();
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
let rows = zp.getCollectionTreeRows();
assert.isTrue(rows.every(r => r.isRecentlyRead()), "Every selected row should be Recently Read");
let libraryIDs = rows.map(r => r.ref.libraryID);
assert.include(libraryIDs, userLibraryID);
assert.include(libraryIDs, groupLibraryID);
await zp.setVirtual(userLibraryID, 'recentlyRead', false);
await zp.setVirtual(groupLibraryID, 'recentlyRead', false);
});
it("should leave a saved-search selection untouched", async function () {
let cv = zp.collectionsView;
// Saved searches combine fine, so this is a valid manual multi-selection,
// but Select All has no useful expansion for them and must not change it
let s1 = await createDataObject('search');
let s2 = await createDataObject('search');
let s3 = await createDataObject('search');
await createDataObject('search');
await cv.selectByID("S" + s1.id);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID("S" + s2.id));
cv.selection.toggleSelect(cv.getRowIndexByID("S" + s3.id));
await zp.onCollectionSelected();
assert.equal(cv.selection.count, 3);
await cv._handleSelectAll();
await zp.onCollectionSelected();
assert.equal(cv.selection.count, 3, "Select All should not add the fourth search");
let rows = zp.getCollectionTreeRows();
assert.sameMembers(rows.map(r => r.ref), [s1, s2, s3]);
});
});
describe("Items display", function () {
it("should show items from multiple selected collections", async function () {
let collection1 = await createDataObject('collection');
let collection2 = await createDataObject('collection');
let item1 = await createDataObject('item', { collections: [collection1.id] });
let item2 = await createDataObject('item', { collections: [collection2.id] });
await selectMultipleCollections([collection1, collection2]);
let itemsView = zp.itemsView;
assert.ok(itemsView.getRowIndexByID(item1.id) !== false, "Item from first collection should be shown");
assert.ok(itemsView.getRowIndexByID(item2.id) !== false, "Item from second collection should be shown");
});
it("should show items from subcollections with recursiveCollections", async function () {
Zotero.Prefs.set('recursiveCollections', true);
let collection1 = await createDataObject('collection');
let subCollection1 = await createDataObject('collection', { parentID: collection1.id });
let collection2 = await createDataObject('collection');
let subCollection2 = await createDataObject('collection', { parentID: collection2.id });
let item1 = await createDataObject('item', { collections: [subCollection1.id] });
let item2 = await createDataObject('item', { collections: [subCollection2.id] });
let item3 = await createDataObject('item', { collections: [collection1.id] });
await selectMultipleCollections([collection1, collection2]);
let itemsView = zp.itemsView;
assert.ok(itemsView.getRowIndexByID(item1.id) !== false,
"Item from subcollection of first collection should be shown");
assert.ok(itemsView.getRowIndexByID(item2.id) !== false,
"Item from subcollection of second collection should be shown");
assert.ok(itemsView.getRowIndexByID(item3.id) !== false,
"Item directly in first collection should be shown");
});
it("should not duplicate items that exist in both selected collections", async function () {
let collection1 = await createDataObject('collection');
let collection2 = await createDataObject('collection');
let item = await createDataObject('item', { collections: [collection1.id, collection2.id] });
await selectMultipleCollections([collection1, collection2]);
let itemsView = zp.itemsView;
// Count how many times the item appears. Compare the row's ref object
// rather than its id: a section header's ref is the library, whose id can
// collide with an item id in a fresh database.
let count = 0;
for (let i = 0; i < itemsView.rowCount; i++) {
if (itemsView.getRow(i).ref === item) {
count++;
}
}
assert.equal(count, 1, "Item in both collections should appear only once");
});
it("should group items by library for a cross-library selection", async function () {
let group = await createGroup();
let collection1 = await createDataObject('collection');
let collection2 = await createDataObject('collection', { libraryID: group.libraryID });
await createDataObject('item', { collections: [collection1.id] });
await createDataObject('item', { libraryID: group.libraryID, collections: [collection2.id] });
await zp.collectionsView.expandLibrary(group.libraryID);
await selectMultipleCollections([collection1, collection2]);
// The detailed grouping behavior (ordering, header heights, gating) is
// covered in collectionViewItemTreeTest; here just confirm that a
// cross-library selection produces a library-grouped view
assert.isNumber(zp.itemsView.getRowIndexByID("L" + Zotero.Libraries.userLibraryID),
"Cross-library selection should show a library header");
await selectLibrary(win);
await group.eraseTx();
});
});
describe("#newItem()", function () {
it("should add a new item to all selected collections", async function () {
let collection1 = await createDataObject('collection');
let collection2 = await createDataObject('collection');
await selectMultipleCollections([collection1, collection2]);
let item = await zp.newItem(Zotero.ItemTypes.getID('book'), {}, null, true);
assert.isTrue(item.inCollection(collection1.id),
"New item should be in first selected collection");
assert.isTrue(item.inCollection(collection2.id),
"New item should be in second selected collection");
});
});
describe("#duplicateSelectedItem()", function () {
it("should add duplicate to the subset of selected collections the original belongs to", async function () {
let collection1 = await createDataObject('collection');
let collection2 = await createDataObject('collection');
let collection3 = await createDataObject('collection');
// Item is in collections 1 and 3, not 2
let item = await createDataObject('item', { collections: [collection1.id, collection3.id] });
// Select collection1, which contains the item
await select(win, collection1);
await zp.itemsView.selectItem(item.id);
// Temporarily override getCollectionTreeRows to simulate multi-collection selection
let cv = zp.collectionsView;
let origGetRows = zp.getCollectionTreeRows;
let row1 = cv.getRowIndexByID("C" + collection1.id);
let row2 = cv.getRowIndexByID("C" + collection2.id);
let row3 = cv.getRowIndexByID("C" + collection3.id);
zp.getCollectionTreeRows = function () {
return [row1, row2, row3].map(index => cv.getRow(index));
};
try {
let duplicate = await zp.duplicateSelectedItem();
// Duplicate should be in collections 1 and 3 (selected + original is there)
// but not in collection 2 (selected but original isn't there)
assert.isTrue(duplicate.inCollection(collection1.id),
"Duplicate should be in collection 1 (selected and original exists)");
assert.isFalse(duplicate.inCollection(collection2.id),
"Duplicate should not be in collection 2 (selected but original doesn't exist)");
assert.isTrue(duplicate.inCollection(collection3.id),
"Duplicate should be in collection 3 (selected and original exists)");
}
finally {
zp.getCollectionTreeRows = origGetRows;
}
});
});
describe("#deleteSelectedCollection()", function () {
it("should move all selected collections to trash", async function () {
let collection1 = await createDataObject('collection');
let collection2 = await createDataObject('collection');
await selectMultipleCollections([collection1, collection2]);
let promise = waitForDialog();
await zp.deleteSelectedCollection();
await promise;
assert.isTrue(collection1.deleted,
"First collection should be in trash");
assert.isTrue(collection2.deleted,
"Second collection should be in trash");
});
});
});
describe("Quick search", function () {
var origWidth, origHeight;
before(function () {
origWidth = win.outerWidth;
origHeight = win.outerHeight;
});
after(function () {
win.resizeTo(origWidth, origHeight);
});
it("should keep the Advanced Search button within the items pane when the items pane is squeezed", async function () {
await selectLibrary(win);
// Narrow enough that the items pane (and its toolbar) is squeezed, but still wide
// enough to stay in standard (non-stacked) layout. With the collections pane and a
// wide item pane, this leaves the search field too narrow to reach its full width.
win.resizeTo(1000, 800);
await waitForCallback(() => win.innerWidth <= 1010, 10, 20);
let container = doc.getElementById('zotero-items-pane-container');
let search = doc.getElementById('zotero-tb-search');
let advButton = search.querySelector('#zotero-tb-search-advanced-button');
assert.ok(advButton, "Advanced Search button exists");
// Precondition: the field is squeezed below its max width, so the wrapper would
// overflow and push the trailing button out if it weren't allowed to shrink
assert.isBelow(search.getBoundingClientRect().width, 300,
"search field is squeezed below its full width");
// The button must stay within the items pane rather than overflowing under the item pane
assert.isAtMost(advButton.getBoundingClientRect().right, container.getBoundingClientRect().right,
"Advanced Search button's right edge is within the items pane");
});
});
})