diff --git a/chrome/content/zotero/xpcom/plugins.js b/chrome/content/zotero/xpcom/plugins.js index 060c22ed96..2e435ccf76 100644 --- a/chrome/content/zotero/xpcom/plugins.js +++ b/chrome/content/zotero/xpcom/plugins.js @@ -30,9 +30,16 @@ Zotero.Plugins = new function () { XPCOMUtils.defineLazyModuleGetters(lazy, { XPIDatabase: "resource://gre/modules/addons/XPIDatabase.jsm", }); + XPCOMUtils.defineLazyServiceGetters(lazy, { + aomStartup: [ + "@mozilla.org/addons/addon-manager-startup;1", + "amIAddonManagerStartup", + ], + }); var scopes = new Map(); var observers = new Set(); var addonVersions = new Map(); + var addonL10nSources = new Map(); const REASONS = { APP_STARTUP: 1, @@ -63,7 +70,7 @@ Zotero.Plugins = new function () { addonVersions.set(addon.id, addon.version); _loadScope(addon); setDefaultPrefs(addon); - registerLocales(addon); + await registerLocales(addon); await _callMethod(addon, 'startup', REASONS.APP_STARTUP); } @@ -366,28 +373,150 @@ Zotero.Plugins = new function () { } - // Automatically register l10n sources for enabled plugins. - // - // A Fluent file located at - // [plugin root]/locale/en-US/make-it-red.ftl - // could be included in an XHTML file as - // - // - // If a plugin doesn't have a subdirectory for the active locale, en-US strings - // will be used as a fallback. - function registerLocales(addon) { - let source = new L10nFileSource( - addon.id, - 'app', - Services.locale.availableLocales, - addon.getResourceURI().spec + 'locale/{locale}/', - ); - L10nRegistry.getInstance().registerSources([source]); + /** + * Automatically register l10n sources for a plugin. + * + * A Fluent file located at + * [plugin root]/locale/en-US/make-it-red.ftl + * could be included in an XHTML file as + * + * + * Locale subdirectories that match Zotero locales (Services.locale.availableLocales) + * are registered as is. Other locales are aliased to a best-fit Zotero locale. + * For example, Zotero has an 'eu-ES' locale but no 'eu-FR' locale. If a plugin + * included an 'eu-FR' locale instead, 'eu-FR' would be aliased to 'eu-ES', + * and 'eu-FR' strings would show if the user's Zotero locale is 'eu-ES'. + * + * If a plugin doesn't have a locale matching the current Zotero locale, 'en-US' + * is used as a fallback. If it doesn't have an 'en-*' locale, Fluent chooses a + * fallback arbitrarily. For instance, a plugin with only a 'de' locale would + * show German strings even if the user's Zotero locale is 'en-US'. + * + * @param addon + * @returns {Promise} + */ + async function registerLocales(addon) { + let rootURI = addon.getResourceURI(); + let zoteroLocales = Services.locale.availableLocales; + let pluginLocales; + try { + pluginLocales = await readDirectory(rootURI, 'locale', true); + if (!pluginLocales.length) { + return; + } + } + catch (e) { + Zotero.logError(e); + return; + } + + let matchedLocales = []; + let unmatchedLocales = []; + for (let pluginLocale of pluginLocales) { + (zoteroLocales.includes(pluginLocale) ? matchedLocales : unmatchedLocales) + .push(pluginLocale); + } + + let sources = []; + // All locales that exactly match a Zotero locale can be registered at once + if (matchedLocales.length) { + sources.push(new L10nFileSource( + addon.id, + 'app', + matchedLocales, + // {locale} is replaced with the locale code + rootURI.spec + 'locale/{locale}/', + )); + } + // Other locales need to be registered individually to create aliases + for (let unmatchedLocale of unmatchedLocales) { + let resolvedLocale = Zotero.Utilities.Internal.resolveLocale(unmatchedLocale, zoteroLocales); + // resolveLocale() returns en-US as a fallback; don't use it unless + // the unmatched plugin locale is en-* + if (resolvedLocale === 'en-US' && !unmatchedLocale.startsWith('en')) { + Zotero.debug(`${addon.id}: No matching locale for ${unmatchedLocale}`); + continue; + } + Zotero.debug(`${addon.id}: Aliasing ${unmatchedLocale} to ${resolvedLocale}`); + if (sources.some(source => source.locales.includes(resolvedLocale))) { + Zotero.debug(`${addon.id}: ${resolvedLocale} already registered`); + continue; + } + sources.push(new L10nFileSource( + addon.id + '-' + unmatchedLocale, + 'app', + [resolvedLocale], + // Don't use the {locale} placeholder here - manually specify the aliased locale code + rootURI.spec + `locale/${unmatchedLocale}/`, + )); + } + L10nRegistry.getInstance().registerSources(sources); + addonL10nSources.set(addon.id, sources.map(source => source.name)); } function unregisterLocales(addon) { - L10nRegistry.getInstance().removeSources([addon.id]); + let sources = addonL10nSources.get(addon.id); + if (sources) { + L10nRegistry.getInstance().removeSources(sources); + addonL10nSources.delete(addon.id); + } + } + + /** + * Read the contents of a directory in a plugin. + * https://searchfox.org/mozilla-esr115/rev/7a83be92b8356ea63559bc3623b2b91a43f2ae05/toolkit/components/extensions/Extension.sys.mjs#882 + * + * @param {nsIURI} rootURI + * @param {string} path + * @param {boolean} [directoriesOnly=false] + * @returns {Promise} + */ + async function readDirectory(rootURI, path, directoriesOnly = false) { + if (rootURI instanceof Ci.nsIFileURL) { + let uri = Services.io.newURI("./" + path, null, rootURI); + let fullPath = uri.QueryInterface(Ci.nsIFileURL).file.path; + + let results = []; + try { + let children = await IOUtils.getChildren(fullPath); + for (let child of children) { + if (!directoriesOnly || (await IOUtils.stat(child)).type == "directory") { + results.push(PathUtils.filename(child)); + } + } + } + catch (ex) { + // Fall-through, return what we have. + } + return results; + } + + rootURI = rootURI.QueryInterface(Ci.nsIJARURI); + + // Append the sub-directory path to the base JAR URI and normalize the + // result. + let entry = `${rootURI.JAREntry}/${path}/` + .replace(/\/\/+/g, "/") + .replace(/^\//, ""); + rootURI = Services.io.newURI(`jar:${rootURI.JARFile.spec}!/${entry}`); + + let results = []; + for (let name of lazy.aomStartup.enumerateJARSubtree(rootURI)) { + if (!name.startsWith(entry)) { + throw new Error("Unexpected ZipReader entry"); + } + + // The enumerator returns the full path of all entries. + // Trim off the leading path, and filter out entries from + // subdirectories. + name = name.slice(entry.length); + if (name && !/\/./.test(name) && (!directoriesOnly || name.endsWith("/"))) { + results.push(name.replace("/", "")); + } + } + + return results; } @@ -521,7 +650,7 @@ Zotero.Plugins = new function () { _loadScope(addon); setDefaultPrefs(addon); - registerLocales(addon); + await registerLocales(addon); await _callMethod(addon, 'install', reason); if (addon.isActive) { await _callMethod(addon, 'startup', reason); @@ -534,7 +663,7 @@ Zotero.Plugins = new function () { } Zotero.debug("Enabling plugin " + addon.id); setDefaultPrefs(addon); - registerLocales(addon); + await registerLocales(addon); await _callMethod(addon, 'startup', REASONS.ADDON_ENABLE); }, @@ -581,7 +710,7 @@ Zotero.Plugins = new function () { await _callMethod(addon, 'install', REASONS.ADDON_INSTALL); if (addon.isActive) { setDefaultPrefs(addon); - registerLocales(addon); + await registerLocales(addon); await _callMethod(addon, 'startup', REASONS.ADDON_INSTALL); } }