From 8235af22db38bc7e8e2da2bb2e5ed00f84b348b0 Mon Sep 17 00:00:00 2001 From: Nils Maier Date: Mon, 26 Aug 2019 02:25:18 +0200 Subject: [PATCH] Implement own locale loader Why? * Because i18n sucks multi-browser, especially on Chrome. * What's more, this will allow overlaying "incomplete" locales with missing strings over the base locale and get a semi-translated one, which probably is better than nothing. * Additionally we kinda need that implementation anyway for node-based tests. * Also, while not currently implemented, it could allow (hot) reloading of locales, or loading external ones, which would help translators, or providing an option to the user to choose a locale. * And finally, not calling i18n will avoid the "context switch" into browserland. Some implementation details: * Before code can use a locale, it has to be loaded. Sadly sync loading is not really supported. So `await locale` or `await localize`. * Background force reloads locales right now, and caches them in localStorage. Windows will look into localStorage for that cache. * The locale loader will not verify locales other than some rudimentary checks. It is assumed that shipped locales where verified before check-in. --- lib/background.ts | 762 +++++++++++++++++---------------- lib/browser.ts | 1 - lib/filters.ts | 6 +- lib/i18n.ts | 220 +++++++--- uikit/lib/modal.ts | 8 +- windows/manager.ts | 70 +-- windows/manager/itemfilters.ts | 5 +- windows/manager/removaldlg.ts | 4 +- windows/manager/state.ts | 6 +- windows/manager/table.ts | 15 +- windows/manager/tooltip.ts | 4 +- windows/prefs.ts | 7 +- windows/single.ts | 6 +- 13 files changed, 623 insertions(+), 491 deletions(-) diff --git a/lib/background.ts b/lib/background.ts index adaa87a..539996f 100644 --- a/lib/background.ts +++ b/lib/background.ts @@ -5,7 +5,7 @@ import { ALLOWED_SCHEMES, TRANSFERABLE_PROPERTIES } from "./constants"; import { API } from "./api"; import { Finisher, makeUniqueItems } from "./item"; import { Prefs } from "./prefs"; -import { _ } from "./i18n"; +import { _, locale } from "./i18n"; import { openPrefs, openManager } from "./windowutils"; import { filters } from "./filters"; import { getManager } from "./manager/man"; @@ -106,409 +106,411 @@ class Handler { } } -new class Action extends Handler { - constructor() { - super(); - this.onClicked = this.onClicked.bind(this); - action.onClicked.addListener(this.onClicked); - } - - async onClicked(tab: {id: number}) { - if (!tab.id) { - return; +locale.then(() => { + new class Action extends Handler { + constructor() { + super(); + this.onClicked = this.onClicked.bind(this); + action.onClicked.addListener(this.onClicked); } - try { - await this.processResults( - true, - await runContentJob( - tab, "/bundles/content-gather.js", { - type: "DTA:gather", - selectionOnly: false, - textLinks: await Prefs.get("text-links", true), - schemes: Array.from(ALLOWED_SCHEMES.values()), - transferable: TRANSFERABLE_PROPERTIES, - })); - } - catch (ex) { - console.error(ex); - } - } -}(); -const menuHandler = new class Menus extends Handler { - constructor() { - super(); - this.onClicked = this.onClicked.bind(this); - const alls = new Map(); - const mcreate = (options: any) => { - if (options.contexts.includes("all")) { - alls.set(options.id, options.contexts); - } - return menus.create(options); - }; - mcreate({ - id: "DTARegular", - contexts: ["all", "browser_action", "tools_menu"], - icons: { - 16: "/style/button-regular.png", - 32: "/style/button-regular@2x.png", - }, - title: _("dta.regular"), - }); - mcreate({ - id: "DTATurbo", - contexts: ["all", "browser_action", "tools_menu"], - icons: { - 16: "/style/button-turbo.png", - 32: "/style/button-turbo@2x.png", - }, - title: _("dta.turbo"), - }); - mcreate({ - id: "DTARegularLink", - contexts: ["link"], - icons: { - 16: "/style/button-regular.png", - 32: "/style/button-regular@2x.png", - }, - title: _("dta.regular.link"), - }); - mcreate({ - id: "DTATurboLink", - contexts: ["link"], - icons: { - 16: "/style/button-turbo.png", - 32: "/style/button-turbo@2x.png", - }, - title: _("dta.turbo.link"), - }); - mcreate({ - id: "DTARegularImage", - contexts: ["image"], - icons: { - 16: "/style/button-regular.png", - 32: "/style/button-regular@2x.png", - }, - title: _("dta.regular.image"), - }); - mcreate({ - id: "DTATurboImage", - contexts: ["image"], - icons: { - 16: "/style/button-turbo.png", - 32: "/style/button-turbo@2x.png", - }, - title: _("dta.turbo.image"), - }); - mcreate({ - id: "DTARegularMedia", - contexts: ["video", "audio"], - icons: { - 16: "/style/button-regular.png", - 32: "/style/button-regular@2x.png", - }, - title: _("dta.regular.media"), - }); - mcreate({ - id: "DTATurboMedia", - contexts: ["video", "audio"], - icons: { - 16: "/style/button-turbo.png", - 32: "/style/button-turbo@2x.png", - }, - title: _("dta.turbo.media"), - }); - mcreate({ - id: "DTARegularSelection", - contexts: ["selection"], - icons: { - 16: "/style/button-regular.png", - 32: "/style/button-regular@2x.png", - }, - title: _("dta.regular.selection"), - }); - mcreate({ - id: "DTATurboSelection", - contexts: ["selection"], - icons: { - 16: "/style/button-turbo.png", - 32: "/style/button-turbo@2x.png", - }, - title: _("dta.turbo.selection"), - }); - mcreate({ - id: "sep-1", - contexts: ["all", "browser_action", "tools_menu"], - type: "separator" - }); - mcreate({ - id: "DTARegularAll", - contexts: ["all", "browser_action", "tools_menu"], - icons: { - 16: "/style/button-regular.png", - 32: "/style/button-regular@2x.png", - }, - title: _("dta-regular-all"), - }); - mcreate({ - id: "DTATurboAll", - contexts: ["all", "browser_action", "tools_menu"], - icons: { - 16: "/style/button-turbo.png", - 32: "/style/button-turbo@2x.png", - }, - title: _("dta-turbo-all"), - }); - const sep2ctx = menus.ACTION_MENU_TOP_LEVEL_LIMIT === 6 ? - ["all", "tools_menu"] : - ["all", "browser_action", "tools_menu"]; - mcreate({ - id: "sep-2", - contexts: sep2ctx, - type: "separator" - }); - mcreate({ - id: "DTAAdd", - contexts: ["all", "browser_action", "tools_menu"], - icons: { - 16: "/style/add.svg", - 32: "/style/add.svg", - 64: "/style/add.svg", - 128: "/style/add.svg", - }, - title: _("add-download"), - }); - mcreate({ - id: "sep-3", - contexts: ["all", "browser_action", "tools_menu"], - type: "separator" - }); - mcreate({ - id: "DTAManager", - contexts: ["all", "browser_action", "tools_menu"], - icons: { - 16: "/style/button-manager.png", - 32: "/style/button-manager@2x.png", - }, - title: _("manager.short"), - }); - mcreate({ - id: "DTAPrefs", - contexts: ["all", "browser_action", "tools_menu"], - icons: { - 16: "/style/settings.svg", - 32: "/style/settings.svg", - 64: "/style/settings.svg", - 128: "/style/settings.svg", - }, - title: _("prefs.short"), - }); - Object.freeze(alls); - - const adjustMenus = (v: boolean) => { - for (const [id, contexts] of alls.entries()) { - const adjusted = v ? - contexts.filter(e => e !== "all") : - contexts; - menus.update(id, { - contexts: adjusted - }); - } - }; - Prefs.get("hide-context", false).then((v: boolean) => { - // This is the initial load, so no need to adjust when visible already - if (!v) { + async onClicked(tab: {id: number}) { + if (!tab.id) { return; } - adjustMenus(v); - }); - Prefs.on("hide-context", (prefs, key, value: boolean) => { - adjustMenus(value); - }); + try { + await this.processResults( + true, + await runContentJob( + tab, "/bundles/content-gather.js", { + type: "DTA:gather", + selectionOnly: false, + textLinks: await Prefs.get("text-links", true), + schemes: Array.from(ALLOWED_SCHEMES.values()), + transferable: TRANSFERABLE_PROPERTIES, + })); + } + catch (ex) { + console.error(ex); + } + } + }(); - menus.onClicked.addListener(this.onClicked); - } + const menuHandler = new class Menus extends Handler { + constructor() { + super(); + this.onClicked = this.onClicked.bind(this); + const alls = new Map(); + const mcreate = (options: any) => { + if (options.contexts.includes("all")) { + alls.set(options.id, options.contexts); + } + return menus.create(options); + }; + mcreate({ + id: "DTARegular", + contexts: ["all", "browser_action", "tools_menu"], + icons: { + 16: "/style/button-regular.png", + 32: "/style/button-regular@2x.png", + }, + title: _("dta.regular"), + }); + mcreate({ + id: "DTATurbo", + contexts: ["all", "browser_action", "tools_menu"], + icons: { + 16: "/style/button-turbo.png", + 32: "/style/button-turbo@2x.png", + }, + title: _("dta.turbo"), + }); + mcreate({ + id: "DTARegularLink", + contexts: ["link"], + icons: { + 16: "/style/button-regular.png", + 32: "/style/button-regular@2x.png", + }, + title: _("dta.regular.link"), + }); + mcreate({ + id: "DTATurboLink", + contexts: ["link"], + icons: { + 16: "/style/button-turbo.png", + 32: "/style/button-turbo@2x.png", + }, + title: _("dta.turbo.link"), + }); + mcreate({ + id: "DTARegularImage", + contexts: ["image"], + icons: { + 16: "/style/button-regular.png", + 32: "/style/button-regular@2x.png", + }, + title: _("dta.regular.image"), + }); + mcreate({ + id: "DTATurboImage", + contexts: ["image"], + icons: { + 16: "/style/button-turbo.png", + 32: "/style/button-turbo@2x.png", + }, + title: _("dta.turbo.image"), + }); + mcreate({ + id: "DTARegularMedia", + contexts: ["video", "audio"], + icons: { + 16: "/style/button-regular.png", + 32: "/style/button-regular@2x.png", + }, + title: _("dta.regular.media"), + }); + mcreate({ + id: "DTATurboMedia", + contexts: ["video", "audio"], + icons: { + 16: "/style/button-turbo.png", + 32: "/style/button-turbo@2x.png", + }, + title: _("dta.turbo.media"), + }); + mcreate({ + id: "DTARegularSelection", + contexts: ["selection"], + icons: { + 16: "/style/button-regular.png", + 32: "/style/button-regular@2x.png", + }, + title: _("dta.regular.selection"), + }); + mcreate({ + id: "DTATurboSelection", + contexts: ["selection"], + icons: { + 16: "/style/button-turbo.png", + 32: "/style/button-turbo@2x.png", + }, + title: _("dta.turbo.selection"), + }); + mcreate({ + id: "sep-1", + contexts: ["all", "browser_action", "tools_menu"], + type: "separator" + }); + mcreate({ + id: "DTARegularAll", + contexts: ["all", "browser_action", "tools_menu"], + icons: { + 16: "/style/button-regular.png", + 32: "/style/button-regular@2x.png", + }, + title: _("dta-regular-all"), + }); + mcreate({ + id: "DTATurboAll", + contexts: ["all", "browser_action", "tools_menu"], + icons: { + 16: "/style/button-turbo.png", + 32: "/style/button-turbo@2x.png", + }, + title: _("dta-turbo-all"), + }); + const sep2ctx = menus.ACTION_MENU_TOP_LEVEL_LIMIT === 6 ? + ["all", "tools_menu"] : + ["all", "browser_action", "tools_menu"]; + mcreate({ + id: "sep-2", + contexts: sep2ctx, + type: "separator" + }); + mcreate({ + id: "DTAAdd", + contexts: ["all", "browser_action", "tools_menu"], + icons: { + 16: "/style/add.svg", + 32: "/style/add.svg", + 64: "/style/add.svg", + 128: "/style/add.svg", + }, + title: _("add-download"), + }); + mcreate({ + id: "sep-3", + contexts: ["all", "browser_action", "tools_menu"], + type: "separator" + }); + mcreate({ + id: "DTAManager", + contexts: ["all", "browser_action", "tools_menu"], + icons: { + 16: "/style/button-manager.png", + 32: "/style/button-manager@2x.png", + }, + title: _("manager.short"), + }); + mcreate({ + id: "DTAPrefs", + contexts: ["all", "browser_action", "tools_menu"], + icons: { + 16: "/style/settings.svg", + 32: "/style/settings.svg", + 64: "/style/settings.svg", + 128: "/style/settings.svg", + }, + title: _("prefs.short"), + }); + Object.freeze(alls); - *makeSingleItemList(url: string, results: any[]) { - for (const result of results) { - const finisher = new Finisher(result); - for (const list of [result.links, result.media]) { - for (const e of list) { - if (e.url !== url) { - continue; + const adjustMenus = (v: boolean) => { + for (const [id, contexts] of alls.entries()) { + const adjusted = v ? + contexts.filter(e => e !== "all") : + contexts; + menus.update(id, { + contexts: adjusted + }); + } + }; + Prefs.get("hide-context", false).then((v: boolean) => { + // This is the initial load, so no need to adjust when visible already + if (!v) { + return; + } + adjustMenus(v); + }); + Prefs.on("hide-context", (prefs, key, value: boolean) => { + adjustMenus(value); + }); + + menus.onClicked.addListener(this.onClicked); + } + + *makeSingleItemList(url: string, results: any[]) { + for (const result of results) { + const finisher = new Finisher(result); + for (const list of [result.links, result.media]) { + for (const e of list) { + if (e.url !== url) { + continue; + } + const finished = finisher.finish(e); + if (!finished) { + continue; + } + yield finished; } - const finished = finisher.finish(e); - if (!finished) { - continue; - } - yield finished; } } } - } - async findSingleItem(tab: any, url: string, turbo = false) { - if (!url) { - return; + async findSingleItem(tab: any, url: string, turbo = false) { + if (!url) { + return; + } + const results = await runContentJob( + tab, "/bundles/content-gather.js", { + type: "DTA:gather", + selectionOnly: false, + schemes: Array.from(ALLOWED_SCHEMES.values()), + transferable: TRANSFERABLE_PROPERTIES, + }); + const found = Array.from(this.makeSingleItemList(url, results)); + const unique = makeUniqueItems([found]); + if (!unique.length) { + return; + } + const [item] = unique; + API[turbo ? "singleTurbo" : "singleRegular"](item); } - const results = await runContentJob( - tab, "/bundles/content-gather.js", { - type: "DTA:gather", + + onClicked(info: any, tab: any) { + if (!tab.id) { + return; + } + const {menuItemId} = info; + const {[`onClicked${menuItemId}`]: handler}: any = this; + if (!handler) { + console.error("Invalid Handler for", menuItemId); + return; + } + handler.call(this, info, tab).catch(console.error); + } + + async enumulate(action: string) { + const tab = await tabs.query({active: true}); + if (!tab || !tab.length) { + return; + } + this.onClicked({ + menuItemId: action + }, tab[0]); + } + + async onClickedDTARegular(info: any, tab: any) { + return await this.performSelection({ selectionOnly: false, - schemes: Array.from(ALLOWED_SCHEMES.values()), - transferable: TRANSFERABLE_PROPERTIES, + allTabs: false, + turbo: false, + tab, }); - const found = Array.from(this.makeSingleItemList(url, results)); - const unique = makeUniqueItems([found]); - if (!unique.length) { - return; } - const [item] = unique; - API[turbo ? "singleTurbo" : "singleRegular"](item); - } - onClicked(info: any, tab: any) { - if (!tab.id) { - return; + async onClickedDTARegularAll(info: any, tab: any) { + return await this.performSelection({ + selectionOnly: false, + allTabs: true, + turbo: false, + tab, + }); } - const {menuItemId} = info; - const {[`onClicked${menuItemId}`]: handler}: any = this; - if (!handler) { - console.error("Invalid Handler for", menuItemId); - return; + + async onClickedDTARegularSelection(info: any, tab: any) { + return await this.performSelection({ + selectionOnly: true, + allTabs: false, + turbo: false, + tab, + }); } - handler.call(this, info, tab).catch(console.error); - } - async enumulate(action: string) { - const tab = await tabs.query({active: true}); - if (!tab || !tab.length) { - return; + async onClickedDTATurbo(info: any, tab: any) { + return await this.performSelection({ + selectionOnly: false, + allTabs: false, + turbo: true, + tab, + }); } - this.onClicked({ - menuItemId: action - }, tab[0]); - } - async onClickedDTARegular(info: any, tab: any) { - return await this.performSelection({ - selectionOnly: false, - allTabs: false, - turbo: false, - tab, + async onClickedDTATurboAll(info: any, tab: any) { + return await this.performSelection({ + selectionOnly: false, + allTabs: true, + turbo: true, + tab, + }); + } + + async onClickedDTATurboSelection(info: any, tab: any) { + return await this.performSelection({ + selectionOnly: true, + allTabs: false, + turbo: true, + tab, + }); + } + + async onClickedDTARegularLink(info: any, tab: any) { + return await this.findSingleItem(tab, info.linkUrl, false); + } + + async onClickedDTATurboLink(info: any, tab: any) { + return await this.findSingleItem(tab, info.linkUrl, true); + } + + async onClickedDTARegularImage(info: any, tab: any) { + return await this.findSingleItem(tab, info.srcUrl, false); + } + + async onClickedDTATurboImage(info: any, tab: any) { + return await this.findSingleItem(tab, info.srcUrl, true); + } + + async onClickedDTARegularMedia(info: any, tab: any) { + return await this.findSingleItem(tab, info.srcUrl, false); + } + + async onClickedDTATurboMedia(info: any, tab: any) { + return await this.findSingleItem(tab, info.srcUrl, true); + } + + onClickedDTAAdd() { + API.singleRegular(null); + } + + async onClickedDTAManager() { + await openManager(); + } + + async onClickedDTAPrefs() { + await openPrefs(); + } + }(); + + Bus.on("do-regular", () => menuHandler.enumulate("DTARegular")); + Bus.on("do-regular-all", () => menuHandler.enumulate("DTARegularAll")); + Bus.on("do-turbo", () => menuHandler.enumulate("DTATurbo")); + Bus.on("do-turbo-all", () => menuHandler.enumulate("DTATurboAll")); + Bus.on("do-single", () => API.singleRegular(null)); + Bus.on("open-manager", () => openManager(true)); + Bus.on("open-prefs", () => openPrefs()); + + function adjustAction(globalTurbo: boolean) { + action.setPopup({ + popup: globalTurbo ? "" : null + }); + action.setIcon({ + path: globalTurbo ? { + 16: "/style/button-turbo.png", + 32: "/style/button-turbo@2x.png", + } : null }); } - async onClickedDTARegularAll(info: any, tab: any) { - return await this.performSelection({ - selectionOnly: false, - allTabs: true, - turbo: false, - tab, + (async function init() { + await Prefs.set("last-run", new Date()); + Prefs.get("global-turbo", false).then(v => adjustAction(v)); + Prefs.on("global-turbo", (prefs, key, value) => { + adjustAction(value); }); - } - - async onClickedDTARegularSelection(info: any, tab: any) { - return await this.performSelection({ - selectionOnly: true, - allTabs: false, - turbo: false, - tab, - }); - } - - async onClickedDTATurbo(info: any, tab: any) { - return await this.performSelection({ - selectionOnly: false, - allTabs: false, - turbo: true, - tab, - }); - } - - async onClickedDTATurboAll(info: any, tab: any) { - return await this.performSelection({ - selectionOnly: false, - allTabs: true, - turbo: true, - tab, - }); - } - - async onClickedDTATurboSelection(info: any, tab: any) { - return await this.performSelection({ - selectionOnly: true, - allTabs: false, - turbo: true, - tab, - }); - } - - async onClickedDTARegularLink(info: any, tab: any) { - return await this.findSingleItem(tab, info.linkUrl, false); - } - - async onClickedDTATurboLink(info: any, tab: any) { - return await this.findSingleItem(tab, info.linkUrl, true); - } - - async onClickedDTARegularImage(info: any, tab: any) { - return await this.findSingleItem(tab, info.srcUrl, false); - } - - async onClickedDTATurboImage(info: any, tab: any) { - return await this.findSingleItem(tab, info.srcUrl, true); - } - - async onClickedDTARegularMedia(info: any, tab: any) { - return await this.findSingleItem(tab, info.srcUrl, false); - } - - async onClickedDTATurboMedia(info: any, tab: any) { - return await this.findSingleItem(tab, info.srcUrl, true); - } - - onClickedDTAAdd() { - API.singleRegular(null); - } - - async onClickedDTAManager() { - await openManager(); - } - - async onClickedDTAPrefs() { - await openPrefs(); - } -}(); - -Bus.on("do-regular", () => menuHandler.enumulate("DTARegular")); -Bus.on("do-regular-all", () => menuHandler.enumulate("DTARegularAll")); -Bus.on("do-turbo", () => menuHandler.enumulate("DTATurbo")); -Bus.on("do-turbo-all", () => menuHandler.enumulate("DTATurboAll")); -Bus.on("do-single", () => API.singleRegular(null)); -Bus.on("open-manager", () => openManager(true)); -Bus.on("open-prefs", () => openPrefs()); - -function adjustAction(globalTurbo: boolean) { - action.setPopup({ - popup: globalTurbo ? "" : null + await filters(); + await getManager(); + })().catch(ex => { + console.error("Failed to init components", ex.toString(), ex.stack, ex); }); - action.setIcon({ - path: globalTurbo ? { - 16: "/style/button-turbo.png", - 32: "/style/button-turbo@2x.png", - } : null - }); -} - -(async function init() { - await Prefs.set("last-run", new Date()); - Prefs.get("global-turbo", false).then(v => adjustAction(v)); - Prefs.on("global-turbo", (prefs, key, value) => { - adjustAction(value); - }); - await filters(); - await getManager(); -})().catch(ex => { - console.error("Failed to init components", ex.toString(), ex.stack, ex); }); diff --git a/lib/browser.ts b/lib/browser.ts index e8c0a57..77b155d 100644 --- a/lib/browser.ts +++ b/lib/browser.ts @@ -3,7 +3,6 @@ // eslint-disable-next-line @typescript-eslint/no-var-requires const polyfill = require("webextension-polyfill"); -export const {i18n} = polyfill; export const {extension} = polyfill; export const {notifications} = polyfill; export const {browserAction} = polyfill; diff --git a/lib/filters.ts b/lib/filters.ts index 670f53e..136538f 100644 --- a/lib/filters.ts +++ b/lib/filters.ts @@ -4,13 +4,14 @@ import uuid from "./uuid"; import "./objectoverlay"; -import { storage, i18n } from "./browser"; +import { storage } from "./browser"; import { EventEmitter } from "./events"; import { TYPE_LINK, TYPE_MEDIA, TYPE_ALL } from "./constants"; // eslint-disable-next-line no-unused-vars import { Overlayable } from "./objectoverlay"; import * as DEFAULT_FILTERS from "../data/filters.json"; import { FASTFILTER } from "./recentlist"; +import { _, locale } from "./i18n"; const REG_ESCAPE = /[{}()[\]\\^$.]/g; const REG_FNMATCH = /[*?]/; @@ -205,7 +206,7 @@ export class Filter { this._label = this.raw.label; if (this.id !== FAST && this.id.startsWith("deffilter-") && !this.raw.isOverridden("label")) { - this._label = i18n.getMessage(this.id) || this._label; + this._label = _(this.id) || this._label; } this._reg = Matcher.fromExpression(this.expr); Object.seal(this); @@ -475,6 +476,7 @@ class Filters extends EventEmitter { } async load() { + await locale; const defaultFilters = DEFAULT_FILTERS as any; let savedFilters = (await storage.local.get("userFilters")); if (savedFilters && "userFilters" in savedFilters) { diff --git a/lib/i18n.ts b/lib/i18n.ts index e88874d..247c28f 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -3,57 +3,170 @@ import {memoize} from "./memoize"; - declare let browser: any; declare let chrome: any; -function load() { - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - if (typeof browser !== "undefined" && browser.i18n) { - return browser.i18n; +interface JSONEntry { + message: string; + placeholders: any; +} + +class Entry { + private message: string; + + constructor(entry: JSONEntry) { + if (!entry.message.includes("$")) { + throw new Error("Not entry-able"); } - if (typeof chrome !== "undefined" && chrome.i18n) { - return chrome.i18n; + let hit = false; + this.message = entry.message.replace(/\$[A-Z0-9]+\$/g, (r: string) => { + hit = true; + const id = r.substr(1, r.length - 2).toLocaleLowerCase(); + const pholder = entry.placeholders[id]; + if (!pholder || !pholder.content) { + throw new Error(`Invalid placeholder: ${id}`); + } + return `${pholder.content}$`; + }); + if (!hit) { + throw new Error("Not entry-able"); } - throw new Error("not in a webext"); } - catch (ex) { + + localize(args: any[]) { + return this.message.replace(/\$\d+\$/g, (r: string) => { + const idx = parseInt(r.substr(1, r.length - 2), 10) - 1; + return args[idx] || ""; + }); + } +} + +class Localization { + private strings: Map; + + constructor(baseLanguage: any, ...overlayLanguages: any) { + this.strings = new Map(); + const mapLanguage = (lang: any) => { + for (const [id, entry] of Object.entries(lang)) { + if (!entry.message) { + continue; + } + try { + this.strings.set(id, new Entry(entry)); + } + catch (ex) { + this.strings.set(id, entry.message); + } + } + }; + mapLanguage(baseLanguage); + overlayLanguages.forEach(() => {}); + } + + localize(id: string, ...args: any[]) { + const entry = this.strings.get(id); + if (!entry) { + return ""; + } + if (typeof entry === "string") { + return entry; + } + if (args.length === 1 && Array.isArray(args)) { + [args] = args; + } + return entry.localize(args); + } +} + +function checkBrowser() { + // eslint-disable-next-line @typescript-eslint/no-var-requires + if (typeof browser !== "undefined" && browser.i18n) { + return; + } + if (typeof chrome !== "undefined" && chrome.i18n) { + return; + } + throw new Error("not in a webext"); +} + +async function fetchLanguage(code: string) { + try { + const resp = await fetch(`/_locales/${code}/messages.json`); + return await resp.json(); + } + catch { + return null; + } +} + +const CACHE_KEY = "_cached_locales"; + +function loadCached() { + if (document.location.pathname.includes("/windows/")) { + const cached = localStorage.getItem(CACHE_KEY); + if (cached) { + return JSON.parse(cached) as any[]; + } + } + return null; +} + +async function loadRawLocales() { + // en is the base locale + const langs = new Set(["en"]); + const ui = (browser.i18n || chrome.i18n).getUILanguage(); + langs.add(ui); + if (ui.includes("-")) { + // Try the base too + langs.add(ui.split(/[_-]+/)[0]); + } + + const fetched = await Promise.all(Array.from(langs, fetchLanguage)); + return fetched.filter(e => !!e); +} + +async function load(): Promise { + try { + checkBrowser(); + try { + // en is the base locale + let valid = loadCached(); + if (!valid) { + valid = await loadRawLocales(); + localStorage.setItem(CACHE_KEY, JSON.stringify(valid)); + } + if (!valid.length) { + throw new Error("Could not lood ANY of these locales"); + } + + const base = valid.shift(); + const rv = new Localization(base, ...valid); + return rv; + } + catch (ex) { + console.error("Failed to load locale", ex.toString(), ex.stack, ex); + return new Localization({}); + } + } + catch { // We might be running under node for tests // eslint-disable-next-line @typescript-eslint/no-var-requires const messages = require("../_locales/en/messages.json"); - const map = new Map(); - for (const [k, v] of Object.entries(messages)) { - const {placeholders = {}} = v; - let {message = ""} = v; - for (const [pname, pval] of Object.entries(placeholders)) { - message = message.replace(`$${pname.toUpperCase()}$`, `${pval.content}$`); - } - map.set(k, message); - } - - return { - getMessage(id: string, subst: string[]) { - const m = map.get(id); - if (typeof subst === undefined) { - return m; - } - if (!Array.isArray(subst)) { - subst = [subst]; - } - return m.replace(/\$\d+\$/g, (r: string) => { - const idx = parseInt(r.substr(1, r.length - 2), 10) - 1; - return subst[idx] || ""; - }); - } - }; + return new Localization(messages); } } -const i18n = load(); -const memoGetMessage = memoize(i18n.getMessage, 10 * 1000, 10); +type MemoLocalize = (id: string, ...args: any[]) => string; + +export const locale = load(); +let loc: Localization | null; +let memoLocalize: MemoLocalize | null = null; +locale.then(l => { + loc = l; + memoLocalize = memoize(loc.localize.bind(loc), 10 * 1000, 10); +}); /** * Localize a message @@ -62,21 +175,21 @@ const memoGetMessage = memoize(i18n.getMessage, 10 * 1000, 10); * @returns {string} Localized message */ export function _(id: string, ...subst: any[]) { + if (!loc || !memoLocalize) { + console.trace("TOO SOON"); + throw new Error("Called too soon"); + } if (!subst.length) { - return memoGetMessage(id); + return memoLocalize(id); } - if (subst.length === 1 && Array.isArray(subst[0])) { - subst = subst.pop(); - } - return i18n.getMessage(id, subst); + return loc.localize(id, subst); } -/** - * Localize a DOM - * @param {Element} elem DOM to localize - * @returns {Element} Passed in element (fluent) - */ -export function localize(elem: T): T { +function localize_(elem: T): T { + for (const tmpl of elem.querySelectorAll("template")) { + localize_(tmpl.content); + } + for (const el of elem.querySelectorAll("*[data-i18n]")) { const {i18n: i} = el.dataset; if (!i) { @@ -109,3 +222,14 @@ export function localize(elem: T): T { } return elem as T; } + +/** + * Localize a DOM + * @param {Element} elem DOM to localize + * @returns {Element} Passed in element (fluent) + */ +export async function localize( + elem: T): Promise { + await locale; + return localize_(elem); +} diff --git a/uikit/lib/modal.ts b/uikit/lib/modal.ts index 8d4443e..52f612d 100644 --- a/uikit/lib/modal.ts +++ b/uikit/lib/modal.ts @@ -23,7 +23,7 @@ export default class ModalDialog { this._default = null; } - _makeEl() { + async _makeEl() { this._dismiss = null; this._default = null; @@ -35,7 +35,7 @@ export default class ModalDialog { const body = document.createElement("article"); body.classList.add("modal-body"); - body.appendChild(this.content); + body.appendChild(await this.getContent()); cont.appendChild(body); const footer = document.createElement("footer"); @@ -87,7 +87,7 @@ export default class ModalDialog { return el; } - get content(): DocumentFragment | HTMLElement { + getContent(): Promise { throw new Error("Not implemented"); } @@ -160,7 +160,7 @@ export default class ModalDialog { return; }; - document.body.appendChild(this.el = this._makeEl()); + document.body.appendChild(this.el = await this._makeEl()); this.shown(); addEventListener("keydown", escapeHandler); addEventListener("keydown", enterHandler); diff --git a/windows/manager.ts b/windows/manager.ts index d767b34..d354ed1 100644 --- a/windows/manager.ts +++ b/windows/manager.ts @@ -20,37 +20,6 @@ const LOADED = new Promise(resolve => { }); }); -LOADED.then(async () => { - const nag = await Prefs.get("nagging", 0); - const nagnext = await Prefs.get("nagging-next", 6); - const next = Math.ceil(Math.log2(Math.max(1, nag))); - const el = $("#nagging"); - const remove = () => { - el.parentElement.removeChild(el); - }; - if (next <= nagnext) { - return; - } - setTimeout(() => { - $("#nagging-donate").addEventListener("click", () => { - PORT.post("donate"); - Prefs.set("nagging-next", next); - remove(); - }); - $("#nagging-later").addEventListener("click", () => { - Prefs.set("nagging-next", next); - remove(); - }); - $("#nagging-never").addEventListener("click", () => { - Prefs.set("nagging-next", Number.MAX_SAFE_INTEGER); - remove(); - }); - $("#nagging-message").textContent = _( - "nagging-message", nag.toLocaleString()); - $("#nagging").classList.remove("hidden"); - }, 2 * 1000); -}); - addEventListener("DOMContentLoaded", function dom() { removeEventListener("DOMContentLoaded", dom); @@ -70,10 +39,40 @@ addEventListener("DOMContentLoaded", function dom() { })(); const tabled = new Promised(); - const loaded = Promise.all([LOADED, platformed]); - const fullyloaded = Promise.all([LOADED, platformed, tabled]); + const localized = localize(document.documentElement); + const loaded = Promise.all([LOADED, platformed, localized]); + const fullyloaded = Promise.all([LOADED, platformed, tabled, localized]); + fullyloaded.then(async () => { + const nag = await Prefs.get("nagging", 0); + const nagnext = await Prefs.get("nagging-next", 6); + const next = Math.ceil(Math.log2(Math.max(1, nag))); + const el = $("#nagging"); + const remove = () => { + el.parentElement.removeChild(el); + }; + if (next <= nagnext) { + return; + } + setTimeout(() => { + $("#nagging-donate").addEventListener("click", () => { + PORT.post("donate"); + Prefs.set("nagging-next", next); + remove(); + }); + $("#nagging-later").addEventListener("click", () => { + Prefs.set("nagging-next", next); + remove(); + }); + $("#nagging-never").addEventListener("click", () => { + Prefs.set("nagging-next", Number.MAX_SAFE_INTEGER); + remove(); + }); + $("#nagging-message").textContent = _( + "nagging-message", nag.toLocaleString()); + $("#nagging").classList.remove("hidden"); + }, 2 * 1000); + }); - localize(document.documentElement); $("#donate").addEventListener("click", () => { PORT.post("donate"); }); @@ -110,7 +109,8 @@ addEventListener("DOMContentLoaded", function dom() { statusNetwork.addEventListener("click", () => { PORT.post("toggle-active"); }); - PORT.on("active", active => { + PORT.on("active", async (active: boolean) => { + await loaded; if (active) { statusNetwork.className = "icon-network-on"; statusNetwork.setAttribute("title", _("statusNetwork-active.title")); diff --git a/windows/manager/itemfilters.ts b/windows/manager/itemfilters.ts index c4b316a..d2eb51a 100644 --- a/windows/manager/itemfilters.ts +++ b/windows/manager/itemfilters.ts @@ -20,7 +20,6 @@ import {DownloadItem, DownloadTable} from "./table"; import {formatSize} from "../../lib/formatters"; import {_} from "../../lib/i18n"; import {$} from "../winutil"; -import {StateTexts} from "./state"; const TIMEOUT_SEARCH = 750; @@ -260,7 +259,9 @@ class FixedMenuFilter extends MenuFilter { } export class StateMenuFilter extends FixedMenuFilter { - constructor(collection: FilteredCollection) { + constructor( + collection: FilteredCollection, + StateTexts: Readonly>) { const items = Array.from(StateTexts.entries()).map(([state, text]) => { return { state, diff --git a/windows/manager/removaldlg.ts b/windows/manager/removaldlg.ts index 80a3eeb..acb5b71 100644 --- a/windows/manager/removaldlg.ts +++ b/windows/manager/removaldlg.ts @@ -21,10 +21,10 @@ export default class RemovalModalDialog extends ModalDialog { this.check = null; } - get content() { + async getContent() { const content = $("#removal-template"). content.cloneNode(true) as DocumentFragment; - localize(content); + await localize(content); this.check = content.querySelector(".removal-remember"); $(".removal-text", content).textContent = this.text; return content; diff --git a/windows/manager/state.ts b/windows/manager/state.ts index 7d5b777..5816d67 100644 --- a/windows/manager/state.ts +++ b/windows/manager/state.ts @@ -2,11 +2,11 @@ // License: MIT import * as _DownloadState from "../../lib/manager/state"; -import { _ } from "../../lib/i18n"; +import { _, locale } from "../../lib/i18n"; export const DownloadState = _DownloadState; -export const StateTexts = Object.freeze(new Map([ +export const StateTexts = locale.then(() => Object.freeze(new Map([ [DownloadState.QUEUED, _("queued")], [DownloadState.RUNNING, _("running")], [DownloadState.FINISHING, _("finishing")], @@ -14,7 +14,7 @@ export const StateTexts = Object.freeze(new Map([ [DownloadState.DONE, _("done")], [DownloadState.CANCELED, _("canceled")], [DownloadState.MISSING, _("missing")], -])); +]))); export const StateClasses = Object.freeze(new Map([ [DownloadState.QUEUED, "queued"], diff --git a/windows/manager/table.ts b/windows/manager/table.ts index cd667c5..c7395a6 100644 --- a/windows/manager/table.ts +++ b/windows/manager/table.ts @@ -11,7 +11,7 @@ import { import { iconForPath } from "../../lib/windowutils"; import { formatSpeed, formatSize, formatTimeDelta } from "../../lib/formatters"; import { filters } from "../../lib/filters"; -import { _, localize } from "../../lib/i18n"; +import { _ } from "../../lib/i18n"; import { EventEmitter } from "../../lib/events"; import { Prefs, PrefWatcher } from "../../lib/prefs"; // eslint-disable-next-line no-unused-vars @@ -52,7 +52,11 @@ const COL_SEGS = 8; const ICON_BASE_SIZE = 16; -const TEXT_SIZE_UNKNOWM = _("size-unknown"); +let TEXT_SIZE_UNKNOWM = "unknown"; +let REAL_STATE_TEXTS = Object.freeze(new Map()); +StateTexts.then(v => { + REAL_STATE_TEXTS = v; +}); const prettyNumber = (function() { const rv = new Intl.NumberFormat(undefined, { @@ -190,7 +194,7 @@ export class DownloadItem extends EventEmitter { if (this.error) { return _(this.error) || this.error; } - return StateTexts.get(this.state); + return REAL_STATE_TEXTS.get(this.state) || ""; } get fmtSpeed() { @@ -342,6 +346,8 @@ export class DownloadTable extends VirtualTable { constructor(treeConfig: any) { super("#items", treeConfig, TREE_CONFIG_VERSION); + TEXT_SIZE_UNKNOWM = _("size-unknown"); + this.finished = 0; this.running = new Set(); this.runningTimer = null; @@ -362,7 +368,7 @@ export class DownloadTable extends VirtualTable { new TextFilter(this.downloads); const menufilters = new Map([ ["colURL", new UrlMenuFilter(this.downloads)], - ["colETA", new StateMenuFilter(this.downloads)], + ["colETA", new StateMenuFilter(this.downloads, REAL_STATE_TEXTS)], ["colSize", new SizeMenuFilter(this.downloads)], ]); this.on("column-clicked", (id, evt, col) => { @@ -402,7 +408,6 @@ export class DownloadTable extends VirtualTable { this.sids = new Map(); this.icons = new Icons($("#icons")); - localize($("#table-context").content); const ctx = this.contextMenu = new ContextMenu("#table-context"); Keys.adoptContext(ctx); Keys.adoptButtons($("#toolbar")); diff --git a/windows/manager/tooltip.ts b/windows/manager/tooltip.ts index ba1b0ca..9acd486 100644 --- a/windows/manager/tooltip.ts +++ b/windows/manager/tooltip.ts @@ -2,7 +2,7 @@ "use strict"; // License: MIT -import { _, localize } from "../../lib/i18n"; +import { _ } from "../../lib/i18n"; import { formatSpeed } from "../../lib/formatters"; import { DownloadState } from "./state"; import { Rect } from "../../uikit/lib/rect"; @@ -155,7 +155,7 @@ export class Tooltip { if (!el) { throw new Error("invalid template"); } - this.elem = localize(el.cloneNode(true) as HTMLElement); + this.elem = el.cloneNode(true) as HTMLElement; this.adjust(pos); // eslint-disable-next-line @typescript-eslint/no-this-alias diff --git a/windows/prefs.ts b/windows/prefs.ts index 16fbb12..bb17d26 100644 --- a/windows/prefs.ts +++ b/windows/prefs.ts @@ -109,15 +109,14 @@ class CreateFilterDialog extends ModalDialog { media: HTMLInputElement; - get content() { - const tmpl = $("#create-filter-template"). + getContent() { + const rv = $("#create-filter-template"). content.cloneNode(true) as DocumentFragment; - const rv = localize(tmpl); this.label = $("#filter-create-label", rv); this.expr = $("#filter-create-expr", rv); this.link = $("#filter-create-type-link", rv); this.media = $("#filter-create-type-media", rv); - return rv; + return Promise.resolve(rv); } get buttons() { diff --git a/windows/single.ts b/windows/single.ts index 644da96..716f196 100644 --- a/windows/single.ts +++ b/windows/single.ts @@ -198,15 +198,15 @@ function cancel() { } async function init() { + await localize(document.documentElement); await Promise.all([MASK.init()]); Mask = new Dropdown("#mask", MASK.values); } -addEventListener("DOMContentLoaded", function dom() { +addEventListener("DOMContentLoaded", async function dom() { removeEventListener("DOMContentLoaded", dom); - init().catch(console.error); + await init(); - localize(document.documentElement); $("#btnDownload").addEventListener("click", () => download(false)); $("#btnPaused").addEventListener("click", () => download(true)); $("#btnCancel").addEventListener(