diff --git a/.github/workflows/ui-tests-playwright.yml b/.github/workflows/ui-tests-playwright.yml index c8b02e8e82..8cc5ba32a5 100644 --- a/.github/workflows/ui-tests-playwright.yml +++ b/.github/workflows/ui-tests-playwright.yml @@ -7,6 +7,7 @@ on: - 'frontend/*.json' - 'frontend/*.js' - 'frontend/*.ts' + - '.github/workflows/ui-tests-playwright.yml' jobs: test: @@ -69,11 +70,13 @@ jobs: cat .env - name: Build frontend - run: cd frontend && yarn build + working-directory: frontend + run: yarn build id: build-frontend - name: Run Playwright tests - run: cd frontend && yarn playwright test + working-directory: frontend + run: yarn playwright test - uses: actions/upload-artifact@v4 if: always() diff --git a/backend/btrixcloud/main.py b/backend/btrixcloud/main.py index 71e0150b15..f6b678cb82 100644 --- a/backend/btrixcloud/main.py +++ b/backend/btrixcloud/main.py @@ -6,6 +6,7 @@ import os import asyncio import sys +from typing import List, Optional from fastapi import FastAPI, HTTPException from fastapi.responses import JSONResponse @@ -121,6 +122,8 @@ class SettingsResponse(BaseModel): salesEmail: str = "" supportEmail: str = "" + localesEnabled: Optional[List[str]] + # ============================================================================ # pylint: disable=too-many-locals, duplicate-code @@ -150,6 +153,11 @@ def main() -> None: signUpUrl=os.environ.get("SIGN_UP_URL", ""), salesEmail=os.environ.get("SALES_EMAIL", ""), supportEmail=os.environ.get("EMAIL_SUPPORT", ""), + localesEnabled=( + [lang.strip() for lang in os.environ.get("LOCALES_ENABLED", "").split(",")] + if os.environ.get("LOCALES_ENABLED") + else None + ), ) invites = init_invites(mdb, email) diff --git a/backend/test/test_api.py b/backend/test/test_api.py index 5c0a1d68b1..fbe265c90e 100644 --- a/backend/test/test_api.py +++ b/backend/test/test_api.py @@ -50,4 +50,5 @@ def test_api_settings(): "signUpUrl": "", "salesEmail": "", "supportEmail": "", + "localesEnabled": ["en", "es"], } diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index 4da46697fe..2a57a6d66a 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -83,6 +83,8 @@ data: BACKEND_IMAGE_PULL_POLICY: "{{ .Values.backend_pull_policy }}" + LOCALES_ENABLED: "{{ .Values.locales_enabled }}" + --- apiVersion: v1 diff --git a/chart/values.yaml b/chart/values.yaml index 2caeb1c5ad..163ce63e63 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -1,3 +1,9 @@ +# Global Settings +# ========================================= + +# locales available to choose from in the front end +locales_enabled: "en,es" + # Crawler Settings # ========================================= diff --git a/frontend/package.json b/frontend/package.json index 312a06927a..c972a2766e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "dependencies": { "@cheap-glitch/mi-cron": "^1.0.1", "@formatjs/intl-durationformat": "^0.6.4", + "@formatjs/intl-localematcher": "^0.5.9", "@ianvs/prettier-plugin-sort-imports": "^4.2.1", "@lit/localize": "^0.12.1", "@lit/task": "^1.0.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index dbf85a6499..58a5212e5f 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -90,5 +90,6 @@ export default defineConfig({ command: "yarn serve", port: 9871, reuseExistingServer: !process.env.CI, + stdout: "pipe", }, }); diff --git a/frontend/scripts/serve.js b/frontend/scripts/serve.js index 7163efa331..b790435d36 100644 --- a/frontend/scripts/serve.js +++ b/frontend/scripts/serve.js @@ -1,10 +1,15 @@ // Serve app locally without building with webpack, e.g. for e2e +const fs = require("fs"); const path = require("path"); -const connectHistoryApiFallback = require("connect-history-api-fallback"); const express = require("express"); const { createProxyMiddleware } = require("http-proxy-middleware"); +const distPath = path.join(process.cwd(), "dist"); +if (!fs.existsSync(path.join(distPath, "index.html"))) { + throw new Error("dist folder is missing"); +} + const dotEnvPath = path.resolve(process.cwd(), ".env.local"); require("dotenv").config({ path: dotEnvPath, @@ -18,10 +23,20 @@ const { devServer } = devConfig; devServer.setupMiddlewares([], { app }); -app.use("/", express.static("dist")); Object.keys(devServer.proxy).forEach((path) => { - app.use(path, createProxyMiddleware(devServer.proxy[path])); + app.use( + path, + createProxyMiddleware({ + target: devServer.proxy[path], + changeOrigin: true, + }), + ); +}); +app.use("/", express.static(distPath)); +app.get("/*", (req, res) => { + res.sendFile(path.join(distPath, "index.html")); }); -app.use(connectHistoryApiFallback()); -app.listen(9871); +app.listen(9871, () => { + console.log("Server listening on http://localhost:9871"); +}); diff --git a/frontend/src/components/orgs-list.ts b/frontend/src/components/orgs-list.ts index 4770a2da19..50afa3afb6 100644 --- a/frontend/src/components/orgs-list.ts +++ b/frontend/src/components/orgs-list.ts @@ -319,7 +319,7 @@ export class OrgsList extends BtrixElement { ${msg( html`Deleting an org will delete all - + ${this.localize.bytes(org.bytesStored)} of data associated with the org.`, )} @@ -327,26 +327,20 @@ export class OrgsList extends BtrixElement { @@ -627,23 +621,14 @@ export class OrgsList extends BtrixElement { - + ${this.localize.date(org.created, { dateStyle: "short" })} ${memberCount ? this.localize.number(memberCount) : none} ${org.bytesStored - ? html`` + ? this.localize.bytes(org.bytesStored, { unitDisplay: "narrow" }) : none} diff --git a/frontend/src/components/ui/config-details.ts b/frontend/src/components/ui/config-details.ts index 506afb9c22..d9f793f71e 100644 --- a/frontend/src/components/ui/config-details.ts +++ b/frontend/src/components/ui/config-details.ts @@ -90,10 +90,7 @@ export class ConfigDetails extends BtrixElement { const renderSize = (valueBytes?: number | null) => { // Eventually we will want to set this to the selected locale if (valueBytes) { - return html``; + return this.localize.bytes(valueBytes, { unitDisplay: "narrow" }); } return html`; */ @localized() @customElement("btrix-file-list-item") -export class FileListItem extends TailwindElement { +export class FileListItem extends BtrixElement { static styles = [ truncate, css` @@ -82,11 +83,11 @@ export class FileListItem extends TailwindElement {
${this.file.name}
${this.progressValue !== undefined - ? html` - / ` - : ""} + ? html`${this.localize.bytes( + (this.progressValue / 100) * this.file.size, + )} + / ` + : ""}${this.localize.bytes(this.file.size)}
diff --git a/frontend/src/components/ui/format-date.ts b/frontend/src/components/ui/format-date.ts new file mode 100644 index 0000000000..17083ddbee --- /dev/null +++ b/frontend/src/components/ui/format-date.ts @@ -0,0 +1,90 @@ +import { localized } from "@lit/localize"; +import { html, LitElement } from "lit"; +import { customElement } from "lit/decorators/custom-element.js"; +import { property } from "lit/decorators/property.js"; + +import { LocalizeController } from "@/controllers/localize"; + +/** + * Re-implementation of Shoelace's `` element using + * Browsertrix's localization implementation. + * + * This allows for multiple locales to be passed into the date formatter, in + * order of the user's preferences. + */ +@customElement("btrix-format-date") +@localized() +export class FormatDate extends LitElement { + private readonly localize = new LocalizeController(this); + + /** + * The date/time to format. If not set, the current date and time will be used. When passing a string, it's strongly + * recommended to use the ISO 8601 format to ensure timezones are handled correctly. To convert a date to this format + * in JavaScript, use [`date.toISOString()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString). + */ + @property() date?: Date | string | null = new Date(); + + /** The format for displaying the weekday. */ + @property() weekday?: "narrow" | "short" | "long"; + + /** The format for displaying the era. */ + @property() era?: "narrow" | "short" | "long"; + + /** The format for displaying the year. */ + @property() year?: "numeric" | "2-digit"; + + /** The format for displaying the month. */ + @property() month?: "numeric" | "2-digit" | "narrow" | "short" | "long"; + + /** The format for displaying the day. */ + @property() day?: "numeric" | "2-digit"; + + /** The format for displaying the hour. */ + @property() hour?: "numeric" | "2-digit"; + + /** The format for displaying the minute. */ + @property() minute?: "numeric" | "2-digit"; + + /** The format for displaying the second. */ + @property() second?: "numeric" | "2-digit"; + + /** The format for displaying the time. */ + @property({ attribute: "time-zone-name" }) timeZoneName?: "short" | "long"; + + /** The time zone to express the time in. */ + @property({ attribute: "time-zone" }) timeZone?: string; + + /** The format for displaying the hour. */ + @property({ attribute: "hour-format" }) hourFormat: "auto" | "12" | "24" = + "auto"; + + render() { + if (!this.date) return undefined; + const date = new Date(this.date); + const hour12 = + this.hourFormat === "auto" ? undefined : this.hourFormat === "12"; + + // Check for an invalid date + if (isNaN(date.getMilliseconds())) { + return undefined; + } + + return html` + + `; + } +} diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 135af614b2..c762e6c998 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -16,9 +16,9 @@ import("./copy-button"); import("./copy-field"); import("./details"); import("./file-list"); +import("./format-date"); import("./inline-input"); import("./language-select"); -import("./user-language-select"); import("./markdown-editor"); import("./markdown-viewer"); import("./menu-item-link"); @@ -30,9 +30,10 @@ import("./pw-strength-alert"); import("./relative-duration"); import("./search-combobox"); import("./section-heading"); -import("./select-crawler"); import("./select-crawler-proxy"); +import("./select-crawler"); import("./table"); import("./tag-input"); import("./tag"); import("./time-input"); +import("./user-language-select"); diff --git a/frontend/src/components/ui/language-select.ts b/frontend/src/components/ui/language-select.ts index 91601c759a..f5e57cceed 100644 --- a/frontend/src/components/ui/language-select.ts +++ b/frontend/src/components/ui/language-select.ts @@ -7,7 +7,7 @@ import { ifDefined } from "lit/directives/if-defined.js"; import sortBy from "lodash/fp/sortBy"; import { allLanguageCodes, type LanguageCode } from "@/types/localization"; -import { getBrowserLang } from "@/utils/localize"; +import { getDefaultLang } from "@/utils/localize"; const languages = sortBy("name")( ISO6391.getLanguages(allLanguageCodes), @@ -53,7 +53,7 @@ export class LanguageSelect extends LitElement { return html` { diff --git a/frontend/src/components/ui/user-language-select.ts b/frontend/src/components/ui/user-language-select.ts index 79b7535b63..3ab5eaa849 100644 --- a/frontend/src/components/ui/user-language-select.ts +++ b/frontend/src/components/ui/user-language-select.ts @@ -3,10 +3,7 @@ import { html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { BtrixElement } from "@/classes/BtrixElement"; -import { - translatedLocales, - type TranslatedLocaleEnum, -} from "@/types/localization"; +import { type TranslatedLocaleEnum } from "@/types/localization"; import localize from "@/utils/localize"; /** @@ -17,7 +14,8 @@ export class LocalePicker extends BtrixElement { @state() private localeNames: { [locale: string]: string } = {}; - firstUpdated() { + connectedCallback() { + super.connectedCallback(); this.setLocaleNames(); } @@ -38,7 +36,7 @@ export class LocalePicker extends BtrixElement { } render() { - const selectedLocale = this.localize.lang(); + const selectedLocale = new Intl.Locale(this.localize.lang()).language; return html` { const duration = roundDuration(value, options); diff --git a/frontend/src/features/archived-items/archived-item-list.ts b/frontend/src/features/archived-items/archived-item-list.ts index 82384428cb..f11f63fa79 100644 --- a/frontend/src/features/archived-items/archived-item-list.ts +++ b/frontend/src/features/archived-items/archived-item-list.ts @@ -226,13 +226,13 @@ export class ArchivedItemListItem extends BtrixElement { @click=${this.onTooltipClick} hoist > - + > @@ -243,11 +243,11 @@ export class ArchivedItemListItem extends BtrixElement { })} @click=${this.onTooltipClick} > - + + ${this.localize.bytes(this.item.fileSize || 0, { + unitDisplay: "narrow", + })} + diff --git a/frontend/src/features/archived-items/crawl-list.ts b/frontend/src/features/archived-items/crawl-list.ts index 6a24045c92..56a0b2596e 100644 --- a/frontend/src/features/archived-items/crawl-list.ts +++ b/frontend/src/features/archived-items/crawl-list.ts @@ -74,14 +74,14 @@ export class CrawlListItem extends BtrixElement {
${this.safeRender( (crawl) => html` - + > `, )}
@@ -144,14 +144,14 @@ export class CrawlListItem extends BtrixElement { ${this.safeRender( (crawl) => html` - + > `, )} @@ -160,14 +160,14 @@ export class CrawlListItem extends BtrixElement { ${this.safeRender((crawl) => crawl.finished ? html` - + > ` : html`--- - + ${this.localize.bytes(this.crawl.fileSize || 0, { + unitDisplay: "narrow", + })} ${this.safeRender((crawl) => { diff --git a/frontend/src/features/archived-items/crawl-logs.ts b/frontend/src/features/archived-items/crawl-logs.ts index 79d075f7ea..e6192c826d 100644 --- a/frontend/src/features/archived-items/crawl-logs.ts +++ b/frontend/src/features/archived-items/crawl-logs.ts @@ -122,7 +122,7 @@ export class CrawlLogs extends LitElement {
${idx + 1}.
- - +
${log.logLevel} diff --git a/frontend/src/features/browser-profiles/select-browser-profile.ts b/frontend/src/features/browser-profiles/select-browser-profile.ts index 739a0b473a..c676861104 100644 --- a/frontend/src/features/browser-profiles/select-browser-profile.ts +++ b/frontend/src/features/browser-profiles/select-browser-profile.ts @@ -84,12 +84,12 @@ export class SelectBrowserProfile extends BtrixElement { ${profile.name}
- + >
`, @@ -102,14 +102,14 @@ export class SelectBrowserProfile extends BtrixElement { ? html` ${msg("Last updated")} - + > ${this.selectedProfile.proxyId ? html` diff --git a/frontend/src/features/collections/collection-workflow-list.ts b/frontend/src/features/collections/collection-workflow-list.ts index a2f9cb09b2..6f58cce061 100644 --- a/frontend/src/features/collections/collection-workflow-list.ts +++ b/frontend/src/features/collections/collection-workflow-list.ts @@ -276,23 +276,22 @@ export class CollectionWorkflowList extends BtrixElement { >
- + >
- + ${this.localize.bytes(crawl.fileSize || 0, { + unitDisplay: "narrow", + })}
${pageCount === 1 diff --git a/frontend/src/features/crawl-workflows/workflow-list.ts b/frontend/src/features/crawl-workflows/workflow-list.ts index 7f3875c557..4b2881e6c9 100644 --- a/frontend/src/features/crawl-workflows/workflow-list.ts +++ b/frontend/src/features/crawl-workflows/workflow-list.ts @@ -260,14 +260,14 @@ export class WorkflowListItem extends BtrixElement {
${this.safeRender((workflow) => { if (workflow.lastCrawlTime && workflow.lastCrawlStartTime) { - return html` + > ${msg( str`in ${this.localize.humanizeDuration( new Date(workflow.lastCrawlTime).valueOf() - @@ -301,37 +301,32 @@ export class WorkflowListItem extends BtrixElement { workflow.totalSize && workflow.lastCrawlSize ) { - return html` + return html`${this.localize.bytes(+workflow.totalSize, { + unitDisplay: "narrow", + })} + - + ${this.localize.bytes(workflow.lastCrawlSize, { + unitDisplay: "narrow", + })} `; } if (workflow.totalSize && workflow.lastCrawlSize) { - return html``; + return this.localize.bytes(+workflow.totalSize, { + unitDisplay: "narrow", + }); } if (workflow.isCrawlRunning && workflow.lastCrawlSize) { return html` - + ${this.localize.bytes(workflow.lastCrawlSize, { + unitDisplay: "narrow", + })} `; } if (workflow.totalSize) { - return html``; + return this.localize.bytes(+workflow.totalSize, { + unitDisplay: "narrow", + }); } return notSpecified; })} @@ -353,14 +348,14 @@ export class WorkflowListItem extends BtrixElement {
${this.safeRender( (workflow) => html` - + > `, )}
diff --git a/frontend/src/features/org/usage-history-table.ts b/frontend/src/features/org/usage-history-table.ts index b716fdc66f..efe97ced7a 100644 --- a/frontend/src/features/org/usage-history-table.ts +++ b/frontend/src/features/org/usage-history-table.ts @@ -147,13 +147,13 @@ export class UsageHistoryTable extends BtrixElement { const tableRows = [ html` - - + `, humanizeExecutionSeconds(crawlTime || 0), totalSecondsUsed ? humanizeExecutionSeconds(totalSecondsUsed) : "--", diff --git a/frontend/src/index.test.ts b/frontend/src/index.test.ts index b27ad4dd0c..e92ac0cf16 100644 --- a/frontend/src/index.test.ts +++ b/frontend/src/index.test.ts @@ -2,8 +2,11 @@ import { expect, fixture } from "@open-wc/testing"; import { html } from "lit"; import { restore, stub } from "sinon"; +import { NavigateController } from "./controllers/navigate"; +import { NotifyController } from "./controllers/notify"; +import { type AppSettings } from "./utils/app"; import AuthService from "./utils/AuthService"; -import { AppStateService } from "./utils/state"; +import appState, { AppStateService } from "./utils/state"; import { formatAPIUser } from "./utils/user"; import { App, type APIUser } from "."; @@ -25,12 +28,28 @@ const mockAPIUser: APIUser = { }; const mockUserInfo = formatAPIUser(mockAPIUser); +const mockAppSettings: AppSettings = { + registrationEnabled: false, + jwtTokenLifetime: 86400, + defaultBehaviorTimeSeconds: 300, + defaultPageLoadTimeSeconds: 120, + maxPagesPerCrawl: 50000, + numBrowsers: 2, + maxScale: 3, + billingEnabled: false, + signUpUrl: "", + salesEmail: "", + supportEmail: "", + localesEnabled: ["en", "es"], +}; + describe("browsertrix-app", () => { beforeEach(() => { AppStateService.resetAll(); AuthService.broadcastChannel = new BroadcastChannel(AuthService.storageKey); window.sessionStorage.clear(); stub(window.history, "pushState"); + stub(NotifyController.prototype, "toast"); }); afterEach(() => { @@ -51,12 +70,26 @@ describe("browsertrix-app", () => { username: "test-auth@example.com", }), ); + // @ts-expect-error checkFreshness is private + stub(AuthService.prototype, "checkFreshness"); + stub(appState, "settings").returns(mockAppSettings); const el = await fixture(html` `); + await el.updateComplete; expect(el.shadowRoot?.querySelector("btrix-home")).to.exist; }); it("renders home when not authenticated", async () => { stub(AuthService, "initSessionStorage").returns(Promise.resolve(null)); + // @ts-expect-error checkFreshness is private + stub(AuthService.prototype, "checkFreshness"); + stub(appState, "settings").returns(mockAppSettings); + stub(NavigateController, "createNavigateEvent").callsFake( + () => + new CustomEvent("x-ignored", { + detail: { url: "", resetScroll: false }, + }), + ); + const el = await fixture(html` `); expect(el.shadowRoot?.querySelector("btrix-home")).to.exist; }); @@ -113,6 +146,9 @@ describe("browsertrix-app", () => { username: "test-auth@example.com", }), ); + stub(AuthService, "createNeedLoginEvent").callsFake( + () => new CustomEvent("x-ignored", { detail: {} }), + ); const el = await fixture(""); diff --git a/frontend/src/index.ts b/frontend/src/index.ts index d1df6fd956..fbfa0e087c 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -40,7 +40,7 @@ import { translatedLocales, type TranslatedLocaleEnum, } from "@/types/localization"; -import { type AppSettings } from "@/utils/app"; +import { getAppSettings, type AppSettings } from "@/utils/app"; import { DEFAULT_MAX_SCALE } from "@/utils/crawler"; import localize from "@/utils/localize"; import { toast } from "@/utils/notify"; @@ -115,6 +115,20 @@ export class App extends BtrixElement { if (authState && !this.userInfo) { void this.fetchAndUpdateUserInfo(); } + + try { + this.settings = await getAppSettings(); + } catch (e) { + console.error(e); + this.notify.toast({ + message: msg("Couldn’t initialize Browsertrix correctly."), + variant: "danger", + icon: "exclamation-octagon", + id: "get-app-settings-error", + }); + } finally { + await localize.initLanguage(); + } super.connectedCallback(); this.addEventListener("btrix-navigate", this.onNavigateTo); @@ -156,10 +170,6 @@ export class App extends BtrixElement { } } - protected firstUpdated(): void { - localize.initLanguage(); - } - getLocationPathname() { return window.location.pathname; } diff --git a/frontend/src/pages/account-settings.ts b/frontend/src/pages/account-settings.ts index 368818b398..1a41d77dfc 100644 --- a/frontend/src/pages/account-settings.ts +++ b/frontend/src/pages/account-settings.ts @@ -1,5 +1,10 @@ import { localized, msg, str } from "@lit/localize"; -import type { SlInput, SlSelectEvent } from "@shoelace-style/shoelace"; +import type { + SlChangeEvent, + SlInput, + SlSelectEvent, + SlSwitch, +} from "@shoelace-style/shoelace"; import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; import type { ZxcvbnResult } from "@zxcvbn-ts/core"; import { html, nothing, type PropertyValues } from "lit"; @@ -12,9 +17,10 @@ import { BtrixElement } from "@/classes/BtrixElement"; import { TailwindElement } from "@/classes/TailwindElement"; import needLogin from "@/decorators/needLogin"; import { pageHeader } from "@/layouts/pageHeader"; -import { translatedLocales, type LanguageCode } from "@/types/localization"; +import { type LanguageCode } from "@/types/localization"; import type { UnderlyingFunction } from "@/types/utils"; import { isApiError } from "@/utils/api"; +import localize from "@/utils/localize"; import PasswordService from "@/utils/PasswordService"; import { AppStateService } from "@/utils/state"; import { tw } from "@/utils/tailwind"; @@ -27,8 +33,8 @@ enum Tab { const { PASSWORD_MINLENGTH, PASSWORD_MAXLENGTH, PASSWORD_MIN_SCORE } = PasswordService; -@localized() @customElement("btrix-request-verify") +@localized() export class RequestVerify extends TailwindElement { @property({ type: String }) email!: string; @@ -99,8 +105,8 @@ export class RequestVerify extends TailwindElement { } } -@localized() @customElement("btrix-account-settings") +@localized() @needLogin export class AccountSettings extends BtrixElement { @property({ type: String }) @@ -242,9 +248,7 @@ export class AccountSettings extends BtrixElement { - ${(translatedLocales as unknown as string[]).length > 1 - ? this.renderLanguage() - : nothing} + ${localize.languages.length > 1 ? this.renderLanguage() : nothing} `; } @@ -348,6 +352,37 @@ export class AccountSettings extends BtrixElement { @sl-select=${this.onSelectLocale} >
+ ${msg( + "Use browser language settings for formatting numbers and dates.", + )} +
+ ${msg("For example:")} + ${this.localize.date(new Date(), { + dateStyle: "short", + })} + ${this.localize.date(new Date(), { + timeStyle: "short", + })} + ${this.localize.humanizeDuration(9283849, { + unitCount: 2, + })} + ${this.localize.bytes(3943298234)} +

${msg("Help us translate Browsertrix.")} @@ -550,4 +585,24 @@ export class AccountSettings extends BtrixElement { id: "language-update-status", }); }; + + /** + * Save formatting setting in local storage + */ + private readonly onSelectFormattingPreference = async (e: SlChangeEvent) => { + const checked = (e.target as SlSwitch).checked; + if ( + checked !== this.appState.userPreferences?.useBrowserLanguageForFormatting + ) { + AppStateService.partialUpdateUserPreferences({ + useBrowserLanguageForFormatting: checked, + }); + } + + this.notify.toast({ + message: msg("Your formatting preference has been updated."), + variant: "success", + icon: "check2-circle", + }); + }; } diff --git a/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts b/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts index 2c8fc3e0c6..33f96ec84a 100644 --- a/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts +++ b/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts @@ -156,7 +156,7 @@ export class ArchivedItemDetail extends BtrixElement { private get formattedFinishedDate() { if (!this.item) return; - return html``; + >`; } willUpdate(changedProperties: PropertyValues) { @@ -799,7 +799,7 @@ export class ArchivedItemDetail extends BtrixElement { ` : html` - + > ${this.item!.finished @@ -856,25 +856,23 @@ export class ArchivedItemDetail extends BtrixElement { ${this.item ? html`${this.item.fileSize - ? html`${this.item.stats - ? html`, - ${this.localize.number(+this.item.stats.done)} - / - ${this.localize.number(+this.item.stats.found)} - - ${pluralOf("pages", +this.item.stats.found)}` - : ""}` + ? html`${this.localize.bytes(this.item.fileSize || 0, { + unitDisplay: "narrow", + })}${this.item.stats + ? html`, + ${this.localize.number(+this.item.stats.done)} + / + ${this.localize.number(+this.item.stats.found)} + + ${pluralOf("pages", +this.item.stats.found)}` + : ""}` : html`${msg("Unknown")}`}` : html``} @@ -1010,7 +1008,7 @@ ${this.item?.description} > `, )} - + ${this.localize.bytes(Number(file.size))}

`, diff --git a/frontend/src/pages/org/archived-item-detail/ui/qa.ts b/frontend/src/pages/org/archived-item-detail/ui/qa.ts index 0bf516c234..0082745926 100644 --- a/frontend/src/pages/org/archived-item-detail/ui/qa.ts +++ b/frontend/src/pages/org/archived-item-detail/ui/qa.ts @@ -349,26 +349,26 @@ export class ArchivedItemDetailQA extends BtrixElement { > - + > ${run.finished ? html` - + > ` : notApplicable()} @@ -429,14 +429,14 @@ export class ArchivedItemDetailQA extends BtrixElement { ${msg( str`This analysis run includes data for ${runToBeDeleted.stats.done} ${pluralOf("pages", runToBeDeleted.stats.done)} and was started on `, )} - + > ${msg("by")} ${runToBeDeleted.userName}.
diff --git a/frontend/src/pages/org/archived-item-qa/archived-item-qa.ts b/frontend/src/pages/org/archived-item-qa/archived-item-qa.ts index 0c3096a0fe..63b371cefd 100644 --- a/frontend/src/pages/org/archived-item-qa/archived-item-qa.ts +++ b/frontend/src/pages/org/archived-item-qa/archived-item-qa.ts @@ -895,16 +895,16 @@ export class ArchivedItemQA extends BtrixElement { ${when( this.page, (page) => html` - - + `, )}
diff --git a/frontend/src/pages/org/browser-profiles-detail.ts b/frontend/src/pages/org/browser-profiles-detail.ts index f1cea267b1..078aad7e41 100644 --- a/frontend/src/pages/org/browser-profiles-detail.ts +++ b/frontend/src/pages/org/browser-profiles-detail.ts @@ -115,7 +115,7 @@ export class BrowserProfilesDetail extends BtrixElement { ${this.profile ? html` - + > ` : nothing} ${this.profile - ? html` ` + >` : nothing} ${ diff --git a/frontend/src/pages/org/browser-profiles-list.ts b/frontend/src/pages/org/browser-profiles-list.ts index 524833d11a..d3b47a938e 100644 --- a/frontend/src/pages/org/browser-profiles-list.ts +++ b/frontend/src/pages/org/browser-profiles-list.ts @@ -278,14 +278,14 @@ export class BrowserProfilesList extends BtrixElement { content=${msg(str`By ${data.createdByName}`)} ?disabled=${!data.createdByName} > - + > @@ -293,7 +293,7 @@ export class BrowserProfilesList extends BtrixElement { content=${msg(str`By ${data.modifiedByName || data.createdByName}`)} ?disabled=${!data.createdByName} > - + > diff --git a/frontend/src/pages/org/collection-detail.ts b/frontend/src/pages/org/collection-detail.ts index b561497ef4..f06e671743 100644 --- a/frontend/src/pages/org/collection-detail.ts +++ b/frontend/src/pages/org/collection-detail.ts @@ -487,13 +487,10 @@ export class CollectionDetail extends BtrixElement { (col) => `${this.localize.number(col.crawlCount)} ${pluralOf("items", col.crawlCount)}`, )} - ${this.renderDetailItem( - msg("Total Size"), - (col) => - html``, + ${this.renderDetailItem(msg("Total Size"), (col) => + this.localize.bytes(col.totalSize || 0, { + unitDisplay: "narrow", + }), )} ${this.renderDetailItem( msg("Total Pages"), @@ -503,14 +500,14 @@ export class CollectionDetail extends BtrixElement { ${this.renderDetailItem( msg("Last Updated"), (col) => - html``, + >`, )} `; diff --git a/frontend/src/pages/org/collections-list.ts b/frontend/src/pages/org/collections-list.ts index 7ab26dc90c..e42a2cf78d 100644 --- a/frontend/src/pages/org/collections-list.ts +++ b/frontend/src/pages/org/collections-list.ts @@ -532,24 +532,23 @@ export class CollectionsList extends BtrixElement { ${pluralOf("items", col.crawlCount)} - + ${this.localize.bytes(col.totalSize || 0, { + unitDisplay: "narrow", + })} ${this.localize.number(col.pageCount, { notation: "compact" })} ${pluralOf("pages", col.pageCount)} - + > ${this.isCrawler ? this.renderActions(col) : ""} diff --git a/frontend/src/pages/org/dashboard.ts b/frontend/src/pages/org/dashboard.ts index a190431125..bbc6dae5b2 100644 --- a/frontend/src/pages/org/dashboard.ts +++ b/frontend/src/pages/org/dashboard.ts @@ -147,9 +147,7 @@ export class Dashboard extends BtrixElement { value: metrics.crawlCount, secondaryValue: hasQuota ? "" - : html``, + : this.localize.bytes(metrics.storageUsedCrawls), singleLabel: msg("Crawl"), pluralLabel: msg("Crawls"), iconProps: { @@ -161,9 +159,7 @@ export class Dashboard extends BtrixElement { value: metrics.uploadCount, secondaryValue: hasQuota ? "" - : html``, + : this.localize.bytes(metrics.storageUsedUploads), singleLabel: msg("Upload"), pluralLabel: msg("Uploads"), iconProps: { name: "upload", color: this.colors.uploads }, @@ -172,9 +168,7 @@ export class Dashboard extends BtrixElement { value: metrics.profileCount, secondaryValue: hasQuota ? "" - : html``, + : this.localize.bytes(metrics.storageUsedProfiles), singleLabel: msg("Browser Profile"), pluralLabel: msg("Browser Profiles"), iconProps: { @@ -189,9 +183,7 @@ export class Dashboard extends BtrixElement { value: metrics.archivedItemCount, secondaryValue: hasQuota ? "" - : html``, + : this.localize.bytes(metrics.storageUsedBytes), singleLabel: msg("Archived Item"), pluralLabel: msg("Archived Items"), iconProps: { name: "file-zip-fill" }, @@ -274,7 +266,9 @@ export class Dashboard extends BtrixElement {
${label}
- + ${this.localize.bytes(value, { + unitDisplay: "narrow", + })} | ${this.renderPercentage(value / metrics.storageUsedBytes)}
@@ -296,10 +290,9 @@ export class Dashboard extends BtrixElement { () => hasQuota ? html` - + ${this.localize.bytes( + metrics.storageQuotaBytes - metrics.storageUsedBytes, + )} ${msg("Available")} ` : "", @@ -349,16 +342,16 @@ export class Dashboard extends BtrixElement {
- - + ${this.localize.bytes(metrics.storageUsedBytes, { + unitDisplay: "narrow", + })} + ${this.localize.bytes(metrics.storageQuotaBytes, { + unitDisplay: "narrow", + })}
`, diff --git a/frontend/src/pages/org/workflow-detail.ts b/frontend/src/pages/org/workflow-detail.ts index 6118f8a71c..5f63799364 100644 --- a/frontend/src/pages/org/workflow-detail.ts +++ b/frontend/src/pages/org/workflow-detail.ts @@ -766,10 +766,9 @@ export class WorkflowDetail extends BtrixElement { ${this.renderDetailItem( msg("Total Size"), (workflow) => - html` `, + html` ${this.localize.bytes(Number(workflow.totalSize), { + unitDisplay: "narrow", + })}`, )} ${this.renderDetailItem(msg("Schedule"), (workflow) => workflow.schedule @@ -966,10 +965,9 @@ export class WorkflowDetail extends BtrixElement { )} ${this.renderDetailItem(msg("Crawl Size"), () => this.workflow - ? html`` + ? this.localize.bytes(this.workflow.lastCrawlSize || 0, { + unitDisplay: "narrow", + }) : skeleton, )} ${this.renderDetailItem(msg("Browser Windows"), () => diff --git a/frontend/src/shoelace.ts b/frontend/src/shoelace.ts index 50a1d9b092..30b7c9e932 100644 --- a/frontend/src/shoelace.ts +++ b/frontend/src/shoelace.ts @@ -42,12 +42,6 @@ import( import( /* webpackChunkName: "shoelace" */ "@shoelace-style/shoelace/dist/components/divider/divider" ); -import( - /* webpackChunkName: "shoelace" */ "@shoelace-style/shoelace/dist/components/format-bytes/format-bytes" -); -import( - /* webpackChunkName: "shoelace" */ "@shoelace-style/shoelace/dist/components/format-date/format-date" -); import( /* webpackChunkName: "shoelace" */ "@shoelace-style/shoelace/dist/components/menu/menu" ); diff --git a/frontend/src/types/app.ts b/frontend/src/types/app.ts deleted file mode 100644 index 3078afea51..0000000000 --- a/frontend/src/types/app.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type AppSettings = { - registrationEnabled: boolean; - jwtTokenLifetime: number; - defaultBehaviorTimeSeconds: number; - defaultPageLoadTimeSeconds: number; - maxPagesPerCrawl: number; - maxScale: number; - billingEnabled: boolean; - signUpUrl: string; - salesEmail: string; - supportEmail: string; -}; diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index 05dfb1047e..7659c379ac 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -50,5 +50,6 @@ export type UserInfo = z.infer; export const userPreferencesSchema = z.object({ newWorkflowScopeType: z.nativeEnum(WorkflowScopeType).optional(), language: languageCodeSchema.optional(), + useBrowserLanguageForFormatting: z.boolean().default(true).optional(), }); export type UserPreferences = z.infer; diff --git a/frontend/src/utils/app.ts b/frontend/src/utils/app.ts index f76265632f..d1bd85347a 100644 --- a/frontend/src/utils/app.ts +++ b/frontend/src/utils/app.ts @@ -1,5 +1,7 @@ import appState from "./state"; +import { translatedLocales } from "@/types/localization"; + export type AppSettings = { registrationEnabled: boolean; jwtTokenLifetime: number; @@ -12,6 +14,7 @@ export type AppSettings = { signUpUrl: string; salesEmail: string; supportEmail: string; + localesEnabled?: readonly string[]; }; export async function getAppSettings(): Promise { @@ -40,6 +43,7 @@ export async function getAppSettings(): Promise { signUpUrl: "", salesEmail: "", supportEmail: "", + localesEnabled: translatedLocales, }; } } diff --git a/frontend/src/utils/cron.ts b/frontend/src/utils/cron.ts index 8fd5d8e70f..c6553d450c 100644 --- a/frontend/src/utils/cron.ts +++ b/frontend/src/utils/cron.ts @@ -92,9 +92,27 @@ export function humanizeSchedule( intervalMsg = msg(str`Every ${formattedWeekDay}`); break; case "monthly": { - intervalMsg = msg( - str`Monthly on the ${localize.number(days[0], { ordinal: true })}`, - ); + intervalMsg = localize.ordinal(days[0], { + // This one doesn't make much sense in English, but it could in other languages/locales + zero: msg(str`Monthly on the zeroth`, { + id: "monthly-interval-ordinal-zero", + }), + one: msg(str`Monthly on the ${localize.number(days[0])}st`, { + id: "monthly-interval-ordinal-one", + }), + two: msg(str`Monthly on the ${localize.number(days[0])}nd`, { + id: "monthly-interval-ordinal-two", + }), + few: msg(str`Monthly on the ${localize.number(days[0])}rd`, { + id: "monthly-interval-ordinal-few", + }), + many: msg(str`Monthly on the ${localize.number(days[0])}th`, { + id: "monthly-interval-ordinal-many", + }), + other: msg(str`Monthly on the ${localize.number(days[0])}th`, { + id: "monthly-interval-ordinal-other", + }), + }); break; } diff --git a/frontend/src/utils/localize.test.ts b/frontend/src/utils/localize.test.ts index 313e5d1aa6..9bb44d4a6a 100644 --- a/frontend/src/utils/localize.test.ts +++ b/frontend/src/utils/localize.test.ts @@ -1,7 +1,7 @@ import { expect } from "@open-wc/testing"; import { restore, stub } from "sinon"; -import { Localize } from "./localize"; +import { Localize, mergeLocales } from "./localize"; import { AppStateService } from "./state"; describe("Localize", () => { @@ -10,6 +10,10 @@ describe("Localize", () => { window.sessionStorage.clear(); AppStateService.resetAll(); document.documentElement.lang = ""; + AppStateService.partialUpdateUserPreferences({ + useBrowserLanguageForFormatting: false, + }); + // TODO write tests with for `useBrowserLanguageForFormatting` }); afterEach(() => { @@ -35,7 +39,7 @@ describe("Localize", () => { it("returns the correct languages", () => { stub(window.navigator, "languages").get(() => ["en-US", "ar", "ko"]); const localize = new Localize(); - expect(localize.languages).to.eql(["en", "es", "ar", "ko"]); + expect(localize.languages).to.eql(["en", "es"]); }); }); @@ -72,9 +76,9 @@ describe("Localize", () => { it("updates the duration formatter", () => { const localize = new Localize(); - localize.setLanguage("es"); + localize.setLanguage("ar"); expect(localize.duration({ days: 1, hours: 2, minutes: 3 })).to.equal( - "1 d, 2 h, 3 min", + "1 ي و2 س و3 د", ); }); @@ -100,11 +104,6 @@ describe("Localize", () => { const localize = new Localize("es"); expect(localize.number(10000)).to.equal("10.000"); }); - - it("formats an ordinal", () => { - const localize = new Localize(); - expect(localize.number(1, { ordinal: true })).to.equal("1st"); - }); }); describe(".date()", () => { @@ -136,7 +135,7 @@ describe("Localize", () => { seconds: 4, milliseconds: 5, }), - ).to.equal("1 ቀናት፣ 2 ሰዓ፣ 3 ደቂቃ፣ 4 ሰከ፣ 5 ሚሴ"); + ).to.equal("1 ቀ፣ 2 ሰ፣ 3 ደ፣ 4 ሰ 5 ሚሴ"); }); it("formats an empty duration", () => { @@ -150,4 +149,44 @@ describe("Localize", () => { expect(() => localize.duration({})).to.throw(); }); }); + + // TODO test `.ordinal()` +}); + +describe("mergeLocales", () => { + it("returns the target lang when navigator locales don't overlap", () => { + expect(mergeLocales("fr", false, ["en-US", "ar", "ko"])).to.deep.equal([ + "fr", + ]); + }); + + it("returns the target lang last when navigator locales do overlap", () => { + expect( + mergeLocales("fr", false, ["fr-FR", "fr-CA", "fr-CH"]), + ).to.deep.equal(["fr-FR", "fr-CA", "fr-CH", "fr"]); + }); + + it("returns the target lang in place last when navigator locales does overlap and contains target lang exactly", () => { + expect( + mergeLocales("fr", false, ["fr-FR", "fr", "fr-CA", "fr-CH"]), + ).to.deep.equal(["fr-FR", "fr", "fr-CA", "fr-CH"]); + }); + + it("handles more complicated locale strings", () => { + expect( + mergeLocales("fr", false, [ + "fr-u-CA-gregory-hc-h12", + "ja-Jpan-JP-u-ca-japanese-hc-h12", + "fr-Latn-FR-u-ca-gregory-hc-h12", + "fr-CA", + ]), + ).to.deep.equal([ + "fr-u-CA-gregory-hc-h12", + "fr-Latn-FR-u-ca-gregory-hc-h12", + "fr-CA", + "fr", + ]); + }); + + // TODO test with `useNavigatorLocales = true` }); diff --git a/frontend/src/utils/localize.ts b/frontend/src/utils/localize.ts index 20467d5de6..00ccf9bedf 100644 --- a/frontend/src/utils/localize.ts +++ b/frontend/src/utils/localize.ts @@ -5,8 +5,11 @@ * to avoid encoding issues when importing the polyfill asynchronously in the test server. * See https://github.com/web-dev-server/web-dev-server/issues/1 */ +import { match } from "@formatjs/intl-localematcher"; import { configureLocalization } from "@lit/localize"; -import uniq from "lodash/fp/uniq"; +import uniq from "lodash/uniq"; + +import { cached } from "./weakCache"; import { sourceLocale, targetLocales } from "@/__generated__/locale-codes"; import { @@ -15,7 +18,6 @@ import { type AllLanguageCodes, type LanguageCode, } from "@/types/localization"; -import { numberFormatter } from "@/utils/number"; import appState from "@/utils/state"; const { getLocale, setLocale } = configureLocalization({ @@ -37,53 +39,143 @@ const defaultDurationOptions: Intl.DurationFormatOptions = { style: "narrow", }; -export class Localize { - // Cache default formatters - private readonly numberFormatter = new Map([ - [sourceLocale, numberFormatter(sourceLocale)], - ]); - private readonly dateFormatter = new Map([ - [sourceLocale, new Intl.DateTimeFormat(sourceLocale, defaultDateOptions)], - ]); - private readonly durationFormatter = new Map([ - [ - sourceLocale, - new Intl.DurationFormat(sourceLocale, defaultDurationOptions), - ], +/** + * Merge app language, app settings, and navigator language into a single list of languages. + * @param targetLang The app language + * @param useNavigatorLocales Use navigator languages not matching app target language + * @param navigatorLocales List of requested languages (from `navigator.languages`) + * @returns List of locales for formatting quantities (e.g. dates, numbers, bytes, etc) + */ +export function mergeLocales( + targetLang: LanguageCode, + useNavigatorLocales: boolean, + navigatorLocales: readonly string[], +) { + if (useNavigatorLocales) { + return uniq([...navigatorLocales, targetLang]); + } + return uniq([ + ...navigatorLocales.filter( + (lang) => new Intl.Locale(lang).language === targetLang, + ), + targetLang, ]); +} + +/** + * Cached number formatter, with smart defaults. + * + * Uses {@linkcode cached} to keep a smart auto-filling and self-clearing cache, + * keyed by app language, user language preferences (from app settings and from + * navigator), and formatter options. + */ +const numberFormatter = cached( + ( + lang: LanguageCode, + useNavigatorLocales: boolean, + navigatorLocales: readonly string[], + options?: Intl.NumberFormatOptions, + ) => + new Intl.NumberFormat( + mergeLocales(lang, useNavigatorLocales, navigatorLocales), + options, + ), +); +/** + * Cached date/time formatter, with smart defaults. + * + * Uses {@linkcode cached} to keep a smart auto-filling and self-clearing cache, + * keyed by app language, user language preferences (from app settings and from + * navigator), and formatter options. + */ +const dateFormatter = cached( + ( + lang: LanguageCode, + useNavigatorLocales: boolean, + navigatorLocales: readonly string[], + options: Intl.DateTimeFormatOptions = defaultDateOptions, + ) => + new Intl.DateTimeFormat( + mergeLocales(lang, useNavigatorLocales, navigatorLocales), + options, + ), +); + +/** + * Cached duration formatter, with smart defaults. + * + * Uses {@linkcode cached} to keep a smart auto-filling and self-clearing cache, + * keyed by app language, user language preferences (from app settings and from + * navigator), and formatter options. + */ +const durationFormatter = cached( + ( + lang: LanguageCode, + useNavigatorLocales: boolean, + navigatorLocales: readonly string[], + options: Intl.DurationFormatOptions = defaultDurationOptions, + ) => + new Intl.DurationFormat( + mergeLocales(lang, useNavigatorLocales, navigatorLocales), + options, + ), +); + +const pluralFormatter = cached( + ( + lang: LanguageCode, + useNavigatorLocales: boolean, + navigatorLocales: readonly string[], + options: Intl.PluralRulesOptions, + ) => + new Intl.PluralRules( + mergeLocales(lang, useNavigatorLocales, navigatorLocales), + options, + ), +); + +export class Localize { get activeLanguage() { // Use html `lang` as the source of truth since that's // the attribute watched by Shoelace - return document.documentElement.lang as LanguageCode; + return new Intl.Locale(document.documentElement.lang) + .language as LanguageCode; } private set activeLanguage(lang: LanguageCode) { // Setting the `lang` attribute will automatically localize // all Shoelace elements and `BtrixElement`s - document.documentElement.lang = lang; + document.documentElement.lang = mergeLocales( + lang, + false, + navigator.languages, + )[0]; + } + + get activeLocales() { + return mergeLocales( + localize.activeLanguage, + appState.userPreferences?.useBrowserLanguageForFormatting ?? true, + navigator.languages, + ); } get languages() { - return uniq([ - ...translatedLocales, - ...window.navigator.languages.map(langShortCode), - ]); + return appState.settings?.localesEnabled ?? translatedLocales; } constructor(initialLanguage: LanguageCode = sourceLocale) { - this.setLanguage(initialLanguage); + void this.setLanguage(initialLanguage); } - initLanguage() { - this.setLanguage( - appState.userPreferences?.language || getBrowserLang() || sourceLocale, - ); + async initLanguage() { + await this.setLanguage(getDefaultLang()); } /** * User-initiated language setting */ - setLanguage(lang: LanguageCode) { + async setLanguage(lang: LanguageCode) { const { error } = languageCodeSchema.safeParse(lang); if (error) { @@ -91,44 +183,33 @@ export class Localize { return; } - if (!this.numberFormatter.get(lang)) { - this.numberFormatter.set(lang, numberFormatter(lang)); - } - if (!this.dateFormatter.get(lang)) { - this.dateFormatter.set( - lang, - new Intl.DateTimeFormat(lang, defaultDateOptions), - ); - } - this.activeLanguage = lang; - this.setTranslation(lang); + await this.setTranslation(lang); } - readonly number = ( - n: number, - opts?: Intl.NumberFormatOptions & { ordinal?: boolean }, - ) => { + readonly number = (n: number, opts?: Intl.NumberFormatOptions) => { if (isNaN(n)) return ""; - let formatter = this.numberFormatter.get(localize.activeLanguage); - - if ((opts && !opts.ordinal) || !formatter) { - formatter = new Intl.NumberFormat(localize.activeLanguage, opts); - } + const formatter = numberFormatter( + localize.activeLanguage, + appState.userPreferences?.useBrowserLanguageForFormatting ?? true, + navigator.languages, + opts, + ); - return formatter.format(n, opts); + return formatter.format(n); }; // Custom date formatter that takes missing `Z` into account readonly date = (d: Date | string, opts?: Intl.DateTimeFormatOptions) => { const date = new Date(d instanceof Date || d.endsWith("Z") ? d : `${d}Z`); - let formatter = this.dateFormatter.get(localize.activeLanguage); - - if (opts || !formatter) { - formatter = new Intl.DateTimeFormat(localize.activeLanguage, opts); - } + const formatter = dateFormatter( + localize.activeLanguage, + appState.userPreferences?.useBrowserLanguageForFormatting ?? true, + navigator.languages, + opts, + ); return formatter.format(date); }; @@ -137,38 +218,51 @@ export class Localize { d: Intl.DurationType, opts?: Intl.DurationFormatOptions, ) => { - let formatter = this.durationFormatter.get(localize.activeLanguage); - - if (opts || !formatter) { - formatter = new Intl.DurationFormat(localize.activeLanguage, opts); - } + const formatter = durationFormatter( + localize.activeLanguage, + appState.userPreferences?.useBrowserLanguageForFormatting ?? true, + navigator.languages, + opts, + ); return formatter.format(d); }; - private setTranslation(lang: LanguageCode) { + private async setTranslation(lang: LanguageCode) { if ( lang !== getLocale() && (translatedLocales as AllLanguageCodes).includes(lang) ) { - void setLocale(lang); + await setLocale(lang); } } + + readonly ordinal = ( + value: number, + phrases: Record, + ) => { + const formatter = pluralFormatter( + localize.activeLanguage, + appState.userPreferences?.useBrowserLanguageForFormatting ?? true, + navigator.languages, + { type: "ordinal" }, + ); + const pluralRule = formatter.select(value); + return phrases[pluralRule]; + }; } const localize = new Localize(sourceLocale); export default localize; -function langShortCode(locale: string) { - return locale.split("-")[0] as LanguageCode; -} - -export function getBrowserLang() { +export function getDefaultLang() { // Default to current user browser language - const browserLanguage = window.navigator.language; - if (browserLanguage) { - return langShortCode(browserLanguage); - } - return null; + return match( + appState.userPreferences?.language + ? [appState.userPreferences.language] + : navigator.languages, + appState.settings?.localesEnabled ?? translatedLocales, + sourceLocale, + ) as LanguageCode; } diff --git a/frontend/src/utils/number.ts b/frontend/src/utils/number.ts deleted file mode 100644 index 62c6804ae8..0000000000 --- a/frontend/src/utils/number.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Create internationalized number formatter - * - * You probably want to use `localize.number()` from `/utils/localize` - * directly instead of creating a new number formatter. - * - * Usage: - * ```ts - * const formatter = numberFormatter() - * formatter.format(10000); // 10,000 - * formatter.format(10, { ordinal: true }); // 10th - * ``` - **/ -export function numberFormatter( - locales?: string | string[], - opts?: Intl.NumberFormatOptions, -) { - const numFormat = new Intl.NumberFormat(locales, opts); - // TODO localize - const pluralRules = new Intl.PluralRules("en", { type: "ordinal" }); - - const suffixes = new Map([ - ["one", "st"], - ["two", "nd"], - ["few", "rd"], - ["other", "th"], - ]); - - const format = (n: number, opts: { ordinal?: boolean } = {}) => { - if (opts.ordinal) { - const rule = pluralRules.select(n); - const suffix = suffixes.get(rule); - return `${numFormat.format(n)}${suffix}`; - } - - return numFormat.format(n); - }; - - return { format }; -} diff --git a/frontend/src/utils/workflow.ts b/frontend/src/utils/workflow.ts index bba76abd1a..a03777d65d 100644 --- a/frontend/src/utils/workflow.ts +++ b/frontend/src/utils/workflow.ts @@ -18,7 +18,7 @@ import { } from "@/types/workflow"; import { DEFAULT_MAX_SCALE, isPageScopeType } from "@/utils/crawler"; import { getNextDate, getScheduleInterval } from "@/utils/cron"; -import localize, { getBrowserLang } from "@/utils/localize"; +import localize, { getDefaultLang } from "@/utils/localize"; import { regexUnescape } from "@/utils/string"; export const BYTES_PER_GB = 1e9; @@ -121,7 +121,7 @@ export const getDefaultFormState = (): FormState => ({ pageLimit: null, scale: 1, blockAds: true, - lang: getBrowserLang(), + lang: getDefaultLang(), scheduleType: "none", scheduleFrequency: "weekly", scheduleDayOfMonth: new Date().getDate(), diff --git a/frontend/xliff/es.xlf b/frontend/xliff/es.xlf index ea8a83d1fe..66c56b4f08 100644 --- a/frontend/xliff/es.xlf +++ b/frontend/xliff/es.xlf @@ -429,10 +429,6 @@ Every Cada - - Monthly on the - Mensualmente el - Every day at Cada día a las @@ -1805,7 +1801,7 @@ All files and logs associated with this item will also be deleted, and the crawl will no longer be visible in its associated Workflow. - Delete + Delete Eliminar @@ -2074,7 +2070,7 @@ + - By + By Por @@ -2750,18 +2746,6 @@ cannot be undone. ¿Está seguro que desea eliminar ? Esta acción no se puede deshacer. - - Deleting an org will delete all of data associated with the org. - - - Crawls: - - - Uploads: - - - Profiles: - Type "" to confirm Escriba "" para confirmar @@ -4377,25 +4361,6 @@ Choose your preferred language for displaying Browsertrix in your browser. Elija su idioma preferido para visualizar Browsertrix en su navegador. - - Deleting an org will delete all - - of data associated with the org. - - - Crawls: - - - - Uploads: - - Subidas: - - - Profiles: - - Perfiles: - Removing Eliminar @@ -4408,12 +4373,6 @@ Your trial ends within one day Su periodo de prueba termina en un día - - Your free trial ends on . To continue using - Browsertrix, select Choose Plan in - . - Su periodo de prueba termina en . Para seguir utilizando Browsertrix, seleccione Elegir plan en . - Your web archives are always yours — you can download any archived items you'd like to keep before the trial ends! @@ -4486,6 +4445,53 @@ Name & Schedule + + Deleting an org will delete all + + of data associated with the org. + + + : + + + + Your free trial ends on . To continue using + Browsertrix, select Subscribe Now in + . + + + Your browser’s language settings will take precedence over the language chosen above when formatting numbers, dates, and durations. + + + Use browser language settings for formatting numbers and dates. + + + For example: + + + Your formatting preference has been updated. + + + Couldn’t initialize Browsertrix correctly. + + + Monthly on the zeroth + + + Monthly on the st + + + Monthly on the nd + + + Monthly on the rd + + + Monthly on the th + + + Monthly on the th + diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 0b19302970..dc3f54d70b 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -824,6 +824,13 @@ dependencies: tslib "2" +"@formatjs/intl-localematcher@^0.5.9": + version "0.5.9" + resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.5.9.tgz#43c6ee22be85b83340bcb09bdfed53657a2720db" + integrity sha512-8zkGu/sv5euxbjfZ/xmklqLyDGQSxsLqg8XOq88JW3cmJtzhCP8EtSJXlaKZnVO4beEaoiT9wj4eIoCQ9smwxA== + dependencies: + tslib "2" + "@humanwhocodes/config-array@^0.11.13": version "0.11.13" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297"