diff --git a/README.md b/README.md index ccf1b1f..8b4fba3 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ VibeGuard is a local-first browser extension that hides toxic social-media posts - Optional toxicity scores on filtered-post placeholders. - Declarative site definitions with URL patterns, selectors, and stable identifiers. - Built-in support for the official Mastodon web interface. -- Import and export of custom site definitions as JSON. +- Versioned JSON definition collections with weekly subscriptions and manual refresh. - Toolbar popup with current-site status, matched definition, pause/resume control, and settings access. - Firefox Manifest V2 and Chromium Manifest V3 builds. @@ -47,7 +47,7 @@ Preparation writes the Transformers.js-compatible files and `model-manifest.json If the model uses generic labels such as `LABEL_0` and `LABEL_1`, pass `--toxic-index` and `--non-toxic-index` to `prepare`; the command refuses to guess an ambiguous mapping. -VibeGuard injects a lightweight content script on ordinary web pages, but reads and processes content only when a validated site definition matches the page. The extension includes a definition for the official Mastodon web interface; custom definitions and overrides are managed as JSON in the options page. Definitions are declarative selectors and URL patterns, never executable code. +VibeGuard injects a lightweight content script on ordinary web pages, but reads and processes content only when a validated site definition matches the page. The extension includes a bundled definition collection for the official Mastodon web interface. The default subscription follows the repository's raw JSON collection and refreshes weekly, while the bundled copy remains available offline. Additional JSON subscriptions, local overrides, and disabled definitions are managed in the options page. Definitions are declarative selectors and URL patterns, never executable code. ## Runtime architecture @@ -68,7 +68,7 @@ The model is loaded once by the shared inference backend for each browser contex ## Options page -Open the extension’s options page to change the threshold, filtering mode, and score display. The Site definitions section accepts a JSON object containing `customDefinitions` and `disabledDefinitionIds`. Definitions can be imported from or exported to a file, and are validated before they are saved. +Open the extension’s options page to change the threshold, filtering mode, and score display. The Site definitions section manages JSON subscriptions, which refresh automatically once per week and can also be refreshed manually. Local overrides can be imported from or exported to JSON and are validated before they are saved. A failed or invalid subscription update leaves the last-known-good definitions active. The options page uses the locally bundled [Bulma CSS](https://bulma.io/) v1.0.3 stylesheet. No options-page styling or runtime dependency is loaded from a CDN. diff --git a/public/manifest.chromium.json b/public/manifest.chromium.json index 3b711d4..cf1cfb1 100644 --- a/public/manifest.chromium.json +++ b/public/manifest.chromium.json @@ -11,7 +11,7 @@ "96": "icons/logo96.png", "128": "icons/logo128.png" }, - "permissions": ["storage", "tabs", "offscreen"], + "permissions": ["storage", "tabs", "alarms", "offscreen"], "host_permissions": [""], "background": { "service_worker": "background.js", "type": "module" }, "content_scripts": [{ "matches": [""], "js": ["content.js"], "run_at": "document_idle" }], diff --git a/public/manifest.firefox.json b/public/manifest.firefox.json index 45e90a3..cf5b2c8 100644 --- a/public/manifest.firefox.json +++ b/public/manifest.firefox.json @@ -12,7 +12,7 @@ "128": "icons/logo128.png" }, "browser_action": { "default_icon": "icons/logo32.png", "default_popup": "popup.html" }, - "permissions": ["storage", "tabs", ""], + "permissions": ["storage", "tabs", "alarms", ""], "background": { "scripts": ["background.js"], "persistent": true }, "content_scripts": [{ "matches": [""], "js": ["content.js"], "run_at": "document_idle" }], "options_ui": { "page": "options.html", "open_in_tab": true }, diff --git a/public/options.html b/public/options.html index 7d5c5ff..46d65db 100644 --- a/public/options.html +++ b/public/options.html @@ -42,10 +42,21 @@
Site definitions
-

VibeGuard includes mastodon for the official Mastodon web interface. Add a custom definition with the same ID to override it, or add its ID to disabledDefinitionIds to disable it.

+

Definitions are loaded from subscriptions weekly. The bundled collection remains available when a subscription is unavailable.

- + +
+
+
+
+
+
+
+ +
+
+
diff --git a/src/background/runtime.ts b/src/background/runtime.ts index 524542a..9a591c7 100644 --- a/src/background/runtime.ts +++ b/src/background/runtime.ts @@ -1,5 +1,6 @@ import { InferenceQueue } from "../inference/queue"; import { loadSettings, saveSettings, setDefinitionPaused } from "../shared/settings"; +import { refreshSubscriptions, subscriptionsAreDue } from "../shared/subscriptions"; import type { InferenceRequest, InferenceResult, RuntimeMessage } from "../shared/types"; export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promise): void { @@ -9,6 +10,16 @@ export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promis return runBatch(requests); }); + const refreshIfDue = async (): Promise => { + const settings = await loadSettings(); + if (subscriptionsAreDue(settings)) { + const result = await refreshSubscriptions(settings); + } + }; + void chrome.alarms?.create?.("vibeguard-subscriptions-weekly", { periodInMinutes: 7 * 24 * 60 }); + chrome.alarms?.onAlarm?.addListener((alarm) => { if (alarm.name === "vibeguard-subscriptions-weekly") void refreshIfDue(); }); + chrome.runtime.onStartup?.addListener(() => { void refreshIfDue(); }); + chrome.runtime.onMessage.addListener((message: RuntimeMessage, sender, sendResponse) => { if (message.type === "INFER") { const request: InferenceRequest = { ...message.request, tabId: message.request.tabId ?? sender.tab?.id }; @@ -24,6 +35,12 @@ export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promis .catch((error) => sendResponse({ error: String(error) })); return true; } + if (message.type === "REFRESH_SUBSCRIPTIONS") { + loadSettings().then((settings) => refreshSubscriptions(settings)).then((result) => { + return result.settings; + }).then((settings) => sendResponse({ type: "SUBSCRIPTIONS_UPDATED", settings })).catch((error) => sendResponse({ error: String(error) })); + return true; + } if (message.type === "PING") { sendResponse({ type: "PONG", pending: queue.pendingCount }); } return false; }); diff --git a/src/content/definitions.ts b/src/content/definitions.ts index 4b41859..9af89c2 100644 --- a/src/content/definitions.ts +++ b/src/content/definitions.ts @@ -1,22 +1,23 @@ -import type { SiteDefinition } from "../shared/types"; +import bundledCollection from "./definitions/default.json"; +import type { DefinitionCollection, Settings, SiteDefinition } from "../shared/types"; -export const BUILTIN_DEFINITIONS: SiteDefinition[] = [{ - id: "mastodon", - name: "Mastodon (official web interface)", - urlPatterns: [""], - requiredSelectors: [".status__content", ".status, .detailed-status"], - post: { - rootSelectors: [".status", ".detailed-status"], - textSelectors: [".status__content", ".e-content"], - excludedSelectors: [".status__content__spoiler-link", ".status__content__read-more-button"], - idAttributes: ["data-id"], - permalinkSelectors: ["a.status__relative-time[href]", "a.detailed-status__datetime[href]", "a.u-url.u-uid[href]"] +export const BUNDLED_COLLECTION = bundledCollection as DefinitionCollection; +export const BUILTIN_DEFINITIONS: SiteDefinition[] = BUNDLED_COLLECTION.definitions; + +export function effectiveDefinitions(settings: Pick | SiteDefinition[], legacyDisabledDefinitionIds?: string[]): SiteDefinition[] { + if (Array.isArray(settings)) settings = { customDefinitions: settings, disabledDefinitionIds: legacyDisabledDefinitionIds ?? [], subscriptions: [], subscriptionCollections: [] }; + const byId = new Map(); + const orderedSubscriptions = [...settings.subscriptions] + .filter((subscription) => subscription.enabled) + .sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id)); + const collections = new Map(settings.subscriptionCollections.map((collection) => [collection.collectionId, collection])); + for (const subscription of orderedSubscriptions) { + for (const definition of collections.get(subscription.id)?.definitions ?? []) { + if (!byId.has(definition.id)) byId.set(definition.id, definition); + } } -}]; - -export function effectiveDefinitions(customDefinitions: SiteDefinition[], disabledDefinitionIds: string[]): SiteDefinition[] { - const customIds = new Set(customDefinitions.map((definition) => definition.id)); - const disabled = new Set(disabledDefinitionIds); - return [...customDefinitions, ...BUILTIN_DEFINITIONS.filter((definition) => !customIds.has(definition.id))] - .filter((definition) => !disabled.has(definition.id)); + for (const definition of BUILTIN_DEFINITIONS) if (!byId.has(definition.id)) byId.set(definition.id, definition); + for (const definition of settings.customDefinitions) byId.set(definition.id, definition); + const disabled = new Set(settings.disabledDefinitionIds); + return [...byId.values()].filter((definition) => !disabled.has(definition.id)); } diff --git a/src/content/definitions/default.json b/src/content/definitions/default.json new file mode 100644 index 0000000..52cd3a7 --- /dev/null +++ b/src/content/definitions/default.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "collectionId": "vibeguard-default", + "name": "VibeGuard default definitions", + "description": "Definitions maintained by the VibeGuard project.", + "definitions": [ + { + "id": "mastodon", + "name": "Mastodon (official web interface)", + "urlPatterns": [""], + "requiredSelectors": [".status__content", ".status, .detailed-status"], + "post": { + "rootSelectors": [".status", ".detailed-status"], + "textSelectors": [".status__content", ".e-content"], + "excludedSelectors": [".status__content__spoiler-link", ".status__content__read-more-button"], + "idAttributes": ["data-id"], + "permalinkSelectors": ["a.status__relative-time[href]", "a.detailed-status__datetime[href]", "a.u-url.u-uid[href]"] + } + } + ] +} diff --git a/src/content/main.ts b/src/content/main.ts index fdc64c8..9d6bba7 100644 --- a/src/content/main.ts +++ b/src/content/main.ts @@ -45,7 +45,7 @@ async function start(): Promise { } function runDefinition(settings: Settings, sequence: number): () => void { - const definition = selectDefinition(effectiveDefinitions(settings.customDefinitions, settings.disabledDefinitionIds)); + const definition = selectDefinition(effectiveDefinitions(settings)); if (!definition) return () => {}; console.debug("[VibeGuard] Supported page detected", { definitionId: definition.id, definitionName: definition.name, url: location.href }); const parser = new DefinitionEngine(definition); @@ -126,7 +126,7 @@ function runDefinition(settings: Settings, sequence: number): () => void { let currentReloadSequence = 0; async function getPageStatus(settings: Settings): Promise { - const definition = selectDefinition(effectiveDefinitions(settings.customDefinitions, [])); + const definition = selectDefinition(effectiveDefinitions({ ...settings, disabledDefinitionIds: [] })); if (!definition) { console.debug("[VibeGuard] No matching definition for page status", { url: location.href }); return { supported: false, paused: false }; diff --git a/src/options/main.ts b/src/options/main.ts index 717d847..a07e4e5 100644 --- a/src/options/main.ts +++ b/src/options/main.ts @@ -13,15 +13,46 @@ const definitions = document.querySelector("#definitions"); const importButton = document.querySelector("#import-definitions"); const exportButton = document.querySelector("#export-definitions"); const definitionFile = document.querySelector("#definition-file"); +const subscriptionUrl = document.querySelector("#subscription-url"); +const subscriptionList = document.querySelector("#subscriptions"); +const addSubscriptionButton = document.querySelector("#add-subscription"); +const refreshSubscriptionsButton = document.querySelector("#refresh-subscriptions"); +let currentSettings: Settings; void loadSettings().then((settings) => { + currentSettings = settings; threshold!.value = String(settings.threshold); mode!.value = settings.filterMode; showScore!.checked = settings.showScore; definitions!.value = JSON.stringify(definitionConfiguration(settings), null, 2); + renderSubscriptions(settings); updateThresholdLabel(); }); +addSubscriptionButton?.addEventListener("click", async () => { + const url = subscriptionUrl?.value.trim(); + if (!url) return; + try { + const parsed = new URL(url); + if (!/^https?:$/.test(parsed.protocol)) throw new Error("Subscriptions must use HTTP or HTTPS."); + const id = `custom-${Date.now().toString(36)}`; + currentSettings = await saveSettings({ subscriptions: [...currentSettings.subscriptions, { id, name: url, url, enabled: true, priority: currentSettings.subscriptions.length }] }); + subscriptionUrl!.value = ""; + renderSubscriptions(currentSettings); + showStatus("Subscription added. Use Update subscriptions now to fetch it."); + } catch (error) { showStatus(error instanceof Error ? error.message : String(error), true); } +}); + +refreshSubscriptionsButton?.addEventListener("click", async () => { + try { + const response = await chrome.runtime.sendMessage({ type: "REFRESH_SUBSCRIPTIONS" }); + if (response?.error) throw new Error(response.error); + currentSettings = response.settings; + renderSubscriptions(currentSettings); + showStatus("Subscriptions updated."); + } catch (error) { showStatus(error instanceof Error ? error.message : String(error), true); } +}); + threshold?.addEventListener("input", updateThresholdLabel); form?.addEventListener("submit", async (event) => { event.preventDefault(); @@ -63,11 +94,11 @@ function updateThresholdLabel(): void { if (thresholdValue && threshold) thresholdValue.textContent = `${Math.round(Number(threshold.value) * 100)}%`; } -function definitionConfiguration(settings?: Settings): { customDefinitions: Settings["customDefinitions"]; disabledDefinitionIds: string[] } { - return { customDefinitions: settings?.customDefinitions ?? [], disabledDefinitionIds: settings?.disabledDefinitionIds ?? [] }; +function definitionConfiguration(settings?: Settings): { customDefinitions: Settings["customDefinitions"]; disabledDefinitionIds: string[]; subscriptions: Settings["subscriptions"]; subscriptionCollections: Settings["subscriptionCollections"] } { + return { customDefinitions: settings?.customDefinitions ?? [], disabledDefinitionIds: settings?.disabledDefinitionIds ?? [], subscriptions: settings?.subscriptions ?? [], subscriptionCollections: settings?.subscriptionCollections ?? [] }; } -function parseDefinitionConfiguration(): { customDefinitions: Settings["customDefinitions"]; disabledDefinitionIds: string[] } { +function parseDefinitionConfiguration(): { customDefinitions: Settings["customDefinitions"]; disabledDefinitionIds: string[]; subscriptions: Settings["subscriptions"]; subscriptionCollections: Settings["subscriptionCollections"] } { let parsed: unknown; try { parsed = JSON.parse(definitions?.value ?? "{}"); } catch { throw new Error("Definition JSON is invalid."); } const validation = validateDefinitionConfiguration(parsed); @@ -75,6 +106,61 @@ function parseDefinitionConfiguration(): { customDefinitions: Settings["customDe return validation.value; } +function renderSubscriptions(settings: Settings): void { + if (!subscriptionList) return; + subscriptionList.replaceChildren(...[...settings.subscriptions].sort((a, b) => a.priority - b.priority).map((subscription) => { + const row = document.createElement("div"); + row.className = "box p-3"; + const title = document.createElement("strong"); + title.textContent = `${subscription.name} (${subscription.enabled ? "enabled" : "disabled"})`; + const detail = document.createElement("small"); + detail.textContent = ` ${subscription.url} — ${subscription.lastError ?? (subscription.lastSuccessfulUpdate ? `updated ${new Date(subscription.lastSuccessfulUpdate).toLocaleString()}` : "not updated yet")}`; + const toggle = document.createElement("input"); + toggle.type = "checkbox"; + toggle.checked = subscription.enabled; + toggle.className = "mr-2"; + toggle.addEventListener("change", async () => { + currentSettings = await saveSettings({ subscriptions: currentSettings.subscriptions.map((item) => item.id === subscription.id ? { ...item, enabled: toggle.checked } : item) }); + renderSubscriptions(currentSettings); + }); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "button is-small is-light ml-2"; + remove.textContent = "Remove"; + remove.addEventListener("click", async () => { + currentSettings = await saveSettings({ subscriptions: currentSettings.subscriptions.filter((item) => item.id !== subscription.id), subscriptionCollections: currentSettings.subscriptionCollections.filter((item) => item.collectionId !== subscription.id) }); + renderSubscriptions(currentSettings); + }); + const raise = document.createElement("button"); + raise.type = "button"; + raise.className = "button is-small is-light ml-1"; + raise.textContent = "↑"; + raise.title = "Increase priority"; + raise.addEventListener("click", () => void moveSubscription(subscription.id, -1)); + const lower = document.createElement("button"); + lower.type = "button"; + lower.className = "button is-small is-light ml-1"; + lower.textContent = "↓"; + lower.title = "Decrease priority"; + lower.addEventListener("click", () => void moveSubscription(subscription.id, 1)); + row.prepend(toggle); + row.append(title, document.createElement("br"), detail, remove, raise, lower); + return row; + })); +} + +async function moveSubscription(id: string, direction: -1 | 1): Promise { + const ordered = [...currentSettings.subscriptions].sort((a, b) => a.priority - b.priority); + const index = ordered.findIndex((subscription) => subscription.id === id); + const swapIndex = index + direction; + if (index < 0 || swapIndex < 0 || swapIndex >= ordered.length) return; + const currentPriority = ordered[index]!.priority; + ordered[index]!.priority = ordered[swapIndex]!.priority; + ordered[swapIndex]!.priority = currentPriority; + currentSettings = await saveSettings({ subscriptions: ordered }); + renderSubscriptions(currentSettings); +} + function showStatus(message: string, isError = false): void { if (!status) return; status.textContent = message; diff --git a/src/shared/settings.ts b/src/shared/settings.ts index 1450f42..3a60916 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -1,7 +1,15 @@ import { DEFAULT_SETTINGS, type Settings, type SiteDefinition } from "./types"; import { validateDefinitionConfiguration } from "./site-definitions"; +import type { DefinitionSubscription } from "./types"; const KEY = "vibeguard.settings"; +export const DEFAULT_SUBSCRIPTION: DefinitionSubscription = { + id: "vibeguard-default", + name: "VibeGuard default definitions", + url: "https://git.jordanwages.com/wagesj45/vibeguard/raw/branch/main/src/content/definitions/default.json", + enabled: true, + priority: 0 +}; export async function loadSettings(): Promise { const stored = await getStorageItem(KEY); @@ -35,20 +43,25 @@ function setStorageItem(items: Record): Promise { } export function mergeSettings(input?: Partial): Settings { + const subscriptions = input?.subscriptions?.length ? input.subscriptions : [DEFAULT_SUBSCRIPTION]; const definitionConfiguration = validateDefinitionConfiguration({ customDefinitions: input?.customDefinitions ?? DEFAULT_SETTINGS.customDefinitions, - disabledDefinitionIds: input?.disabledDefinitionIds ?? DEFAULT_SETTINGS.disabledDefinitionIds + disabledDefinitionIds: input?.disabledDefinitionIds ?? DEFAULT_SETTINGS.disabledDefinitionIds, + subscriptions, + subscriptionCollections: input?.subscriptionCollections ?? DEFAULT_SETTINGS.subscriptionCollections }); return { ...DEFAULT_SETTINGS, ...input, threshold: clamp(Number(input?.threshold ?? DEFAULT_SETTINGS.threshold), 0, 1), customDefinitions: definitionConfiguration.valid ? definitionConfiguration.value.customDefinitions : DEFAULT_SETTINGS.customDefinitions, - disabledDefinitionIds: definitionConfiguration.valid ? definitionConfiguration.value.disabledDefinitionIds : DEFAULT_SETTINGS.disabledDefinitionIds + disabledDefinitionIds: definitionConfiguration.valid ? definitionConfiguration.value.disabledDefinitionIds : DEFAULT_SETTINGS.disabledDefinitionIds, + subscriptions: definitionConfiguration.valid ? definitionConfiguration.value.subscriptions : [DEFAULT_SUBSCRIPTION], + subscriptionCollections: definitionConfiguration.valid ? definitionConfiguration.value.subscriptionCollections : DEFAULT_SETTINGS.subscriptionCollections }; } -export function saveDefinitionConfiguration(configuration: { customDefinitions: SiteDefinition[]; disabledDefinitionIds: string[] }): Promise { +export function saveDefinitionConfiguration(configuration: { customDefinitions: SiteDefinition[]; disabledDefinitionIds: string[]; subscriptions?: DefinitionSubscription[]; subscriptionCollections?: Settings["subscriptionCollections"] }): Promise { const validation = validateDefinitionConfiguration(configuration); if (!validation.valid) return Promise.reject(new Error(validation.errors.join("\n"))); return saveSettings(validation.value); diff --git a/src/shared/site-definitions.ts b/src/shared/site-definitions.ts index 0e2a6d7..7b5fb7d 100644 --- a/src/shared/site-definitions.ts +++ b/src/shared/site-definitions.ts @@ -1,8 +1,10 @@ -import type { SiteDefinition } from "./types"; +import type { DefinitionCollection, DefinitionSubscription, SiteDefinition } from "./types"; export interface DefinitionConfiguration { customDefinitions: SiteDefinition[]; disabledDefinitionIds: string[]; + subscriptions: DefinitionSubscription[]; + subscriptionCollections: DefinitionCollection[]; } export type DefinitionValidation = @@ -15,10 +17,13 @@ export function validateDefinitionConfiguration(value: unknown): DefinitionValid if (!isRecord(value)) return invalid("Definition configuration must be an object."); const customDefinitions = value.customDefinitions; const disabledDefinitionIds = value.disabledDefinitionIds; + const subscriptions = value.subscriptions ?? []; + const subscriptionCollections = value.subscriptionCollections ?? []; if (!Array.isArray(customDefinitions)) return invalid("customDefinitions must be an array."); if (!Array.isArray(disabledDefinitionIds) || disabledDefinitionIds.some((id) => typeof id !== "string" || !ID_PATTERN.test(id))) { return invalid("disabledDefinitionIds must be an array of valid definition IDs."); } + if (!Array.isArray(subscriptions) || !Array.isArray(subscriptionCollections)) return invalid("Subscription state must contain arrays."); const errors: string[] = []; const definitions: SiteDefinition[] = []; @@ -29,7 +34,17 @@ export function validateDefinitionConfiguration(value: unknown): DefinitionValid else if (ids.has(validation.value.id)) errors.push(`Duplicate definition ID: ${validation.value.id}.`); else { ids.add(validation.value.id); definitions.push(validation.value); } }); - return errors.length > 0 ? { valid: false, errors } : { valid: true, value: { customDefinitions: definitions, disabledDefinitionIds: [...new Set(disabledDefinitionIds)] } }; + const validSubscriptions = subscriptions.map((subscription, index) => validateSubscription(subscription, `subscriptions[${index}]`, errors)).filter((subscription): subscription is DefinitionSubscription => Boolean(subscription)); + const validCollections = subscriptionCollections.map((collection, index) => validateCollection(collection, `subscriptionCollections[${index}]`, errors)).filter((collection): collection is DefinitionCollection => Boolean(collection)); + const subscriptionIds = new Set(); + for (const subscription of validSubscriptions) if (subscriptionIds.has(subscription.id)) errors.push(`Duplicate subscription ID: ${subscription.id}.`); else subscriptionIds.add(subscription.id); + return errors.length > 0 ? { valid: false, errors } : { valid: true, value: { customDefinitions: definitions, disabledDefinitionIds: [...new Set(disabledDefinitionIds)], subscriptions: validSubscriptions, subscriptionCollections: validCollections } }; +} + +export function validateDefinitionCollection(value: unknown): { valid: true; value: DefinitionCollection } | { valid: false; errors: string[] } { + const errors: string[] = []; + const collection = validateCollection(value, "collection", errors); + return collection && errors.length === 0 ? { valid: true, value: collection } : { valid: false, errors: errors.length ? errors : ["Invalid definition collection."] }; } export function validateSiteDefinition(value: unknown, path = "definition"): { valid: true; value: SiteDefinition } | { valid: false; errors: string[] } { @@ -73,4 +88,26 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function validateSubscription(value: unknown, path: string, errors: string[]): DefinitionSubscription | undefined { + if (!isRecord(value) || typeof value.id !== "string" || !ID_PATTERN.test(value.id) || typeof value.name !== "string" || !value.name.trim() || typeof value.url !== "string" || !/^https?:\/\//.test(value.url) || typeof value.enabled !== "boolean" || typeof value.priority !== "number" || !Number.isFinite(value.priority)) { + errors.push(`${path} is invalid.`); return undefined; + } + return { id: value.id, name: value.name.trim(), url: value.url, enabled: value.enabled, priority: value.priority, ...(typeof value.lastChecked === "string" ? { lastChecked: value.lastChecked } : {}), ...(typeof value.lastSuccessfulUpdate === "string" ? { lastSuccessfulUpdate: value.lastSuccessfulUpdate } : {}), ...(typeof value.lastKnownGoodHash === "string" ? { lastKnownGoodHash: value.lastKnownGoodHash } : {}), ...(typeof value.lastError === "string" ? { lastError: value.lastError } : {}) }; +} + +function validateCollection(value: unknown, path: string, errors: string[]): DefinitionCollection | undefined { + if (!isRecord(value) || value.schemaVersion !== 1 || typeof value.collectionId !== "string" || !ID_PATTERN.test(value.collectionId) || typeof value.name !== "string" || !value.name.trim() || !Array.isArray(value.definitions) || value.definitions.length > 1000) { + errors.push(`${path} must be a version 1 collection with a valid ID, name, and definitions array.`); return undefined; + } + const definitions: SiteDefinition[] = []; + const ids = new Set(); + value.definitions.forEach((definition, index) => { + const result = validateSiteDefinition(definition, `${path}.definitions[${index}]`); + if (!result.valid) errors.push(...result.errors); + else if (ids.has(result.value.id)) errors.push(`Duplicate definition ID: ${result.value.id}.`); + else { ids.add(result.value.id); definitions.push(result.value); } + }); + return errors.length > 0 ? undefined : { schemaVersion: 1, collectionId: value.collectionId, name: value.name.trim(), ...(typeof value.description === "string" ? { description: value.description } : {}), ...(typeof value.updatedAt === "string" ? { updatedAt: value.updatedAt } : {}), definitions }; +} + function invalid(error: string): { valid: false; errors: string[] } { return { valid: false, errors: [error] }; } diff --git a/src/shared/subscriptions.ts b/src/shared/subscriptions.ts new file mode 100644 index 0000000..99dd19e --- /dev/null +++ b/src/shared/subscriptions.ts @@ -0,0 +1,45 @@ +import { validateDefinitionCollection } from "./site-definitions"; +import { saveSettings } from "./settings"; +import type { DefinitionCollection, DefinitionSubscription, Settings } from "./types"; + +export const SUBSCRIPTION_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; + +export interface SubscriptionRefreshResult { settings: Settings; changed: boolean; } + +export async function refreshSubscriptions(settings: Settings, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise { + let changed = false; + const collections = new Map(settings.subscriptionCollections.map((collection) => [collection.collectionId, collection])); + const subscriptions: DefinitionSubscription[] = []; + for (const subscription of settings.subscriptions) { + if (!subscription.enabled) { subscriptions.push(subscription); continue; } + try { + const response = await fetchImpl(subscription.url, { headers: { Accept: "application/json" } }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const validation = validateDefinitionCollection(await response.json() as unknown); + if (!validation.valid) throw new Error(validation.errors.join(" ")); + const collection = validation.value; + const storedCollection: DefinitionCollection = { ...collection, collectionId: subscription.id }; + const hash = await digest(JSON.stringify(storedCollection)); + changed ||= subscription.lastKnownGoodHash !== hash || !collections.has(subscription.id); + collections.set(subscription.id, storedCollection); + subscriptions.push({ ...subscription, lastChecked: new Date(now).toISOString(), lastSuccessfulUpdate: new Date(now).toISOString(), lastKnownGoodHash: hash, lastError: undefined }); + } catch (error) { + subscriptions.push({ ...subscription, lastChecked: new Date(now).toISOString(), lastError: error instanceof Error ? error.message : String(error) }); + } + } + const next = await saveSettings({ subscriptions, subscriptionCollections: [...collections.values()] }); + return { settings: next, changed }; +} + +export function subscriptionIsDue(subscription: DefinitionSubscription, now = Date.now()): boolean { + return !subscription.lastSuccessfulUpdate || now - Date.parse(subscription.lastSuccessfulUpdate) >= SUBSCRIPTION_INTERVAL_MS; +} + +export function subscriptionsAreDue(settings: Settings, now = Date.now()): boolean { + return settings.subscriptions.some((subscription) => subscription.enabled && subscriptionIsDue(subscription, now)); +} + +async function digest(value: string): Promise { + const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/src/shared/types.ts b/src/shared/types.ts index dcbc9cc..668d101 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -15,6 +15,27 @@ export interface SiteDefinition { }; } +export interface DefinitionCollection { + schemaVersion: 1; + collectionId: string; + name: string; + description?: string; + updatedAt?: string; + definitions: SiteDefinition[]; +} + +export interface DefinitionSubscription { + id: string; + name: string; + url: string; + enabled: boolean; + priority: number; + lastChecked?: string; + lastSuccessfulUpdate?: string; + lastKnownGoodHash?: string; + lastError?: string; +} + export interface NormalizedPost { id: string; text: string; @@ -54,6 +75,8 @@ export interface Settings { showScore: boolean; customDefinitions: SiteDefinition[]; disabledDefinitionIds: string[]; + subscriptions: DefinitionSubscription[]; + subscriptionCollections: DefinitionCollection[]; } export interface PageStatus { @@ -67,7 +90,9 @@ export const DEFAULT_SETTINGS: Settings = { filterMode: "collapse", showScore: true, customDefinitions: [], - disabledDefinitionIds: [] + disabledDefinitionIds: [], + subscriptions: [], + subscriptionCollections: [] }; export type RuntimeMessage = @@ -79,5 +104,7 @@ export type RuntimeMessage = | { type: "GET_PAGE_STATUS" } | { type: "PAGE_STATUS"; status: PageStatus } | { type: "SET_DEFINITION_PAUSED"; definitionId: string; paused: boolean } + | { type: "REFRESH_SUBSCRIPTIONS" } + | { type: "SUBSCRIPTIONS_UPDATED"; settings: Settings } | { type: "OFFSCREEN_INFER"; requests: InferenceRequest[] } | { type: "PING" }; diff --git a/tests/subscriptions.test.ts b/tests/subscriptions.test.ts new file mode 100644 index 0000000..6461cd0 --- /dev/null +++ b/tests/subscriptions.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { subscriptionIsDue } from "../src/shared/subscriptions"; +import { validateDefinitionCollection } from "../src/shared/site-definitions"; + +const definition = { + id: "example", + name: "Example", + urlPatterns: ["https://example.test/*"], + requiredSelectors: ["main"], + post: { rootSelectors: ["article"], textSelectors: [".content"] } +}; + +describe("definition subscriptions", () => { + it("validates versioned collections and rejects duplicate IDs", () => { + const result = validateDefinitionCollection({ schemaVersion: 1, collectionId: "example-source", name: "Example", definitions: [definition, definition] }); + expect(result.valid).toBe(false); + }); + + it("is due when never updated or at least seven days old", () => { + const now = Date.parse("2026-08-25T00:00:00Z"); + expect(subscriptionIsDue({ id: "x", name: "X", url: "https://example.test/x.json", enabled: true, priority: 0 }, now)).toBe(true); + expect(subscriptionIsDue({ id: "x", name: "X", url: "https://example.test/x.json", enabled: true, priority: 0, lastSuccessfulUpdate: "2026-08-18T00:00:00Z" }, now)).toBe(true); + expect(subscriptionIsDue({ id: "x", name: "X", url: "https://example.test/x.json", enabled: true, priority: 0, lastSuccessfulUpdate: "2026-08-24T00:00:00Z" }, now)).toBe(false); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index aff8964..7b5a3e3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "lib": ["ES2022", "DOM", "WebWorker"], "module": "ESNext", "moduleResolution": "Bundler", + "resolveJsonModule": true, "strict": true, "noUncheckedIndexedAccess": true, "noEmit": true,