/gi));
const results = [];
for (const match of detailMatches) {
const block = match[0];
const category = this._categoryBefore(markdown, match.index || 0);
const categoryMatch = this._textMatches(category, domain, siteKey);
if (!categoryMatch && !this._strongAwesomeMatch(block, domain, siteKey)) continue;
const item = this._normalizeAwesomeBlock(block, category, siteKey);
if (item) results.push(item);
}
return results.slice(0, 80);
}
_normalizeAwesomeBlock(block, category, siteKey) {
const doc = new DOMParser().parseFromString(`${block}
`, "text/html");
const summary = doc.querySelector("summary");
const mainLink = summary?.querySelector("a[href]");
if (!summary || !mainLink) return null;
const name = cleanText(mainLink.textContent) || "Curated userscript";
const summaryText = cleanText(summary.textContent);
const description = this._descriptionFromSummary(summaryText, name);
const installLink = Array.from(doc.querySelectorAll("a[href]")).find(link => {
const href = link.getAttribute("href") || "";
return /\.user\.js(?:[?#].*)?$/i.test(href) || /update\.greasyfork\.org\/scripts\//i.test(href);
});
const pageUrl = this._absoluteUrl(mainLink.getAttribute("href"));
const installUrl = installLink ? this._absoluteUrl(installLink.getAttribute("href")) : null;
const fullName = installUrl || pageUrl || `${category}/${name}`;
return {
_source: "catalogs",
name,
description,
url: pageUrl,
code_url: installUrl,
version: null,
license: null,
users: [{ name: category ? `Awesome: ${category}` : "Awesome Userscripts" }],
daily_installs: null,
total_installs: null,
good_ratings: null,
fan_score: null,
code_updated_at: null,
created_at: null,
_catalog_source: "Awesome Userscripts",
_category: category || (siteKey ? siteKey[0].toUpperCase() + siteKey.slice(1) : "Curated"),
_full_name: fullName,
_topics: ["awesome", category, siteKey].filter(Boolean)
};
}
_categoryBefore(markdown, index) {
const before = markdown.slice(0, index);
const headings = before.match(/^###\s+.*$/gmi);
if (!headings?.length) return null;
return cleanText(headings[headings.length - 1].replace(/^###\s+/, "").replace(/<[^>]*>/g, " "));
}
_strongAwesomeMatch(block, domain, siteKey) {
const doc = new DOMParser().parseFromString(`${block}
`, "text/html");
const summaryTitle = cleanText(doc.querySelector("summary a[href]")?.textContent);
const hrefs = Array.from(doc.querySelectorAll("a[href]")).map(link => link.getAttribute("href") || "").join(" ");
return this._textMatches(`${summaryTitle} ${hrefs}`, domain, siteKey);
}
_textMatches(text, domain, siteKey) {
const haystack = String(text || "").toLowerCase();
return haystack.includes(String(domain || "").toLowerCase()) || (siteKey && haystack.includes(siteKey));
}
_descriptionFromSummary(summaryText, name) {
const withoutName = summaryText.replace(name, "").replace(/^\s*[-–—]\s*/, "");
return withoutName || "Curated userscript entry from Awesome Userscripts.";
}
_siteKey(domain) {
const label = String(domain || "").split(".").find(part => part && !["www", "com", "net", "org", "io", "co"].includes(part));
return label ? label.toLowerCase() : null;
}
_userscriptZoneUrl(domain) {
return `https://www.userscript.zone/search?q=${encodeURIComponent(domain)}&utm_source=tm.net&utm_medium=search`;
}
_absoluteUrl(href) {
try { return new URL(href, "https://github.com/awesome-scripts/awesome-userscripts/").href; }
catch { return href || null; }
}
getDirectSearchUrl(domain) {
return this._userscriptZoneUrl(domain);
}
}
class GitHubGistService {
constructor() {
this.serviceName = "githubgist";
this.baseUrl = "https://gist.github.com";
this.cache = new Map();
}
async searchScriptsByHost(host, settings) {
let scripts = await this._searchWithDomain(host, settings);
if (scripts.length === 0) {
const root = HostService.extractRootDomain(host);
if (root !== host) scripts = await this._searchWithDomain(root, settings);
}
return scripts;
}
async _searchWithDomain(domain, settings) {
const cacheKey = `githubgist_${domain}`;
const { cached, data } = SourceRuntime.freshCache(this, cacheKey, settings);
if (data) return data;
const backoff = SourceRuntime.backoffFallback(this, cacheKey, cached);
if (backoff) return backoff;
const queries = [
`${domain} userscript`,
`${domain} tampermonkey`,
`${domain} greasemonkey`
];
const results = [];
const seen = new Set();
const errors = [];
for (const query of queries) {
try {
const html = await this._fetchSearch(query);
for (const script of this._parseSearchResults(html)) {
const key = script._full_name;
if (!seen.has(key)) {
seen.add(key);
results.push(script);
}
}
} catch(err) {
errors.push(err);
if (err?.kind === "rate-limit") break;
}
}
if (!results.length && errors.length) return SourceRuntime.staleOrThrow(this, cacheKey, cached, errors[0]);
const saved = SourceRuntime.saveCache(this, cacheKey, results);
if (errors.length) return SourceRuntime.withStatus(saved, {
type: "partial",
title: "GitHub Gists partially loaded",
detail: `${errors.length} gist search ${errors.length === 1 ? "query" : "queries"} failed; showing successful gist matches.`,
checkedAt: Date.now(),
cachedAt: Date.now()
});
return saved;
}
_fetchSearch(query) {
return this._fetch(`${this.baseUrl}/search?q=${encodeURIComponent(query)}`);
}
_fetch(url) {
return SourceRuntime.requestText(url, {
source: SourceRuntime.label(this),
headers: { Accept: "text/html,application/xhtml+xml" },
notFound: ""
});
}
_parseSearchResults(html) {
if (!html || typeof DOMParser !== "function") return [];
const doc = new DOMParser().parseFromString(html, "text/html");
const snippets = Array.from(doc.querySelectorAll(".gist-snippet"));
const seen = new Set();
return snippets.map(snippet => this._normalizeSnippet(snippet)).filter(script => {
if (!script || seen.has(script._full_name)) return false;
seen.add(script._full_name);
return true;
}).slice(0, 100);
}
_normalizeSnippet(snippet) {
const fileLink = Array.from(snippet.querySelectorAll('a[href]')).find(link => {
const href = link.getAttribute("href") || "";
return /^\/[^/]+\/[a-f0-9]{32}$/i.test(href) && link.querySelector(".css-truncate-target");
});
if (!fileLink) return null;
const path = fileLink.getAttribute("href");
const match = path.match(/^\/([^/]+)\/([a-f0-9]{32})$/i);
if (!match) return null;
const owner = this._decodePathSegment(match[1]);
const id = match[2];
const fileName = cleanText(fileLink.querySelector(".css-truncate-target")?.textContent) || id;
const updatedEl = snippet.querySelector("relative-time[datetime]");
const descEl = snippet.querySelector(".gist-snippet-meta span.f6.color-fg-muted");
const fileCount = this._parseStat(snippet, /files?/i);
const description = cleanText(descEl?.textContent) ||
this._extractMetadata(snippet, "description") ||
`${this._fileLabel(fileCount)} on GitHub Gist`;
const isUserScript = /\.user\.js$/i.test(fileName);
return {
_source: "githubgist",
name: fileName,
description,
url: this.baseUrl + path,
code_url: isUserScript ? this._rawUrl(owner, id, fileName) : null,
version: this._extractMetadata(snippet, "version"),
license: null,
users: [{ name: owner }],
daily_installs: null,
total_installs: null,
good_ratings: this._parseStat(snippet, /stars?/i),
fan_score: null,
code_updated_at: updatedEl?.getAttribute("datetime") || null,
created_at: null,
_stars: this._parseStat(snippet, /stars?/i),
_forks: this._parseStat(snippet, /forks?/i),
_comments: this._parseStat(snippet, /comments?/i),
_files: fileCount,
_owner: owner,
_full_name: `${owner}/${id}`,
_topics: [fileName, "gist"].filter(Boolean)
};
}
_extractMetadata(snippet, key) {
const matcher = new RegExp(`@${key}\\s+([^\\n\\r<]+)`, "i");
const text = Array.from(snippet.querySelectorAll(".blob-code-inner, .blob-code"))
.map(el => cleanText(el.textContent))
.join("\n");
const match = text.match(matcher);
return match ? cleanText(match[1]) : null;
}
_parseStat(snippet, labelPattern) {
const stat = Array.from(snippet.querySelectorAll(".gist-snippet-meta li")).find(li =>
labelPattern.test(cleanText(li.textContent))
);
return this._parseNumber(stat?.textContent);
}
_parseNumber(text) {
const match = String(text || "").replace(/,/g, "").match(/(\d+(?:\.\d+)?)\s*([kKmM])?/);
if (!match) return null;
const multiplier = match[2]?.toLowerCase() === "m" ? 1000000 : match[2]?.toLowerCase() === "k" ? 1000 : 1;
return Number(match[1]) * multiplier;
}
_fileLabel(count) {
if (!count) return "Gist";
return `${count} ${count === 1 ? "file" : "files"}`;
}
_rawUrl(owner, id, fileName) {
return `https://gist.githubusercontent.com/${encodeURIComponent(owner)}/${id}/raw/${encodeURIComponent(fileName)}`;
}
_decodePathSegment(segment) {
if (!segment) return null;
try { return decodeURIComponent(segment.replace(/\+/g, "%20")); }
catch { return segment; }
}
getDirectSearchUrl(domain) {
return `${this.baseUrl}/search?q=${encodeURIComponent(domain + ' userscript')}`;
}
}
if (typeof window !== "undefined" && window.__SF_TEST_HOOKS__) {
Object.assign(window.__SF_TEST_HOOKS__, {
ScriptService,
OpenUserJSScriptService,
ChromeWebStoreService,
MozillaAddonsService,
GitHubScriptService,
CatalogScriptService,
GitHubGistService,
reputationScore,
normalizedRating,
matchesLanguageFilter,
extensionTrustBadges,
normalizeStringList,
hasBroadHostAccess
});
}
class ToastService {
constructor(shadowRoot) { this.root = shadowRoot; this.el = null; this.timer = null; this.undoCallback = null; }
show(message, undoCallback = null) {
this.hide();
this.undoCallback = undoCallback;
this.el = document.createElement("div");
this.el.className = "sf-toast";
const msgSpan = document.createElement("span");
msgSpan.textContent = message;
this.el.appendChild(msgSpan);
if (undoCallback) {
const btn = document.createElement("button");
btn.className = "sf-toast-undo";
btn.textContent = "Undo";
btn.addEventListener("click", () => { undoCallback(); this.hide(); });
this.el.appendChild(btn);
}
this.root.appendChild(this.el);
requestAnimationFrame(() => requestAnimationFrame(() => this.el.classList.add("show")));
this.timer = setTimeout(() => this.hide(), undoCallback ? 5000 : 3000);
}
hide() {
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
if (this.el) {
this.el.classList.remove("show");
const old = this.el;
setTimeout(() => old.remove(), 350);
this.el = null;
}
}
}
// ── CSS ─────────────────────────────────────────────────────────────
const CSS = `
/* ── HOST ── */
:host {
all: initial !important;
display: block !important;
position: fixed !important;
bottom: 0 !important;
right: 0 !important;
z-index: 2147483647 !important;
font-family: -apple-system,BlinkMacSystemFont,system-ui,sans-serif !important;
pointer-events: none !important;
width: 0 !important;
height: 0 !important;
overflow: visible !important;
}
/* ── ANIMATIONS ── */
@keyframes sfSpin { to { transform: rotate(360deg); } }
@keyframes sfSlideUp {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes sfFadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes sfShimmer {
0% { background-position: -200% center; }
100% { background-position: 200% center; }
}
@keyframes sfModalIn {
from { opacity: 0; transform: translateY(16px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes sfModalOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(10px) scale(0.97); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
}
button:focus-visible, select:focus-visible, input:focus-visible, textarea:focus-visible, a:focus-visible, [tabindex]:focus-visible {
outline: 2px solid ${THEME.green}; outline-offset: 2px;
}
/* ── TOAST ── */
.sf-toast {
position: fixed; top: 20px; left: 50%; transform: translateX(-50%) translateY(-20px);
background: ${THEME.surface1}; color: ${THEME.text};
padding: 12px 20px; border-radius: 12px; font: 600 13px/1.4 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;
box-shadow: 0 12px 40px ${THEME.shadow}; border: 1px solid ${THEME.glassBorder};
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
opacity: 0; transition: all 0.35s cubic-bezier(0.34,1.56,0.64,1);
z-index: 2147483647; pointer-events: auto; display: flex; align-items: center; gap: 12px;
max-width: 440px; width: max-content;
}
.sf-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.sf-toast-undo {
background: ${THEME.green}; color: ${THEME.base}; border: none; padding: 4px 12px;
border-radius: 6px; font: 700 12px/1 inherit; cursor: pointer;
transition: all 0.15s ease; white-space: nowrap;
}
.sf-toast-undo:hover { filter: brightness(1.1); transform: scale(1.04); }
/* ── MODAL ── */
.sf-modal {
position: fixed; bottom: 14px; right: 14px; width: min(500px, calc(100vw - 24px));
max-height: min(84vh, 800px);
background: ${THEME.base}; border-radius: 16px;
border: 1px solid ${THEME.glassBorder};
box-shadow: 0 32px 80px ${THEME.shadow}, 0 0 0 1px rgba(255,255,255,0.02);
overflow: hidden; display: flex; flex-direction: column;
opacity: 0; pointer-events: none;
transform: translateY(10px) scale(0.97);
transition: all 0.3s cubic-bezier(0.34,1.56,0.64,1);
}
.sf-modal.visible {
opacity: 1; pointer-events: auto; transform: translateY(0) scale(1);
}
/* Header */
.sf-modal-header {
padding: 16px 20px; position: relative;
background: linear-gradient(180deg, ${THEME.surface0} 0%, ${THEME.base} 100%);
border-bottom: 1px solid ${THEME.glassBorder};
}
.sf-modal-header::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
background: linear-gradient(90deg, ${THEME.green}, ${THEME.teal});
transition: background 0.3s ease;
}
.sf-modal-header.sleazyfork::before { background: linear-gradient(90deg, ${THEME.purple}, ${THEME.mauve}); }
.sf-modal-header.openuserjs::before { background: linear-gradient(90deg, ${THEME.openuserjsDim}, ${THEME.openuserjs}); }
.sf-modal-header.chromewebstore::before { background: linear-gradient(90deg, ${THEME.chromewebstoreDim}, ${THEME.chromewebstore}); }
.sf-modal-header.mozillaaddons::before { background: linear-gradient(90deg, ${THEME.mozillaaddonsDim}, ${THEME.mozillaaddons}); }
.sf-modal-header.catalogs::before { background: linear-gradient(90deg, ${THEME.catalogsDim}, ${THEME.catalogs}); }
.sf-modal-header.githubgist::before { background: linear-gradient(90deg, ${THEME.githubgistDim}, ${THEME.githubgist}); }
.sf-modal-header.github::before { background: linear-gradient(90deg, ${THEME.github}, ${THEME.peach}); }
.sf-header-row { display: flex; align-items: center; gap: 12px; }
.sf-header-left { flex: 1; min-width: 0; }
.sf-modal-title {
font: 700 16px/1.3 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;
color: ${THEME.text}; margin: 0 0 4px 0; letter-spacing: -0.3px;
background: linear-gradient(90deg, ${THEME.text}, ${THEME.subtext1}, ${THEME.text});
background-size: 200% auto;
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
animation: sfShimmer 4s linear infinite;
}
.sf-modal-subtitle {
font: 600 12px/1 inherit; color: ${THEME.subtext0}; margin: 0;
display: flex; align-items: center; gap: 6px;
}
.sf-subtitle-count {
display: inline-flex; align-items: center; justify-content: center;
min-width: 20px; height: 20px; padding: 0 6px;
background: ${THEME.surface2}; border-radius: 6px;
font: 700 11px/1 inherit; color: ${THEME.text};
}
.sf-header-btn {
width: 32px; height: 32px; border-radius: 8px; border: 1px solid ${THEME.glassBorder};
cursor: pointer; background: ${THEME.surface1}; color: ${THEME.subtext0};
display: grid; place-items: center; transition: all 0.2s ease; flex-shrink: 0;
}
.sf-header-btn:hover { background: ${THEME.surface2}; color: ${THEME.text}; transform: scale(1.06); }
.sf-header-btn:active { transform: scale(0.94); }
.sf-header-btn svg { width: 14px; height: 14px; }
/* Search bar */
.sf-search-wrap {
padding: 0 20px 12px; background: transparent; margin-top: -2px;
}
.sf-search-box {
display: flex; align-items: center; gap: 8px;
background: ${THEME.surface0}; border: 1px solid ${THEME.glassBorder};
border-radius: 10px; padding: 0 12px; height: 36px;
transition: all 0.2s ease;
}
.sf-search-box:focus-within { border-color: ${THEME.green}33; box-shadow: 0 0 0 3px ${THEME.green}11; }
.sf-search-box.sleazyfork:focus-within { border-color: ${THEME.purple}33; box-shadow: 0 0 0 3px ${THEME.purple}11; }
.sf-search-box.openuserjs:focus-within { border-color: ${THEME.openuserjs}33; box-shadow: 0 0 0 3px ${THEME.openuserjs}11; }
.sf-search-box.chromewebstore:focus-within { border-color: ${THEME.chromewebstore}44; box-shadow: 0 0 0 3px ${THEME.chromewebstore}11; }
.sf-search-box.mozillaaddons:focus-within { border-color: ${THEME.mozillaaddons}44; box-shadow: 0 0 0 3px ${THEME.mozillaaddons}11; }
.sf-search-box.catalogs:focus-within { border-color: ${THEME.catalogs}44; box-shadow: 0 0 0 3px ${THEME.catalogs}11; }
.sf-search-box.githubgist:focus-within { border-color: ${THEME.githubgist}44; box-shadow: 0 0 0 3px ${THEME.githubgist}11; }
.sf-search-box.github:focus-within { border-color: ${THEME.github}33; box-shadow: 0 0 0 3px ${THEME.github}11; }
.sf-search-box svg { width: 14px; height: 14px; color: ${THEME.overlay}; flex-shrink: 0; }
.sf-search-input {
flex: 1; border: none; background: transparent; outline: none;
font: 500 13px/1 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;
color: ${THEME.text}; padding: 0;
}
.sf-search-input::placeholder { color: ${THEME.overlay}; }
.sf-search-count { font: 600 11px/1 inherit; color: ${THEME.overlay}; white-space: nowrap; }
/* Tabs */
.sf-tabs {
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px;
padding: 10px 20px; background: ${THEME.surface0}44;
border-bottom: 1px solid ${THEME.glassBorder};
}
.sf-tab {
padding: 9px 10px; border: 1px solid ${THEME.glassBorder}; border-radius: 8px;
cursor: pointer; background: ${THEME.surface0}; font: 600 12px/1 inherit;
color: ${THEME.subtext0}; transition: all 0.2s ease; position: relative;
text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.sf-tab:hover { background: ${THEME.surface1}; color: ${THEME.text}; }
.sf-tab[data-health-label]:not([data-health-label=""]) { padding-right: 42px; }
.sf-tab[data-health-label]:not([data-health-label=""])::after {
content: attr(data-health-label); position: absolute; top: 3px; right: 5px;
font: 800 8px/1 inherit; letter-spacing: 0; opacity: 0.9;
}
.sf-tab.health-ok::after { color: ${THEME.green}; }
.sf-tab.health-cached::after, .sf-tab.health-stale::after, .sf-tab.health-partial::after { color: ${THEME.yellow}; }
.sf-tab.health-rate-limited::after, .sf-tab.health-failed::after { color: ${THEME.red}; }
.sf-tab.active {
background: linear-gradient(135deg, ${THEME.greenDim}, ${THEME.green}33);
color: ${THEME.green}; border-color: ${THEME.green}33;
box-shadow: 0 0 16px ${THEME.glow};
}
.sf-tab.sleazyfork.active {
background: linear-gradient(135deg, ${THEME.purpleDim}44, ${THEME.purple}22);
color: ${THEME.purple}; border-color: ${THEME.purple}33;
box-shadow: 0 0 16px ${THEME.glowPurple};
}
.sf-tab.openuserjs.active {
background: linear-gradient(135deg, ${THEME.openuserjsDim}44, ${THEME.openuserjs}22);
color: ${THEME.openuserjs}; border-color: ${THEME.openuserjs}33;
box-shadow: 0 0 16px ${THEME.glowOpenUserJS};
}
.sf-tab.chromewebstore.active {
background: linear-gradient(135deg, ${THEME.chromewebstoreDim}44, ${THEME.chromewebstore}22);
color: ${THEME.chromewebstore}; border-color: ${THEME.chromewebstore}44;
box-shadow: 0 0 16px ${THEME.glowChromeWebStore};
}
.sf-tab.mozillaaddons.active {
background: linear-gradient(135deg, ${THEME.mozillaaddonsDim}44, ${THEME.mozillaaddons}22);
color: ${THEME.mozillaaddons}; border-color: ${THEME.mozillaaddons}44;
box-shadow: 0 0 16px ${THEME.glowMozillaAddons};
}
.sf-tab.catalogs.active {
background: linear-gradient(135deg, ${THEME.catalogsDim}44, ${THEME.catalogs}22);
color: ${THEME.catalogs}; border-color: ${THEME.catalogs}44;
box-shadow: 0 0 16px ${THEME.glowCatalogs};
}
.sf-tab.githubgist.active {
background: linear-gradient(135deg, ${THEME.githubgistDim}44, ${THEME.githubgist}22);
color: ${THEME.githubgist}; border-color: ${THEME.githubgist}44;
box-shadow: 0 0 16px ${THEME.glowGitHubGist};
}
.sf-tab.github.active {
background: linear-gradient(135deg, ${THEME.githubDim}44, ${THEME.github}22);
color: ${THEME.github}; border-color: ${THEME.github}33;
box-shadow: 0 0 16px ${THEME.glowGithub};
}
/* Sort bar */
.sf-sort-bar {
padding: 10px 20px; background: ${THEME.surface0}22;
border-bottom: 1px solid ${THEME.glassBorder};
display: flex; align-items: center; gap: 10px;
}
.sf-filter-bar {
padding: 10px 20px; background: ${THEME.surface0}18;
border-bottom: 1px solid ${THEME.glassBorder};
display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto;
align-items: center; gap: 8px;
}
.sf-sort-label { font: 600 12px/1 inherit; color: ${THEME.subtext0}; flex-shrink: 0; }
.sf-query-mode-select {
padding: 7px 10px; border-radius: 8px;
border: 1px solid ${THEME.glassBorder}; background: ${THEME.surface0};
color: ${THEME.text}; font: 500 12px/1 inherit; cursor: pointer; outline: none;
}
.sf-query-mode-select option { background: ${THEME.surface1}; color: ${THEME.text}; }
.sf-sort-select {
flex: 1; padding: 7px 10px; border-radius: 8px;
border: 1px solid ${THEME.glassBorder}; background: ${THEME.surface0};
color: ${THEME.text}; font: 500 12px/1 inherit; cursor: pointer; outline: none;
transition: border-color 0.2s ease;
}
.sf-filter-select, .sf-filter-toggle {
min-width: 0; padding: 7px 9px; border-radius: 8px;
border: 1px solid ${THEME.glassBorder}; background: ${THEME.surface0};
color: ${THEME.text}; font: 600 11px/1 inherit; cursor: pointer; outline: none;
}
.sf-filter-toggle.active {
background: ${THEME.green}22; color: ${THEME.green}; border-color: ${THEME.green}44;
}
.sf-sort-select option { background: ${THEME.surface1}; color: ${THEME.text}; }
.sf-filter-select option { background: ${THEME.surface1}; color: ${THEME.text}; }
.sf-sort-select:focus { border-color: ${THEME.green}44; }
.sf-sort-select.sleazyfork:focus { border-color: ${THEME.purple}44; }
.sf-sort-select.openuserjs:focus { border-color: ${THEME.openuserjs}44; }
.sf-sort-select.chromewebstore:focus { border-color: ${THEME.chromewebstore}44; }
.sf-sort-select.mozillaaddons:focus { border-color: ${THEME.mozillaaddons}44; }
.sf-sort-select.catalogs:focus { border-color: ${THEME.catalogs}44; }
.sf-sort-select.githubgist:focus { border-color: ${THEME.githubgist}44; }
.sf-sort-select.github:focus { border-color: ${THEME.github}44; }
/* Content */
.sf-content {
flex: 1; overflow-y: auto; background: ${THEME.base};
scrollbar-width: thin; scrollbar-color: ${THEME.surface2} transparent;
}
.sf-content::-webkit-scrollbar { width: 6px; }
.sf-content::-webkit-scrollbar-track { background: transparent; }
.sf-content::-webkit-scrollbar-thumb { background: ${THEME.surface2}; border-radius: 3px; }
.sf-content::-webkit-scrollbar-thumb:hover { background: ${THEME.overlay0}; }
/* Script items */
.sf-item {
padding: 14px 20px; border-bottom: 1px solid ${THEME.glassBorder};
cursor: pointer; position: relative; background: transparent;
transition: all 0.2s ease;
animation: sfFadeIn 0.3s ease both;
}
.sf-item:hover { background: ${THEME.glassHover}; transform: translateY(-1px); }
.sf-item:hover::after {
content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 2px;
background: linear-gradient(180deg, ${THEME.green}, ${THEME.teal});
}
.sf-item.sleazyfork:hover::after { background: linear-gradient(180deg, ${THEME.purple}, ${THEME.mauve}); }
.sf-item.openuserjs:hover::after { background: linear-gradient(180deg, ${THEME.openuserjsDim}, ${THEME.openuserjs}); }
.sf-item.chromewebstore:hover::after { background: linear-gradient(180deg, ${THEME.chromewebstoreDim}, ${THEME.chromewebstore}); }
.sf-item.mozillaaddons:hover::after { background: linear-gradient(180deg, ${THEME.mozillaaddonsDim}, ${THEME.mozillaaddons}); }
.sf-item.catalogs:hover::after { background: linear-gradient(180deg, ${THEME.catalogsDim}, ${THEME.catalogs}); }
.sf-item.githubgist:hover::after { background: linear-gradient(180deg, ${THEME.githubgistDim}, ${THEME.githubgist}); }
.sf-item.github:hover::after { background: linear-gradient(180deg, ${THEME.github}, ${THEME.peach}); }
.sf-item:last-child { border-bottom: none; }
/* Dense mode */
:host(.dense) .sf-item { padding: 10px 20px; }
:host(.dense) .sf-script-title { font-size: 13px; }
:host(.dense) .sf-script-desc { display: none; }
:host(.dense) .sf-script-meta { gap: 4px; }
:host(.dense) .sf-badge { padding: 3px 7px; font-size: 10px; }
.sf-script-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
.sf-script-info { flex: 1; min-width: 0; }
.sf-script-title {
display: block; text-decoration: none; color: ${THEME.text};
font: 700 14px/1.4 inherit; transition: color 0.15s ease;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sf-script-title:hover { color: ${THEME.green}; }
.sf-item.sleazyfork .sf-script-title:hover { color: ${THEME.purple}; }
.sf-item.openuserjs .sf-script-title:hover { color: ${THEME.openuserjs}; }
.sf-item.chromewebstore .sf-script-title:hover { color: ${THEME.chromewebstore}; }
.sf-item.mozillaaddons .sf-script-title:hover { color: ${THEME.mozillaaddons}; }
.sf-item.catalogs .sf-script-title:hover { color: ${THEME.catalogs}; }
.sf-item.githubgist .sf-script-title:hover { color: ${THEME.githubgist}; }
.sf-item.github .sf-script-title:hover { color: ${THEME.github}; }
.sf-script-sub {
display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
font: 500 11px/1.2 inherit; color: ${THEME.overlay}; margin-top: 3px;
}
.sf-script-sub svg { width: 12px; height: 12px; margin-right: 2px; vertical-align: -1px; }
.sf-dot { opacity: 0.3; font-size: 8px; }
.sf-script-actions { flex-shrink: 0; display: flex; align-items: center; gap: 6px; }
.sf-install-btn, .sf-preview-btn {
flex-shrink: 0; display: flex; align-items: center; gap: 4px;
padding: 6px 12px; border-radius: 8px; border: none;
background: ${THEME.green}22; color: ${THEME.green};
font: 700 11px/1 inherit; cursor: pointer;
transition: all 0.2s ease; white-space: nowrap;
}
.sf-install-btn:hover, .sf-preview-btn:hover { background: ${THEME.green}44; transform: scale(1.04); }
.sf-install-btn:active, .sf-preview-btn:active { transform: scale(0.96); }
.sf-install-btn svg, .sf-preview-btn svg { width: 14px; height: 14px; }
.sf-item.sleazyfork .sf-install-btn { background: ${THEME.purple}22; color: ${THEME.purple}; }
.sf-item.sleazyfork .sf-install-btn:hover { background: ${THEME.purple}44; }
.sf-item.openuserjs .sf-install-btn { background: ${THEME.openuserjs}22; color: ${THEME.openuserjs}; }
.sf-item.openuserjs .sf-install-btn:hover { background: ${THEME.openuserjs}44; }
.sf-item.chromewebstore .sf-install-btn { background: ${THEME.chromewebstore}22; color: ${THEME.chromewebstore}; }
.sf-item.chromewebstore .sf-install-btn:hover { background: ${THEME.chromewebstore}44; }
.sf-item.mozillaaddons .sf-install-btn { background: ${THEME.mozillaaddons}22; color: ${THEME.mozillaaddons}; }
.sf-item.mozillaaddons .sf-install-btn:hover { background: ${THEME.mozillaaddons}44; }
.sf-item.catalogs .sf-install-btn { background: ${THEME.catalogs}22; color: ${THEME.catalogs}; }
.sf-item.catalogs .sf-install-btn:hover { background: ${THEME.catalogs}44; }
.sf-item.githubgist .sf-install-btn { background: ${THEME.githubgist}22; color: ${THEME.githubgist}; }
.sf-item.githubgist .sf-install-btn:hover { background: ${THEME.githubgist}44; }
.sf-item.github .sf-install-btn { background: ${THEME.github}22; color: ${THEME.github}; }
.sf-item.github .sf-install-btn:hover { background: ${THEME.github}44; }
.sf-preview-btn { background: ${THEME.surface1}; color: ${THEME.subtext1}; border: 1px solid ${THEME.glassBorder}; }
.sf-preview-btn:hover { background: ${THEME.surface2}; color: ${THEME.text}; }
.sf-match-preview {
margin-top: 10px; padding: 10px 12px; border-radius: 8px;
background: ${THEME.surface0}; border: 1px solid ${THEME.glassBorder};
color: ${THEME.subtext1}; font: 500 11px/1.45 inherit;
}
.sf-match-preview.hidden { display: none; }
.sf-match-preview.good { border-color: ${THEME.green}33; color: ${THEME.green}; }
.sf-match-preview.warn { border-color: ${THEME.yellow}33; color: ${THEME.yellow}; }
.sf-match-preview.bad { border-color: ${THEME.red}33; color: ${THEME.red}; }
.sf-match-preview-title { font-weight: 800; margin-bottom: 4px; color: ${THEME.text}; }
.sf-match-preview code { color: ${THEME.subtext1}; word-break: break-all; }
.sf-install-warning {
margin-top: 8px; color: ${THEME.yellow}; font: 700 11px/1.4 inherit;
}
.sf-script-desc {
color: ${THEME.subtext0}; font: 400 12px/1.5 inherit;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; margin-bottom: 8px;
}
.sf-script-meta { display: flex; flex-wrap: wrap; gap: 5px; }
.sf-badge {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 8px; border-radius: 6px; font: 700 10px/1 inherit;
background: ${THEME.surface1}; color: ${THEME.subtext1};
border: 1px solid ${THEME.glassBorder}; transition: all 0.15s ease;
}
.sf-badge svg { width: 12px; height: 12px; }
.sf-badge:hover { background: ${THEME.surface2}; border-color: ${THEME.overlay0}; }
.sf-badge.score-high { background: ${THEME.green}18; color: ${THEME.green}; border-color: ${THEME.green}33; }
.sf-badge.score-mid { background: ${THEME.yellow}18; color: ${THEME.yellow}; border-color: ${THEME.yellow}33; }
.sf-badge.score-low { background: ${THEME.red}18; color: ${THEME.red}; border-color: ${THEME.red}33; }
.sf-badge.stale-flag { background: ${THEME.red}18; color: ${THEME.red}; border-color: ${THEME.red}33; }
.sf-queue-btn { background: none; border: none; color: ${THEME.overlay}; cursor: pointer; padding: 8px; border-radius: 6px; transition: color 0.15s, background 0.15s; flex-shrink: 0; }
.sf-queue-btn:hover { color: ${THEME.yellow}; background: ${THEME.yellow}18; }
.sf-queue-btn.queued { color: ${THEME.yellow}; }
.sf-dismiss-btn { background: none; border: none; color: ${THEME.overlay}; cursor: pointer; padding: 8px; border-radius: 6px; transition: color 0.15s, background 0.15s; flex-shrink: 0; }
.sf-dismiss-btn:hover { color: ${THEME.red}; background: ${THEME.red}18; }
.sf-source-badge { font: 600 10px/1 inherit; padding: 2px 6px; border-radius: 4px; background: ${THEME.surface1}; color: ${THEME.subtext0}; }
.sf-source-badge.sleazyfork { color: ${THEME.purple}; }
.sf-source-badge.openuserjs { color: ${THEME.openuserjs}; }
.sf-source-badge.chromewebstore { color: ${THEME.chromewebstore}; }
.sf-source-badge.mozillaaddons { color: ${THEME.mozillaaddons}; }
.sf-source-badge.catalogs { color: ${THEME.catalogs}; }
.sf-source-badge.githubgist { color: ${THEME.githubgist}; }
.sf-source-badge.github { color: ${THEME.github}; }
.sf-dismissed-notice { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 20px; color: ${THEME.subtext0}; font: 500 12px/1 inherit; border-top: 1px solid ${THEME.glassBorder}22; }
.sf-restore-dismissed { background: none; border: none; color: ${THEME.green}; font: 600 12px/1 inherit; cursor: pointer; text-decoration: underline; }
.sf-badge.coverage-exact { background: ${THEME.green}18; color: ${THEME.green}; border-color: ${THEME.green}33; }
.sf-badge.coverage-broad { background: ${THEME.yellow}18; color: ${THEME.yellow}; border-color: ${THEME.yellow}33; }
.sf-badge.coverage-uncertain { background: ${THEME.surface2}; color: ${THEME.subtext1}; border-color: ${THEME.glassBorder}; }
.sf-badge.trust-ok { background: ${THEME.green}18; color: ${THEME.green}; border-color: ${THEME.green}33; }
.sf-badge.trust-info { background: ${THEME.blue}18; color: ${THEME.blue}; border-color: ${THEME.blue}33; }
.sf-badge.trust-warn { background: ${THEME.yellow}18; color: ${THEME.yellow}; border-color: ${THEME.yellow}33; }
.sf-badge.trust-bad { background: ${THEME.red}18; color: ${THEME.red}; border-color: ${THEME.red}33; }
/* Loading / empty / error */
.sf-loading { padding: 50px 20px; text-align: center; display: grid; gap: 14px; place-items: center; }
.sf-spinner {
width: 36px; height: 36px; border-radius: 50%;
border: 3px solid ${THEME.surface2}; border-top-color: ${THEME.green};
animation: sfSpin 0.7s linear infinite;
}
.sf-spinner.sleazyfork { border-top-color: ${THEME.purple}; }
.sf-spinner.openuserjs { border-top-color: ${THEME.openuserjs}; }
.sf-spinner.chromewebstore { border-top-color: ${THEME.chromewebstore}; }
.sf-spinner.mozillaaddons { border-top-color: ${THEME.mozillaaddons}; }
.sf-spinner.catalogs { border-top-color: ${THEME.catalogs}; }
.sf-spinner.githubgist { border-top-color: ${THEME.githubgist}; }
.sf-spinner.github { border-top-color: ${THEME.github}; }
.sf-loading-text { font: 500 13px/1 inherit; color: ${THEME.subtext0}; }
.sf-empty, .sf-error { padding: 50px 28px; text-align: center; }
.sf-empty-title, .sf-error-title { font: 700 15px/1.3 inherit; color: ${THEME.text}; margin-bottom: 8px; }
.sf-error-title { color: ${THEME.red}; }
.sf-empty-text, .sf-error-text { color: ${THEME.subtext0}; font: 400 13px/1.5 inherit; margin-bottom: 18px; }
.sf-source-notice {
margin: 12px 20px; padding: 12px 14px; border-radius: 10px;
background: ${THEME.surface0}; border: 1px solid ${THEME.glassBorder};
color: ${THEME.subtext1}; font: 500 12px/1.45 inherit;
}
.sf-source-notice.warn { border-color: ${THEME.yellow}44; color: ${THEME.yellow}; }
.sf-source-notice.bad { border-color: ${THEME.red}44; color: ${THEME.red}; }
.sf-source-notice-title { color: ${THEME.text}; font-weight: 800; margin-bottom: 4px; }
.sf-source-notice a { color: inherit; font-weight: 800; text-decoration: underline; }
.sf-compat-list { margin: 10px auto 0; padding: 0; max-width: 420px; display: grid; gap: 6px; list-style: none; }
.sf-compat-list li { color: ${THEME.subtext1}; font: 600 12px/1.4 inherit; }
.sf-error-actions { display: flex; align-items: center; justify-content: center; gap: 10px; flex-wrap: wrap; }
.sf-action-btn {
display: inline-flex; align-items: center; justify-content: center;
padding: 10px 18px; border-radius: 10px;
background: linear-gradient(135deg, ${THEME.greenDim}, ${THEME.green}88);
color: ${THEME.base}; font: 700 13px/1 inherit; border: none;
cursor: pointer; transition: all 0.2s ease; text-decoration: none;
}
.sf-action-btn:hover { transform: translateY(-1px); filter: brightness(1.1); }
.sf-action-btn.sleazyfork { background: linear-gradient(135deg, ${THEME.purpleDim}, ${THEME.purple}88); }
.sf-action-btn.openuserjs { background: linear-gradient(135deg, ${THEME.openuserjsDim}, ${THEME.openuserjs}88); }
.sf-action-btn.chromewebstore { background: linear-gradient(135deg, ${THEME.chromewebstoreDim}, ${THEME.chromewebstore}88); }
.sf-action-btn.mozillaaddons { background: linear-gradient(135deg, ${THEME.mozillaaddonsDim}, ${THEME.mozillaaddons}88); }
.sf-action-btn.catalogs { background: linear-gradient(135deg, ${THEME.catalogsDim}, ${THEME.catalogs}88); }
.sf-action-btn.githubgist { background: linear-gradient(135deg, ${THEME.githubgistDim}, ${THEME.githubgist}88); }
.sf-action-btn.github { background: linear-gradient(135deg, ${THEME.githubDim}, ${THEME.github}88); }
/* Disclosure */
.sf-disclosure { padding: 30px 24px; text-align: left; }
.sf-disclosure-title { font: 700 15px/1.3 inherit; color: ${THEME.text}; margin-bottom: 8px; }
.sf-disclosure-text { color: ${THEME.subtext0}; font: 400 13px/1.5 inherit; margin-bottom: 16px; }
.sf-disclosure-table { width: 100%; border-collapse: collapse; margin-bottom: 18px; }
.sf-disclosure-table th { text-align: left; font: 700 11px/1 inherit; color: ${THEME.subtext1}; text-transform: uppercase; letter-spacing: 0.5px; padding: 6px 8px; border-bottom: 1px solid ${THEME.glassBorder}; }
.sf-disclosure-table td { padding: 8px; border-bottom: 1px solid ${THEME.glassBorder}22; vertical-align: middle; }
.sf-disclosure-source { display: flex; align-items: center; gap: 8px; font: 600 13px/1.3 inherit; color: ${THEME.text}; cursor: pointer; }
.sf-disclosure-source input { accent-color: ${THEME.green}; width: 16px; height: 16px; cursor: pointer; }
.sf-disclosure-hosts { font: 400 12px/1.4 inherit; color: ${THEME.subtext0}; font-family: monospace; }
.sf-disclosure-actions { text-align: center; }
/* Diagnostics fallback */
.sf-diagnostics-fallback { padding: 12px 20px; border-top: 1px solid ${THEME.glassBorder}; background: ${THEME.surface0}88; }
.sf-diagnostics-fallback-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font: 600 12px/1 inherit; color: ${THEME.subtext1}; }
.sf-diagnostics-fallback-close { background: none; border: none; color: ${THEME.subtext0}; cursor: pointer; padding: 4px; }
.sf-diagnostics-textarea { width: 100%; height: 100px; resize: vertical; background: ${THEME.base}; color: ${THEME.text}; border: 1px solid ${THEME.glassBorder}; border-radius: 8px; padding: 8px; font: 400 11px/1.4 monospace; box-sizing: border-box; }
.sf-diagnostics-fallback-actions { margin-top: 8px; text-align: right; }
.sf-diagnostics-retry-btn { background: ${THEME.surface1}; color: ${THEME.text}; border: 1px solid ${THEME.glassBorder}; border-radius: 8px; padding: 6px 14px; font: 600 12px/1 inherit; cursor: pointer; }
.sf-diagnostics-retry-btn:hover { background: ${THEME.surface2}; }
/* Footer */
.sf-footer {
padding: 12px 20px; border-top: 1px solid ${THEME.glassBorder};
background: ${THEME.surface0}44;
display: flex; align-items: center; justify-content: space-between; gap: 10px;
font: 600 11px/1 inherit;
}
.sf-footer-text { color: ${THEME.overlay}; display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; }
.sf-footer a { color: ${THEME.green}; text-decoration: none; font-weight: 700; }
.sf-footer a:hover { text-decoration: underline; }
.sf-footer a.sleazyfork { color: ${THEME.purple}; }
.sf-footer a.openuserjs { color: ${THEME.openuserjs}; }
.sf-footer a.chromewebstore { color: ${THEME.chromewebstore}; }
.sf-footer a.mozillaaddons { color: ${THEME.mozillaaddons}; }
.sf-footer a.catalogs { color: ${THEME.catalogs}; }
.sf-footer a.githubgist { color: ${THEME.githubgist}; }
.sf-footer a.github { color: ${THEME.github}; }
.sf-health-pill {
display: inline-flex; align-items: center; padding: 3px 7px; border-radius: 6px;
background: ${THEME.surface1}; color: ${THEME.subtext1}; border: 1px solid ${THEME.glassBorder};
font: 800 10px/1 inherit; white-space: nowrap;
}
.sf-health-pill.ok { color: ${THEME.green}; border-color: ${THEME.green}33; }
.sf-health-pill.cached, .sf-health-pill.stale, .sf-health-pill.partial { color: ${THEME.yellow}; border-color: ${THEME.yellow}33; }
.sf-health-pill.rate-limited, .sf-health-pill.failed { color: ${THEME.red}; border-color: ${THEME.red}33; }
.sf-diagnostics-btn {
border: 1px solid ${THEME.glassBorder}; background: ${THEME.surface1}; color: ${THEME.subtext1};
border-radius: 7px; padding: 5px 9px; font: 800 10px/1 inherit; cursor: pointer; flex-shrink: 0;
}
.sf-diagnostics-btn:hover { color: ${THEME.text}; background: ${THEME.surface2}; }
/* Settings panel */
.sf-settings {
display: none; padding: 16px 20px; border-top: 1px solid ${THEME.glassBorder};
background: ${THEME.surface0}44;
}
.sf-settings.visible {
display: block; max-height: min(36vh, 330px); overflow-y: auto; flex-shrink: 0;
}
.sf-settings::-webkit-scrollbar { width: 6px; }
.sf-settings::-webkit-scrollbar-track { background: transparent; }
.sf-settings::-webkit-scrollbar-thumb { background: ${THEME.surface2}; border-radius: 3px; }
.sf-settings-title { font: 700 13px/1 inherit; color: ${THEME.text}; margin-bottom: 12px; }
.sf-settings-subtitle {
font: 800 10px/1 inherit; color: ${THEME.overlay}; text-transform: uppercase;
margin: 14px 0 6px; letter-spacing: 0;
}
.sf-setting-row {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 0; border-bottom: 1px solid ${THEME.glassBorder};
}
.sf-setting-block {
display: grid; gap: 8px; padding: 8px 0; border-bottom: 1px solid ${THEME.glassBorder};
}
.sf-source-setting-row { padding: 6px 0; }
.sf-setting-row:last-child { border-bottom: none; }
.sf-setting-label { font: 500 12px/1.3 inherit; color: ${THEME.subtext1}; }
.sf-setting-help { color: ${THEME.overlay}; font: 500 11px/1.35 inherit; }
.sf-setting-textarea {
min-height: 64px; resize: vertical; padding: 8px 9px; border-radius: 7px;
border: 1px solid ${THEME.glassBorder}; background: ${THEME.surface0};
color: ${THEME.text}; font: 500 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace;
outline: none;
}
.sf-setting-mini-btn {
border: 1px solid ${THEME.glassBorder}; background: ${THEME.surface1};
color: ${THEME.subtext1}; border-radius: 7px; padding: 6px 10px;
font: 800 10px/1 inherit; cursor: pointer;
}
.sf-setting-mini-btn:hover { color: ${THEME.text}; background: ${THEME.surface2}; }
.sf-settings-export-row { gap: 8px; }
.sf-toggle {
position: relative; width: 36px; height: 20px; border-radius: 10px;
background: ${THEME.surface2}; cursor: pointer; transition: background 0.2s ease;
border: none; padding: 0;
}
.sf-toggle::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 16px; height: 16px; border-radius: 50%;
background: ${THEME.subtext0}; transition: all 0.2s ease;
}
.sf-toggle.on { background: ${THEME.green}55; }
.sf-toggle.on::after { left: 18px; background: ${THEME.green}; }
.sf-setting-select {
padding: 4px 8px; border-radius: 6px; border: 1px solid ${THEME.glassBorder};
background: ${THEME.surface0}; color: ${THEME.text}; font: 500 12px/1 inherit;
cursor: pointer; outline: none;
}
.sf-setting-select option { background: ${THEME.surface1}; }
/* Responsive */
@media (max-width: 520px) {
.sf-modal { width: calc(100vw - 24px); right: 12px; max-height: min(88vh, 700px); }
.sf-modal-header { padding: 14px 16px; }
.sf-tabs { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 8px 16px; }
.sf-sort-bar, .sf-search-wrap { padding-left: 16px; padding-right: 16px; }
.sf-sort-bar { flex-wrap: wrap; }
.sf-sort-bar .sf-sort-label:first-child { display: none; }
.sf-filter-bar { grid-template-columns: 1fr 1fr; padding-left: 16px; padding-right: 16px; }
.sf-filter-bar .sf-sort-label { display: none; }
.sf-item { padding: 12px 16px; }
.sf-footer { padding: 10px 16px; flex-wrap: wrap; justify-content: center; }
}
`;
// ── Main Controller ─────────────────────────────────────────────────
class ScriptFinder {
constructor() {
this.settings = new SettingsService();
this.services = {
greasyfork: new ScriptService("https://greasyfork.org", "greasyfork"),
sleazyfork: new ScriptService("https://sleazyfork.org", "sleazyfork"),
openuserjs: new OpenUserJSScriptService(),
chromewebstore: new ChromeWebStoreService(),
mozillaaddons: new MozillaAddonsService(),
catalogs: new CatalogScriptService(),
githubgist: new GitHubGistService(),
github: new GitHubScriptService()
};
this.currentService = this._firstEnabledSource(this.settings.get("lastService") || "greasyfork");
this.currentSort = this.settings.get("defaultSort");
this.filters = { updatedMonths: "any", minRating: "any", languageFilter: "any" };
this.queryMode = this.settings.get("queryMode") || "auto";
this.currentDomain = HostService.getCurrentHost();
this.isOpen = false;
this.isLoading = false;
this.allScripts = [];
this.sourceStatus = null;
this.sourceHealth = {};
this.searchQuery = "";
this.settingsOpen = false;
this.previousFocus = null;
this.uiBuilt = false;
this._allLoadGen = 0;
this.compatibility = ManagerCompatibility.report();
}
init() {
this.compatibility = ManagerCompatibility.report();
if (!this.compatibility.canMenu) {
this._ensureUI();
this._openCompatibilityReport();
return;
}
this._setupMenuCommands();
this.settings.watchForChanges(() => this._onSettingsChanged());
}
_onSettingsChanged() {
this._ensureCurrentSource();
this._setupMenuCommands();
if (this.uiBuilt) {
this._syncSettingsUi();
this._renderTabs();
this._updateTabs();
}
}
_syncSettingsUi() {
this.modal.querySelectorAll(".sf-source-toggle").forEach(btn => {
const source = btn.dataset.source;
const enabled = this._isSourceEnabled(source);
btn.classList.toggle("on", enabled);
btn.setAttribute("aria-pressed", String(enabled));
});
this.modal.querySelectorAll(".sf-toggle[data-key]").forEach(btn => {
const key = btn.dataset.key;
const val = !!this.settings.get(key);
btn.classList.toggle("on", val);
btn.setAttribute("aria-pressed", String(val));
});
this.modal.querySelectorAll(".sf-setting-select").forEach(sel => {
const key = sel.dataset.key;
const val = this.settings.get(key);
if (val != null) sel.value = String(val);
});
const denseMode = this.settings.get("denseMode");
this.host.classList.toggle("dense", !!denseMode);
}
_ensureUI() {
if (this.uiBuilt) return;
this._buildUI();
this._setupEvents();
this.uiBuilt = true;
}
_serviceClass(serviceName) {
return serviceName === "greasyfork" ? "" : serviceName;
}
_isSourceEnabled(serviceName) {
return this.settings.get("sources")?.[serviceName] !== false;
}
_enabledSourceNames() {
return SOURCE_ORDER.filter(source => this.services[source] && this._isSourceEnabled(source));
}
_currentHostBlock() {
return HostService.sensitiveHostMatch(HostService.getCurrentHost(), this.settings);
}
_currentHostOverridden() {
return HostService.isHostOverridden(HostService.getCurrentHost(), this.settings);
}
_firstEnabledSource(preferred = null) {
if (preferred && this.services[preferred] && this._isSourceEnabled(preferred)) return preferred;
return this._enabledSourceNames()[0] || "greasyfork";
}
_ensureCurrentSource() {
const next = this._firstEnabledSource(this.currentService);
if (next !== this.currentService) {
this.currentService = next;
this.settings.set("lastService", next);
}
return next;
}
_tabsHtml() {
if (this._currentHostBlock()) return "";
this._ensureCurrentSource();
const allActive = this.currentService === "_all";
const allTab = `All `;
const sourceTabs = this._enabledSourceNames().map(source => {
const active = source === this.currentService;
const cls = ["sf-tab", this._serviceClass(source), active ? "active" : ""].filter(Boolean).join(" ");
return `${escapeHtml(SOURCE_META[source].tab)} `;
}).join("");
return allTab + sourceTabs;
}
_sourceSettingsHtml() {
return SOURCE_ORDER.map(source => {
const enabled = this._isSourceEnabled(source);
return `
${escapeHtml(SOURCE_META[source].label)}
`;
}).join("");
}
_privacySettingsHtml() {
const protectedHost = this._currentHostBlock();
const overridden = this._currentHostOverridden();
const host = HostService.getCurrentHost() || "this host";
const overrideLabel = overridden ? "Block this host again" : "Allow this host";
const overrideHelp = protectedHost
? `Blocked by ${protectedHost.pattern}. Allowing this host enables source menus and searches here only.`
: (overridden ? "This host is currently allowed even if it matches a sensitive-host rule." : "Current host does not match the sensitive-host rules.");
return `
Host privacy
Sensitive host protection
Extra blocked hosts
One host or wildcard pattern per line. Built-in rules already cover banks, government, identity, admin, and local network hosts.
${escapeHtml(host)}
${escapeHtml(overrideLabel)}
${escapeHtml(overrideHelp)}
`;
}
_renderTabs() {
if (!this.modal) return;
const tabs = this.modal.querySelector(".sf-tabs");
if (tabs) _safeHTML(tabs, this._tabsHtml());
}
_syncSourceControls() {
if (!this.modal) return;
this.modal.querySelectorAll(".sf-source-toggle").forEach(btn => {
const enabled = this._isSourceEnabled(btn.dataset.source);
btn.classList.toggle("on", enabled);
btn.setAttribute("aria-pressed", String(enabled));
});
}
_setSourceEnabled(source, enabled) {
if (!SOURCE_META[source]) return;
const sources = { ...this.settings.get("sources"), [source]: enabled };
if (!SOURCE_ORDER.some(name => sources[name])) {
this.toast?.show("At least one source must stay enabled");
this._syncSourceControls();
return;
}
this.settings.set("sources", sources);
let shouldLoad = false;
if (!enabled && source === this.currentService) {
this.currentService = this._firstEnabledSource();
this.settings.set("lastService", this.currentService);
shouldLoad = this.isOpen;
}
this._renderTabs();
this._syncSourceControls();
this._setupMenuCommands();
this._updateTabs();
if (shouldLoad) this._loadScripts();
}
_setCurrentHostOverride(allowed) {
const host = HostService.normalizeHost(HostService.getCurrentHost());
if (!host) return;
const overrides = new Set(this.settings.get("sensitiveHostOverrides") || []);
if (allowed) overrides.add(host);
else overrides.delete(host);
this.settings.set("sensitiveHostOverrides", [...overrides]);
this._syncHostPrivacyControls();
this._refreshHostProtection();
}
_syncHostPrivacyControls() {
if (!this.modal) return;
const protection = this.settings.get("sensitiveHostProtection") !== false;
const protectionBtn = this.modal.querySelector('.sf-toggle[data-key="sensitiveHostProtection"]');
if (protectionBtn) {
protectionBtn.classList.toggle("on", protection);
protectionBtn.setAttribute("aria-pressed", String(protection));
}
const block = this._currentHostBlock();
const overridden = this._currentHostOverridden();
const btn = this.modal.querySelector(".sf-host-override-btn");
if (btn) btn.textContent = overridden ? "Block this host again" : "Allow this host";
const help = this.modal.querySelector(".sf-host-override-help");
if (help) {
help.textContent = block
? `Blocked by ${block.pattern}. Allowing this host enables source menus and searches here only.`
: (overridden ? "This host is currently allowed even if it matches a sensitive-host rule." : "Current host does not match the sensitive-host rules.");
}
}
_refreshHostProtection() {
this._setupMenuCommands();
this._renderTabs();
this._updateTabs();
if (this.isOpen) this._loadScripts();
}
// ── UI Build ────────────────────────────────────────────────────
_buildUI() {
this.host = document.createElement("div");
this.shadow = this.host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = CSS;
this.shadow.appendChild(style);
this.toast = new ToastService(this.shadow);
// Modal
this.modal = document.createElement("div");
this.modal.className = "sf-modal";
this.modal.setAttribute("role", "dialog");
this.modal.setAttribute("aria-modal", "true");
this.modal.setAttribute("aria-labelledby", "sf-modal-title");
this.modal.setAttribute("aria-describedby", "sf-modal-subtitle");
this.modal.setAttribute("tabindex", "-1");
_safeHTML(this.modal, `
${this._tabsHtml()}
Mode
Auto (domain)
Exact host
Root domain
Keyword
Sort by
Daily installs
Total installs
Ratings
Fan score
Author reputation
Last update
Created
Filters
Any update
3 months
6 months
12 months
24 months
Any rating
3+ rating
4+ rating
4.5+ rating
Any language
Browser language
English
Settings
Dense mode
Default sort
Daily installs
Total installs
Ratings
Fan score
Author reputation
Last update
Created
Cache (minutes)
1
5
10
30
${this._privacySettingsHtml()}
Sources
${this._sourceSettingsHtml()}
Data
Export settings
Import settings
`);
this.shadow.appendChild(this.modal);
// Refs
this.content = this.modal.querySelector(".sf-content");
this.searchInput = this.modal.querySelector(".sf-search-input");
this.searchCount = this.modal.querySelector(".sf-search-count");
this.searchBox = this.modal.querySelector(".sf-search-box");
this.sortSelect = this.modal.querySelector(".sf-sort-select");
this.filterBar = this.modal.querySelector(".sf-filter-bar");
if (this.settings.get("denseMode")) this.host.classList.add("dense");
document.body.appendChild(this.host);
}
// ── Events ──────────────────────────────────────────────────────
_setupEvents() {
// Modal buttons
this.modal.querySelector(".sf-btn-close").addEventListener("click", () => this._close());
this.modal.querySelector(".sf-btn-settings").addEventListener("click", () => this._toggleSettings());
this.modal.querySelector(".sf-diagnostics-btn").addEventListener("click", () => this._copyDiagnostics());
// Tabs
this.modal.querySelector(".sf-tabs").addEventListener("click", e => {
const tab = e.target.closest(".sf-tab");
if (!tab) return;
const svc = tab.dataset.service;
if (svc !== this.currentService && (svc === "_all" || this._isSourceEnabled(svc))) {
this.currentService = svc;
if (svc !== "_all") this.settings.set("lastService", svc);
this._updateTabs();
this._loadScripts();
}
});
// Query mode
this.queryModeSelect = this.modal.querySelector(".sf-query-mode-select");
if (this.queryModeSelect) {
this.queryModeSelect.value = this.queryMode;
this.queryModeSelect.addEventListener("change", e => {
this.queryMode = e.target.value;
this.settings.set("queryMode", this.queryMode);
this._loadScripts();
});
}
// Sort
this.sortSelect.addEventListener("change", e => {
this.currentSort = e.target.value;
this._displayScripts();
});
this.modal.querySelectorAll(".sf-filter-select").forEach(sel => {
sel.addEventListener("change", () => {
this.filters[sel.dataset.filter] = sel.value;
this._displayScripts();
});
});
// Search filter
this.searchInput.addEventListener("input", e => {
this.searchQuery = e.target.value.toLowerCase().trim();
this._displayScripts();
});
// Settings toggles
this.modal.querySelectorAll(".sf-toggle[data-key]").forEach(btn => {
btn.addEventListener("click", () => {
const key = btn.dataset.key;
const val = !this.settings.get(key);
this.settings.set(key, val);
btn.classList.toggle("on", val);
btn.setAttribute("aria-pressed", String(val));
if (key === "denseMode") this.host.classList.toggle("dense", val);
if (key === "sensitiveHostProtection") this._refreshHostProtection();
});
});
this.modal.querySelectorAll(".sf-source-toggle").forEach(btn => {
btn.addEventListener("click", () => {
this._setSourceEnabled(btn.dataset.source, !this._isSourceEnabled(btn.dataset.source));
});
});
this.modal.querySelector(".sf-setting-textarea[data-key='sensitiveHostPatterns']")?.addEventListener("change", e => {
this.settings.set("sensitiveHostPatterns", e.target.value);
this._syncHostPrivacyControls();
this._refreshHostProtection();
});
this.modal.querySelector(".sf-host-override-btn")?.addEventListener("click", () => {
this._setCurrentHostOverride(!this._currentHostOverridden());
});
// Settings selects
this.modal.querySelectorAll(".sf-setting-select").forEach(sel => {
sel.addEventListener("change", () => {
const key = sel.dataset.key;
let val = sel.value;
if (key === "cacheDuration") val = parseInt(val);
this.settings.set(key, val);
if (key === "defaultSort") { this.currentSort = val; this.sortSelect.value = val; this._displayScripts(); }
});
});
// Export/Import settings
this.modal.querySelector(".sf-export-btn")?.addEventListener("click", () => {
const data = JSON.stringify(this.settings.settings, null, 2);
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "userscript-finder-settings.json";
a.click();
URL.revokeObjectURL(url);
this.toast.show(STRINGS.toastSettingsExported);
});
const importFile = this.modal.querySelector(".sf-import-file");
this.modal.querySelector(".sf-import-btn")?.addEventListener("click", () => importFile?.click());
importFile?.addEventListener("change", e => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const imported = JSON.parse(reader.result);
if (typeof imported !== "object" || imported === null) throw new Error("Invalid settings");
const allowedKeys = new Set(Object.keys(DEFAULT_SETTINGS));
Object.entries(imported).forEach(([key, value]) => {
if (allowedKeys.has(key)) this.settings.set(key, value);
});
this._ensureCurrentSource();
this._setupMenuCommands();
this._syncSettingsUi();
this._renderTabs();
this._updateTabs();
this.toast.show(STRINGS.toastSettingsImported);
} catch { this.toast.show(STRINGS.toastImportFailed); }
};
reader.readAsText(file);
e.target.value = "";
});
// Outside click / Escape
document.addEventListener("click", e => { if (this.isOpen && !this.host.contains(e.target)) this._close(); });
document.addEventListener("keydown", e => {
if (!this.isOpen) return;
if (e.key === "Escape") this._close();
else if (e.key === "Tab") this._trapFocus(e);
});
}
// ── Menu commands ───────────────────────────────────────────────
_setupMenuCommands() {
const registerMenuCommand = gmFunction("GM_registerMenuCommand");
const unregisterMenuCommand = gmFunction("GM_unregisterMenuCommand");
if (!registerMenuCommand) return;
if (window._sfMenuIds) window._sfMenuIds.forEach(id => { try { unregisterMenuCommand?.(id); } catch {} });
window._sfMenuIds = [];
this.compatibility = ManagerCompatibility.report();
this._ensureCurrentSource();
const domain = HostService.extractRootDomain(this.currentDomain);
const hostBlock = this._currentHostBlock();
if (!this.compatibility.ok) {
window._sfMenuIds.push(registerMenuCommand("Script Finder compatibility report", () => {
this._ensureUI();
this._openCompatibilityReport();
}));
window._sfMenuIds.push(registerMenuCommand("Reset Script Finder Settings", () => this._resetSettings()));
return;
}
if (hostBlock) {
window._sfMenuIds.push(registerMenuCommand(`Script Finder blocked on ${hostBlock.host} (Settings)`, () => {
this._ensureUI();
this._open();
}));
window._sfMenuIds.push(registerMenuCommand("Reset Script Finder Settings", () => this._resetSettings()));
return;
}
this._enabledSourceNames().forEach(source => {
const meta = SOURCE_META[source];
window._sfMenuIds.push(registerMenuCommand(`Find ${meta.menuKind} for ${domain} (${meta.menuName})`, () => {
this._ensureUI();
if (this._isSourceEnabled(source)) this.currentService = source;
this._open();
}));
});
window._sfMenuIds.push(registerMenuCommand("Reset Script Finder Settings", () => this._resetSettings()));
}
_resetSettings() {
gmDeleteValue("sf_settings_v4");
location.reload();
}
_focusableElements() {
return Array.from(this.modal.querySelectorAll('a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'))
.filter(el => !el.disabled && el.getAttribute("aria-hidden") !== "true" && el.offsetParent !== null);
}
_trapFocus(event) {
const focusable = this._focusableElements();
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = this.shadow.activeElement || document.activeElement;
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
} else if (!focusable.includes(active)) {
event.preventDefault();
first.focus();
}
}
// ── Modal control ───────────────────────────────────────────────
_open() {
this.isOpen = true;
this.previousFocus = document.activeElement;
this.compatibility = ManagerCompatibility.report();
this._ensureCurrentSource();
this._renderTabs();
this._updateTabs();
this._updateServiceColors();
this.sortSelect.value = this.currentSort;
this.modal.classList.add("visible");
this.searchInput.value = "";
this.searchQuery = "";
requestAnimationFrame(() => this.searchInput.focus({ preventScroll: true }));
if (!this.compatibility.ok) {
this._showCompatibilityReport();
return;
}
const hostBlock = this._currentHostBlock();
if (hostBlock) {
this._showHostBlocked(hostBlock);
return;
}
if (this._needsDisclosure()) {
this._showDisclosure();
return;
}
this._loadScripts();
}
_close() {
this.isOpen = false;
this.settingsOpen = false;
this.modal.querySelector(".sf-settings").classList.remove("visible");
this.modal.classList.remove("visible");
if (this.previousFocus && typeof this.previousFocus.focus === "function") {
this.previousFocus.focus({ preventScroll: true });
}
}
_toggleSettings() {
this.settingsOpen = !this.settingsOpen;
this.modal.querySelector(".sf-settings").classList.toggle("visible", this.settingsOpen);
}
// ── Tab/color updates ───────────────────────────────────────────
_updateTabs() {
this.modal.querySelectorAll(".sf-tab").forEach(t => {
const active = t.dataset.service === this.currentService;
t.classList.toggle("active", active);
t.setAttribute("aria-selected", String(active));
});
this._updateServiceColors();
}
_updateServiceColors() {
const svc = this.currentService;
const svcNames = SOURCE_ORDER;
const header = this.modal.querySelector(".sf-modal-header");
svcNames.forEach(s => header.classList.toggle(s, s === svc));
svcNames.forEach(s => this.sortSelect.classList.toggle(s, s === svc));
svcNames.forEach(s => this.searchBox.classList.toggle(s, s === svc));
const footerLink = this.modal.querySelector(".sf-footer a");
if (footerLink) {
svcNames.forEach(s => footerLink.classList.toggle(s, s === svc));
if (svc === "_all") {
footerLink.textContent = "All sources";
footerLink.removeAttribute("href");
} else {
footerLink.textContent = SOURCE_META[svc]?.label || svc;
footerLink.href = SOURCE_META[svc]?.footerUrl || "#";
}
}
this._updateHealthUi();
}
_setResultCount(count) {
const countEl = this.modal.querySelector(".sf-subtitle-count");
const textEl = this.modal.querySelector(".sf-subtitle-text");
const unit = this.currentService === "_all" ? "result" : (SOURCE_META[this.currentService]?.unit || "script");
if (countEl) countEl.textContent = count || 0;
if (textEl) textEl.textContent = count === 1 ? `${unit} found` : `${unit}s found`;
}
_sourceNoticeHtml() {
const notices = [];
const compat = this.compatibility || ManagerCompatibility.report();
if (compat.warnings.length) {
notices.push(`
Manager degraded mode
${escapeHtml(compat.warnings[0])}
`);
}
if (this.sourceStatus) {
const status = this.sourceStatus;
const cls = status.type === "stale" || status.type === "partial" ? "warn" : "bad";
const svc = this.services[this.currentService];
const directUrl = svc ? svc.getDirectSearchUrl(this.currentDomain) : "#";
notices.push(`
${escapeHtml(status.title || "Source status")}
${escapeHtml(status.detail || "The source returned a degraded result.")}
Search manually
`);
}
return notices.join("");
}
_sourceErrorTitle(err, label) {
if (err?.kind === "compat") return `${label} compatibility issue`;
if (err?.kind === "rate-limit") return `${label} rate limit`;
if (err?.kind === "timeout") return `${label} timed out`;
if (err?.kind === "backoff") return `${label} is backing off`;
if (err?.kind === "parse") return `${label} changed its response`;
return `${label} unavailable`;
}
_showHostBlocked(hostBlock) {
const host = hostBlock?.host || HostService.getCurrentHost() || "this host";
this.allScripts = [];
this.sourceStatus = null;
this._setResultCount(0);
this.content.setAttribute("aria-busy", "false");
_safeHTML(this.content, `
Search disabled on sensitive host
No source requests will run on ${escapeHtml(host)} . Matched rule: ${escapeHtml(hostBlock?.pattern || "sensitive host")}.
Allow this host
Settings
`);
this.content.querySelector(".sf-host-allow-btn")?.addEventListener("click", () => this._setCurrentHostOverride(true));
this.content.querySelector(".sf-host-settings-btn")?.addEventListener("click", () => {
this.settingsOpen = true;
this.modal.querySelector(".sf-settings").classList.add("visible");
});
this._syncHostPrivacyControls();
}
_openCompatibilityReport() {
this.isOpen = true;
this.previousFocus = document.activeElement;
this.modal.classList.add("visible");
requestAnimationFrame(() => this.modal.focus({ preventScroll: true }));
this._showCompatibilityReport();
}
_showCompatibilityReport() {
const report = this.compatibility || ManagerCompatibility.report();
const issues = [
...report.missingRequired.map(name => `${name} is required for source menus or network requests.`),
...report.warnings
];
this.allScripts = [];
this.sourceStatus = null;
this._setResultCount(0);
this.content.setAttribute("aria-busy", "false");
this.modal.querySelector(".sf-modal-title").textContent = STRINGS.modalTitleCompat;
_safeHTML(this.content, `
Userscript manager degraded mode
Detected ${escapeHtml(report.manager.handler)} ${escapeHtml(report.manager.version)}. Script Finder needs menu commands and GM_xmlhttpRequest for full discovery.
${issues.map(issue => `${escapeHtml(issue)} `).join("")}
Settings
`);
this.content.querySelector(".sf-compat-settings-btn")?.addEventListener("click", () => {
this.settingsOpen = true;
this.modal.querySelector(".sf-settings").classList.add("visible");
});
this._updateHealthUi();
}
_recordSourceHealth(serviceName, health) {
const normalized = {
type: health?.type || "ok",
title: health?.title || "Source checked",
detail: health?.detail || "",
checkedAt: health?.checkedAt || Date.now(),
cachedAt: health?.cachedAt || null
};
if (normalized.type === "rate-limit") normalized.type = "rate-limited";
this.sourceHealth[serviceName] = normalized;
this._updateHealthUi();
}
_healthLabel(health) {
const labels = {
ok: "OK",
cached: "CACHE",
stale: "STALE",
partial: "PARTIAL",
"rate-limited": "RATE",
failed: "FAIL"
};
return labels[health?.type] || "CHECK";
}
_healthClass(health) {
return ["ok", "cached", "stale", "partial", "rate-limited", "failed"].includes(health?.type) ? health.type : "failed";
}
_healthTitle(serviceName, health) {
if (!health) return "Source has not been checked yet.";
const checked = new Date(health.checkedAt).toISOString();
const cacheAge = health.cachedAt ? SourceRuntime.ageLabel(health.cachedAt) : "none";
return `${SourceRuntime.sourceLabels[serviceName] || serviceName}: ${this._healthLabel(health)}. Last checked ${checked}. Cache age ${cacheAge}. ${health.detail || ""}`.trim();
}
_updateHealthUi() {
if (!this.modal) return;
this.modal.querySelectorAll(".sf-tab").forEach(tab => {
const health = this.sourceHealth[tab.dataset.service];
["ok", "cached", "stale", "partial", "rate-limited", "failed"].forEach(cls => tab.classList.remove(`health-${cls}`));
tab.dataset.healthLabel = health ? this._healthLabel(health) : "";
if (health) tab.classList.add(`health-${this._healthClass(health)}`);
tab.title = this._healthTitle(tab.dataset.service, health);
});
const health = this.sourceHealth[this.currentService];
const pill = this.modal.querySelector(".sf-health-pill");
if (pill) {
const cls = health ? this._healthClass(health) : "";
pill.className = `sf-health-pill ${cls}`;
pill.textContent = health ? `${this._healthLabel(health)} ${SourceRuntime.ageLabel(health.checkedAt)}` : "Not checked";
pill.title = this._healthTitle(this.currentService, health);
}
}
_diagnosticString() {
const health = this.sourceHealth[this.currentService] || { type: "unchecked", checkedAt: Date.now() };
const rootHost = HostService.extractRootDomain(this.currentDomain || HostService.getCurrentHost());
const cacheAge = health.cachedAt ? SourceRuntime.ageLabel(health.cachedAt) : "none";
return [
"UserScript Finder source diagnostic",
...ManagerCompatibility.diagnosticLines(this.compatibility || ManagerCompatibility.report()),
`source=${this.currentService}`,
`host=${rootHost}`,
`status=${health.type}`,
`checkedAt=${new Date(health.checkedAt).toISOString()}`,
`cacheAge=${cacheAge}`,
`resultCount=${Array.isArray(this.allScripts) ? this.allScripts.length : 0}`
].join("\n");
}
async _copyDiagnostics() {
const diagnostic = this._diagnosticString();
try {
if (!navigator.clipboard?.writeText) throw new Error("Clipboard unavailable");
await navigator.clipboard.writeText(diagnostic);
this.toast.show(STRINGS.toastDiagCopied);
} catch(err) {
console.info("[Script Finder diagnostics]", diagnostic);
this._showDiagnosticsFallback(diagnostic);
}
}
_showDiagnosticsFallback(text) {
const existing = this.modal.querySelector(".sf-diagnostics-fallback");
if (existing) { existing.remove(); return; }
const panel = document.createElement("div");
panel.className = "sf-diagnostics-fallback";
_safeHTML(panel, `
Retry copy
`);
const footer = this.modal.querySelector(".sf-footer");
footer.parentNode.insertBefore(panel, footer);
const textarea = panel.querySelector(".sf-diagnostics-textarea");
textarea.focus();
textarea.select();
panel.querySelector(".sf-diagnostics-fallback-close").addEventListener("click", () => panel.remove());
panel.querySelector(".sf-diagnostics-retry-btn").addEventListener("click", async () => {
try {
if (!navigator.clipboard?.writeText) throw new Error("Clipboard unavailable");
await navigator.clipboard.writeText(text);
this.toast.show(STRINGS.toastDiagCopied);
panel.remove();
} catch { this.toast.show(STRINGS.toastDiagConsole); }
});
}
// ── Disclosure ──────────────────────────────────────────────────
_needsDisclosure() {
const acked = this.settings.get("disclosureAckedSources") || [];
return this._enabledSourceNames().some(s => !acked.includes(s));
}
_showDisclosure() {
this.allScripts = [];
this.sourceStatus = null;
this._setResultCount(0);
this.content.setAttribute("aria-busy", "false");
this.modal.querySelector(".sf-modal-title").textContent = STRINGS.modalTitleDisclosure;
const enabled = this._enabledSourceNames();
const rows = enabled.map(source => {
const meta = SOURCE_META[source];
const hosts = (SOURCE_CONNECT[source] || []).map(h => escapeHtml(h)).join(", ");
return ` ${escapeHtml(meta.label)}${hosts} `;
}).join("");
_safeHTML(this.content, `
${STRINGS.disclosureTitle}
${STRINGS.disclosureText}
Source Hosts contacted
${rows}
Continue
`);
this.content.querySelector(".sf-disclosure-continue").addEventListener("click", () => {
const checked = [...this.content.querySelectorAll(".sf-disclosure-source input")];
const enabledSources = [];
checked.forEach(cb => {
const source = cb.dataset.source;
if (cb.checked) {
enabledSources.push(source);
} else {
const sources = this.settings.get("sources");
sources[source] = false;
this.settings.set("sources", sources);
}
});
const acked = [...new Set([...(this.settings.get("disclosureAckedSources") || []), ...enabledSources])];
this.settings.set("disclosureAckedSources", acked);
this._ensureCurrentSource();
this._renderTabs();
this._updateTabs();
this._loadScripts();
});
}
async _loadAllSources() {
const loadId = ++this._allLoadGen;
this.content.setAttribute("aria-busy", "true");
_safeHTML(this.content, `
${STRINGS.loadingAllSources}
`);
const host = HostService.getCurrentHost();
this.currentDomain = host;
const queryHost = this._resolveQueryHost(host);
const enabled = this._enabledSourceNames();
const results = [];
const seen = new Set();
const promises = enabled.map(async source => {
try {
const scripts = await this.services[source].searchScriptsByHost(queryHost, this.settings);
const health = scripts?._sfHealth || { type: "ok", title: `${SOURCE_META[source].label} loaded`, checkedAt: Date.now() };
this._recordSourceHealth(source, health);
return Array.isArray(scripts) ? scripts : [];
} catch (err) {
this._recordSourceHealth(source, {
type: err?.kind === "rate-limit" ? "rate-limited" : "failed",
title: `${SOURCE_META[source].label} failed`,
detail: err?.message || "Unknown error",
checkedAt: Date.now()
});
return [];
}
});
const allResults = await Promise.all(promises);
if (this.currentService !== "_all" || loadId !== this._allLoadGen) {
this.content.setAttribute("aria-busy", "false");
return;
}
for (const batch of allResults) {
for (const script of batch) {
const key = script.url || script.code_url || script._full_name || script.name;
if (key && !seen.has(key)) {
seen.add(key);
results.push(script);
}
}
}
this.allScripts = results;
this.sourceStatus = null;
this._setResultCount(results.length);
this._displayScripts();
this.content.setAttribute("aria-busy", "false");
}
_resolveQueryHost(host) {
switch (this.queryMode) {
case "exact": return HostService.normalizeHost(host);
case "root": return HostService.extractRootDomain(host);
case "keyword": return HostService.extractRootDomain(host).split(".")[0];
default: return host;
}
}
// ── Data ────────────────────────────────────────────────────────
async _loadScripts() {
this.compatibility = ManagerCompatibility.report();
if (!this.compatibility.ok) {
this._showCompatibilityReport();
return;
}
const hostBlock = this._currentHostBlock();
if (hostBlock) {
this._showHostBlocked(hostBlock);
return;
}
if (this.currentService === "_all") return this._loadAllSources();
if (this.isLoading) return;
this._ensureCurrentSource();
if (!this._isSourceEnabled(this.currentService)) {
this.allScripts = [];
this.sourceStatus = null;
this._setResultCount(0);
_safeHTML(this.content, `${STRINGS.emptySourceDisabled}
${STRINGS.emptySourceDisabledText}
`);
return;
}
this.isLoading = true;
const activeService = this.currentService;
const svc = this.services[activeService];
const svcClass = this._serviceClass(activeService);
const svcLabel = SOURCE_META[activeService]?.label || "Source";
this.content.setAttribute("aria-busy", "true");
_safeHTML(this.content, ``);
try {
const host = HostService.getCurrentHost();
this.currentDomain = host;
const queryHost = this._resolveQueryHost(host);
const scripts = await svc.searchScriptsByHost(queryHost, this.settings);
const activeHostBlock = this._currentHostBlock();
if (activeHostBlock) {
this._showHostBlocked(activeHostBlock);
return;
}
if (activeService !== this.currentService || !this._isSourceEnabled(activeService)) return;
this.allScripts = scripts;
this.sourceStatus = this.allScripts?._sfStatus || null;
this._recordSourceHealth(activeService, this.allScripts?._sfHealth || {
type: "ok",
title: `${svcLabel} loaded`,
detail: "Fresh source results loaded.",
checkedAt: Date.now()
});
this._setResultCount(this.allScripts.length);
this._displayScripts();
} catch(err) {
if (activeService !== this.currentService || !this._isSourceEnabled(activeService)) return;
this.sourceStatus = null;
this.allScripts = [];
this._recordSourceHealth(activeService, {
type: err?.kind === "rate-limit" ? "rate-limited" : "failed",
title: this._sourceErrorTitle(err, svcLabel),
detail: err?.message || "Unknown error",
checkedAt: Date.now()
});
this._setResultCount(0);
const directUrl = svc.getDirectSearchUrl(this.currentDomain);
_safeHTML(this.content, `
${escapeHtml(this._sourceErrorTitle(err, svcLabel))}
${escapeHtml(err?.message || 'Unknown error')}
`);
this.content.querySelector(".sf-action-btn")?.addEventListener("click", () => this._loadScripts());
} finally {
this.isLoading = false;
this.content.setAttribute("aria-busy", "false");
if (this.isOpen && activeService !== this.currentService && this._isSourceEnabled(this.currentService)) this._loadScripts();
}
}
_sortScripts(scripts) {
const copy = [...scripts];
switch (this.currentSort) {
case "daily": return copy.sort((a,b) => (b.daily_installs || b.total_installs || b._stars || 0) - (a.daily_installs || a.total_installs || a._stars || 0));
case "total": return copy.sort((a,b) => (b.total_installs || b._stars || 0) - (a.total_installs || a._stars || 0));
case "good": return copy.sort((a,b) => (b.good_ratings || b._rating || 0) - (a.good_ratings || a._rating || 0));
case "fanscore": return copy.sort((a,b) => (b.fan_score || b._forks || 0) - (a.fan_score || a._forks || 0));
case "authorrep": return copy.sort((a,b) => reputationScore(b) - reputationScore(a));
case "updatedate": return copy.sort((a,b) => new Date(b.code_updated_at||0) - new Date(a.code_updated_at||0));
case "createdate": return copy.sort((a,b) => new Date(b.created_at||0) - new Date(a.created_at||0));
default: return copy.sort((a,b) => (b.daily_installs || b.total_installs || b._stars || 0) - (a.daily_installs || a.total_installs || a._stars || 0));
}
}
_displayScripts() {
let scripts = this.allScripts || [];
const svcClass = this._serviceClass(this.currentService);
const svcLabel = this.currentService === "_all" ? "all sources" : (SOURCE_META[this.currentService]?.label || "Source");
const displayHost = HostService.extractRootDomain(this.currentDomain);
const noticeHtml = this._sourceNoticeHtml();
// Update title
const titleFn = this.currentService === "_all" ? STRINGS.modalTitleResults : this.currentService === "catalogs" ? STRINGS.modalTitleCatalogs : this.currentService === "githubgist" ? STRINGS.modalTitleGists : ["chromewebstore", "mozillaaddons"].includes(this.currentService) ? STRINGS.modalTitleExtensions : STRINGS.modalTitleScripts;
this.modal.querySelector(".sf-modal-title").textContent = titleFn(displayHost);
if (this.searchQuery) {
const { fields, text } = this._parseFieldedQuery(this.searchQuery);
scripts = scripts.filter(s => {
for (const { key, value } of fields) {
const target = this._fieldValue(s, key);
if (!target.includes(value)) return false;
}
if (text) {
return (s.name || "").toLowerCase().includes(text) ||
(s.description || "").toLowerCase().includes(text) ||
(s.users?.[0]?.name || "").toLowerCase().includes(text) ||
(s._full_name || "").toLowerCase().includes(text) ||
(s._catalog_source || "").toLowerCase().includes(text) ||
(s._category || "").toLowerCase().includes(text) ||
(s._topics || []).some(t => t.toLowerCase().includes(text));
}
return true;
});
}
scripts = this._applyFilters(scripts);
const dismissed = this._getDismissed();
if (dismissed.length) {
scripts = scripts.filter(s => !dismissed.includes(this._scriptKey(s)));
}
const constrained = this.searchQuery || this._hasActiveFilters();
this.searchCount.textContent = constrained ? `${scripts.length}/${this.allScripts.length}` : "";
if (!scripts.length) {
const svc = this.services[this.currentService];
const directUrl = svc ? svc.getDirectSearchUrl(this.currentDomain) : "#";
if (this.searchQuery) {
_safeHTML(this.content, `${noticeHtml}No matches
No scripts match "${escapeHtml(this.searchQuery)}"
`);
} else if (this._hasActiveFilters()) {
_safeHTML(this.content, `${noticeHtml}No matches
No scripts match the active filters.
`);
} else {
_safeHTML(this.content, `
${noticeHtml}
No scripts found
Nothing matched ${escapeHtml(displayHost)} on ${svcLabel}.
${svc ? `
Search manually ` : ""}
`);
}
this._setResultCount(this.allScripts.length);
return;
}
const sorted = this._sortScripts(scripts);
this._setResultCount(this.allScripts.length);
if (this._chunkTimer) { cancelAnimationFrame(this._chunkTimer); this._chunkTimer = null; }
_safeHTML(this.content, noticeHtml);
this._renderChunked(sorted, svcClass);
}
_renderChunked(items, svcClass) {
if (this._chunkTimer) { cancelAnimationFrame(this._chunkTimer); this._chunkTimer = null; }
const isAll = this.currentService === "_all";
const CHUNK = 30;
let offset = 0;
const renderBatch = () => {
const end = Math.min(offset + CHUNK, items.length);
for (let i = offset; i < end; i++) {
const itemClass = isAll ? this._serviceClass(items[i]._source || "greasyfork") : svcClass;
this.content.appendChild(this._createScriptItem(items[i], itemClass, i));
}
offset = end;
if (offset < items.length) {
this._chunkTimer = requestAnimationFrame(renderBatch);
} else {
this._chunkTimer = null;
this._updateDismissedCount();
}
};
renderBatch();
}
_scriptKey(script) {
return `${script._source || ""}:${script._full_name || script.url || script.name || ""}`;
}
_getDismissed() {
const domain = HostService.extractRootDomain(this.currentDomain || HostService.getCurrentHost());
const all = gmGetValue("sf_dismissed", {}) || {};
return all[domain] || [];
}
_dismissScript(script) {
const domain = HostService.extractRootDomain(this.currentDomain || HostService.getCurrentHost());
const all = gmGetValue("sf_dismissed", {}) || {};
const list = all[domain] || [];
const key = this._scriptKey(script);
if (!list.includes(key)) list.push(key);
all[domain] = list;
gmSetValue("sf_dismissed", all);
}
_updateDismissedCount() {
const dismissed = this._getDismissed();
if (dismissed.length) {
const existing = this.content.querySelector(".sf-dismissed-notice");
if (existing) {
existing.querySelector(".sf-dismissed-count").textContent = dismissed.length;
} else {
const notice = document.createElement("div");
notice.className = "sf-dismissed-notice";
_safeHTML(notice, `${dismissed.length} hidden Show all `);
notice.querySelector(".sf-restore-dismissed").addEventListener("click", () => {
const domain = HostService.extractRootDomain(this.currentDomain || HostService.getCurrentHost());
const all = gmGetValue("sf_dismissed", {}) || {};
delete all[domain];
gmSetValue("sf_dismissed", all);
this._displayScripts();
});
this.content.appendChild(notice);
}
}
}
_getQueue() {
return gmGetValue("sf_queue", []) || [];
}
_isQueued(script) {
const key = this._scriptKey(script);
return this._getQueue().some(q => q.key === key);
}
_toggleQueued(script) {
const key = this._scriptKey(script);
const queue = this._getQueue();
const idx = queue.findIndex(q => q.key === key);
if (idx >= 0) {
queue.splice(idx, 1);
gmSetValue("sf_queue", queue);
return false;
}
queue.push({
key,
name: script.name || "Untitled",
url: script.url || script.code_url || "",
source: script._source || "",
addedAt: new Date().toISOString()
});
gmSetValue("sf_queue", queue);
return true;
}
_parseFieldedQuery(raw) {
const fields = [];
const textParts = [];
for (const token of raw.split(/\s+/)) {
const match = token.match(/^(author|license|source|name|url):(.+)$/i);
if (match) {
fields.push({ key: match[1].toLowerCase(), value: match[2].toLowerCase() });
} else {
textParts.push(token);
}
}
return { fields, text: textParts.join(" ").trim() };
}
_fieldValue(script, key) {
switch (key) {
case "author": return (script.users?.[0]?.name || script.users?.[0]?.url || "").toLowerCase();
case "license": return (script.license || "").toLowerCase();
case "source": return (script._source || "").toLowerCase();
case "name": return (script.name || "").toLowerCase();
case "url": return (script.url || script.code_url || "").toLowerCase();
default: return "";
}
}
_hasActiveFilters() {
return this.filters.updatedMonths !== "any" || this.filters.minRating !== "any" || this.filters.languageFilter !== "any";
}
_applyFilters(scripts) {
const updatedMonths = this.filters.updatedMonths;
const minRating = this.filters.minRating;
const cutoff = updatedMonths === "any" ? null : Date.now() - (Number(updatedMonths) * 30 * 24 * 60 * 60 * 1000);
const ratingFloor = minRating === "any" ? null : Number(minRating);
return scripts.filter(script => {
if (cutoff) {
const updated = new Date(script.code_updated_at || 0).getTime();
if (!Number.isFinite(updated) || updated < cutoff) return false;
}
if (ratingFloor != null) {
const rating = normalizedRating(script);
if (rating == null || rating < ratingFloor) return false;
}
if (this.filters.languageFilter !== "any" && !matchesLanguageFilter(script, this.filters.languageFilter)) return false;
return true;
});
}
async _toggleMatchPreview(script, pane, button) {
if (!pane.classList.contains("hidden")) {
pane.classList.add("hidden");
return;
}
pane.className = "sf-match-preview";
_safeHTML(pane, `Checking match coverage...
`);
button.disabled = true;
try {
if (!script._matchPreview) {
const source = await this._fetchPreviewSource(script.code_url, script);
script._matchPreview = this._parseMatchCoverage(source, HostService.getCurrentHost(), window.location.href);
}
const preview = script._matchPreview;
pane.className = `sf-match-preview ${preview.status}`;
_safeHTML(pane, `
${escapeHtml(preview.title)}
${escapeHtml(preview.detail)}
${preview.patterns.length ? `Metadata: ${preview.patterns.map(p => `${escapeHtml(p)}`).join(", ")}
` : ""}
`);
} catch(err) {
pane.className = "sf-match-preview warn";
_safeHTML(pane, `
Coverage unavailable
${escapeHtml(err?.message || "Could not load script metadata.")}
`);
} finally {
button.disabled = false;
}
}
_fetchPreviewSource(url, script = null) {
const validation = InstallSafety.validateInstallUrl(script || { _source: this.currentService }, url);
if (!validation.ok) return Promise.reject(new Error(`Preview blocked: ${validation.reason}`));
return new Promise((resolve, reject) => {
const request = gmFunction("GM_xmlhttpRequest");
if (!request) {
reject(SourceRuntime.error("Preview", "GM_xmlhttpRequest is unavailable in this userscript manager", "compat", 0));
return;
}
request({
method: "GET", url: validation.url,
headers: { Accept: "text/plain,*/*" },
timeout: SourceRuntime.timeoutMs,
onload: r => {
if (r.status >= 200 && r.status < 300) resolve(r.responseText || "");
else reject(new Error(`Preview HTTP ${r.status}`));
},
onerror: () => reject(new Error("Preview network request failed")),
ontimeout: () => reject(new Error("Preview timed out"))
});
});
}
_openUrl(url) {
const openInTab = gmFunction("GM_openInTab");
if (openInTab) {
openInTab(url, { active: true });
return;
}
const opened = window.open(url, "_blank", "noopener,noreferrer");
if (!opened) window.location.href = url;
}
_parseMatchCoverage(source, host, currentUrl) {
return MatchCoverage.evaluate(source, host, currentUrl);
}
async _openInstallTarget(script, button) {
const validation = InstallSafety.validateInstallUrl(script, button.dataset.url || script.code_url);
if (!validation.ok) {
this.toast.show(`Install blocked: ${validation.reason}`);
return;
}
const oldHtml = button.innerHTML;
button.disabled = true;
button.textContent = "Checking...";
try {
const source = await this._fetchPreviewSource(validation.url, script);
if (!InstallSafety.hasUserScriptMetadata(source)) {
this.toast.show(STRINGS.toastInstallMetaMissing);
return;
}
const dangerousGrants = InstallSafety.dangerousGrants(source);
if (dangerousGrants.length) {
this.toast.show(`Warning: requests ${dangerousGrants.join(", ")}`);
}
this._openUrl(validation.url);
} catch(err) {
this.toast.show(`Install check failed: ${err?.message || "could not load metadata"}`);
} finally {
button.disabled = false;
_safeHTML(button, oldHtml);
}
}
_createScriptItem(script, svcClass, index) {
const item = document.createElement("div");
item.className = `sf-item ${svcClass}`;
item.style.animationDelay = `${Math.min(index * 30, 300)}ms`;
const isGH = script._source === "github";
const isCWS = script._source === "chromewebstore";
const isAMO = script._source === "mozillaaddons";
const isCatalog = script._source === "catalogs";
const isGist = script._source === "githubgist";
const daily = formatNumber(script.daily_installs);
const total = formatNumber(script.total_installs);
const good = formatNumber(script.good_ratings);
const fanScore = script.fan_score != null ? Number(script.fan_score) : null;
const fanText = Number.isFinite(fanScore) ? fanScore.toFixed(1) : null;
const updated = relativeTime(script.code_updated_at);
const created = relativeTime(script.created_at);
const author = script.users?.[0]?.name || null;
const baseUrls = { sleazyfork: "https://sleazyfork.org", greasyfork: "https://greasyfork.org", openuserjs: "https://openuserjs.org" };
const rawUrl = (isGH || isGist || isCatalog) ? script.url : (script.url?.startsWith("http") ? script.url : (baseUrls[svcClass] || baseUrls.greasyfork) + (script.url || ""));
const scriptUrl = rawUrl?.startsWith("http") ? rawUrl : "#";
const installUrl = script.code_url || null;
const installValidation = installUrl ? InstallSafety.validateInstallUrl(script, installUrl) : null;
const safeInstallUrl = installValidation?.ok ? installValidation.url : null;
const installWarning = installValidation && !installValidation.ok ? `Install blocked: ${installValidation.reason}` : "";
const fanClass = fanScore >= 8 ? "score-high" : fanScore >= 6 ? "score-mid" : fanScore >= 0 ? "score-low" : "";
const TWO_YEARS = 2 * 365 * 24 * 60 * 60 * 1000;
const isStale = script.code_updated_at && (Date.now() - new Date(script.code_updated_at).getTime()) > TWO_YEARS;
const badge = (icon, text, title, cls = "") => {
if (!text) return "";
return `${getIcon(icon)} ${escapeHtml(text)} `;
};
// GitHub: show stars + forks; Chrome Web Store: show users + rating; script registries show install stats.
let metaHtml;
if (isGH) {
const stars = formatNumber(script._stars);
const forks = formatNumber(script._forks);
const lang = script._language;
metaHtml = `
${badge("star", stars, "Stars")}
${badge("gitFork", forks, "Forks")}
${lang ? badge("gitBranch", lang, "Language") : ""}
${badge("clockwise", updated, "Updated")}
${badge("calendarPlus", created, "Created")}
${isStale ? badge("warning", "Stale", "Last updated more than 2 years ago", "stale-flag") : ""}
`;
} else if (isGist) {
const stars = formatNumber(script._stars);
const forks = formatNumber(script._forks);
const files = script._files ? `${script._files} ${script._files === 1 ? "file" : "files"}` : null;
metaHtml = `
${badge("gitBranch", files, "Files")}
${badge("star", stars, "Stars")}
${badge("gitFork", forks, "Forks")}
${badge("clockwise", updated, "Last active")}
${isStale ? badge("warning", "Stale", "Last updated more than 2 years ago", "stale-flag") : ""}
`;
} else if (isCatalog) {
metaHtml = `
${badge("search", script._catalog_source, "Catalog source")}
${badge("gitBranch", script._category, "Category")}
${badge("clockwise", updated, "Updated")}
`;
} else if (isCWS || isAMO) {
const totalUsers = formatNumber(script.total_installs);
const rating = script._rating != null ? Number(script._rating).toFixed(1) : null;
const ratingCount = formatNumber(script._rating_count);
const trustBadges = extensionTrustBadges(script);
metaHtml = `
${badge("chartBar", totalUsers, "Users")}
${badge("star", rating, "Average rating")}
${badge("user", ratingCount, "Rating count")}
${badge("clockwise", updated, "Updated")}
${trustBadges.map(trust => badge(trust.icon, trust.text, trust.title, trust.cls)).join("")}
`;
} else {
metaHtml = `
${badge("download", daily ? `${daily}/day` : null, "Daily installs")}
${badge("chartBar", total, "Total installs")}
${badge("star", good, "Ratings")}
${badge("flame", fanText, "Fan score", fanText ? fanClass : "")}
${badge("search", script._hostCoverageLabel, script._hostCoverageDetail || "Host coverage", script._hostCoverageClass || "")}
${badge("clockwise", updated, "Updated")}
${badge("calendarPlus", created, "Created")}
${isStale ? badge("warning", "Stale", "Last updated more than 2 years ago", "stale-flag") : ""}
`;
}
// Non-userscript sources get a "View" button; script registries get "Install".
let actionBtn;
if (isGH || isCWS || isAMO || !safeInstallUrl) {
const icon = (isGH || isGist) ? "githubLogo" : "search";
const title = isGH ? "View repository" : isGist ? "View gist" : isCatalog ? "Open catalog result" : isCWS || isAMO ? "View extension" : "View script page";
actionBtn = `${getIcon(icon)} View `;
} else if (safeInstallUrl) {
actionBtn = `${getIcon('install')} Install `;
} else {
actionBtn = "";
}
const previewBtn = safeInstallUrl ? `${getIcon('search')} Coverage ` : "";
const queued = this._isQueued(script);
const queueBtn = `${getIcon('calendarPlus')} `;
const dismissBtn = `${getIcon('x')} `;
const actionsHtml = `${actionBtn}${previewBtn}${queueBtn}${dismissBtn}
`;
_safeHTML(item, `
${escapeHtml(script.name || "Untitled")}
${this.currentService === "_all" && script._source ? `${escapeHtml(SOURCE_META[script._source]?.tab || script._source)} • ` : ""}
${author ? `${getIcon('user')} ${escapeHtml(author)} ` : ""}
${author && script.version ? `• ` : ""}
${script.version ? `${getIcon('gitBranch')} v${escapeHtml(script.version)} ` : ""}
${(author || script.version) && script.license ? `• ` : ""}
${script.license ? `${getIcon('scales')} ${escapeHtml(script.license)} ` : ""}
${actionsHtml}
${installWarning ? `${escapeHtml(installWarning)}
` : ""}
${escapeHtml(script.description || "No description")}
${metaHtml}
`);
// Action button handler
const actionEl = item.querySelector(".sf-install-btn");
if (actionEl) {
actionEl.addEventListener("click", (e) => {
e.stopPropagation();
if (actionEl.dataset.action === "install") this._openInstallTarget(script, actionEl);
else this._openUrl(actionEl.dataset.url);
});
}
const previewEl = item.querySelector(".sf-preview-btn");
const previewPane = item.querySelector(".sf-match-preview");
if (previewEl && previewPane) {
previewEl.addEventListener("click", (e) => {
e.stopPropagation();
this._toggleMatchPreview(script, previewPane, previewEl);
});
}
// Queue handler
const queueEl = item.querySelector(".sf-queue-btn");
if (queueEl) {
queueEl.addEventListener("click", (e) => {
e.stopPropagation();
const isQueued = this._toggleQueued(script);
queueEl.classList.toggle("queued", isQueued);
queueEl.setAttribute("aria-pressed", String(isQueued));
queueEl.title = isQueued ? "Remove from queue" : "Queue to try later";
this.toast.show(isQueued ? STRINGS.toastQueued : STRINGS.toastUnqueued);
});
}
// Dismiss handler
const dismissEl = item.querySelector(".sf-dismiss-btn");
if (dismissEl) {
dismissEl.addEventListener("click", (e) => {
e.stopPropagation();
this._dismissScript(script);
item.style.opacity = "0";
item.style.transition = "opacity 0.2s ease";
setTimeout(() => { item.remove(); this._updateDismissedCount(); }, 200);
});
}
// Click-to-open script page
item.addEventListener("click", (e) => {
if (e.target.closest("a") || e.target.closest("button")) return;
this._openUrl(scriptUrl);
});
return item;
}
}
// ── Init ────────────────────────────────────────────────────────────
function boot() {
try { new ScriptFinder().init(); }
catch(e) { console.error("[Script Finder v4]", e); }
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
else setTimeout(boot, 50);
})();