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.
This commit is contained in:
Nils Maier 2019-08-26 02:25:18 +02:00
parent 2bfb3d5363
commit 8235af22db
13 changed files with 623 additions and 491 deletions

View File

@ -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<string, string[]>();
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<string, string[]>();
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);
});

View File

@ -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;

View File

@ -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) {

View File

@ -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<string, Entry | string>;
constructor(baseLanguage: any, ...overlayLanguages: any) {
this.strings = new Map();
const mapLanguage = (lang: any) => {
for (const [id, entry] of Object.entries<JSONEntry>(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<string>(["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<Localization> {
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<any>(messages)) {
const {placeholders = {}} = v;
let {message = ""} = v;
for (const [pname, pval] of Object.entries<any>(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<T extends HTMLElement | DocumentFragment>(elem: T): T {
function localize_<T extends HTMLElement | DocumentFragment>(elem: T): T {
for (const tmpl of elem.querySelectorAll<HTMLTemplateElement>("template")) {
localize_(tmpl.content);
}
for (const el of elem.querySelectorAll<HTMLElement>("*[data-i18n]")) {
const {i18n: i} = el.dataset;
if (!i) {
@ -109,3 +222,14 @@ export function localize<T extends HTMLElement | DocumentFragment>(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<T extends HTMLElement | DocumentFragment>(
elem: T): Promise<T> {
await locale;
return localize_(elem);
}

View File

@ -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<DocumentFragment | HTMLElement> {
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);

View File

@ -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"));

View File

@ -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<Map<number, string>>) {
const items = Array.from(StateTexts.entries()).map(([state, text]) => {
return {
state,

View File

@ -21,10 +21,10 @@ export default class RemovalModalDialog extends ModalDialog {
this.check = null;
}
get content() {
async getContent() {
const content = $<HTMLTemplateElement>("#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;

View File

@ -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"],

View File

@ -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<number, string>());
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<string, MenuFilter>([
["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<number, DownloadItem>();
this.icons = new Icons($("#icons"));
localize($<HTMLTemplateElement>("#table-context").content);
const ctx = this.contextMenu = new ContextMenu("#table-context");
Keys.adoptContext(ctx);
Keys.adoptButtons($("#toolbar"));

View File

@ -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

View File

@ -109,15 +109,14 @@ class CreateFilterDialog extends ModalDialog {
media: HTMLInputElement;
get content() {
const tmpl = $<HTMLTemplateElement>("#create-filter-template").
getContent() {
const rv = $<HTMLTemplateElement>("#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() {

View File

@ -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(