Add JSON definition subscriptions

This commit is contained in:
Jordan Wages 2026-08-25 00:40:30 -05:00
commit bedeb8495d
15 changed files with 321 additions and 37 deletions

View file

@ -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 extensions 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 extensions 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.

View file

@ -11,7 +11,7 @@
"96": "icons/logo96.png",
"128": "icons/logo128.png"
},
"permissions": ["storage", "tabs", "offscreen"],
"permissions": ["storage", "tabs", "alarms", "offscreen"],
"host_permissions": ["<all_urls>"],
"background": { "service_worker": "background.js", "type": "module" },
"content_scripts": [{ "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" }],

View file

@ -12,7 +12,7 @@
"128": "icons/logo128.png"
},
"browser_action": { "default_icon": "icons/logo32.png", "default_popup": "popup.html" },
"permissions": ["storage", "tabs", "<all_urls>"],
"permissions": ["storage", "tabs", "alarms", "<all_urls>"],
"background": { "scripts": ["background.js"], "persistent": true },
"content_scripts": [{ "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" }],
"options_ui": { "page": "options.html", "open_in_tab": true },

View file

@ -42,10 +42,21 @@
<fieldset class="field">
<legend class="label">Site definitions</legend>
<div class="content">
<p>VibeGuard includes <code>mastodon</code> for the official Mastodon web interface. Add a custom definition with the same ID to override it, or add its ID to <code>disabledDefinitionIds</code> to disable it.</p>
<p>Definitions are loaded from subscriptions weekly. The bundled collection remains available when a subscription is unavailable.</p>
</div>
<div class="field">
<label class="label" for="definitions">Custom definitions (JSON)</label>
<label class="label" for="subscription-url">Add JSON subscription URL</label>
<div class="field has-addons">
<div class="control is-expanded"><input class="input" id="subscription-url" type="url" placeholder="https://example.com/definitions.json"></div>
<div class="control"><button class="button" type="button" id="add-subscription">Add</button></div>
</div>
</div>
<div id="subscriptions" class="content"></div>
<div class="buttons">
<button class="button" type="button" id="refresh-subscriptions">Update subscriptions now</button>
</div>
<div class="field">
<label class="label" for="definitions">Advanced local overrides (JSON)</label>
<div class="control">
<textarea class="textarea vibeguard-json" id="definitions" rows="18" spellcheck="false"></textarea>
</div>

View file

@ -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<InferenceResult[]>): void {
@ -9,6 +10,16 @@ export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promis
return runBatch(requests);
});
const refreshIfDue = async (): Promise<void> => {
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;
});

View file

@ -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: ["<all_urls>"],
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<Settings, "customDefinitions" | "disabledDefinitionIds" | "subscriptions" | "subscriptionCollections"> | SiteDefinition[], legacyDisabledDefinitionIds?: string[]): SiteDefinition[] {
if (Array.isArray(settings)) settings = { customDefinitions: settings, disabledDefinitionIds: legacyDisabledDefinitionIds ?? [], subscriptions: [], subscriptionCollections: [] };
const byId = new Map<string, SiteDefinition>();
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));
}

View file

@ -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": ["<all_urls>"],
"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]"]
}
}
]
}

View file

@ -45,7 +45,7 @@ async function start(): Promise<void> {
}
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<PageStatus> {
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 };

View file

@ -13,15 +13,46 @@ const definitions = document.querySelector<HTMLTextAreaElement>("#definitions");
const importButton = document.querySelector<HTMLButtonElement>("#import-definitions");
const exportButton = document.querySelector<HTMLButtonElement>("#export-definitions");
const definitionFile = document.querySelector<HTMLInputElement>("#definition-file");
const subscriptionUrl = document.querySelector<HTMLInputElement>("#subscription-url");
const subscriptionList = document.querySelector<HTMLElement>("#subscriptions");
const addSubscriptionButton = document.querySelector<HTMLButtonElement>("#add-subscription");
const refreshSubscriptionsButton = document.querySelector<HTMLButtonElement>("#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<void> {
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;

View file

@ -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<Settings> {
const stored = await getStorageItem(KEY);
@ -35,20 +43,25 @@ function setStorageItem(items: Record<string, unknown>): Promise<void> {
}
export function mergeSettings(input?: Partial<Settings>): 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<Settings> {
export function saveDefinitionConfiguration(configuration: { customDefinitions: SiteDefinition[]; disabledDefinitionIds: string[]; subscriptions?: DefinitionSubscription[]; subscriptionCollections?: Settings["subscriptionCollections"] }): Promise<Settings> {
const validation = validateDefinitionConfiguration(configuration);
if (!validation.valid) return Promise.reject(new Error(validation.errors.join("\n")));
return saveSettings(validation.value);

View file

@ -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<string>();
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<string, unknown> {
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<string>();
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] }; }

View file

@ -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<SubscriptionRefreshResult> {
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<string> {
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("");
}

View file

@ -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" };

View file

@ -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);
});
});

View file

@ -4,6 +4,7 @@
"lib": ["ES2022", "DOM", "WebWorker"],
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,