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 {
!!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.