From 075c664ffad4ca5b73eef41efd16e8b6cf42396c Mon Sep 17 00:00:00 2001 From: Emma Zuehlcke Date: Tue, 1 Jul 2025 20:51:06 +0200 Subject: [PATCH 01/11] Add support for evaluating JEXL-based filter expressions. This allows us to filter entries based on which Firefox version they apply to. Uses mozjexl extended with the version compare method from addons-moz-compare. --- package-lock.json | 16 ++++- package.json | 4 +- src/filter-expression/addons-moz-compare.d.ts | 7 ++ src/filter-expression/filter-expression.ts | 64 +++++++++++++++++++ src/filter-expression/mozjexl.d.ts | 61 ++++++++++++++++++ 5 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 src/filter-expression/addons-moz-compare.d.ts create mode 100644 src/filter-expression/filter-expression.ts create mode 100644 src/filter-expression/mozjexl.d.ts diff --git a/package-lock.json b/package-lock.json index 761d067..b31a2f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,9 @@ "name": "url-classifier-exceptions-ui", "license": "MPL-2.0", "dependencies": { - "lit": "^3.3.0" + "addons-moz-compare": "^1.3.0", + "lit": "^3.3.0", + "mozjexl": "^1.1.6" }, "devDependencies": { "dotenv": "^17.0.0", @@ -789,6 +791,12 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/addons-moz-compare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/addons-moz-compare/-/addons-moz-compare-1.3.0.tgz", + "integrity": "sha512-/rXpQeaY0nOKhNx00pmZXdk5Mu+KhVlL3/pSBuAYwrxRrNiTvI/9xfQI8Lmm7DMMl+PDhtfAHY/0ibTpdeoQQQ==", + "license": "MPL-2.0" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -987,6 +995,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mozjexl": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/mozjexl/-/mozjexl-1.1.6.tgz", + "integrity": "sha512-34nvJEO7yLjMxooe0qfVUfBvotz270UFuhX0MmTM/0tR5HPJpjTYQLE7IyNag7EF6HPNmnBu0Y6kKoH6MvH8Lw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", diff --git a/package.json b/package.json index 04156f2..2943b2d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "vite": "^7.0.0" }, "dependencies": { - "lit": "^3.3.0" + "addons-moz-compare": "^1.3.0", + "lit": "^3.3.0", + "mozjexl": "^1.1.6" } } diff --git a/src/filter-expression/addons-moz-compare.d.ts b/src/filter-expression/addons-moz-compare.d.ts new file mode 100644 index 0000000..e484ad9 --- /dev/null +++ b/src/filter-expression/addons-moz-compare.d.ts @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +declare module "addons-moz-compare" { + export function mozCompare(a: string, b: string): boolean; +} diff --git a/src/filter-expression/filter-expression.ts b/src/filter-expression/filter-expression.ts new file mode 100644 index 0000000..e543c79 --- /dev/null +++ b/src/filter-expression/filter-expression.ts @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import mozjexl from "mozjexl"; +import { mozCompare } from "addons-moz-compare"; + +const jexl = new mozjexl.Jexl(); +jexl.addTransforms({ + versionCompare: (value: unknown, ...args: unknown[]) => { + // Ensure we have two string arguments for version comparison + if (args.length < 1) { + throw new Error("versionCompare requires two arguments"); + } + const a = String(value); + const b = String(args[0]); + return mozCompare(a, b); + }, +}); + +/** + * Evaluates a JEXL-based filter expression. + * @param expression The filter expression to evaluate. + * @param context The context to evaluate the expression in. + * @returns The result of the evaluation. + */ +export function evaluateFilterExpression(expression: string, context: Record) { + return jexl.eval(expression, context); +} + +/** + * Evaluates a given JEXL filter expression against a Gecko / Firefox version + * number. + * @param version The version number to check. + * @param filterExpression The filter expression to evaluate. + * @returns True if the version number matches the filter expression, false + * otherwise. + */ +export async function versionNumberMatchesFilterExpression( + version: string, + filterExpression: string | undefined, +): Promise { + // An empty filter expression always matches. + if (!filterExpression?.length) { + return true; + } + + const context = { + env: { + version, + }, + }; + const result = await evaluateFilterExpression(filterExpression, context); + console.debug( + "Evaluating filter expression", + filterExpression, + "for version", + version, + "result", + result, + ); + + return result === true; +} diff --git a/src/filter-expression/mozjexl.d.ts b/src/filter-expression/mozjexl.d.ts new file mode 100644 index 0000000..31715d4 --- /dev/null +++ b/src/filter-expression/mozjexl.d.ts @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Provides basic type definitions for mozjexl. +// Only the properties that are used in this project are defined. + +declare module "mozjexl" { + /** + * A transform function that can be used in JEXL expressions. Transform + * functions receive the value to be transformed as the first argument, + * followed by any additional arguments passed to the transform. + */ + export type TransformFunction = ( + value: unknown, + ...args: unknown[] + ) => unknown | Promise; + + /** + * A map of transform names to their corresponding transform functions. + */ + export interface Transforms { + [key: string]: TransformFunction; + } + + /** + * The context object passed to JEXL expressions, containing variables + * accessible during evaluation. + */ + export interface JexlContext { + [key: string]: unknown; + } + + /** + * The main JEXL class for parsing and evaluating expressions. + */ + export class Jexl { + constructor(); + /** + * Adds multiple transform functions at once. + * @param map A map of transform names to transform functions + */ + addTransforms(map: Transforms): void; + + /** + * Evaluates a JEXL expression within an optional context. + * @param expression The JEXL expression to be evaluated + * @param context A mapping of variables to values, which will be made + * accessible to the JEXL expression + * @returns A Promise that resolves with the result of the evaluation + */ + eval(expression: string, context?: JexlContext): Promise; + } + + // The default export is an instance of Jexl + const mozjexl: Jexl & { + Jexl: typeof Jexl; + }; + + export default mozjexl; +} From 9e24835969882d8c259e7fa7487a733a1c445a7c Mon Sep 17 00:00:00 2001 From: Emma Zuehlcke Date: Wed, 2 Jul 2025 13:24:13 +0200 Subject: [PATCH 02/11] Add Firefox version filtering and fetching functionality. This adds code to fetch the current Firefox versions per release channel (nightly, beta , release). A new dropdown allows users to switch between the release channels. Only records which apply to the selected release channel will be shown. --- src/app.ts | 235 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 206 insertions(+), 29 deletions(-) diff --git a/src/app.ts b/src/app.ts index 240709b..58c3b7c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,6 +10,8 @@ import "./exceptions-table/exceptions-table"; import "./exceptions-table/top-exceptions-table"; import "./github-corner"; +import { versionNumberMatchesFilterExpression } from "./filter-expression/filter-expression.js"; + const GITHUB_URL = "https://github.com/mozilla/url-classifier-exceptions-ui"; // Query parameter which can be used to override the RS environment. @@ -120,6 +122,40 @@ async function fetchBugMetadata(bugIds: Set): Promise { return bugMetaMap; } +/** + * Fetch the versions for each Firefox release channel. + * @returns The versions for each release channel. + */ +async function fetchVersionsPerChannel(): Promise<{ + nightly: string; + beta: string; + release: string; +}> { + let [nightly, beta, release] = await Promise.all([ + fetchVersionNumber("nightly"), + fetchVersionNumber("beta"), + fetchVersionNumber("release"), + ]); + return { + nightly, + beta, + release, + }; +} + +/** + * Fetch the version number for a given release channel. + * @param releaseChannel The release channel to fetch the version number for. + * @returns The version number for the given release channel. + */ +async function fetchVersionNumber(releaseChannel: string): Promise { + let url = new URL("https://whattrainisitnow.com/api/release/schedule/"); + url.searchParams.set("version", releaseChannel); + const response = await fetch(url); + const json = await response.json(); + return json.version; +} + @customElement("app-root") export class App extends LitElement { // The Remote Settings environment to use. The default is configured via env @@ -138,6 +174,11 @@ export class App extends LitElement { @state() records: ExceptionListEntry[] = []; + // Holds all fetched records matching the current Firefox version filter or + // all records if no filter is selected. + @state() + displayRecords: ExceptionListEntry[] = []; + // Holds the metadata for all bugs that are associated with the exceptions // list. @state() @@ -150,6 +191,17 @@ export class App extends LitElement { @state() error: string | null = null; + // Holds the version numbers for each Firefox release channel. + @state() + firefoxVersions: { + nightly: string; + beta: string; + release: string; + } | null = null; + + @state() + filterFirefoxChannel: "nightly" | "beta" | "release" | null = null; + static styles = css` /* Sticky headings. */ h2 { @@ -211,21 +263,17 @@ export class App extends LitElement { try { this.loading = true; - const urlStr = getRSEndpoint(this.rsEnv, this.rsEnvUsePreview).toString(); - this.records = await fetchRecords(urlStr); + await this.updateVersionInfo(); - // Spot check if the format is as expected. - if (this.records.length && this.records[0].bugIds == null) { - throw new Error("Unexpected or outdated format."); + // If we successfully fetched the version info, set the default filter to + // the latest release channel. We need to do this before calling + // this.updateRecords so that the initial display is correctly filtered. + if (this.firefoxVersions) { + this.filterFirefoxChannel = "release"; } - // Sort so most recently modified records are at the top. - this.records.sort((a, b) => b.last_modified - a.last_modified); - - // Fetch the metadata for all bugs that are associated with the exceptions list. - this.bugMeta = await fetchBugMetadata( - new Set(this.records.flatMap((record) => record.bugIds || [])), - ); + // Fetch the records. + await this.updateRecords(); this.error = null; } catch (error: any) { @@ -235,19 +283,102 @@ export class App extends LitElement { } } + /** + * Fetch the versions for each Firefox release channel and store it. + */ + async updateVersionInfo() { + // Fetch the versions for each release channel. + try { + this.firefoxVersions = await fetchVersionsPerChannel(); + console.debug("versions", this.firefoxVersions); + } catch (error) { + console.error("Failed to fetch Firefox versions", error); + } + } + + /** + * Fetch the records from the Remote Settings environment and store it. + */ + async updateRecords() { + const urlStr = getRSEndpoint(this.rsEnv, this.rsEnvUsePreview).toString(); + this.records = await fetchRecords(urlStr); + + // Spot check if the format is as expected. + if (this.records.length && this.records[0].bugIds == null) { + throw new Error("Unexpected or outdated format."); + } + + // Sort so most recently modified records are at the top. + this.records.sort((a, b) => b.last_modified - a.last_modified); + + // Update the filtered records based on the selected Firefox version. + let updateFilteredRecordsPromise = this.updateFilteredRecords(); + + // Fetch the metadata for all bugs that are associated with the exceptions list. + let fetchBugMetadataPromise = fetchBugMetadata( + new Set(this.records.flatMap((record) => record.bugIds || [])), + ).then((bugMeta) => { + this.bugMeta = bugMeta; + }); + + // Wait for both promises to complete. + await Promise.all([updateFilteredRecordsPromise, fetchBugMetadataPromise]); + } + + /** + * Updates the filtered records based on current filter settings. + * This should be called whenever records filterFirefoxChannel, or + * firefoxVersions change. + */ + private async updateFilteredRecords() { + // If no Firefox version is selected, show all records. + if (!this.filterFirefoxChannel || !this.firefoxVersions) { + this.displayRecords = this.records; + return; + } + + // Get the records that match the selected Firefox version. + const targetVersion = this.firefoxVersions[this.filterFirefoxChannel]; + this.displayRecords = await this.getRecordsMatchingFirefoxVersion(targetVersion); + } + /** * Get the number of unique bugs that are associated with the exceptions list * @returns The number of unique bugs. */ get uniqueBugCount(): number { - return new Set(this.records.flatMap((record) => record.bugIds || [])).size; + return new Set(this.displayRecords.flatMap((record) => record.bugIds || [])).size; + } + + /** + * Get the records that match the given Firefox version. + * Uses the filter_expression field to match records. + * @param firefoxVersion The Firefox version to match. + * @returns The records that match the given Firefox version. + */ + private async getRecordsMatchingFirefoxVersion( + firefoxVersion: string, + ): Promise { + let filteredRecords = await Promise.all( + this.records.map(async (record) => { + let matches = await versionNumberMatchesFilterExpression( + firefoxVersion, + record.filter_expression, + ); + if (matches) { + return record; + } + return null; + }), + ); + return filteredRecords.filter((record) => record !== null); } /** * Handle changes to RS environment settings via the UI. * @param event The change event either the dropdown or the checkbox. */ - private handleRSEnvChange(event: Event) { + private async handleRSEnvChange(event: Event) { const target = event.target as HTMLSelectElement | HTMLInputElement; if (target.id === "rs-env") { @@ -265,7 +396,31 @@ export class App extends LitElement { window.history.pushState({}, "", url); // Fetch the records again with the new settings - this.init(); + try { + this.loading = true; + + await this.updateRecords(); + + this.error = null; + } catch (error: any) { + this.error = error?.message || "Failed to initialize"; + } finally { + this.loading = false; + } + } + + /** + * Handle changes to the Firefox version filter via the UI. + * @param event The change event. + */ + private async handleFirefoxVersionFilterChange(event: Event) { + const target = event.target as HTMLSelectElement; + + this.filterFirefoxChannel = target.value as keyof typeof this.firefoxVersions; + + // Update the filtered records based on the selected Firefox version. + // This does not require a full re-fetch of the records. + await this.updateFilteredRecords(); } /** @@ -304,19 +459,20 @@ export class App extends LitElement { return html`
Loading...
`; } - if (this.records.length === 0) { + if (this.displayRecords.length === 0) { return html`
No records found.
`; } return html`

- There are currently a total of ${this.records.length} exceptions on record. - ${this.records.filter((e) => !e.topLevelUrlPattern?.length).length} + There are currently a total of ${this.displayRecords.length} exceptions on record. + ${this.displayRecords.filter((e) => !e.topLevelUrlPattern?.length).length} global exceptions and - ${this.records.filter((e) => e.topLevelUrlPattern?.length).length} + ${this.displayRecords.filter((e) => e.topLevelUrlPattern?.length).length} per-site exceptions. ${this.records.filter((e) => e.category === "baseline").length} of them are baseline - exceptions and ${this.records.filter((e) => e.category === "convenience").length} - convenience exceptions. + >. ${this.displayRecords.filter((e) => e.category === "baseline").length} of them are + baseline exceptions and + ${this.displayRecords.filter((e) => e.category === "convenience").length} convenience + exceptions.

Overall the exceptions resolve ${this.uniqueBugCount} known bugs. Note that global @@ -333,7 +489,7 @@ export class App extends LitElement {

Baseline

!entry.topLevelUrlPattern?.length && entry.category === "baseline"} @@ -342,7 +498,7 @@ export class App extends LitElement {

Convenience

!entry.topLevelUrlPattern?.length && entry.category === "convenience"} @@ -359,7 +515,7 @@ export class App extends LitElement {

Baseline

!!entry.topLevelUrlPattern?.length && entry.category === "baseline"} @@ -368,7 +524,7 @@ export class App extends LitElement {

Convenience

!!entry.topLevelUrlPattern?.length && entry.category === "convenience"} @@ -381,7 +537,7 @@ export class App extends LitElement { that it should be added to the global exceptions list.

@@ -409,7 +565,7 @@ export class App extends LitElement { ${this.renderMainContent()}