diff --git a/chrome/content/zotero/dictionaryManager.js b/chrome/content/zotero/dictionaryManager.js
new file mode 100644
index 0000000000..d85630d740
--- /dev/null
+++ b/chrome/content/zotero/dictionaryManager.js
@@ -0,0 +1,193 @@
+/*
+ ***** BEGIN LICENSE BLOCK *****
+
+ Copyright © 2021 Corporation for Digital Scholarship
+ Vienna, Virginia, USA
+ https://digitalscholar.org
+
+ This file is part of Zotero.
+
+ Zotero is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Zotero is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with Zotero. If not, see .
+
+ ***** END LICENSE BLOCK *****
+*/
+
+"use strict";
+
+// eslint-disable-next-line camelcase, no-unused-vars
+var Zotero_Dictionary_Manager = new function () {
+ const HTML_NS = 'http://www.w3.org/1999/xhtml';
+
+ var installed;
+ var updateMap;
+
+ this.init = async function () {
+ document.title = Zotero.getString('spellCheck.dictionaryManager.title');
+
+ installed = new Set(Zotero.Dictionaries.dictionaries.map(d => d.id));
+ var installedLocales = new Set(Zotero.Dictionaries.dictionaries.map(d => d.locale));
+ var availableDictionaries = await Zotero.Dictionaries.fetchDictionariesList();
+ var availableUpdates = await Zotero.Dictionaries.getAvailableUpdates(availableDictionaries);
+ updateMap = new Map(availableUpdates.map(x => [x.old.id, x.new.id]));
+
+ var { InlineSpellChecker } = ChromeUtils.import("resource://gre/modules/InlineSpellChecker.jsm", {});
+ var isc = new InlineSpellChecker();
+
+ // Start with installed dictionaries
+ var list = [];
+ for (let d of Zotero.Dictionaries.dictionaries) {
+ let name = Zotero.Dictionaries.getBestDictionaryName(d.locale, isc);
+ list.push(Object.assign({}, d, { name }));
+ }
+ // Add remote dictionaries not in the list
+ for (let d of availableDictionaries) {
+ if (!installed.has(d.id) && !installedLocales.has(d.locale)) {
+ list.push(d);
+ }
+ }
+ var positionMap = new Map(availableDictionaries.map((d, i) => [d.locale, i + 1]));
+ list.sort((a, b) => {
+ // If both locales are in original list, use the original sort order
+ let posA = positionMap.get(a.locale);
+ let posB = positionMap.get(b.locale);
+ if (posA && posB) {
+ return posA - posB;
+ }
+ // Otherwise compare the locale codes
+ return Zotero.localeCompare(a.locale, b.locale);
+ });
+
+ // Build list
+ var listbox = document.getElementById('dictionaries');
+ for (let d of list) {
+ let name = d.name;
+ let li = document.createElement('richlistitem');
+ let div = document.createElementNS(HTML_NS, 'div');
+
+ let checkbox = document.createElementNS(HTML_NS, 'input');
+ checkbox.type = 'checkbox';
+ checkbox.id = d.locale;
+ // Store properties on element
+ // .id will be the current id for installed dictionaries and otherwise the remote id
+ checkbox.dataset.dictId = d.id;
+ checkbox.dataset.dictLocale = d.locale;
+ checkbox.dataset.dictName = d.name;
+ // en-US is always checked and disabled
+ checkbox.checked = d.locale == 'en-US' || installed.has(d.id);
+ if (d.locale == 'en-US') {
+ checkbox.disabled = true;
+ }
+ checkbox.setAttribute('tabindex', -1);
+
+ let label = document.createElementNS(HTML_NS, 'label');
+ label.setAttribute('for', d.locale);
+ // Add " (update available)"
+ if (updateMap.has(d.id)) {
+ name = Zotero.getString('spellCheck.dictionaryManager.updateAvailable', name);
+ }
+ label.textContent = name;
+ // Don't toggle checkbox for single-click on label
+ label.onclick = (event) => {
+ if (event.detail == 1) {
+ event.preventDefault();
+ }
+ };
+
+ div.appendChild(checkbox);
+ div.appendChild(label);
+ li.appendChild(div);
+ listbox.appendChild(li);
+ }
+ listbox.selectedIndex = 0;
+ };
+
+ this.handleAccept = async function () {
+ // Download selected dictionaries if updated or not currently installed
+ var elems = document.querySelectorAll('input[type=checkbox]');
+ var toRemove = [];
+ var toDownload = [];
+ for (let elem of elems) {
+ if (elem.dataset.dictLocale == 'en-US') {
+ continue;
+ }
+
+ let id = elem.dataset.dictId;
+ if (!elem.checked) {
+ if (installed.has(id)) {
+ toRemove.push(id);
+ }
+ continue;
+ }
+
+ if (updateMap.has(id)) {
+ // If id is changing, delete the old one first
+ toRemove.push(id);
+ toDownload.push({ id: updateMap.get(id), name: elem.dataset.dictName });
+ }
+ else if (!installed.has(id)) {
+ toDownload.push({ id, name: elem.dataset.dictName });
+ }
+ }
+ if (toRemove.length) {
+ for (let id of toRemove) {
+ await Zotero.Dictionaries.remove(id);
+ }
+ }
+ if (toDownload.length) {
+ for (let { id, name } of toDownload) {
+ _updateStatus(Zotero.getString('general.downloading.quoted', name));
+ try {
+ await Zotero.Dictionaries.install(id);
+ }
+ catch (e) {
+ Zotero.logError(e);
+ Zotero.alert(
+ null,
+ Zotero.getString('general.error'),
+ Zotero.getString('spellCheck.dictionaryManager.error.unableToInstall', name)
+ + "\n\n" + e.message
+ );
+ return;
+ }
+ finally {
+ _updateStatus();
+ }
+ }
+ }
+ window.close();
+ };
+
+ function _updateStatus(msg) {
+ var elem = document.getElementById('status');
+ elem.textContent = msg
+ // Use non-breaking space to maintain height when empty
+ || '\xA0';
+ }
+};
+
+window.addEventListener('keypress', function (event) {
+ // Toggle checkbox on spacebar
+ if (event.key == ' ') {
+ if (event.target.localName == 'richlistbox') {
+ let elem = event.target.selectedItem.querySelector('input[type=checkbox]');
+ if (!elem.disabled) {
+ elem.checked = !elem.checked;
+ }
+ }
+ }
+
+ if (event.key == 'Enter') {
+ document.querySelector('button[dlgtype="accept"]').click();
+ }
+});
diff --git a/chrome/content/zotero/dictionaryManager.xul b/chrome/content/zotero/dictionaryManager.xul
new file mode 100644
index 0000000000..482b3102ad
--- /dev/null
+++ b/chrome/content/zotero/dictionaryManager.xul
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/chrome/content/zotero/xpcom/dictionaries.js b/chrome/content/zotero/xpcom/dictionaries.js
new file mode 100644
index 0000000000..f2c262d0b2
--- /dev/null
+++ b/chrome/content/zotero/xpcom/dictionaries.js
@@ -0,0 +1,289 @@
+/*
+ ***** BEGIN LICENSE BLOCK *****
+
+ Copyright © 2021 Corporation for Digital Scholarship
+ Vienna, Virginia, USA
+ http://digitalscholar.org/
+
+ This file is part of Zotero.
+
+ Zotero is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Zotero is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with Zotero. If not, see .
+
+ ***** END LICENSE BLOCK *****
+*/
+
+Zotero.Dictionaries = new function () {
+ let _dictionaries = [];
+ let _spellChecker = Cc['@mozilla.org/spellchecker/engine;1']
+ .getService(Ci.mozISpellCheckingEngine);
+ _spellChecker.QueryInterface(Ci.mozISpellCheckingEngine);
+
+ Zotero.defineProperty(this, 'baseURL', {
+ get: () => {
+ let url = ZOTERO_CONFIG.DICTIONARIES_URL;
+ if (!url.endsWith('/')) {
+ url += '/';
+ }
+ return url;
+ }
+ });
+
+ // Note: Doesn't include bundled en-US
+ Zotero.defineProperty(this, 'dictionaries', {
+ get: () => {
+ return _dictionaries;
+ }
+ });
+
+ /**
+ * Load all dictionaries
+ *
+ * @return {Promise}
+ */
+ this.init = async function () {
+ let dictionariesDir = OS.Path.join(Zotero.Profile.dir, 'dictionaries');
+ if (!(await OS.File.exists(dictionariesDir))) {
+ return;
+ }
+ let iterator = new OS.File.DirectoryIterator(dictionariesDir);
+ try {
+ await iterator.forEach(async function (entry) {
+ if (entry.name.startsWith('.')) {
+ return;
+ }
+ try {
+ let dir = OS.Path.join(dictionariesDir, entry.name);
+ await _loadDirectory(dir);
+ }
+ catch (e) {
+ Zotero.logError(e);
+ }
+ });
+ }
+ finally {
+ iterator.close();
+ }
+ };
+
+ /**
+ * Get available dictionaries from server
+ *
+ * @return {Promise