diff --git a/README.md b/README.md index aaeca51..e3cc13a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ Firefox addon that deletes history older than a specified amount of days. * Interval in minutes between triggering. * Only has effect if trigger mode is set to timer. * Defaults to 1440 (24 hours), Minimum 1 +* Regex Clean + * Manually preview & delete history entries whose URL matches a regular expression. + * Supports case-insensitive/unicode flags, live pattern validation. + * Results show page title, visit count, visit time, sortable by last visit, visit count, URL. + * Recent patterns saved for easy re-use later. + * Hidden by default. Enable the section from the Extras section of the popup. ## Permissions diff --git a/src/OptionsInterface.ts b/src/OptionsInterface.ts index 8be4cb9..d3cbef1 100644 --- a/src/OptionsInterface.ts +++ b/src/OptionsInterface.ts @@ -1,5 +1,10 @@ import browser from "./we"; +export interface RegexHistoryEntry { + pattern: string; + flags: string; +} + /** Shape of options object */ export interface OptionsInterface extends Record { dirty: boolean; @@ -10,6 +15,8 @@ export interface OptionsInterface extends Record { deleteMode: "idle" | "startup" | "timer" | string; notifications: boolean; downloads: boolean; + regexClean: boolean; + regexHistory?: RegexHistoryEntry[]; // filterHistory: boolean; // filterList: string[]; @@ -28,6 +35,7 @@ export interface FormElements extends HTMLFormControlsCollection { notifications: HTMLInputElement; // notificationsPermission: HTMLInputElement; downloads: HTMLInputElement; + regexClean: HTMLInputElement; // downloadsPermission: HTMLInputElement; // filterHistory: HTMLInputElement; // filterList: HTMLTextAreaElement; @@ -49,6 +57,8 @@ export class Options implements OptionsInterface { deleteMode = "timer"; notifications = false; downloads = false; + regexClean = false; + regexHistory: RegexHistoryEntry[] = []; icon = "theme"; // filterHistory = false // filterList = ["example.com", "example.org"] @@ -110,6 +120,20 @@ export class Options implements OptionsInterface { this.downloads = optionsObj.downloads; } + if (typeof optionsObj.regexClean === "boolean") { + this.regexClean = optionsObj.regexClean; + } + + if (Array.isArray(optionsObj.regexHistory)) { + const entries: RegexHistoryEntry[] = []; + for (const entry of optionsObj.regexHistory) { + if (typeof entry === "object" && entry !== null && typeof entry.pattern === "string" && typeof entry.flags === "string") { + entries.push({ pattern: entry.pattern, flags: entry.flags }); + } + } + this.regexHistory = entries.slice(0, 10); + } + // if (typeof optionsObj.filterHistory === "boolean") { // this.filterHistory = optionsObj.filterHistory; // } diff --git a/src/RegexClean.ts b/src/RegexClean.ts new file mode 100644 index 0000000..2c0d625 --- /dev/null +++ b/src/RegexClean.ts @@ -0,0 +1,41 @@ +import browser from "./we"; + +export const REGEX_SEARCH_CAP = 100000; + +export interface RegexMatch { + url: string; + title: string; + lastVisitTime: number; + visitCount: number; +} + +export interface RegexPreview { + items: RegexMatch[]; + truncated: boolean; +} + +export async function previewRegexClean(pattern: string, flags: string): Promise { + const re = new RegExp(pattern, flags); + const results = await browser.history.search({ text: "", startTime: 0, maxResults: REGEX_SEARCH_CAP }); + const seen = new Set(); + const items: RegexMatch[] = []; + for (const item of results) { + if (typeof item.url === "string" && !seen.has(item.url) && re.test(item.url)) { + seen.add(item.url); + items.push({ + url: item.url, + title: item.title ?? "", + lastVisitTime: item.lastVisitTime ?? 0, + visitCount: item.visitCount ?? 0 + }); + } + } + return { items, truncated: results.length >= REGEX_SEARCH_CAP }; +} + +export async function deleteUrls(urls: string[]): Promise { + const chunkSize = 50; + for (let i = 0; i < urls.length; i += chunkSize) { + await Promise.all(urls.slice(i, i + chunkSize).map(url => browser.history.deleteUrl({ url }))); + } +} diff --git a/src/RegexHighlight.ts b/src/RegexHighlight.ts new file mode 100644 index 0000000..dbf8ab1 --- /dev/null +++ b/src/RegexHighlight.ts @@ -0,0 +1,99 @@ +type RegexTokenKind = "escape" | "charClass" | "quantifier" | "group" | "alternation" | "anchor" | "literal"; + +interface RegexToken { + text: string; + kind: RegexTokenKind; +} + +function tokenizeRegex(pattern: string): RegexToken[] { + const tokens: RegexToken[] = []; + let i = 0; + let inClass = false; + while (i < pattern.length) { + const c = pattern[i]; + if (c === "\\" && i + 1 < pattern.length) { + tokens.push({ text: pattern.slice(i, i + 2), kind: "escape" }); + i += 2; + continue; + } + if (inClass) { + if (c === "]") { + inClass = false; + } + tokens.push({ text: c, kind: "charClass" }); + i += 1; + continue; + } + if (c === "[") { + inClass = true; + tokens.push({ text: c, kind: "charClass" }); + i += 1; + continue; + } + if (c === "(") { + if (pattern[i + 1] === "?") { + let j = i + 2; + while (j < pattern.length && ":=!<>".includes(pattern[j])) { + j += 1; + } + tokens.push({ text: pattern.slice(i, j), kind: "group" }); + i = j; + } else { + tokens.push({ text: c, kind: "group" }); + i += 1; + } + continue; + } + if (c === ")") { + tokens.push({ text: c, kind: "group" }); + i += 1; + continue; + } + if (c === "{") { + const end = pattern.indexOf("}", i); + if (end !== -1 && /^\{\d+(,\d*)?\}$/.test(pattern.slice(i, end + 1))) { + tokens.push({ text: pattern.slice(i, end + 1), kind: "quantifier" }); + i = end + 1; + continue; + } + tokens.push({ text: c, kind: "literal" }); + i += 1; + continue; + } + if (c === "*" || c === "+" || c === "?") { + tokens.push({ text: c, kind: "quantifier" }); + i += 1; + continue; + } + if (c === "|") { + tokens.push({ text: c, kind: "alternation" }); + i += 1; + continue; + } + if (c === "^" || c === "$" || c === ".") { + tokens.push({ text: c, kind: "anchor" }); + i += 1; + continue; + } + tokens.push({ text: c, kind: "literal" }); + i += 1; + } + return tokens; +} + +export function renderRegexTokens(container: HTMLElement, pattern: string): void { + container.replaceChildren(); + let current: HTMLSpanElement | null = null; + let currentKind: RegexTokenKind | null = null; + for (const token of tokenizeRegex(pattern)) { + if (current !== null && token.kind === currentKind) { + current.innerText += token.text; + continue; + } + current = document.createElement("span"); + currentKind = token.kind; + current.className = "re-" + token.kind; + current.innerText = token.text; + container.append(current); + } +} diff --git a/src/_locales/en_US/messages.json b/src/_locales/en_US/messages.json index 0a065d0..7f9486a 100644 --- a/src/_locales/en_US/messages.json +++ b/src/_locales/en_US/messages.json @@ -288,5 +288,121 @@ "statisticsUnsavedChanges": { "message": "History will not be cleared because there are unsaved changes.", "description": "" + }, + "optionRegexCleanCheckbox": { + "message": "Enable regex clean section", + "description": "" + }, + "regexCleanSection": { + "message": "Regex Clean", + "description": "" + }, + "regexCleanInfo": { + "message": "Preview/Delete history entries whose URL matches a regular expression.", + "description": "" + }, + "regexCleanPatternLabel": { + "message": "Regular expression", + "description": "" + }, + "regexCleanFlagIgnoreCase": { + "message": "Case insensitive (i)", + "description": "" + }, + "regexCleanFlagUnicode": { + "message": "Unicode (u)", + "description": "" + }, + "regexCleanRegex101": { + "message": "Open in regex101.com", + "description": "" + }, + "regexCleanPreview": { + "message": "Preview Matches", + "description": "" + }, + "regexCleanDelete": { + "message": "Delete Matches", + "description": "" + }, + "regexCleanMatchCount": { + "message": "$COUNT$ matches", + "description": "", + "placeholders": { + "count": { + "content": "$1", + "example": "" + } + } + }, + "regexCleanNoMatches": { + "message": "No matches", + "description": "" + }, + "regexCleanSearchError": { + "message": "History search failed", + "description": "" + }, + "regexCleanDeleted": { + "message": "Deleted $COUNT$ entries", + "description": "", + "placeholders": { + "count": { + "content": "$1", + "example": "" + } + } + }, + "regexCleanDeleteError": { + "message": "History deletion failed", + "description": "" + }, + "regexCleanTruncated": { + "message": "(capped at $CAP$)", + "description": "", + "placeholders": { + "cap": { + "content": "$1", + "example": "" + } + } + }, + "regexCleanShowMore": { + "message": "Show $COUNT$ more", + "description": "", + "placeholders": { + "count": { + "content": "$1", + "example": "" + } + } + }, + "regexCleanSortLabel": { + "message": "Sort by", + "description": "" + }, + "regexCleanSortLastVisit": { + "message": "Last visit", + "description": "" + }, + "regexCleanSortVisitCount": { + "message": "Visit count", + "description": "" + }, + "regexCleanSortUrl": { + "message": "URL", + "description": "" + }, + "regexCleanRecentPatterns": { + "message": "Recent patterns", + "description": "" + }, + "regexCleanRemovePattern": { + "message": "Remove from recent patterns", + "description": "" + }, + "regexCleanPopout": { + "message": "Open in window", + "description": "" } } diff --git a/src/cr_popup.html b/src/cr_popup.html index ab41dab..ec21d18 100644 --- a/src/cr_popup.html +++ b/src/cr_popup.html @@ -124,6 +124,73 @@

+ +

@@ -152,6 +219,12 @@

+

+ +

Available on Firefox Available on Chrome Web Store diff --git a/src/ff_popup.html b/src/ff_popup.html index b91f84c..b37d8df 100644 --- a/src/ff_popup.html +++ b/src/ff_popup.html @@ -142,6 +142,73 @@

+ +

@@ -170,6 +237,12 @@

+

+ +

Available on Firefox Available on Chrome Web Store diff --git a/src/ff_popup.ts b/src/ff_popup.ts index e4fb1a6..0c01a70 100644 --- a/src/ff_popup.ts +++ b/src/ff_popup.ts @@ -1,10 +1,14 @@ -import { Options, OptionsInterface, FormElements } from "./OptionsInterface"; +import { Options, OptionsInterface, FormElements, type RegexHistoryEntry } from "./OptionsInterface"; import { Message, MessageState } from "./MessageInterface"; import browser from "./we"; import { PermissionCheckbox } from "./PermissionCheckbox"; +import { previewRegexClean, deleteUrls, REGEX_SEARCH_CAP, type RegexPreview, type RegexMatch } from "./RegexClean"; + +import { renderRegexTokens } from "./RegexHighlight"; + import { i18n } from "./i18n"; import "./popup.css"; @@ -25,11 +29,53 @@ const exportButton = document.querySelector("#export") as HTMLAnchorElement; const importButton = document.querySelector("#import") as HTMLButtonElement; const importFile = document.querySelector("#import-file") as HTMLInputElement; -const submitButton = document.querySelector("#activate") as HTMLButtonElement; +const submitButton = document.querySelector("#activate") as HTMLButtonElement | null; // manual delete button const manualDeleteButton = document.querySelector("#manual-delete") as HTMLButtonElement; +const regexSection = document.querySelector("#regex-clean-section") as HTMLDetailsElement; +const regexPatternInput = document.querySelector("#regex-pattern") as HTMLInputElement; +const regexPreviewButton = document.querySelector("#regex-preview") as HTMLButtonElement; +const regexDeleteButton = document.querySelector("#regex-delete") as HTMLButtonElement; +const regexStatus = document.querySelector("#regex-status") as HTMLParagraphElement; +const regexResults = document.querySelector("#regex-results") as HTMLDivElement; +const regexCount = document.querySelector("#regex-count") as HTMLSpanElement; +const regexInputWrap = document.querySelector("#regex-input-wrap") as HTMLDivElement; +const regexFlagIgnoreCase = document.querySelector("#regex-flag-i") as HTMLInputElement; +const regexFlagUnicode = document.querySelector("#regex-flag-u") as HTMLInputElement; +const regex101Link = document.querySelector("#regex-101") as HTMLAnchorElement; +const regexValidity = document.querySelector("#regex-validity") as HTMLSpanElement; +const regexSort = document.querySelector("#regex-sort") as HTMLSelectElement; +const regexHistoryWrap = document.querySelector("#regex-history-wrap") as HTMLDivElement; +const regexHistoryList = document.querySelector("#regex-history") as HTMLUListElement; +const regexHighlight = document.querySelector("#regex-highlight") as HTMLDivElement; +const regexResultsCard = document.querySelector("#regex-results-card") as HTMLDivElement; +const regexPopoutButton = document.querySelector("#regex-popout") as HTMLButtonElement; + +const regexPopParams = new URLSearchParams(location.search); +if (regexPopParams.has("regexPop")) { + document.body.classList.add("regex-popout"); + regexPatternInput.value = regexPopParams.get("pattern") ?? ""; + regexFlagIgnoreCase.checked = (regexPopParams.get("flags") ?? "").includes("i"); + regexFlagUnicode.checked = (regexPopParams.get("flags") ?? "").includes("u"); +} + +let regexPreviewedItems: RegexMatch[] = []; + +let regexPreviewGen = 0; + +const REGEX_RENDER_CHUNK = 200; + +let regexHistoryWrite: Promise = Promise.resolve(); + +function queueRegexHistoryUpdate(update: (entries: RegexHistoryEntry[]) => RegexHistoryEntry[]): void { + regexHistoryWrite = regexHistoryWrite.then(async () => { + const res = new Options(await browser.storage.local.get()); + await browser.storage.local.set({ regexHistory: update(res.regexHistory) }); + }).catch((error) => console.error("regexHistory update failed", error)); +} + /** * Resets the trigger mode * Used when importing from file or sync @@ -68,6 +114,314 @@ function manualDelete(e: MouseEvent): void { browser.runtime.sendMessage(msg); } +function regexInvalidatePreview(): void { + regexPreviewGen += 1; + regexPreviewedItems = []; + regexDeleteButton.disabled = true; + regexStatus.innerText = ""; + regexResults.replaceChildren(); + regexResultsCard.style.display = "none"; +} + +function regexFlags(): string { + let flags = ""; + if (regexFlagIgnoreCase.checked) { + flags += "i"; + } + if (regexFlagUnicode.checked) { + flags += "u"; + } + return flags; +} + +function escapeSlashesForRegex101(pattern: string): string { + let out = ""; + let escaped = false; + for (const c of pattern) { + if (escaped) { + out += c; + escaped = false; + continue; + } + if (c === "\\") { + out += c; + escaped = true; + continue; + } + out += c === "/" ? "\\/" : c; + } + return out; +} + +function regexRefreshValidity(): void { + const pattern = regexPatternInput.value; + regex101Link.href = "https://regex101.com/?flavor=javascript®ex=" + encodeURIComponent(escapeSlashesForRegex101(pattern)) + "&flags=" + regexFlags(); + renderRegexTokens(regexHighlight, pattern); + regexInputWrap.style.width = "clamp(240px, calc(" + (pattern.length + 1) + "ch + 40px), 100%)"; + regexHighlight.scrollLeft = regexPatternInput.scrollLeft; + regexPatternInput.classList.remove("regex-valid", "regex-invalid"); + regexValidity.classList.remove("valid", "invalid"); + if (pattern === "") { + regexPreviewButton.disabled = true; + return; + } + try { + new RegExp(pattern, regexFlags()); + regexPatternInput.classList.add("regex-valid"); + regexValidity.classList.add("valid"); + regexPreviewButton.disabled = false; + } catch { + regexPatternInput.classList.add("regex-invalid"); + regexValidity.classList.add("invalid"); + regexPreviewButton.disabled = true; + } +} + +function closeIconSvg(): SVGSVGElement { + const ns = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(ns, "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + const path = document.createElementNS(ns, "path"); + path.setAttribute("d", "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"); + svg.append(path); + return svg; +} + +function regexGroupKey(url: string): string { + try { + const parsed = new URL(url); + const segments = parsed.pathname.split("/").filter(part => part !== ""); + return segments.length > 0 ? parsed.origin + "/" + segments[0] : parsed.origin; + } catch { + return url; + } +} + +function buildResultRow(item: RegexMatch, prefix: string): HTMLLIElement { + const li = document.createElement("li"); + const line = document.createElement("div"); + line.className = "regex-row-line"; + const path = document.createElement("span"); + path.className = "regex-row-path"; + const remainder = item.url.startsWith(prefix) ? item.url.slice(prefix.length) : item.url; + path.innerText = remainder === "" ? "/" : remainder; + line.append(path); + const meta = document.createElement("span"); + meta.className = "regex-row-meta"; + meta.innerText = item.lastVisitTime > 0 ? item.visitCount + "x " + new Date(item.lastVisitTime).toLocaleDateString() : item.visitCount + "x"; + line.append(meta); + li.append(line); + const full = document.createElement("div"); + full.className = "regex-row-full"; + if (item.title !== "") { + const title = document.createElement("div"); + title.innerText = item.title; + full.append(title); + } + const url = document.createElement("div"); + url.innerText = item.url; + full.append(url); + if (item.lastVisitTime > 0) { + const visited = document.createElement("div"); + visited.innerText = new Date(item.lastVisitTime).toLocaleString(); + full.append(visited); + } + li.append(full); + li.addEventListener("click", () => li.classList.toggle("expanded")); + return li; +} + +function renderRegexResults(): void { + regexResults.replaceChildren(); + const sorted = [...regexPreviewedItems]; + switch (regexSort.value) { + case "visitCount": + sorted.sort((a, b) => b.visitCount - a.visitCount); + break; + case "url": + sorted.sort((a, b) => a.url.localeCompare(b.url)); + break; + default: + sorted.sort((a, b) => b.lastVisitTime - a.lastVisitTime); + break; + } + const groups = new Map(); + for (const item of sorted) { + const key = regexGroupKey(item.url); + const bucket = groups.get(key); + if (bucket === undefined) { + groups.set(key, [item]); + } else { + bucket.push(item); + } + } + const single = groups.size === 1; + for (const [key, entries] of groups) { + const group = document.createElement("details"); + group.open = single; + const summary = document.createElement("summary"); + summary.innerText = key + " (" + entries.length + ")"; + group.append(summary); + const list = document.createElement("ul"); + group.append(list); + let renderedCount = 0; + const renderEntries = () => { + if (renderedCount >= entries.length) { + return; + } + const moreRow = list.querySelector(".regex-show-more"); + if (moreRow !== null) { + moreRow.remove(); + } + const end = Math.min(renderedCount + REGEX_RENDER_CHUNK, entries.length); + for (let i = renderedCount; i < end; i += 1) { + list.append(buildResultRow(entries[i], key)); + } + renderedCount = end; + if (renderedCount < entries.length) { + const li = document.createElement("li"); + li.className = "regex-show-more"; + li.innerText = browser.i18n.getMessage("regexCleanShowMore", [(entries.length - renderedCount).toString()]); + li.addEventListener("click", (e) => { + e.stopPropagation(); + renderEntries(); + }); + list.append(li); + } + }; + group.addEventListener("toggle", () => { + if (group.open && renderedCount === 0) { + renderEntries(); + } + }); + if (group.open) { + renderEntries(); + } + regexResults.append(group); + } +} + +function recordRegexHistory(pattern: string, flags: string): void { + queueRegexHistoryUpdate(entries => { + const next = entries.filter(entry => entry.pattern !== pattern || entry.flags !== flags); + next.unshift({ pattern, flags }); + return next.slice(0, 10); + }); +} + +function renderRegexHistory(entries: RegexHistoryEntry[]): void { + regexHistoryList.replaceChildren(); + regexHistoryWrap.style.display = entries.length === 0 ? "none" : ""; + for (const entry of entries) { + const li = document.createElement("li"); + const use = document.createElement("button"); + use.type = "button"; + use.className = "regex-history-use"; + const patternSpan = document.createElement("span"); + renderRegexTokens(patternSpan, entry.pattern); + use.append(patternSpan); + if (entry.flags !== "") { + const flagsSpan = document.createElement("span"); + flagsSpan.className = "regex-history-flags"; + flagsSpan.innerText = " /" + entry.flags; + use.append(flagsSpan); + } + use.title = entry.flags === "" ? entry.pattern : entry.pattern + " /" + entry.flags; + use.addEventListener("click", (e) => { + e.preventDefault(); + regexPatternInput.value = entry.pattern; + regexFlagIgnoreCase.checked = entry.flags.includes("i"); + regexFlagUnicode.checked = entry.flags.includes("u"); + regexInvalidatePreview(); + regexRefreshValidity(); + }); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "regex-history-remove"; + remove.title = browser.i18n.getMessage("regexCleanRemovePattern"); + remove.append(closeIconSvg()); + remove.addEventListener("click", (e) => { + e.preventDefault(); + queueRegexHistoryUpdate(items => items.filter(item => item.pattern !== entry.pattern || item.flags !== entry.flags)); + }); + li.append(use, remove); + regexHistoryList.append(li); + } +} + +async function regexPreview(e: MouseEvent): Promise { + e.preventDefault(); + regexInvalidatePreview(); + + const gen = regexPreviewGen; + const pattern = regexPatternInput.value; + const flags = regexFlags(); + + regexPreviewButton.disabled = true; + let matches: RegexPreview; + try { + matches = await previewRegexClean(pattern, flags); + } catch { + if (gen === regexPreviewGen) { + regexStatus.innerText = browser.i18n.getMessage("regexCleanSearchError"); + } + return; + } finally { + regexRefreshValidity(); + } + + if (gen !== regexPreviewGen) { + return; + } + + recordRegexHistory(pattern, flags); + + if (matches.items.length === 0) { + regexStatus.innerText = browser.i18n.getMessage("regexCleanNoMatches"); + if (matches.truncated) { + regexStatus.innerText += " " + browser.i18n.getMessage("regexCleanTruncated", [REGEX_SEARCH_CAP.toString()]); + } + return; + } + + regexPreviewedItems = matches.items; + regexCount.innerText = browser.i18n.getMessage("regexCleanMatchCount", [matches.items.length.toString()]); + if (matches.truncated) { + regexCount.innerText += " " + browser.i18n.getMessage("regexCleanTruncated", [REGEX_SEARCH_CAP.toString()]); + } + regexResultsCard.style.display = ""; + renderRegexResults(); + regexDeleteButton.disabled = false; +} + +async function regexPopout(e: MouseEvent): Promise { + e.preventDefault(); + const url = browser.runtime.getURL("popup.html") + "?regexPop=1&pattern=" + encodeURIComponent(regexPatternInput.value) + "&flags=" + regexFlags(); + await browser.windows.create({ url, type: "popup", width: 640, height: 720 }); + window.close(); +} + +async function regexDelete(e: MouseEvent): Promise { + e.preventDefault(); + if (regexPreviewedItems.length === 0) { + return; + } + + const count = regexPreviewedItems.length; + regexDeleteButton.disabled = true; + regexPreviewButton.disabled = true; + let failed = false; + try { + await deleteUrls(regexPreviewedItems.map(item => item.url)); + } catch { + failed = true; + } finally { + regexInvalidatePreview(); + regexRefreshValidity(); + } + regexStatus.innerText = failed ? browser.i18n.getMessage("regexCleanDeleteError") : browser.i18n.getMessage("regexCleanDeleted", [count.toString()]); +} + /** * Upload current local storage to sync storage */ @@ -121,7 +475,7 @@ function importConfig() { * @param e event */ function markSettingsDirty(e?: Event): void { - let target = e?.target as HTMLInputElement | undefined; + const target = e?.target as HTMLInputElement | undefined; if (target?.name === "behavior") { // if behavior is ENABLED, mark settings DIRTY @@ -129,8 +483,10 @@ function markSettingsDirty(e?: Event): void { // if behavior is DISABLED, mark settings CLEAN // since no destructive things can occur while disabled, don't need confirmation if (target.value !== "disable") { - submitButton.classList.add("ready"); - browser.storage.local.set({ dirty: true }); + if (submitButton !== null) { + submitButton.classList.add("ready"); + browser.storage.local.set({ dirty: true }); + } } else { markSettingsClean(); } @@ -142,7 +498,7 @@ function markSettingsDirty(e?: Event): void { * Extension will activate when dirty flag is cleared */ function markSettingsClean(): void { - submitButton.classList.remove("ready"); + submitButton?.classList.remove("ready"); browser.storage.local.set({ dirty: false }) } @@ -155,7 +511,7 @@ function markSettingsClean(): void { */ async function save(e?: Event): Promise { if (form.checkValidity()) { - submitButton.classList.remove("ready"); + submitButton?.classList.remove("ready"); const opts: OptionsInterface = { behavior: formElements.behavior.value, @@ -165,6 +521,7 @@ async function save(e?: Event): Promise { timerInterval: parseInt(formElements.timerInterval.value), notifications: formElements.notifications.checked, downloads: formElements.downloads.checked, + regexClean: formElements.regexClean.checked, // filterHistory: formElements.filterHistory.checked, // filterList: formElements.filterList.value.split("\n"), @@ -261,11 +618,15 @@ async function load(): Promise { formElements.downloads.checked = false; } + formElements.regexClean.checked = res.regexClean; + regexSection.style.display = res.regexClean ? "" : "none"; + renderRegexHistory(res.regexHistory); + if (res.behavior === "disable") { nextRun.innerText = browser.i18n.getMessage("statisticsNextRunDisable"); } else if (res.dirty) { nextRun.innerText = browser.i18n.getMessage("statisticsUnsavedChanges"); - submitButton.classList.add("ready"); + submitButton?.classList.add("ready"); } else { const alarm = await browser.alarms.get("DeleteHistoryAlarm"); if (res.deleteMode === "timer" && alarm !== undefined) { @@ -300,7 +661,7 @@ document.addEventListener("DOMContentLoaded", load); form.addEventListener("input", save); form.addEventListener("input", markSettingsDirty); -submitButton.addEventListener("click", markSettingsClean); +submitButton?.addEventListener("click", markSettingsClean); form.addEventListener("submit", save); form.addEventListener("submit", markSettingsClean); @@ -315,4 +676,33 @@ downloadButton.addEventListener("click", download); importButton.addEventListener("click", () => importFile.click()); importFile.addEventListener("change", importConfig); +function regexOnEdit(e: Event): void { + e.stopPropagation(); + regexInvalidatePreview(); + regexRefreshValidity(); +} + +regexPatternInput.addEventListener("input", regexOnEdit); +regexPatternInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (!regexPreviewButton.disabled) { + regexPreviewButton.click(); + } + } +}); +regexFlagIgnoreCase.addEventListener("input", regexOnEdit); +regexFlagUnicode.addEventListener("input", regexOnEdit); +regexSort.addEventListener("input", (e) => { + e.stopPropagation(); + renderRegexResults(); +}); +regexRefreshValidity(); +regexPreviewButton.addEventListener("click", regexPreview); +regexDeleteButton.addEventListener("click", regexDelete); +regexPopoutButton.addEventListener("click", regexPopout); +regexPatternInput.addEventListener("scroll", () => { + regexHighlight.scrollLeft = regexPatternInput.scrollLeft; +}); + browser.storage.onChanged.addListener(load); diff --git a/src/popup.css b/src/popup.css index c8b6803..ac34eee 100644 --- a/src/popup.css +++ b/src/popup.css @@ -15,6 +15,19 @@ --accent-color: #ffffff; --save-bg: #26A269; + + --re-escape: #1565c0; + --re-charClass: #6a1b9a; + --re-quantifier: #2e7d32; + --re-group: #e65100; + --re-alternation: #c62828; + --re-anchor: #00838f; + --regex-valid: #30e60b; + --regex-invalid: #ff4f5e; + --regex-tint-bg: rgba(128, 128, 128, 0.12); + --regex-tint-line: rgba(128, 128, 128, 0.25); + --regex-tint-border: rgba(128, 128, 128, 0.35); + --regex-tint-scroll: rgba(128, 128, 128, 0.5); } @media (prefers-color-scheme: dark) { @@ -30,6 +43,13 @@ --hover: #3d3846; --pressed: #5e5c64; + + --re-escape: #64b5f6; + --re-charClass: #ce93d8; + --re-quantifier: #81c784; + --re-group: #ffb74d; + --re-alternation: #e57373; + --re-anchor: #4dd0e1; } } @@ -303,3 +323,265 @@ a:hover { fill: white; } } + +#regex-results-card { + background: var(--regex-tint-bg); + border: 1px solid var(--regex-tint-border); + border-radius: 4px; + padding: 8px; +} + +#regex-card-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin: 0 0 6px; +} + +#regex-count { + opacity: 0.8; + font-size: 0.9em; +} + +#regex-results { + max-height: 320px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--regex-tint-scroll) transparent; +} + +#regex-results::-webkit-scrollbar { + width: 8px; +} + +#regex-results::-webkit-scrollbar-track { + background: transparent; +} + +#regex-results::-webkit-scrollbar-thumb { + background: var(--regex-tint-scroll); + border-radius: 4px; +} + +#regex-results details { + border-bottom: 1px solid var(--regex-tint-line); +} + +#regex-results summary { + cursor: pointer; + font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; + font-size: 12px; + padding: 6px 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#regex-results ul { + list-style: none; + padding-inline-start: 16px; + margin: 0; +} + +#regex-results li { + padding: 3px 4px; + cursor: pointer; +} + +.regex-row-line { + display: flex; + gap: 8px; + align-items: baseline; +} + +.regex-row-path { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; + font-size: 12px; +} + +.regex-row-meta { + flex: 0 0 auto; + white-space: nowrap; + opacity: 0.6; + font-size: 0.8em; +} + +.regex-row-full { + display: none; +} + +.regex-show-more { + cursor: pointer; + opacity: 0.7; + font-size: 0.85em; +} + +#regex-results li.expanded .regex-row-full { + display: block; + word-break: break-all; + font-size: 0.85em; + opacity: 0.85; + padding: 2px 0 4px; +} + +#regex-history { + list-style: none; + padding-inline-start: 0; +} + +#regex-history li { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 0; +} + +.regex-history-use { + flex: 0 1 auto; + min-width: 0; + max-width: calc(100% - 28px); + overflow: hidden; + white-space: nowrap; + font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; + font-size: 12px; + text-overflow: ellipsis; + text-overflow: "[...]"; +} + +.regex-history-flags { + opacity: 0.6; +} + +.regex-history-remove { + flex: 0 0 auto; + width: 24px; + height: 24px; + padding: 0; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.regex-history-remove svg { + width: 14px; + height: 14px; + margin-right: 0; +} + +.regex-history-remove svg path { + fill: var(--regex-invalid); +} + +#regex-pattern.regex-valid { + border-color: var(--regex-valid); + box-shadow: 0 0 0 1px var(--regex-valid); +} + +#regex-pattern.regex-invalid { + border-color: var(--regex-invalid); + box-shadow: 0 0 0 1px var(--regex-invalid); +} + +#regex-input-wrap { + position: relative; +} + +#regex-pattern { + position: relative; + padding-right: 28px; + background: transparent; + color: transparent; + caret-color: var(--color); + border-color: var(--color); +} + +#regex-pattern, +#regex-highlight, +#regex-input-wrap { + font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; + font-size: 13px; + line-height: 18px; + height: 40px; + box-sizing: border-box; +} + +#regex-highlight { + position: absolute; + inset: 0; + box-sizing: border-box; + border: 1px solid transparent; + padding: 10px 28px 10px 10px; + background: var(--button-color); + white-space: pre; + overflow: hidden; + pointer-events: none; +} + +#regex-highlight .re-escape, #regex-history .re-escape { color: var(--re-escape); } +#regex-highlight .re-charClass, #regex-history .re-charClass { color: var(--re-charClass); } +#regex-highlight .re-quantifier, #regex-history .re-quantifier { color: var(--re-quantifier); } +#regex-highlight .re-group, #regex-history .re-group { color: var(--re-group); } +#regex-highlight .re-alternation, #regex-history .re-alternation { color: var(--re-alternation); } +#regex-highlight .re-anchor, #regex-history .re-anchor { color: var(--re-anchor); } +#regex-highlight .re-literal, #regex-history .re-literal { color: var(--color); } + +#regex-validity { + display: none; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); +} + +#regex-validity.valid, +#regex-validity.invalid { + display: flex; + align-items: center; +} + +#regex-validity svg { + width: 16px; + height: 16px; +} + +#regex-validity.valid .invalid-icon { + display: none; +} + +#regex-validity.invalid .valid-icon { + display: none; +} + +#regex-validity.valid .valid-icon path { + fill: var(--regex-valid); +} + +#regex-validity.invalid .invalid-icon path { + fill: var(--regex-invalid); +} + +h2 #regex-101 { + margin-left: 8px; + font-size: 0.8em; + font-weight: normal; +} + +body.regex-popout header, +body.regex-popout form > details:not(#regex-clean-section), +body.regex-popout form > input[type="submit"], +body.regex-popout #regex-popout { + display: none; +} + +html:has(body.regex-popout), +body.regex-popout { + width: 100%; + max-width: none; +}