This page appears to be a Fandom wiki article. You can open the same article on ${selectedMirror} (recommended), or continue to visit the Fandom page.
-
+
@@ -273,7 +298,7 @@
if (openBtn) openBtn.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
console.debug('Flean: open mirror button clicked');
- try { window.location.replace(mirrorUrl); } catch (err) { console.warn('Flean: failed to open mirror', err); }
+ try { window.location.replace(finalDestUrl); } catch (err) { console.warn('Flean: failed to open mirror', err); }
});
if (onceBtn) onceBtn.addEventListener('click', (e) => {
diff --git a/mos/Flean Extension/Resources/manifest.json b/mos/Flean Extension/Resources/manifest.json
index acc7afd..1ab0c73 100644
--- a/mos/Flean Extension/Resources/manifest.json
+++ b/mos/Flean Extension/Resources/manifest.json
@@ -19,7 +19,9 @@
"type": "module"
},
- "permissions": [ "storage" ],
+ "permissions": [ "storage", "alarms" ],
+
+ "host_permissions": [ "https://raw.githubusercontent.com/*" ],
"content_scripts": [{
"js": [ "content.js" ],
diff --git a/mos/Flean Extension/Resources/scripts/wiki-data-manager.js b/mos/Flean Extension/Resources/scripts/wiki-data-manager.js
new file mode 100644
index 0000000..e6d4cc4
--- /dev/null
+++ b/mos/Flean Extension/Resources/scripts/wiki-data-manager.js
@@ -0,0 +1,190 @@
+// wiki-data-manager.js
+// Remote wiki data loading with local caching and heuristic fallback.
+// Data source: indie-wiki-buddy (https://github.com/KevinPayravi/indie-wiki-buddy)
+
+const WIKI_DATA_URL = 'https://raw.githubusercontent.com/KevinPayravi/indie-wiki-buddy/main/data/sitesEN.json';
+const CACHE_KEY = 'wikiData';
+const CACHE_TS_KEY = 'wikiDataLastFetch';
+const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
+
+const BASE64REGEX = /^[A-Za-z0-9+/]+=*$/;
+
+/**
+ * Compress a JS value to a gzip+base64 string.
+ * Falls back to plain JSON string if CompressionStream is unavailable.
+ */
+export async function compressJSON(value) {
+ const json = JSON.stringify(value);
+ if (typeof CompressionStream === 'undefined') return btoa(json);
+ try {
+ const bytes = new TextEncoder().encode(json);
+ const stream = new CompressionStream('gzip');
+ const writer = stream.writable.getWriter();
+ writer.write(bytes);
+ writer.close();
+ const compressed = await new Response(stream.readable).arrayBuffer();
+ const uint8 = new Uint8Array(compressed);
+ let binary = '';
+ for (let i = 0; i < uint8.length; i++) binary += String.fromCharCode(uint8[i]);
+ return btoa(binary);
+ } catch (e) {
+ return btoa(json);
+ }
+}
+
+/**
+ * Decompress a gzip+base64 string back to the original value.
+ * Accepts both gzip-compressed and plain base64-encoded JSON (fallback).
+ */
+export async function decompressJSON(value) {
+ if (!value || !BASE64REGEX.test(value)) throw new Error('Invalid compressed data');
+ if (typeof DecompressionStream === 'undefined') return JSON.parse(atob(value));
+ try {
+ const binary = atob(value);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ const stream = new DecompressionStream('gzip');
+ const writer = stream.writable.getWriter();
+ writer.write(bytes);
+ writer.close();
+ const decompressed = await new Response(stream.readable).arrayBuffer();
+ return JSON.parse(new TextDecoder().decode(decompressed));
+ } catch (e) {
+ // May be plain base64 JSON (written by the fallback path above)
+ try { return JSON.parse(atob(value)); } catch (e2) { throw e; }
+ }
+}
+
+/**
+ * Fetch fresh wiki data from the GitHub CDN, compress it, and store in browser.storage.local.
+ * Returns the raw data array, or throws on failure.
+ */
+export async function fetchWikiData() {
+ const response = await fetch(WIKI_DATA_URL);
+ if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
+ const data = await response.json();
+ const compressed = await compressJSON(data);
+ await browser.storage.local.set({ [CACHE_KEY]: compressed, [CACHE_TS_KEY]: Date.now() });
+ return data;
+}
+
+/**
+ * Get wiki data from cache, fetching fresh data if the cache is stale (>7 days) or absent.
+ * Returns the data array, or null if unavailable (network failure, etc.).
+ */
+export async function getWikiData() {
+ try {
+ const stored = await browser.storage.local.get([CACHE_KEY, CACHE_TS_KEY]);
+ const ts = stored[CACHE_TS_KEY] || 0;
+ const compressed = stored[CACHE_KEY];
+ if (compressed && (Date.now() - ts) < CACHE_TTL_MS) {
+ try {
+ return await decompressJSON(compressed);
+ } catch (e) {
+ console.warn('Flean: wiki data decompression failed, re-fetching', e);
+ }
+ }
+ return await fetchWikiData();
+ } catch (e) {
+ console.warn('Flean: could not load wiki data, falling back to heuristics', e);
+ return null;
+ }
+}
+
+/** Build an in-memory Map from normalised origin host → { wiki, originEntry }. */
+function buildIndex(wikiData) {
+ const index = new Map();
+ if (!Array.isArray(wikiData)) return index;
+ for (const wiki of wikiData) {
+ if (!wiki.origins || !wiki.destination_base_url) continue;
+ for (const origin of wiki.origins) {
+ if (!origin.origin_base_url) continue;
+ const key = origin.origin_base_url.toLowerCase()
+ .replace(/^https?:\/\//, '')
+ .replace(/\/$/, '');
+ index.set(key, { wiki, originEntry: origin });
+ }
+ }
+ return index;
+}
+
+let _wikiIndex = null;
+let _wikiIndexPromise = null;
+
+/** Ensure the in-memory lookup index is built. */
+async function ensureIndex() {
+ if (_wikiIndex !== null) return _wikiIndex;
+ if (_wikiIndexPromise) return _wikiIndexPromise;
+ _wikiIndexPromise = getWikiData().then(data => {
+ _wikiIndex = buildIndex(data);
+ _wikiIndexPromise = null;
+ return _wikiIndex;
+ }).catch(() => {
+ _wikiIndex = new Map();
+ _wikiIndexPromise = null;
+ return _wikiIndex;
+ });
+ return _wikiIndexPromise;
+}
+
+/**
+ * Invalidate the in-memory index so it is rebuilt on the next lookup.
+ * Call this after a successful data refresh.
+ */
+export function invalidateIndex() {
+ _wikiIndex = null;
+}
+
+/**
+ * Given a URL string, find the best matching independent wiki destination.
+ * Returns { destinationUrl: string, wikiName: string } or null if no match found.
+ */
+export async function findMatchingWiki(urlString) {
+ try {
+ const url = new URL(urlString);
+ const host = url.host.toLowerCase();
+ const index = await ensureIndex();
+
+ // Try exact host, then without leading 'www.'
+ const match = index.get(host) || (host.startsWith('www.') ? index.get(host.slice(4)) : undefined);
+ if (!match) return null;
+
+ const { wiki, originEntry } = match;
+
+ // Extract the article name from the path using the origin's content path prefix.
+ // indie-wiki-buddy templates use a single `$1` placeholder (e.g. "/wiki/$1").
+ // We strip everything from `$1` onward to obtain just the path prefix.
+ const originPathPrefix = (originEntry.origin_content_path || '/wiki/')
+ .replace(/\$1.*$/, '');
+ let article = url.pathname;
+ // Case-insensitive prefix match is intentional: wiki path prefixes like /wiki/
+ // are the same regardless of casing on every platform targeted by this data.
+ if (article.toLowerCase().startsWith(originPathPrefix.toLowerCase())) {
+ article = article.slice(originPathPrefix.length);
+ }
+ try { article = decodeURIComponent(article); } catch (e) { /* keep encoded */ }
+
+ // Build destination URL based on platform
+ const destBase = wiki.destination_base_url
+ .replace(/^https?:\/\//, '')
+ .replace(/\/$/, '');
+ const platform = (wiki.destination_platform || 'mediawiki').toLowerCase();
+ let destPath;
+
+ if (wiki.destination_content_path) {
+ destPath = wiki.destination_content_path
+ .replace('$1', encodeURIComponent(article.replace(/ /g, '_')));
+ } else if (platform === 'dokuwiki') {
+ destPath = '/doku.php?id=' + encodeURIComponent(article.replace(/ /g, '_').toLowerCase());
+ } else {
+ // Default: MediaWiki-style /wiki/ArticleName
+ destPath = '/wiki/' + encodeURIComponent(article.replace(/ /g, '_'));
+ }
+
+ const destinationUrl = `https://${destBase}${destPath}${url.search}${url.hash}`;
+ const wikiName = wiki.destination || wiki.article || destBase;
+ return { destinationUrl, wikiName };
+ } catch (e) {
+ return null;
+ }
+}
From 813d03d3bf869d9eda0793f52374b24f9cb31676 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 26 Mar 2026 19:39:36 +0000
Subject: [PATCH 3/6] Settings copy, threshold=2, reload detection, settings
button cascade, version 2.1.3
Agent-Logs-Url: https://github.com/SillyLittleTech/Flean/sessions/9f48cef1-3648-4c02-9639-a400a683d1db
Co-authored-by: kiyarose <75678535+kiyarose@users.noreply.github.com>
---
ios/Flean.xcodeproj/project.pbxproj | 32 ++++++------
ios/extention/Resources/content.js | 56 ++++++++++++++++-----
ios/extention/Resources/manifest.json | 2 +-
ios/extention/Resources/popup.html | 5 +-
mos/Flean Extension/Resources/content.js | 56 ++++++++++++++++-----
mos/Flean Extension/Resources/manifest.json | 2 +-
mos/Flean Extension/Resources/popup.html | 5 +-
mos/Flean.xcodeproj/project.pbxproj | 32 ++++++------
8 files changed, 126 insertions(+), 64 deletions(-)
diff --git a/ios/Flean.xcodeproj/project.pbxproj b/ios/Flean.xcodeproj/project.pbxproj
index a1ecb15..894c7cb 100644
--- a/ios/Flean.xcodeproj/project.pbxproj
+++ b/ios/Flean.xcodeproj/project.pbxproj
@@ -380,7 +380,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_ASSET_PATHS = extention;
DEVELOPMENT_TEAM = PWL627GZ4Y;
GENERATE_INFOPLIST_FILE = YES;
@@ -393,7 +393,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 2.1.3;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -416,7 +416,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_ASSET_PATHS = extention;
DEVELOPMENT_TEAM = PWL627GZ4Y;
GENERATE_INFOPLIST_FILE = YES;
@@ -429,7 +429,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 2.1.3;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -577,7 +577,7 @@
CODE_SIGN_ENTITLEMENTS = Flean/Flean.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PWL627GZ4Y;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -590,7 +590,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 2.1.3;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -616,7 +616,7 @@
CODE_SIGN_ENTITLEMENTS = Flean/Flean.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PWL627GZ4Y;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -629,7 +629,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 2.1.3;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -652,11 +652,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PWL627GZ4Y;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 2.1.3;
PRODUCT_BUNDLE_IDENTIFIER = slf.FleanTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -670,11 +670,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PWL627GZ4Y;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 2.1.3;
PRODUCT_BUNDLE_IDENTIFIER = slf.FleanTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -687,10 +687,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PWL627GZ4Y;
GENERATE_INFOPLIST_FILE = YES;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 2.1.3;
PRODUCT_BUNDLE_IDENTIFIER = slf.FleanUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -703,10 +703,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PWL627GZ4Y;
GENERATE_INFOPLIST_FILE = YES;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 2.1.3;
PRODUCT_BUNDLE_IDENTIFIER = slf.FleanUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
diff --git a/ios/extention/Resources/content.js b/ios/extention/Resources/content.js
index f45326b..bdb2d64 100644
--- a/ios/extention/Resources/content.js
+++ b/ios/extention/Resources/content.js
@@ -142,7 +142,7 @@
let finalDestLabel = selectedMirror;
let isIndieWiki = false;
try {
- let cancelTimeout;
+ let cancelTimeout = null;
const structured = await Promise.race([
browser.runtime.sendMessage({ action: 'findWiki', url: url.href }),
new Promise(resolve => { cancelTimeout = setTimeout(() => resolve(null), 500); })
@@ -158,19 +158,34 @@
// Fast-path for askOnVisit=false using sessionStorage-only attempt tracking
if (!askOnVisit) {
const ATTEMPT_WINDOW_MS = 10 * 1000; // 10s window
- const refHost = (document.referrer ? (() => { try { return new URL(document.referrer).host.toLowerCase(); } catch (e) { return null; } })() : null);
- const fromMirror = refHost && (mirrors.includes(refHost) || refHost === selectedMirror);
- const ATTEMPT_THRESHOLD = fromMirror ? 2 : 3;
+ const ATTEMPT_THRESHOLD = 2; // either 2 navigations within the window OR 2 consecutive reloads independently trigger suppression
const SUPPRESS_COOLDOWN_MS = 30 * 1000;
const now = Date.now();
const pageKey = url.href;
const attemptsKey = '__flean_attempts:' + pageKey;
const suppressKey = '__flean_suppressed:' + pageKey;
+ const reloadKey = '__flean_reloads:' + pageKey;
function readSessionArray(k) { try { return JSON.parse(window.sessionStorage.getItem(k) || '[]'); } catch (e) { return []; } }
function writeSessionArray(k, arr) { try { window.sessionStorage.setItem(k, JSON.stringify(arr)); } catch (e) { /* ignore */ } }
+ // Detect whether this page load is a browser reload (F5 / Cmd-R)
+ let isReload = false;
+ try {
+ const navEntry = performance.getEntriesByType('navigation')[0];
+ isReload = navEntry ? navEntry.type === 'reload' : (performance.navigation && performance.navigation.type === 1);
+ } catch (e) { /* ignore */ }
+
+ // Track consecutive reloads separately; two in a row bypasses the redirect.
+ // Intentionally resets to 0 on any non-reload navigation so only truly
+ // back-to-back reloads of the same page count toward suppression.
+ let reloadCount = 0;
+ try {
+ reloadCount = isReload ? (parseInt(window.sessionStorage.getItem(reloadKey) || '0', 10) + 1) : 0;
+ window.sessionStorage.setItem(reloadKey, String(reloadCount));
+ } catch (e) { /* ignore */ }
+
const recent = readSessionArray(attemptsKey).filter(ts => (now - ts) <= ATTEMPT_WINDOW_MS);
recent.push(now);
writeSessionArray(attemptsKey, recent);
@@ -182,9 +197,9 @@
return;
}
- if (recent.length >= ATTEMPT_THRESHOLD) {
+ if (reloadCount >= ATTEMPT_THRESHOLD || recent.length >= ATTEMPT_THRESHOLD) {
window.sessionStorage.setItem(suppressKey, String(now + SUPPRESS_COOLDOWN_MS));
- console.info('Flean: suppressing redirect for', pageKey, 'for', SUPPRESS_COOLDOWN_MS, 'ms (fromMirror=' + !!fromMirror + ')');
+ console.info('Flean: suppressing redirect for', pageKey, 'for', SUPPRESS_COOLDOWN_MS, 'ms (reloads=' + reloadCount + ', navigations=' + recent.length + ')');
showSuppressionBanner();
return;
}
@@ -232,13 +247,19 @@
if (container) container.insertBefore(banner, container.firstChild);
const cfg = document.getElementById('flean-suppress-config');
- if (cfg) cfg.addEventListener('click', (e) => {
+ if (cfg) cfg.addEventListener('click', async (e) => {
e.preventDefault(); e.stopPropagation();
try {
- if (browser && browser.runtime && typeof browser.runtime.openOptionsPage === 'function') {
- browser.runtime.openOptionsPage();
+ if (browser.action && typeof browser.action.openPopup === 'function') {
+ await browser.action.openPopup(); return;
}
- } catch (err) { console.warn('Flean: could not open options from banner', err); }
+ } catch (e) { /* try next */ }
+ try {
+ if (browser.runtime && typeof browser.runtime.openOptionsPage === 'function') {
+ browser.runtime.openOptionsPage(); return;
+ }
+ } catch (e) { /* try next */ }
+ try { await browser.tabs.create({ url: browser.runtime.getURL('popup.html') }); } catch (e) { console.warn('Flean: could not open settings', e); }
});
closeBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); banner.remove(); });
console.debug('Flean: suppression banner shown');
@@ -322,10 +343,19 @@
});
if (settingsLink) {
- const canOpenOptions = !!(browser && browser.runtime && typeof browser.runtime.openOptionsPage === 'function');
- if (!canOpenOptions) settingsLink.remove(); else settingsLink.addEventListener('click', (e) => {
+ settingsLink.addEventListener('click', async (e) => {
e.preventDefault(); e.stopPropagation();
- try { browser.runtime.openOptionsPage(); } catch (err) { console.warn('Flean: could not open options page', err); settingsLink.remove(); }
+ try {
+ if (browser.action && typeof browser.action.openPopup === 'function') {
+ await browser.action.openPopup(); return;
+ }
+ } catch (e) { /* try next */ }
+ try {
+ if (browser.runtime && typeof browser.runtime.openOptionsPage === 'function') {
+ browser.runtime.openOptionsPage(); return;
+ }
+ } catch (e) { /* try next */ }
+ try { await browser.tabs.create({ url: browser.runtime.getURL('popup.html') }); } catch (e) { console.warn('Flean: could not open settings', e); }
});
}
diff --git a/ios/extention/Resources/manifest.json b/ios/extention/Resources/manifest.json
index 567554a..334d4ce 100644
--- a/ios/extention/Resources/manifest.json
+++ b/ios/extention/Resources/manifest.json
@@ -4,7 +4,7 @@
"name": "Flean Extension",
"description": "Redirect Fandom wiki pages to independent mirrors.",
- "version": "1.0",
+ "version": "2.1.3",
"icons": {
"48": "images/icon-48.png",
diff --git a/ios/extention/Resources/popup.html b/ios/extention/Resources/popup.html
index eae9ba0..b4b5f28 100644
--- a/ios/extention/Resources/popup.html
+++ b/ios/extention/Resources/popup.html
@@ -11,12 +11,13 @@
-
+
+ Independent (self-hosted) wikis are always preferred when available. This mirror is only used when no independent wiki is found.
diff --git a/mos/Flean Extension/Resources/content.js b/mos/Flean Extension/Resources/content.js
index f45326b..bdb2d64 100644
--- a/mos/Flean Extension/Resources/content.js
+++ b/mos/Flean Extension/Resources/content.js
@@ -142,7 +142,7 @@
let finalDestLabel = selectedMirror;
let isIndieWiki = false;
try {
- let cancelTimeout;
+ let cancelTimeout = null;
const structured = await Promise.race([
browser.runtime.sendMessage({ action: 'findWiki', url: url.href }),
new Promise(resolve => { cancelTimeout = setTimeout(() => resolve(null), 500); })
@@ -158,19 +158,34 @@
// Fast-path for askOnVisit=false using sessionStorage-only attempt tracking
if (!askOnVisit) {
const ATTEMPT_WINDOW_MS = 10 * 1000; // 10s window
- const refHost = (document.referrer ? (() => { try { return new URL(document.referrer).host.toLowerCase(); } catch (e) { return null; } })() : null);
- const fromMirror = refHost && (mirrors.includes(refHost) || refHost === selectedMirror);
- const ATTEMPT_THRESHOLD = fromMirror ? 2 : 3;
+ const ATTEMPT_THRESHOLD = 2; // either 2 navigations within the window OR 2 consecutive reloads independently trigger suppression
const SUPPRESS_COOLDOWN_MS = 30 * 1000;
const now = Date.now();
const pageKey = url.href;
const attemptsKey = '__flean_attempts:' + pageKey;
const suppressKey = '__flean_suppressed:' + pageKey;
+ const reloadKey = '__flean_reloads:' + pageKey;
function readSessionArray(k) { try { return JSON.parse(window.sessionStorage.getItem(k) || '[]'); } catch (e) { return []; } }
function writeSessionArray(k, arr) { try { window.sessionStorage.setItem(k, JSON.stringify(arr)); } catch (e) { /* ignore */ } }
+ // Detect whether this page load is a browser reload (F5 / Cmd-R)
+ let isReload = false;
+ try {
+ const navEntry = performance.getEntriesByType('navigation')[0];
+ isReload = navEntry ? navEntry.type === 'reload' : (performance.navigation && performance.navigation.type === 1);
+ } catch (e) { /* ignore */ }
+
+ // Track consecutive reloads separately; two in a row bypasses the redirect.
+ // Intentionally resets to 0 on any non-reload navigation so only truly
+ // back-to-back reloads of the same page count toward suppression.
+ let reloadCount = 0;
+ try {
+ reloadCount = isReload ? (parseInt(window.sessionStorage.getItem(reloadKey) || '0', 10) + 1) : 0;
+ window.sessionStorage.setItem(reloadKey, String(reloadCount));
+ } catch (e) { /* ignore */ }
+
const recent = readSessionArray(attemptsKey).filter(ts => (now - ts) <= ATTEMPT_WINDOW_MS);
recent.push(now);
writeSessionArray(attemptsKey, recent);
@@ -182,9 +197,9 @@
return;
}
- if (recent.length >= ATTEMPT_THRESHOLD) {
+ if (reloadCount >= ATTEMPT_THRESHOLD || recent.length >= ATTEMPT_THRESHOLD) {
window.sessionStorage.setItem(suppressKey, String(now + SUPPRESS_COOLDOWN_MS));
- console.info('Flean: suppressing redirect for', pageKey, 'for', SUPPRESS_COOLDOWN_MS, 'ms (fromMirror=' + !!fromMirror + ')');
+ console.info('Flean: suppressing redirect for', pageKey, 'for', SUPPRESS_COOLDOWN_MS, 'ms (reloads=' + reloadCount + ', navigations=' + recent.length + ')');
showSuppressionBanner();
return;
}
@@ -232,13 +247,19 @@
if (container) container.insertBefore(banner, container.firstChild);
const cfg = document.getElementById('flean-suppress-config');
- if (cfg) cfg.addEventListener('click', (e) => {
+ if (cfg) cfg.addEventListener('click', async (e) => {
e.preventDefault(); e.stopPropagation();
try {
- if (browser && browser.runtime && typeof browser.runtime.openOptionsPage === 'function') {
- browser.runtime.openOptionsPage();
+ if (browser.action && typeof browser.action.openPopup === 'function') {
+ await browser.action.openPopup(); return;
}
- } catch (err) { console.warn('Flean: could not open options from banner', err); }
+ } catch (e) { /* try next */ }
+ try {
+ if (browser.runtime && typeof browser.runtime.openOptionsPage === 'function') {
+ browser.runtime.openOptionsPage(); return;
+ }
+ } catch (e) { /* try next */ }
+ try { await browser.tabs.create({ url: browser.runtime.getURL('popup.html') }); } catch (e) { console.warn('Flean: could not open settings', e); }
});
closeBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); banner.remove(); });
console.debug('Flean: suppression banner shown');
@@ -322,10 +343,19 @@
});
if (settingsLink) {
- const canOpenOptions = !!(browser && browser.runtime && typeof browser.runtime.openOptionsPage === 'function');
- if (!canOpenOptions) settingsLink.remove(); else settingsLink.addEventListener('click', (e) => {
+ settingsLink.addEventListener('click', async (e) => {
e.preventDefault(); e.stopPropagation();
- try { browser.runtime.openOptionsPage(); } catch (err) { console.warn('Flean: could not open options page', err); settingsLink.remove(); }
+ try {
+ if (browser.action && typeof browser.action.openPopup === 'function') {
+ await browser.action.openPopup(); return;
+ }
+ } catch (e) { /* try next */ }
+ try {
+ if (browser.runtime && typeof browser.runtime.openOptionsPage === 'function') {
+ browser.runtime.openOptionsPage(); return;
+ }
+ } catch (e) { /* try next */ }
+ try { await browser.tabs.create({ url: browser.runtime.getURL('popup.html') }); } catch (e) { console.warn('Flean: could not open settings', e); }
});
}
diff --git a/mos/Flean Extension/Resources/manifest.json b/mos/Flean Extension/Resources/manifest.json
index 1ab0c73..65f63cd 100644
--- a/mos/Flean Extension/Resources/manifest.json
+++ b/mos/Flean Extension/Resources/manifest.json
@@ -4,7 +4,7 @@
"name": "__MSG_extension_name__",
"description": "__MSG_extension_description__",
- "version": "1.0",
+ "version": "2.1.3",
"icons": {
"48": "images/icon-48.png",
diff --git a/mos/Flean Extension/Resources/popup.html b/mos/Flean Extension/Resources/popup.html
index eae9ba0..b4b5f28 100644
--- a/mos/Flean Extension/Resources/popup.html
+++ b/mos/Flean Extension/Resources/popup.html
@@ -11,12 +11,13 @@
-
+
+ Independent (self-hosted) wikis are always preferred when available. This mirror is only used when no independent wiki is found.
diff --git a/mos/Flean.xcodeproj/project.pbxproj b/mos/Flean.xcodeproj/project.pbxproj
index 910e6fc..ff4df4f 100644
--- a/mos/Flean.xcodeproj/project.pbxproj
+++ b/mos/Flean.xcodeproj/project.pbxproj
@@ -378,7 +378,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "Flean Extension/Flean_Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PWL627GZ4Y;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -391,7 +391,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 2.1.3;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -409,7 +409,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "Flean Extension/Flean_Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PWL627GZ4Y;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -422,7 +422,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 2.1.3;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -563,7 +563,7 @@
CODE_SIGN_ENTITLEMENTS = Flean/Flean.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PWL627GZ4Y;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -576,7 +576,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 2.1.3;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -602,7 +602,7 @@
CODE_SIGN_ENTITLEMENTS = Flean/Flean.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PWL627GZ4Y;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -615,7 +615,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 2.1.3;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -638,11 +638,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PWL627GZ4Y;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 2.1.3;
PRODUCT_BUNDLE_IDENTIFIER = slf.FleanTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -656,11 +656,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PWL627GZ4Y;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 2.1.3;
PRODUCT_BUNDLE_IDENTIFIER = slf.FleanTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -673,10 +673,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PWL627GZ4Y;
GENERATE_INFOPLIST_FILE = YES;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 2.1.3;
PRODUCT_BUNDLE_IDENTIFIER = slf.FleanUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -689,10 +689,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PWL627GZ4Y;
GENERATE_INFOPLIST_FILE = YES;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 2.1.3;
PRODUCT_BUNDLE_IDENTIFIER = slf.FleanUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
From 390323f9fcc60105efb0fb2a1be156161d6f83e1 Mon Sep 17 00:00:00 2001
From: "deepsource-autofix[bot]"
<62050782+deepsource-autofix[bot]@users.noreply.github.com>
Date: Thu, 26 Mar 2026 19:40:17 +0000
Subject: [PATCH 4/6] style: format code with PHP CS Fixer, StandardJS and
swift-format
This commit fixes the style issues introduced in 813d03d according to the output
from PHP CS Fixer, StandardJS and swift-format.
Details: https://github.com/SillyLittleTech/Flean/pull/2
---
ios/extention/Resources/background.js | 68 +-
ios/extention/Resources/content.js | 667 +++++++++---------
.../Resources/scripts/wiki-data-manager.js | 278 ++++----
mos/Flean Extension/Resources/background.js | 68 +-
mos/Flean Extension/Resources/content.js | 667 +++++++++---------
.../Resources/scripts/wiki-data-manager.js | 278 ++++----
6 files changed, 1020 insertions(+), 1006 deletions(-)
diff --git a/ios/extention/Resources/background.js b/ios/extention/Resources/background.js
index 19a9e9d..0f1c608 100644
--- a/ios/extention/Resources/background.js
+++ b/ios/extention/Resources/background.js
@@ -1,44 +1,44 @@
-import { getWikiData, fetchWikiData, findMatchingWiki, invalidateIndex } from './scripts/wiki-data-manager.js';
+import { getWikiData, fetchWikiData, findMatchingWiki, invalidateIndex } from './scripts/wiki-data-manager.js'
// Initialise wiki data on extension startup (loads from cache or fetches fresh)
-async function initWikiData() {
- try {
- await getWikiData();
- console.log('Flean: wiki data ready');
- } catch (e) {
- console.warn('Flean: wiki data init failed, heuristics will be used', e);
- }
+async function initWikiData () {
+ try {
+ await getWikiData()
+ console.log('Flean: wiki data ready')
+ } catch (e) {
+ console.warn('Flean: wiki data init failed, heuristics will be used', e)
+ }
}
// Schedule a weekly background refresh using browser.alarms (if supported)
-function setupRefreshAlarm() {
- if (typeof browser === 'undefined' || !browser.alarms) return;
- try {
- browser.alarms.create('wikiDataRefresh', { periodInMinutes: 7 * 24 * 60 });
- browser.alarms.onAlarm.addListener(async (alarm) => {
- if (alarm.name !== 'wikiDataRefresh') return;
- try {
- await fetchWikiData();
- invalidateIndex();
- console.log('Flean: wiki data refreshed');
- } catch (e) {
- console.warn('Flean: scheduled wiki data refresh failed', e);
- }
- });
- } catch (e) {
- console.warn('Flean: could not set up refresh alarm', e);
- }
+function setupRefreshAlarm () {
+ if (typeof browser === 'undefined' || !browser.alarms) return
+ try {
+ browser.alarms.create('wikiDataRefresh', { periodInMinutes: 7 * 24 * 60 })
+ browser.alarms.onAlarm.addListener(async (alarm) => {
+ if (alarm.name !== 'wikiDataRefresh') return
+ try {
+ await fetchWikiData()
+ invalidateIndex()
+ console.log('Flean: wiki data refreshed')
+ } catch (e) {
+ console.warn('Flean: scheduled wiki data refresh failed', e)
+ }
+ })
+ } catch (e) {
+ console.warn('Flean: could not set up refresh alarm', e)
+ }
}
browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
- if (request.greeting === 'hello') {
- return Promise.resolve({ farewell: 'goodbye' });
- }
+ if (request.greeting === 'hello') {
+ return Promise.resolve({ farewell: 'goodbye' })
+ }
- if (request.action === 'findWiki') {
- return findMatchingWiki(request.url).catch(() => null);
- }
-});
+ if (request.action === 'findWiki') {
+ return findMatchingWiki(request.url).catch(() => null)
+ }
+})
-initWikiData();
-setupRefreshAlarm();
+initWikiData()
+setupRefreshAlarm()
diff --git a/ios/extention/Resources/content.js b/ios/extention/Resources/content.js
index bdb2d64..c09fbff 100644
--- a/ios/extention/Resources/content.js
+++ b/ios/extention/Resources/content.js
@@ -1,276 +1,278 @@
// Content script: intercept fandom/wikia wiki pages and redirect to a selected Breezewiki mirror.
(async function () {
+ try {
+ const url = new URL(window.location.href)
+ const host = url.host.toLowerCase()
+ const path = url.pathname
+
+ // Only handle wiki pages: /wiki/...
+ if (!path.startsWith('/wiki/')) return
+
+ // Known source hosts to rewrite from
+ const isFandom = host === 'fandom.com' || host.endsWith('.fandom.com') || host.endsWith('.wikia.com')
+ if (!isFandom) return
+
+ // Defaults
+ const DEFAULTS = {
+ allowedSites: [],
+ selectedMirror: 'breezewiki.com',
+ askOnVisit: false,
+ mirrors: [
+ 'breezewiki.com',
+ 'antifandom.com',
+ 'breezewiki.pussthecat.org',
+ 'bw.hamstro.dev',
+ 'bw.projectsegfau.lt',
+ 'breeze.hostux.net',
+ 'bw.artemislena.eu',
+ 'nerd.whatever.social',
+ 'breezewiki.frontendfriendly.xyz',
+ 'breeze.nohost.network',
+ 'breeze.whateveritworks.org',
+ 'z.opnxng.com',
+ 'breezewiki.hyperreal.coffee',
+ 'breezewiki.catsarch.com',
+ 'breeze.mint.lgbt',
+ 'breezewiki.woodland.cafe',
+ 'breezewiki.nadeko.net',
+ 'fandom.reallyaweso.me',
+ 'breezewiki.4o1x5.dev',
+ 'breezewiki.r4fo.com',
+ 'breezewiki.private.coffee',
+ 'fan.blitzw.in'
+ ]
+ }
+
+ // Helper to normalize mirror strings to a host (e.g. strip https:// and paths)
+ function normalizeHost (str) {
+ if (!str) return str
+ try {
+ // If it's a full URL, URL() will succeed and we can read host
+ return new URL(str).host.toLowerCase()
+ } catch (e) {
+ try { return new URL('https://' + str).host.toLowerCase() } catch (e2) { return str.toLowerCase() }
+ }
+ }
+
+ // Fast synchronous cache read (localStorage) to avoid blocking the page.
+ let allowedSites = DEFAULTS.allowedSites.slice()
+ let selectedMirror = DEFAULTS.selectedMirror
+ let askOnVisit = DEFAULTS.askOnVisit
+ let mirrors = DEFAULTS.mirrors.slice()
try {
- const url = new URL(window.location.href);
- const host = url.host.toLowerCase();
- const path = url.pathname;
-
- // Only handle wiki pages: /wiki/...
- if (!path.startsWith('/wiki/')) return;
-
- // Known source hosts to rewrite from
- const isFandom = host === 'fandom.com' || host.endsWith('.fandom.com') || host.endsWith('.wikia.com');
- if (!isFandom) return;
-
- // Defaults
- const DEFAULTS = {
- allowedSites: [],
- selectedMirror: 'breezewiki.com',
- askOnVisit: false,
- mirrors: [
- 'breezewiki.com',
- 'antifandom.com',
- 'breezewiki.pussthecat.org',
- 'bw.hamstro.dev',
- 'bw.projectsegfau.lt',
- 'breeze.hostux.net',
- 'bw.artemislena.eu',
- 'nerd.whatever.social',
- 'breezewiki.frontendfriendly.xyz',
- 'breeze.nohost.network',
- 'breeze.whateveritworks.org',
- 'z.opnxng.com',
- 'breezewiki.hyperreal.coffee',
- 'breezewiki.catsarch.com',
- 'breeze.mint.lgbt',
- 'breezewiki.woodland.cafe',
- 'breezewiki.nadeko.net',
- 'fandom.reallyaweso.me',
- 'breezewiki.4o1x5.dev',
- 'breezewiki.r4fo.com',
- 'breezewiki.private.coffee',
- 'fan.blitzw.in'
- ]
- };
-
- // Helper to normalize mirror strings to a host (e.g. strip https:// and paths)
- function normalizeHost(str) {
- if (!str) return str;
- try {
- // If it's a full URL, URL() will succeed and we can read host
- return new URL(str).host.toLowerCase();
- } catch (e) {
- try { return new URL('https://' + str).host.toLowerCase(); } catch (e2) { return str.toLowerCase(); }
- }
- }
+ const cache = JSON.parse(window.localStorage.getItem('__flean_cache') || 'null')
+ if (cache && cache.ts && (Date.now() - cache.ts) < 30 * 1000) {
+ allowedSites = cache.allowedSites || allowedSites
+ selectedMirror = normalizeHost(cache.selectedMirror || selectedMirror)
+ askOnVisit = !!cache.askOnVisit
+ mirrors = (cache.mirrors || mirrors).map(normalizeHost)
+ }
+ } catch (e) { /* ignore cache parse errors */ }
+
+ // Background refresh of storage to keep the cache fresh (non-blocking)
+ (async () => {
+ try {
+ const s = await browser.storage.local.get({ allowedSites: [], selectedMirror: DEFAULTS.selectedMirror, askOnVisit: DEFAULTS.askOnVisit, mirrors: DEFAULTS.mirrors })
+ const cache = { allowedSites: s.allowedSites || [], selectedMirror: normalizeHost(s.selectedMirror || DEFAULTS.selectedMirror), askOnVisit: !!s.askOnVisit, mirrors: (s.mirrors || DEFAULTS.mirrors).map(normalizeHost), ts: Date.now() }
+ try { window.localStorage.setItem('__flean_cache', JSON.stringify(cache)) } catch (e) { /* ignore */ }
+ } catch (e) { /* ignore background refresh errors */ }
+ })()
+
+ // Keep the localStorage cache in sync immediately when preferences change.
+ if (browser && browser.storage && typeof browser.storage.onChanged === 'object') {
+ try {
+ browser.storage.onChanged.addListener((changes, areaName) => {
+ if (areaName !== 'local') return
+ let updated = false
+ let cache = null
+ try { cache = JSON.parse(window.localStorage.getItem('__flean_cache') || 'null') || { ts: 0 } } catch (e) { cache = { ts: 0 } }
+ if (changes.allowedSites) { cache.allowedSites = changes.allowedSites.newValue || []; updated = true }
+ if (changes.selectedMirror) { cache.selectedMirror = normalizeHost(changes.selectedMirror.newValue || DEFAULTS.selectedMirror); updated = true }
+ if (changes.askOnVisit) { cache.askOnVisit = !!changes.askOnVisit.newValue; updated = true }
+ if (changes.mirrors) { cache.mirrors = (changes.mirrors.newValue || DEFAULTS.mirrors).map(normalizeHost); updated = true }
+ if (updated) {
+ cache.ts = Date.now()
+ try { window.localStorage.setItem('__flean_cache', JSON.stringify(cache)) } catch (e) { /* ignore */ }
+ }
+ })
+ } catch (e) { /* ignore if onChanged isn't available */ }
+ }
- // Fast synchronous cache read (localStorage) to avoid blocking the page.
- let allowedSites = DEFAULTS.allowedSites.slice();
- let selectedMirror = DEFAULTS.selectedMirror;
- let askOnVisit = DEFAULTS.askOnVisit;
- let mirrors = DEFAULTS.mirrors.slice();
- try {
- const cache = JSON.parse(window.localStorage.getItem('__flean_cache') || 'null');
- if (cache && cache.ts && (Date.now() - cache.ts) < 30 * 1000) {
- allowedSites = cache.allowedSites || allowedSites;
- selectedMirror = normalizeHost(cache.selectedMirror || selectedMirror);
- askOnVisit = !!cache.askOnVisit;
- mirrors = (cache.mirrors || mirrors).map(normalizeHost);
- }
- } catch (e) { /* ignore cache parse errors */ }
-
- // Background refresh of storage to keep the cache fresh (non-blocking)
- (async () => {
- try {
- const s = await browser.storage.local.get({ allowedSites: [], selectedMirror: DEFAULTS.selectedMirror, askOnVisit: DEFAULTS.askOnVisit, mirrors: DEFAULTS.mirrors });
- const cache = { allowedSites: s.allowedSites || [], selectedMirror: normalizeHost(s.selectedMirror || DEFAULTS.selectedMirror), askOnVisit: !!s.askOnVisit, mirrors: (s.mirrors || DEFAULTS.mirrors).map(normalizeHost), ts: Date.now() };
- try { window.localStorage.setItem('__flean_cache', JSON.stringify(cache)); } catch (e) { /* ignore */ }
- } catch (e) { /* ignore background refresh errors */ }
- })();
-
- // Keep the localStorage cache in sync immediately when preferences change.
- if (browser && browser.storage && typeof browser.storage.onChanged === 'object') {
- try {
- browser.storage.onChanged.addListener((changes, areaName) => {
- if (areaName !== 'local') return;
- let updated = false;
- let cache = null;
- try { cache = JSON.parse(window.localStorage.getItem('__flean_cache') || 'null') || { ts: 0 }; } catch (e) { cache = { ts: 0 }; }
- if (changes.allowedSites) { cache.allowedSites = changes.allowedSites.newValue || []; updated = true; }
- if (changes.selectedMirror) { cache.selectedMirror = normalizeHost(changes.selectedMirror.newValue || DEFAULTS.selectedMirror); updated = true; }
- if (changes.askOnVisit) { cache.askOnVisit = !!changes.askOnVisit.newValue; updated = true; }
- if (changes.mirrors) { cache.mirrors = (changes.mirrors.newValue || DEFAULTS.mirrors).map(normalizeHost); updated = true; }
- if (updated) {
- cache.ts = Date.now();
- try { window.localStorage.setItem('__flean_cache', JSON.stringify(cache)); } catch (e) { /* ignore */ }
- }
- });
- } catch (e) { /* ignore if onChanged isn't available */ }
- }
+ // Fast session-scoped allow (for immediate navigation within this tab)
+ let isSessionAllowed = false
+ try {
+ const sess = window.sessionStorage.getItem('__flean_allow_until')
+ if (sess) {
+ const until = parseInt(sess, 10) || 0
+ if (Date.now() <= until) isSessionAllowed = true
+ else window.sessionStorage.removeItem('__flean_allow_until')
+ }
+ } catch (e) { /* ignore */ }
+
+ if ((allowedSites || []).includes(host) || isSessionAllowed) {
+ console.log('Flean: host is in ignore list or session-allowed, skipping redirection for', host)
+ return
+ }
- // Fast session-scoped allow (for immediate navigation within this tab)
- let isSessionAllowed = false;
- try {
- const sess = window.sessionStorage.getItem('__flean_allow_until');
- if (sess) {
- const until = parseInt(sess, 10) || 0;
- if (Date.now() <= until) isSessionAllowed = true;
- else window.sessionStorage.removeItem('__flean_allow_until');
- }
- } catch (e) { /* ignore */ }
-
- if ((allowedSites || []).includes(host) || isSessionAllowed) {
- console.log('Flean: host is in ignore list or session-allowed, skipping redirection for', host);
- return;
- }
+ // Derive the wiki name
+ let wikiName = host
+ if (host.endsWith('.fandom.com') || host.endsWith('.wikia.com')) {
+ const parts = host.split('.')
+ if (parts.length >= 3) wikiName = parts[parts.length - 3]; else wikiName = parts[0]
+ } else {
+ wikiName = host.split('.')[0]
+ }
- // Derive the wiki name
- let wikiName = host;
- if (host.endsWith('.fandom.com') || host.endsWith('.wikia.com')) {
- const parts = host.split('.');
- if (parts.length >= 3) wikiName = parts[parts.length - 3]; else wikiName = parts[0];
- } else {
- wikiName = host.split('.')[0];
- }
+ // Extract page title
+ let page = path.replace(/^\/wiki\//i, '')
+ try { page = decodeURIComponent(page) } catch (e) { /* ignore */ }
- // Extract page title
- let page = path.replace(/^\/wiki\//i, '');
- try { page = decodeURIComponent(page); } catch (e) { /* ignore */ }
+ // Compute mirror URL (fallback)
+ const pageForMirror = page.replace(/\s+/g, '_').replace(/^\/+|\/+$/g, '')
+ const wikiNameCap = wikiName.charAt(0).toUpperCase() + wikiName.slice(1)
+ const mirrorUrl = `${url.protocol}//${selectedMirror}/${wikiNameCap}/wiki/${pageForMirror}${url.search}${url.hash}`
- // Compute mirror URL (fallback)
- const pageForMirror = page.replace(/\s+/g, '_').replace(/^\/+|\/+$/g, '');
- const wikiNameCap = wikiName.charAt(0).toUpperCase() + wikiName.slice(1);
- const mirrorUrl = `${url.protocol}//${selectedMirror}/${wikiNameCap}/wiki/${pageForMirror}${url.search}${url.hash}`;
+ // If already on a mirror host, do nothing
+ if (host === selectedMirror || (mirrors || []).includes(host)) return
- // If already on a mirror host, do nothing
- if (host === selectedMirror || (mirrors || []).includes(host)) return;
+ // Query background.js for a structured indie wiki match.
+ // Falls back to the BreezeWiki mirror URL if unavailable or no match found.
+ let finalDestUrl = mirrorUrl
+ let finalDestLabel = selectedMirror
+ let isIndieWiki = false
+ try {
+ let cancelTimeout = null
+ const structured = await Promise.race([
+ browser.runtime.sendMessage({ action: 'findWiki', url: url.href }),
+ new Promise(resolve => { cancelTimeout = setTimeout(() => resolve(null), 500) })
+ ])
+ clearTimeout(cancelTimeout)
+ if (structured && structured.destinationUrl) {
+ finalDestUrl = structured.destinationUrl
+ finalDestLabel = structured.wikiName || new URL(structured.destinationUrl).host
+ isIndieWiki = true
+ }
+ } catch (e) { /* background unavailable – fall back to heuristic mirror */ }
+
+ // Fast-path for askOnVisit=false using sessionStorage-only attempt tracking
+ if (!askOnVisit) {
+ const ATTEMPT_WINDOW_MS = 10 * 1000 // 10s window
+ const ATTEMPT_THRESHOLD = 2 // either 2 navigations within the window OR 2 consecutive reloads independently trigger suppression
+ const SUPPRESS_COOLDOWN_MS = 30 * 1000
+
+ const now = Date.now()
+ const pageKey = url.href
+ const attemptsKey = '__flean_attempts:' + pageKey
+ const suppressKey = '__flean_suppressed:' + pageKey
+ const reloadKey = '__flean_reloads:' + pageKey
+
+ function readSessionArray (k) { try { return JSON.parse(window.sessionStorage.getItem(k) || '[]') } catch (e) { return [] } }
+ function writeSessionArray (k, arr) { try { window.sessionStorage.setItem(k, JSON.stringify(arr)) } catch (e) { /* ignore */ } }
+
+ // Detect whether this page load is a browser reload (F5 / Cmd-R)
+ let isReload = false
+ try {
+ const navEntry = performance.getEntriesByType('navigation')[0]
+ isReload = navEntry ? navEntry.type === 'reload' : (performance.navigation && performance.navigation.type === 1)
+ } catch (e) { /* ignore */ }
+
+ // Track consecutive reloads separately; two in a row bypasses the redirect.
+ // Intentionally resets to 0 on any non-reload navigation so only truly
+ // back-to-back reloads of the same page count toward suppression.
+ let reloadCount = 0
+ try {
+ reloadCount = isReload ? (parseInt(window.sessionStorage.getItem(reloadKey) || '0', 10) + 1) : 0
+ window.sessionStorage.setItem(reloadKey, String(reloadCount))
+ } catch (e) { /* ignore */ }
+
+ const recent = readSessionArray(attemptsKey).filter(ts => (now - ts) <= ATTEMPT_WINDOW_MS)
+ recent.push(now)
+ writeSessionArray(attemptsKey, recent)
+
+ const suppressedUntil = parseInt(window.sessionStorage.getItem(suppressKey) || '0', 10) || 0
+ if (now < suppressedUntil) {
+ console.debug('Flean: redirect suppressed until', new Date(suppressedUntil).toISOString())
+ showSuppressionBanner()
+ return
+ }
+
+ if (reloadCount >= ATTEMPT_THRESHOLD || recent.length >= ATTEMPT_THRESHOLD) {
+ window.sessionStorage.setItem(suppressKey, String(now + SUPPRESS_COOLDOWN_MS))
+ console.info('Flean: suppressing redirect for', pageKey, 'for', SUPPRESS_COOLDOWN_MS, 'ms (reloads=' + reloadCount + ', navigations=' + recent.length + ')')
+ showSuppressionBanner()
+ return
+ }
+
+ // Redirect quickly
+ console.log('Flean: askOnVisit=false, redirecting', url.href, '->', finalDestUrl)
+ try { window.location.replace(finalDestUrl) } catch (e) { console.warn('Flean: failed to redirect', e) }
+ return
+ }
- // Query background.js for a structured indie wiki match.
- // Falls back to the BreezeWiki mirror URL if unavailable or no match found.
- let finalDestUrl = mirrorUrl;
- let finalDestLabel = selectedMirror;
- let isIndieWiki = false;
- try {
- let cancelTimeout = null;
- const structured = await Promise.race([
- browser.runtime.sendMessage({ action: 'findWiki', url: url.href }),
- new Promise(resolve => { cancelTimeout = setTimeout(() => resolve(null), 500); })
- ]);
- clearTimeout(cancelTimeout);
- if (structured && structured.destinationUrl) {
- finalDestUrl = structured.destinationUrl;
- finalDestLabel = structured.wikiName || new URL(structured.destinationUrl).host;
- isIndieWiki = true;
- }
- } catch (e) { /* background unavailable – fall back to heuristic mirror */ }
-
- // Fast-path for askOnVisit=false using sessionStorage-only attempt tracking
- if (!askOnVisit) {
- const ATTEMPT_WINDOW_MS = 10 * 1000; // 10s window
- const ATTEMPT_THRESHOLD = 2; // either 2 navigations within the window OR 2 consecutive reloads independently trigger suppression
- const SUPPRESS_COOLDOWN_MS = 30 * 1000;
-
- const now = Date.now();
- const pageKey = url.href;
- const attemptsKey = '__flean_attempts:' + pageKey;
- const suppressKey = '__flean_suppressed:' + pageKey;
- const reloadKey = '__flean_reloads:' + pageKey;
-
- function readSessionArray(k) { try { return JSON.parse(window.sessionStorage.getItem(k) || '[]'); } catch (e) { return []; } }
- function writeSessionArray(k, arr) { try { window.sessionStorage.setItem(k, JSON.stringify(arr)); } catch (e) { /* ignore */ } }
-
- // Detect whether this page load is a browser reload (F5 / Cmd-R)
- let isReload = false;
+ // Helper: show suppression banner
+ function showSuppressionBanner () {
+ try {
+ if (document.getElementById('flean-suppress-banner')) return
+ const banner = document.createElement('div')
+ banner.id = 'flean-suppress-banner'
+ banner.style.position = 'fixed'
+ banner.style.top = '0'
+ banner.style.left = '0'
+ banner.style.right = '0'
+ banner.style.zIndex = '2147483646'
+ banner.style.background = '#fff3bf'
+ banner.style.color = '#222'
+ banner.style.borderBottom = '1px solid rgba(0,0,0,0.08)'
+ banner.style.padding = '10px 12px'
+ banner.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial'
+ banner.style.display = 'flex'
+ banner.style.alignItems = 'center'
+ banner.style.justifyContent = 'space-between'
+
+ const left = document.createElement('div')
+ left.innerHTML = 'You were not redirected because of multiple quick attempts to open this page. Configure settings for Flean'
+ banner.appendChild(left)
+
+ const closeBtn = document.createElement('button')
+ closeBtn.id = 'flean-suppress-close'
+ closeBtn.textContent = 'Dismiss'
+ closeBtn.style.background = 'transparent'
+ closeBtn.style.border = 'none'
+ closeBtn.style.cursor = 'pointer'
+ closeBtn.style.color = '#0b6cff'
+ banner.appendChild(closeBtn)
+
+ const container = document.body || document.documentElement
+ if (container) container.insertBefore(banner, container.firstChild)
+
+ const cfg = document.getElementById('flean-suppress-config')
+ if (cfg) {
+ cfg.addEventListener('click', async (e) => {
+ e.preventDefault(); e.stopPropagation()
try {
- const navEntry = performance.getEntriesByType('navigation')[0];
- isReload = navEntry ? navEntry.type === 'reload' : (performance.navigation && performance.navigation.type === 1);
- } catch (e) { /* ignore */ }
-
- // Track consecutive reloads separately; two in a row bypasses the redirect.
- // Intentionally resets to 0 on any non-reload navigation so only truly
- // back-to-back reloads of the same page count toward suppression.
- let reloadCount = 0;
+ if (browser.action && typeof browser.action.openPopup === 'function') {
+ await browser.action.openPopup(); return
+ }
+ } catch (e) { /* try next */ }
try {
- reloadCount = isReload ? (parseInt(window.sessionStorage.getItem(reloadKey) || '0', 10) + 1) : 0;
- window.sessionStorage.setItem(reloadKey, String(reloadCount));
- } catch (e) { /* ignore */ }
-
- const recent = readSessionArray(attemptsKey).filter(ts => (now - ts) <= ATTEMPT_WINDOW_MS);
- recent.push(now);
- writeSessionArray(attemptsKey, recent);
-
- const suppressedUntil = parseInt(window.sessionStorage.getItem(suppressKey) || '0', 10) || 0;
- if (now < suppressedUntil) {
- console.debug('Flean: redirect suppressed until', new Date(suppressedUntil).toISOString());
- showSuppressionBanner();
- return;
- }
-
- if (reloadCount >= ATTEMPT_THRESHOLD || recent.length >= ATTEMPT_THRESHOLD) {
- window.sessionStorage.setItem(suppressKey, String(now + SUPPRESS_COOLDOWN_MS));
- console.info('Flean: suppressing redirect for', pageKey, 'for', SUPPRESS_COOLDOWN_MS, 'ms (reloads=' + reloadCount + ', navigations=' + recent.length + ')');
- showSuppressionBanner();
- return;
- }
-
- // Redirect quickly
- console.log('Flean: askOnVisit=false, redirecting', url.href, '->', finalDestUrl);
- try { window.location.replace(finalDestUrl); } catch (e) { console.warn('Flean: failed to redirect', e); }
- return;
- }
-
- // Helper: show suppression banner
- function showSuppressionBanner() {
- try {
- if (document.getElementById('flean-suppress-banner')) return;
- const banner = document.createElement('div');
- banner.id = 'flean-suppress-banner';
- banner.style.position = 'fixed';
- banner.style.top = '0';
- banner.style.left = '0';
- banner.style.right = '0';
- banner.style.zIndex = '2147483646';
- banner.style.background = '#fff3bf';
- banner.style.color = '#222';
- banner.style.borderBottom = '1px solid rgba(0,0,0,0.08)';
- banner.style.padding = '10px 12px';
- banner.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial';
- banner.style.display = 'flex';
- banner.style.alignItems = 'center';
- banner.style.justifyContent = 'space-between';
-
- const left = document.createElement('div');
- left.innerHTML = 'You were not redirected because of multiple quick attempts to open this page. Configure settings for Flean';
- banner.appendChild(left);
-
- const closeBtn = document.createElement('button');
- closeBtn.id = 'flean-suppress-close';
- closeBtn.textContent = 'Dismiss';
- closeBtn.style.background = 'transparent';
- closeBtn.style.border = 'none';
- closeBtn.style.cursor = 'pointer';
- closeBtn.style.color = '#0b6cff';
- banner.appendChild(closeBtn);
-
- const container = document.body || document.documentElement;
- if (container) container.insertBefore(banner, container.firstChild);
-
- const cfg = document.getElementById('flean-suppress-config');
- if (cfg) cfg.addEventListener('click', async (e) => {
- e.preventDefault(); e.stopPropagation();
- try {
- if (browser.action && typeof browser.action.openPopup === 'function') {
- await browser.action.openPopup(); return;
- }
- } catch (e) { /* try next */ }
- try {
- if (browser.runtime && typeof browser.runtime.openOptionsPage === 'function') {
- browser.runtime.openOptionsPage(); return;
- }
- } catch (e) { /* try next */ }
- try { await browser.tabs.create({ url: browser.runtime.getURL('popup.html') }); } catch (e) { console.warn('Flean: could not open settings', e); }
- });
- closeBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); banner.remove(); });
- console.debug('Flean: suppression banner shown');
- } catch (e) {
- console.warn('Flean: failed to create suppression banner', e);
- }
+ if (browser.runtime && typeof browser.runtime.openOptionsPage === 'function') {
+ browser.runtime.openOptionsPage(); return
+ }
+ } catch (e) { /* try next */ }
+ try { await browser.tabs.create({ url: browser.runtime.getURL('popup.html') }) } catch (e) { console.warn('Flean: could not open settings', e) }
+ })
}
+ closeBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); banner.remove() })
+ console.debug('Flean: suppression banner shown')
+ } catch (e) {
+ console.warn('Flean: failed to create suppression banner', e)
+ }
+ }
- // If askOnVisit is true, show the overlay/interstitial so the user can decide.
- const style = document.createElement('style');
- style.textContent = `
+ // If askOnVisit is true, show the overlay/interstitial so the user can decide.
+ const style = document.createElement('style')
+ style.textContent = `
#flean-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.6); color:#fff; display:flex; align-items:center; justify-content:center; z-index:2147483647; }
#flean-card { background:#0b1220; color:#fff; padding:18px; border-radius:10px; max-width:520px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; box-shadow:0 8px 30px rgba(0,0,0,0.6); }
#flean-card h1 { font-size:18px; margin:0 0 8px; }
@@ -279,17 +281,17 @@
.flean-btn { background:#1f6feb; color:#fff; border:none; padding:8px 10px; border-radius:6px; cursor:pointer }
.flean-btn.secondary { background:#2d3748 }
.flean-link { color:#9bd1ff; text-decoration:underline; cursor:pointer }
- `;
-
- const overlay = document.createElement('div');
- overlay.id = 'flean-overlay';
- const overlayHeading = isIndieWiki
- ? `Open this wiki on its independent mirror?`
- : `Open this Fandom wiki on a Breezewiki mirror?`;
- const overlayBody = isIndieWiki
- ? `This page has an independent wiki. You can open the same article on ${finalDestLabel} (recommended), or continue to visit the Fandom page.`
- : `This page appears to be a Fandom wiki article. You can open the same article on ${finalDestLabel} (recommended), or continue to visit the Fandom page.`;
- overlay.innerHTML = `
+ `
+
+ const overlay = document.createElement('div')
+ overlay.id = 'flean-overlay'
+ const overlayHeading = isIndieWiki
+ ? 'Open this wiki on its independent mirror?'
+ : 'Open this Fandom wiki on a Breezewiki mirror?'
+ const overlayBody = isIndieWiki
+ ? `This page has an independent wiki. You can open the same article on ${finalDestLabel} (recommended), or continue to visit the Fandom page.`
+ : `This page appears to be a Fandom wiki article. You can open the same article on ${finalDestLabel} (recommended), or continue to visit the Fandom page.`
+ overlay.innerHTML = `
${overlayHeading}
${overlayBody}
@@ -300,66 +302,71 @@
- `;
-
- if (document.head) document.head.appendChild(style); else document.documentElement.appendChild(style);
- if (document.body) document.body.appendChild(overlay); else document.documentElement.appendChild(overlay);
-
- const openBtn = overlay.querySelector('#flean-open-mirror');
- const onceBtn = overlay.querySelector('#flean-visit-once');
- const allowBtn = overlay.querySelector('#flean-allow-site');
- const settingsLink = overlay.querySelector('#flean-open-popup');
-
- console.debug('Flean: overlay buttons', { openBtn: !!openBtn, onceBtn: !!onceBtn, allowBtn: !!allowBtn, settingsLink: !!settingsLink });
-
- overlay.addEventListener('click', (ev) => {
- try { console.debug('Flean: overlay click', ev.target && (ev.target.id || ev.target.className || ev.target.tagName)); } catch (e) { /* ignore */ }
- }, { capture: true });
-
- if (openBtn) openBtn.addEventListener('click', (e) => {
- e.preventDefault(); e.stopPropagation();
- console.debug('Flean: open mirror button clicked');
- try { window.location.replace(finalDestUrl); } catch (err) { console.warn('Flean: failed to open mirror', err); }
- });
-
- if (onceBtn) onceBtn.addEventListener('click', (e) => {
- e.preventDefault(); e.stopPropagation();
- console.debug('Flean: visit once clicked');
- overlay.remove();
- style.remove();
- });
-
- if (allowBtn) allowBtn.addEventListener('click', (e) => {
- e.preventDefault(); e.stopPropagation();
- console.debug('Flean: allow site clicked for', host);
- try {
- const SESSION_ALLOW_MS = 5 * 1000; // 5 seconds
- const until = Date.now() + SESSION_ALLOW_MS;
- try { window.sessionStorage.setItem('__flean_allow_until', String(until)); } catch (e) { /* ignore */ }
- console.info('Flean: session-allow for host', host, 'until', new Date(until).toISOString());
- } catch (err) { console.warn('Flean: failed to set session allow', err); }
- overlay.remove();
- style.remove();
- });
-
- if (settingsLink) {
- settingsLink.addEventListener('click', async (e) => {
- e.preventDefault(); e.stopPropagation();
- try {
- if (browser.action && typeof browser.action.openPopup === 'function') {
- await browser.action.openPopup(); return;
- }
- } catch (e) { /* try next */ }
- try {
- if (browser.runtime && typeof browser.runtime.openOptionsPage === 'function') {
- browser.runtime.openOptionsPage(); return;
- }
- } catch (e) { /* try next */ }
- try { await browser.tabs.create({ url: browser.runtime.getURL('popup.html') }); } catch (e) { console.warn('Flean: could not open settings', e); }
- });
- }
+ `
+
+ if (document.head) document.head.appendChild(style); else document.documentElement.appendChild(style)
+ if (document.body) document.body.appendChild(overlay); else document.documentElement.appendChild(overlay)
+
+ const openBtn = overlay.querySelector('#flean-open-mirror')
+ const onceBtn = overlay.querySelector('#flean-visit-once')
+ const allowBtn = overlay.querySelector('#flean-allow-site')
+ const settingsLink = overlay.querySelector('#flean-open-popup')
+
+ console.debug('Flean: overlay buttons', { openBtn: !!openBtn, onceBtn: !!onceBtn, allowBtn: !!allowBtn, settingsLink: !!settingsLink })
+
+ overlay.addEventListener('click', (ev) => {
+ try { console.debug('Flean: overlay click', ev.target && (ev.target.id || ev.target.className || ev.target.tagName)) } catch (e) { /* ignore */ }
+ }, { capture: true })
- } catch (err) {
- console.error('Flean content script error:', err);
+ if (openBtn) {
+ openBtn.addEventListener('click', (e) => {
+ e.preventDefault(); e.stopPropagation()
+ console.debug('Flean: open mirror button clicked')
+ try { window.location.replace(finalDestUrl) } catch (err) { console.warn('Flean: failed to open mirror', err) }
+ })
+ }
+
+ if (onceBtn) {
+ onceBtn.addEventListener('click', (e) => {
+ e.preventDefault(); e.stopPropagation()
+ console.debug('Flean: visit once clicked')
+ overlay.remove()
+ style.remove()
+ })
+ }
+
+ if (allowBtn) {
+ allowBtn.addEventListener('click', (e) => {
+ e.preventDefault(); e.stopPropagation()
+ console.debug('Flean: allow site clicked for', host)
+ try {
+ const SESSION_ALLOW_MS = 5 * 1000 // 5 seconds
+ const until = Date.now() + SESSION_ALLOW_MS
+ try { window.sessionStorage.setItem('__flean_allow_until', String(until)) } catch (e) { /* ignore */ }
+ console.info('Flean: session-allow for host', host, 'until', new Date(until).toISOString())
+ } catch (err) { console.warn('Flean: failed to set session allow', err) }
+ overlay.remove()
+ style.remove()
+ })
+ }
+
+ if (settingsLink) {
+ settingsLink.addEventListener('click', async (e) => {
+ e.preventDefault(); e.stopPropagation()
+ try {
+ if (browser.action && typeof browser.action.openPopup === 'function') {
+ await browser.action.openPopup(); return
+ }
+ } catch (e) { /* try next */ }
+ try {
+ if (browser.runtime && typeof browser.runtime.openOptionsPage === 'function') {
+ browser.runtime.openOptionsPage(); return
+ }
+ } catch (e) { /* try next */ }
+ try { await browser.tabs.create({ url: browser.runtime.getURL('popup.html') }) } catch (e) { console.warn('Flean: could not open settings', e) }
+ })
}
-})();
+ } catch (err) {
+ console.error('Flean content script error:', err)
+ }
+})()
diff --git a/ios/extention/Resources/scripts/wiki-data-manager.js b/ios/extention/Resources/scripts/wiki-data-manager.js
index e6d4cc4..9c6aa41 100644
--- a/ios/extention/Resources/scripts/wiki-data-manager.js
+++ b/ios/extention/Resources/scripts/wiki-data-manager.js
@@ -2,189 +2,189 @@
// Remote wiki data loading with local caching and heuristic fallback.
// Data source: indie-wiki-buddy (https://github.com/KevinPayravi/indie-wiki-buddy)
-const WIKI_DATA_URL = 'https://raw.githubusercontent.com/KevinPayravi/indie-wiki-buddy/main/data/sitesEN.json';
-const CACHE_KEY = 'wikiData';
-const CACHE_TS_KEY = 'wikiDataLastFetch';
-const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
+const WIKI_DATA_URL = 'https://raw.githubusercontent.com/KevinPayravi/indie-wiki-buddy/main/data/sitesEN.json'
+const CACHE_KEY = 'wikiData'
+const CACHE_TS_KEY = 'wikiDataLastFetch'
+const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
-const BASE64REGEX = /^[A-Za-z0-9+/]+=*$/;
+const BASE64REGEX = /^[A-Za-z0-9+/]+=*$/
/**
* Compress a JS value to a gzip+base64 string.
* Falls back to plain JSON string if CompressionStream is unavailable.
*/
-export async function compressJSON(value) {
- const json = JSON.stringify(value);
- if (typeof CompressionStream === 'undefined') return btoa(json);
- try {
- const bytes = new TextEncoder().encode(json);
- const stream = new CompressionStream('gzip');
- const writer = stream.writable.getWriter();
- writer.write(bytes);
- writer.close();
- const compressed = await new Response(stream.readable).arrayBuffer();
- const uint8 = new Uint8Array(compressed);
- let binary = '';
- for (let i = 0; i < uint8.length; i++) binary += String.fromCharCode(uint8[i]);
- return btoa(binary);
- } catch (e) {
- return btoa(json);
- }
+export async function compressJSON (value) {
+ const json = JSON.stringify(value)
+ if (typeof CompressionStream === 'undefined') return btoa(json)
+ try {
+ const bytes = new TextEncoder().encode(json)
+ const stream = new CompressionStream('gzip')
+ const writer = stream.writable.getWriter()
+ writer.write(bytes)
+ writer.close()
+ const compressed = await new Response(stream.readable).arrayBuffer()
+ const uint8 = new Uint8Array(compressed)
+ let binary = ''
+ for (let i = 0; i < uint8.length; i++) binary += String.fromCharCode(uint8[i])
+ return btoa(binary)
+ } catch (e) {
+ return btoa(json)
+ }
}
/**
* Decompress a gzip+base64 string back to the original value.
* Accepts both gzip-compressed and plain base64-encoded JSON (fallback).
*/
-export async function decompressJSON(value) {
- if (!value || !BASE64REGEX.test(value)) throw new Error('Invalid compressed data');
- if (typeof DecompressionStream === 'undefined') return JSON.parse(atob(value));
- try {
- const binary = atob(value);
- const bytes = new Uint8Array(binary.length);
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
- const stream = new DecompressionStream('gzip');
- const writer = stream.writable.getWriter();
- writer.write(bytes);
- writer.close();
- const decompressed = await new Response(stream.readable).arrayBuffer();
- return JSON.parse(new TextDecoder().decode(decompressed));
- } catch (e) {
- // May be plain base64 JSON (written by the fallback path above)
- try { return JSON.parse(atob(value)); } catch (e2) { throw e; }
- }
+export async function decompressJSON (value) {
+ if (!value || !BASE64REGEX.test(value)) throw new Error('Invalid compressed data')
+ if (typeof DecompressionStream === 'undefined') return JSON.parse(atob(value))
+ try {
+ const binary = atob(value)
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
+ const stream = new DecompressionStream('gzip')
+ const writer = stream.writable.getWriter()
+ writer.write(bytes)
+ writer.close()
+ const decompressed = await new Response(stream.readable).arrayBuffer()
+ return JSON.parse(new TextDecoder().decode(decompressed))
+ } catch (e) {
+ // May be plain base64 JSON (written by the fallback path above)
+ try { return JSON.parse(atob(value)) } catch (e2) { throw e }
+ }
}
/**
* Fetch fresh wiki data from the GitHub CDN, compress it, and store in browser.storage.local.
* Returns the raw data array, or throws on failure.
*/
-export async function fetchWikiData() {
- const response = await fetch(WIKI_DATA_URL);
- if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
- const data = await response.json();
- const compressed = await compressJSON(data);
- await browser.storage.local.set({ [CACHE_KEY]: compressed, [CACHE_TS_KEY]: Date.now() });
- return data;
+export async function fetchWikiData () {
+ const response = await fetch(WIKI_DATA_URL)
+ if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
+ const data = await response.json()
+ const compressed = await compressJSON(data)
+ await browser.storage.local.set({ [CACHE_KEY]: compressed, [CACHE_TS_KEY]: Date.now() })
+ return data
}
/**
* Get wiki data from cache, fetching fresh data if the cache is stale (>7 days) or absent.
* Returns the data array, or null if unavailable (network failure, etc.).
*/
-export async function getWikiData() {
- try {
- const stored = await browser.storage.local.get([CACHE_KEY, CACHE_TS_KEY]);
- const ts = stored[CACHE_TS_KEY] || 0;
- const compressed = stored[CACHE_KEY];
- if (compressed && (Date.now() - ts) < CACHE_TTL_MS) {
- try {
- return await decompressJSON(compressed);
- } catch (e) {
- console.warn('Flean: wiki data decompression failed, re-fetching', e);
- }
- }
- return await fetchWikiData();
- } catch (e) {
- console.warn('Flean: could not load wiki data, falling back to heuristics', e);
- return null;
+export async function getWikiData () {
+ try {
+ const stored = await browser.storage.local.get([CACHE_KEY, CACHE_TS_KEY])
+ const ts = stored[CACHE_TS_KEY] || 0
+ const compressed = stored[CACHE_KEY]
+ if (compressed && (Date.now() - ts) < CACHE_TTL_MS) {
+ try {
+ return await decompressJSON(compressed)
+ } catch (e) {
+ console.warn('Flean: wiki data decompression failed, re-fetching', e)
+ }
}
+ return await fetchWikiData()
+ } catch (e) {
+ console.warn('Flean: could not load wiki data, falling back to heuristics', e)
+ return null
+ }
}
/** Build an in-memory Map from normalised origin host → { wiki, originEntry }. */
-function buildIndex(wikiData) {
- const index = new Map();
- if (!Array.isArray(wikiData)) return index;
- for (const wiki of wikiData) {
- if (!wiki.origins || !wiki.destination_base_url) continue;
- for (const origin of wiki.origins) {
- if (!origin.origin_base_url) continue;
- const key = origin.origin_base_url.toLowerCase()
- .replace(/^https?:\/\//, '')
- .replace(/\/$/, '');
- index.set(key, { wiki, originEntry: origin });
- }
+function buildIndex (wikiData) {
+ const index = new Map()
+ if (!Array.isArray(wikiData)) return index
+ for (const wiki of wikiData) {
+ if (!wiki.origins || !wiki.destination_base_url) continue
+ for (const origin of wiki.origins) {
+ if (!origin.origin_base_url) continue
+ const key = origin.origin_base_url.toLowerCase()
+ .replace(/^https?:\/\//, '')
+ .replace(/\/$/, '')
+ index.set(key, { wiki, originEntry: origin })
}
- return index;
+ }
+ return index
}
-let _wikiIndex = null;
-let _wikiIndexPromise = null;
+let _wikiIndex = null
+let _wikiIndexPromise = null
/** Ensure the in-memory lookup index is built. */
-async function ensureIndex() {
- if (_wikiIndex !== null) return _wikiIndex;
- if (_wikiIndexPromise) return _wikiIndexPromise;
- _wikiIndexPromise = getWikiData().then(data => {
- _wikiIndex = buildIndex(data);
- _wikiIndexPromise = null;
- return _wikiIndex;
- }).catch(() => {
- _wikiIndex = new Map();
- _wikiIndexPromise = null;
- return _wikiIndex;
- });
- return _wikiIndexPromise;
+async function ensureIndex () {
+ if (_wikiIndex !== null) return _wikiIndex
+ if (_wikiIndexPromise) return _wikiIndexPromise
+ _wikiIndexPromise = getWikiData().then(data => {
+ _wikiIndex = buildIndex(data)
+ _wikiIndexPromise = null
+ return _wikiIndex
+ }).catch(() => {
+ _wikiIndex = new Map()
+ _wikiIndexPromise = null
+ return _wikiIndex
+ })
+ return _wikiIndexPromise
}
/**
* Invalidate the in-memory index so it is rebuilt on the next lookup.
* Call this after a successful data refresh.
*/
-export function invalidateIndex() {
- _wikiIndex = null;
+export function invalidateIndex () {
+ _wikiIndex = null
}
/**
* Given a URL string, find the best matching independent wiki destination.
* Returns { destinationUrl: string, wikiName: string } or null if no match found.
*/
-export async function findMatchingWiki(urlString) {
- try {
- const url = new URL(urlString);
- const host = url.host.toLowerCase();
- const index = await ensureIndex();
-
- // Try exact host, then without leading 'www.'
- const match = index.get(host) || (host.startsWith('www.') ? index.get(host.slice(4)) : undefined);
- if (!match) return null;
-
- const { wiki, originEntry } = match;
-
- // Extract the article name from the path using the origin's content path prefix.
- // indie-wiki-buddy templates use a single `$1` placeholder (e.g. "/wiki/$1").
- // We strip everything from `$1` onward to obtain just the path prefix.
- const originPathPrefix = (originEntry.origin_content_path || '/wiki/')
- .replace(/\$1.*$/, '');
- let article = url.pathname;
- // Case-insensitive prefix match is intentional: wiki path prefixes like /wiki/
- // are the same regardless of casing on every platform targeted by this data.
- if (article.toLowerCase().startsWith(originPathPrefix.toLowerCase())) {
- article = article.slice(originPathPrefix.length);
- }
- try { article = decodeURIComponent(article); } catch (e) { /* keep encoded */ }
-
- // Build destination URL based on platform
- const destBase = wiki.destination_base_url
- .replace(/^https?:\/\//, '')
- .replace(/\/$/, '');
- const platform = (wiki.destination_platform || 'mediawiki').toLowerCase();
- let destPath;
-
- if (wiki.destination_content_path) {
- destPath = wiki.destination_content_path
- .replace('$1', encodeURIComponent(article.replace(/ /g, '_')));
- } else if (platform === 'dokuwiki') {
- destPath = '/doku.php?id=' + encodeURIComponent(article.replace(/ /g, '_').toLowerCase());
- } else {
- // Default: MediaWiki-style /wiki/ArticleName
- destPath = '/wiki/' + encodeURIComponent(article.replace(/ /g, '_'));
- }
-
- const destinationUrl = `https://${destBase}${destPath}${url.search}${url.hash}`;
- const wikiName = wiki.destination || wiki.article || destBase;
- return { destinationUrl, wikiName };
- } catch (e) {
- return null;
+export async function findMatchingWiki (urlString) {
+ try {
+ const url = new URL(urlString)
+ const host = url.host.toLowerCase()
+ const index = await ensureIndex()
+
+ // Try exact host, then without leading 'www.'
+ const match = index.get(host) || (host.startsWith('www.') ? index.get(host.slice(4)) : undefined)
+ if (!match) return null
+
+ const { wiki, originEntry } = match
+
+ // Extract the article name from the path using the origin's content path prefix.
+ // indie-wiki-buddy templates use a single `$1` placeholder (e.g. "/wiki/$1").
+ // We strip everything from `$1` onward to obtain just the path prefix.
+ const originPathPrefix = (originEntry.origin_content_path || '/wiki/')
+ .replace(/\$1.*$/, '')
+ let article = url.pathname
+ // Case-insensitive prefix match is intentional: wiki path prefixes like /wiki/
+ // are the same regardless of casing on every platform targeted by this data.
+ if (article.toLowerCase().startsWith(originPathPrefix.toLowerCase())) {
+ article = article.slice(originPathPrefix.length)
}
+ try { article = decodeURIComponent(article) } catch (e) { /* keep encoded */ }
+
+ // Build destination URL based on platform
+ const destBase = wiki.destination_base_url
+ .replace(/^https?:\/\//, '')
+ .replace(/\/$/, '')
+ const platform = (wiki.destination_platform || 'mediawiki').toLowerCase()
+ let destPath
+
+ if (wiki.destination_content_path) {
+ destPath = wiki.destination_content_path
+ .replace('$1', encodeURIComponent(article.replace(/ /g, '_')))
+ } else if (platform === 'dokuwiki') {
+ destPath = '/doku.php?id=' + encodeURIComponent(article.replace(/ /g, '_').toLowerCase())
+ } else {
+ // Default: MediaWiki-style /wiki/ArticleName
+ destPath = '/wiki/' + encodeURIComponent(article.replace(/ /g, '_'))
+ }
+
+ const destinationUrl = `https://${destBase}${destPath}${url.search}${url.hash}`
+ const wikiName = wiki.destination || wiki.article || destBase
+ return { destinationUrl, wikiName }
+ } catch (e) {
+ return null
+ }
}
diff --git a/mos/Flean Extension/Resources/background.js b/mos/Flean Extension/Resources/background.js
index 19a9e9d..0f1c608 100644
--- a/mos/Flean Extension/Resources/background.js
+++ b/mos/Flean Extension/Resources/background.js
@@ -1,44 +1,44 @@
-import { getWikiData, fetchWikiData, findMatchingWiki, invalidateIndex } from './scripts/wiki-data-manager.js';
+import { getWikiData, fetchWikiData, findMatchingWiki, invalidateIndex } from './scripts/wiki-data-manager.js'
// Initialise wiki data on extension startup (loads from cache or fetches fresh)
-async function initWikiData() {
- try {
- await getWikiData();
- console.log('Flean: wiki data ready');
- } catch (e) {
- console.warn('Flean: wiki data init failed, heuristics will be used', e);
- }
+async function initWikiData () {
+ try {
+ await getWikiData()
+ console.log('Flean: wiki data ready')
+ } catch (e) {
+ console.warn('Flean: wiki data init failed, heuristics will be used', e)
+ }
}
// Schedule a weekly background refresh using browser.alarms (if supported)
-function setupRefreshAlarm() {
- if (typeof browser === 'undefined' || !browser.alarms) return;
- try {
- browser.alarms.create('wikiDataRefresh', { periodInMinutes: 7 * 24 * 60 });
- browser.alarms.onAlarm.addListener(async (alarm) => {
- if (alarm.name !== 'wikiDataRefresh') return;
- try {
- await fetchWikiData();
- invalidateIndex();
- console.log('Flean: wiki data refreshed');
- } catch (e) {
- console.warn('Flean: scheduled wiki data refresh failed', e);
- }
- });
- } catch (e) {
- console.warn('Flean: could not set up refresh alarm', e);
- }
+function setupRefreshAlarm () {
+ if (typeof browser === 'undefined' || !browser.alarms) return
+ try {
+ browser.alarms.create('wikiDataRefresh', { periodInMinutes: 7 * 24 * 60 })
+ browser.alarms.onAlarm.addListener(async (alarm) => {
+ if (alarm.name !== 'wikiDataRefresh') return
+ try {
+ await fetchWikiData()
+ invalidateIndex()
+ console.log('Flean: wiki data refreshed')
+ } catch (e) {
+ console.warn('Flean: scheduled wiki data refresh failed', e)
+ }
+ })
+ } catch (e) {
+ console.warn('Flean: could not set up refresh alarm', e)
+ }
}
browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
- if (request.greeting === 'hello') {
- return Promise.resolve({ farewell: 'goodbye' });
- }
+ if (request.greeting === 'hello') {
+ return Promise.resolve({ farewell: 'goodbye' })
+ }
- if (request.action === 'findWiki') {
- return findMatchingWiki(request.url).catch(() => null);
- }
-});
+ if (request.action === 'findWiki') {
+ return findMatchingWiki(request.url).catch(() => null)
+ }
+})
-initWikiData();
-setupRefreshAlarm();
+initWikiData()
+setupRefreshAlarm()
diff --git a/mos/Flean Extension/Resources/content.js b/mos/Flean Extension/Resources/content.js
index bdb2d64..c09fbff 100644
--- a/mos/Flean Extension/Resources/content.js
+++ b/mos/Flean Extension/Resources/content.js
@@ -1,276 +1,278 @@
// Content script: intercept fandom/wikia wiki pages and redirect to a selected Breezewiki mirror.
(async function () {
+ try {
+ const url = new URL(window.location.href)
+ const host = url.host.toLowerCase()
+ const path = url.pathname
+
+ // Only handle wiki pages: /wiki/...
+ if (!path.startsWith('/wiki/')) return
+
+ // Known source hosts to rewrite from
+ const isFandom = host === 'fandom.com' || host.endsWith('.fandom.com') || host.endsWith('.wikia.com')
+ if (!isFandom) return
+
+ // Defaults
+ const DEFAULTS = {
+ allowedSites: [],
+ selectedMirror: 'breezewiki.com',
+ askOnVisit: false,
+ mirrors: [
+ 'breezewiki.com',
+ 'antifandom.com',
+ 'breezewiki.pussthecat.org',
+ 'bw.hamstro.dev',
+ 'bw.projectsegfau.lt',
+ 'breeze.hostux.net',
+ 'bw.artemislena.eu',
+ 'nerd.whatever.social',
+ 'breezewiki.frontendfriendly.xyz',
+ 'breeze.nohost.network',
+ 'breeze.whateveritworks.org',
+ 'z.opnxng.com',
+ 'breezewiki.hyperreal.coffee',
+ 'breezewiki.catsarch.com',
+ 'breeze.mint.lgbt',
+ 'breezewiki.woodland.cafe',
+ 'breezewiki.nadeko.net',
+ 'fandom.reallyaweso.me',
+ 'breezewiki.4o1x5.dev',
+ 'breezewiki.r4fo.com',
+ 'breezewiki.private.coffee',
+ 'fan.blitzw.in'
+ ]
+ }
+
+ // Helper to normalize mirror strings to a host (e.g. strip https:// and paths)
+ function normalizeHost (str) {
+ if (!str) return str
+ try {
+ // If it's a full URL, URL() will succeed and we can read host
+ return new URL(str).host.toLowerCase()
+ } catch (e) {
+ try { return new URL('https://' + str).host.toLowerCase() } catch (e2) { return str.toLowerCase() }
+ }
+ }
+
+ // Fast synchronous cache read (localStorage) to avoid blocking the page.
+ let allowedSites = DEFAULTS.allowedSites.slice()
+ let selectedMirror = DEFAULTS.selectedMirror
+ let askOnVisit = DEFAULTS.askOnVisit
+ let mirrors = DEFAULTS.mirrors.slice()
try {
- const url = new URL(window.location.href);
- const host = url.host.toLowerCase();
- const path = url.pathname;
-
- // Only handle wiki pages: /wiki/...
- if (!path.startsWith('/wiki/')) return;
-
- // Known source hosts to rewrite from
- const isFandom = host === 'fandom.com' || host.endsWith('.fandom.com') || host.endsWith('.wikia.com');
- if (!isFandom) return;
-
- // Defaults
- const DEFAULTS = {
- allowedSites: [],
- selectedMirror: 'breezewiki.com',
- askOnVisit: false,
- mirrors: [
- 'breezewiki.com',
- 'antifandom.com',
- 'breezewiki.pussthecat.org',
- 'bw.hamstro.dev',
- 'bw.projectsegfau.lt',
- 'breeze.hostux.net',
- 'bw.artemislena.eu',
- 'nerd.whatever.social',
- 'breezewiki.frontendfriendly.xyz',
- 'breeze.nohost.network',
- 'breeze.whateveritworks.org',
- 'z.opnxng.com',
- 'breezewiki.hyperreal.coffee',
- 'breezewiki.catsarch.com',
- 'breeze.mint.lgbt',
- 'breezewiki.woodland.cafe',
- 'breezewiki.nadeko.net',
- 'fandom.reallyaweso.me',
- 'breezewiki.4o1x5.dev',
- 'breezewiki.r4fo.com',
- 'breezewiki.private.coffee',
- 'fan.blitzw.in'
- ]
- };
-
- // Helper to normalize mirror strings to a host (e.g. strip https:// and paths)
- function normalizeHost(str) {
- if (!str) return str;
- try {
- // If it's a full URL, URL() will succeed and we can read host
- return new URL(str).host.toLowerCase();
- } catch (e) {
- try { return new URL('https://' + str).host.toLowerCase(); } catch (e2) { return str.toLowerCase(); }
- }
- }
+ const cache = JSON.parse(window.localStorage.getItem('__flean_cache') || 'null')
+ if (cache && cache.ts && (Date.now() - cache.ts) < 30 * 1000) {
+ allowedSites = cache.allowedSites || allowedSites
+ selectedMirror = normalizeHost(cache.selectedMirror || selectedMirror)
+ askOnVisit = !!cache.askOnVisit
+ mirrors = (cache.mirrors || mirrors).map(normalizeHost)
+ }
+ } catch (e) { /* ignore cache parse errors */ }
+
+ // Background refresh of storage to keep the cache fresh (non-blocking)
+ (async () => {
+ try {
+ const s = await browser.storage.local.get({ allowedSites: [], selectedMirror: DEFAULTS.selectedMirror, askOnVisit: DEFAULTS.askOnVisit, mirrors: DEFAULTS.mirrors })
+ const cache = { allowedSites: s.allowedSites || [], selectedMirror: normalizeHost(s.selectedMirror || DEFAULTS.selectedMirror), askOnVisit: !!s.askOnVisit, mirrors: (s.mirrors || DEFAULTS.mirrors).map(normalizeHost), ts: Date.now() }
+ try { window.localStorage.setItem('__flean_cache', JSON.stringify(cache)) } catch (e) { /* ignore */ }
+ } catch (e) { /* ignore background refresh errors */ }
+ })()
+
+ // Keep the localStorage cache in sync immediately when preferences change.
+ if (browser && browser.storage && typeof browser.storage.onChanged === 'object') {
+ try {
+ browser.storage.onChanged.addListener((changes, areaName) => {
+ if (areaName !== 'local') return
+ let updated = false
+ let cache = null
+ try { cache = JSON.parse(window.localStorage.getItem('__flean_cache') || 'null') || { ts: 0 } } catch (e) { cache = { ts: 0 } }
+ if (changes.allowedSites) { cache.allowedSites = changes.allowedSites.newValue || []; updated = true }
+ if (changes.selectedMirror) { cache.selectedMirror = normalizeHost(changes.selectedMirror.newValue || DEFAULTS.selectedMirror); updated = true }
+ if (changes.askOnVisit) { cache.askOnVisit = !!changes.askOnVisit.newValue; updated = true }
+ if (changes.mirrors) { cache.mirrors = (changes.mirrors.newValue || DEFAULTS.mirrors).map(normalizeHost); updated = true }
+ if (updated) {
+ cache.ts = Date.now()
+ try { window.localStorage.setItem('__flean_cache', JSON.stringify(cache)) } catch (e) { /* ignore */ }
+ }
+ })
+ } catch (e) { /* ignore if onChanged isn't available */ }
+ }
- // Fast synchronous cache read (localStorage) to avoid blocking the page.
- let allowedSites = DEFAULTS.allowedSites.slice();
- let selectedMirror = DEFAULTS.selectedMirror;
- let askOnVisit = DEFAULTS.askOnVisit;
- let mirrors = DEFAULTS.mirrors.slice();
- try {
- const cache = JSON.parse(window.localStorage.getItem('__flean_cache') || 'null');
- if (cache && cache.ts && (Date.now() - cache.ts) < 30 * 1000) {
- allowedSites = cache.allowedSites || allowedSites;
- selectedMirror = normalizeHost(cache.selectedMirror || selectedMirror);
- askOnVisit = !!cache.askOnVisit;
- mirrors = (cache.mirrors || mirrors).map(normalizeHost);
- }
- } catch (e) { /* ignore cache parse errors */ }
-
- // Background refresh of storage to keep the cache fresh (non-blocking)
- (async () => {
- try {
- const s = await browser.storage.local.get({ allowedSites: [], selectedMirror: DEFAULTS.selectedMirror, askOnVisit: DEFAULTS.askOnVisit, mirrors: DEFAULTS.mirrors });
- const cache = { allowedSites: s.allowedSites || [], selectedMirror: normalizeHost(s.selectedMirror || DEFAULTS.selectedMirror), askOnVisit: !!s.askOnVisit, mirrors: (s.mirrors || DEFAULTS.mirrors).map(normalizeHost), ts: Date.now() };
- try { window.localStorage.setItem('__flean_cache', JSON.stringify(cache)); } catch (e) { /* ignore */ }
- } catch (e) { /* ignore background refresh errors */ }
- })();
-
- // Keep the localStorage cache in sync immediately when preferences change.
- if (browser && browser.storage && typeof browser.storage.onChanged === 'object') {
- try {
- browser.storage.onChanged.addListener((changes, areaName) => {
- if (areaName !== 'local') return;
- let updated = false;
- let cache = null;
- try { cache = JSON.parse(window.localStorage.getItem('__flean_cache') || 'null') || { ts: 0 }; } catch (e) { cache = { ts: 0 }; }
- if (changes.allowedSites) { cache.allowedSites = changes.allowedSites.newValue || []; updated = true; }
- if (changes.selectedMirror) { cache.selectedMirror = normalizeHost(changes.selectedMirror.newValue || DEFAULTS.selectedMirror); updated = true; }
- if (changes.askOnVisit) { cache.askOnVisit = !!changes.askOnVisit.newValue; updated = true; }
- if (changes.mirrors) { cache.mirrors = (changes.mirrors.newValue || DEFAULTS.mirrors).map(normalizeHost); updated = true; }
- if (updated) {
- cache.ts = Date.now();
- try { window.localStorage.setItem('__flean_cache', JSON.stringify(cache)); } catch (e) { /* ignore */ }
- }
- });
- } catch (e) { /* ignore if onChanged isn't available */ }
- }
+ // Fast session-scoped allow (for immediate navigation within this tab)
+ let isSessionAllowed = false
+ try {
+ const sess = window.sessionStorage.getItem('__flean_allow_until')
+ if (sess) {
+ const until = parseInt(sess, 10) || 0
+ if (Date.now() <= until) isSessionAllowed = true
+ else window.sessionStorage.removeItem('__flean_allow_until')
+ }
+ } catch (e) { /* ignore */ }
+
+ if ((allowedSites || []).includes(host) || isSessionAllowed) {
+ console.log('Flean: host is in ignore list or session-allowed, skipping redirection for', host)
+ return
+ }
- // Fast session-scoped allow (for immediate navigation within this tab)
- let isSessionAllowed = false;
- try {
- const sess = window.sessionStorage.getItem('__flean_allow_until');
- if (sess) {
- const until = parseInt(sess, 10) || 0;
- if (Date.now() <= until) isSessionAllowed = true;
- else window.sessionStorage.removeItem('__flean_allow_until');
- }
- } catch (e) { /* ignore */ }
-
- if ((allowedSites || []).includes(host) || isSessionAllowed) {
- console.log('Flean: host is in ignore list or session-allowed, skipping redirection for', host);
- return;
- }
+ // Derive the wiki name
+ let wikiName = host
+ if (host.endsWith('.fandom.com') || host.endsWith('.wikia.com')) {
+ const parts = host.split('.')
+ if (parts.length >= 3) wikiName = parts[parts.length - 3]; else wikiName = parts[0]
+ } else {
+ wikiName = host.split('.')[0]
+ }
- // Derive the wiki name
- let wikiName = host;
- if (host.endsWith('.fandom.com') || host.endsWith('.wikia.com')) {
- const parts = host.split('.');
- if (parts.length >= 3) wikiName = parts[parts.length - 3]; else wikiName = parts[0];
- } else {
- wikiName = host.split('.')[0];
- }
+ // Extract page title
+ let page = path.replace(/^\/wiki\//i, '')
+ try { page = decodeURIComponent(page) } catch (e) { /* ignore */ }
- // Extract page title
- let page = path.replace(/^\/wiki\//i, '');
- try { page = decodeURIComponent(page); } catch (e) { /* ignore */ }
+ // Compute mirror URL (fallback)
+ const pageForMirror = page.replace(/\s+/g, '_').replace(/^\/+|\/+$/g, '')
+ const wikiNameCap = wikiName.charAt(0).toUpperCase() + wikiName.slice(1)
+ const mirrorUrl = `${url.protocol}//${selectedMirror}/${wikiNameCap}/wiki/${pageForMirror}${url.search}${url.hash}`
- // Compute mirror URL (fallback)
- const pageForMirror = page.replace(/\s+/g, '_').replace(/^\/+|\/+$/g, '');
- const wikiNameCap = wikiName.charAt(0).toUpperCase() + wikiName.slice(1);
- const mirrorUrl = `${url.protocol}//${selectedMirror}/${wikiNameCap}/wiki/${pageForMirror}${url.search}${url.hash}`;
+ // If already on a mirror host, do nothing
+ if (host === selectedMirror || (mirrors || []).includes(host)) return
- // If already on a mirror host, do nothing
- if (host === selectedMirror || (mirrors || []).includes(host)) return;
+ // Query background.js for a structured indie wiki match.
+ // Falls back to the BreezeWiki mirror URL if unavailable or no match found.
+ let finalDestUrl = mirrorUrl
+ let finalDestLabel = selectedMirror
+ let isIndieWiki = false
+ try {
+ let cancelTimeout = null
+ const structured = await Promise.race([
+ browser.runtime.sendMessage({ action: 'findWiki', url: url.href }),
+ new Promise(resolve => { cancelTimeout = setTimeout(() => resolve(null), 500) })
+ ])
+ clearTimeout(cancelTimeout)
+ if (structured && structured.destinationUrl) {
+ finalDestUrl = structured.destinationUrl
+ finalDestLabel = structured.wikiName || new URL(structured.destinationUrl).host
+ isIndieWiki = true
+ }
+ } catch (e) { /* background unavailable – fall back to heuristic mirror */ }
+
+ // Fast-path for askOnVisit=false using sessionStorage-only attempt tracking
+ if (!askOnVisit) {
+ const ATTEMPT_WINDOW_MS = 10 * 1000 // 10s window
+ const ATTEMPT_THRESHOLD = 2 // either 2 navigations within the window OR 2 consecutive reloads independently trigger suppression
+ const SUPPRESS_COOLDOWN_MS = 30 * 1000
+
+ const now = Date.now()
+ const pageKey = url.href
+ const attemptsKey = '__flean_attempts:' + pageKey
+ const suppressKey = '__flean_suppressed:' + pageKey
+ const reloadKey = '__flean_reloads:' + pageKey
+
+ function readSessionArray (k) { try { return JSON.parse(window.sessionStorage.getItem(k) || '[]') } catch (e) { return [] } }
+ function writeSessionArray (k, arr) { try { window.sessionStorage.setItem(k, JSON.stringify(arr)) } catch (e) { /* ignore */ } }
+
+ // Detect whether this page load is a browser reload (F5 / Cmd-R)
+ let isReload = false
+ try {
+ const navEntry = performance.getEntriesByType('navigation')[0]
+ isReload = navEntry ? navEntry.type === 'reload' : (performance.navigation && performance.navigation.type === 1)
+ } catch (e) { /* ignore */ }
+
+ // Track consecutive reloads separately; two in a row bypasses the redirect.
+ // Intentionally resets to 0 on any non-reload navigation so only truly
+ // back-to-back reloads of the same page count toward suppression.
+ let reloadCount = 0
+ try {
+ reloadCount = isReload ? (parseInt(window.sessionStorage.getItem(reloadKey) || '0', 10) + 1) : 0
+ window.sessionStorage.setItem(reloadKey, String(reloadCount))
+ } catch (e) { /* ignore */ }
+
+ const recent = readSessionArray(attemptsKey).filter(ts => (now - ts) <= ATTEMPT_WINDOW_MS)
+ recent.push(now)
+ writeSessionArray(attemptsKey, recent)
+
+ const suppressedUntil = parseInt(window.sessionStorage.getItem(suppressKey) || '0', 10) || 0
+ if (now < suppressedUntil) {
+ console.debug('Flean: redirect suppressed until', new Date(suppressedUntil).toISOString())
+ showSuppressionBanner()
+ return
+ }
+
+ if (reloadCount >= ATTEMPT_THRESHOLD || recent.length >= ATTEMPT_THRESHOLD) {
+ window.sessionStorage.setItem(suppressKey, String(now + SUPPRESS_COOLDOWN_MS))
+ console.info('Flean: suppressing redirect for', pageKey, 'for', SUPPRESS_COOLDOWN_MS, 'ms (reloads=' + reloadCount + ', navigations=' + recent.length + ')')
+ showSuppressionBanner()
+ return
+ }
+
+ // Redirect quickly
+ console.log('Flean: askOnVisit=false, redirecting', url.href, '->', finalDestUrl)
+ try { window.location.replace(finalDestUrl) } catch (e) { console.warn('Flean: failed to redirect', e) }
+ return
+ }
- // Query background.js for a structured indie wiki match.
- // Falls back to the BreezeWiki mirror URL if unavailable or no match found.
- let finalDestUrl = mirrorUrl;
- let finalDestLabel = selectedMirror;
- let isIndieWiki = false;
- try {
- let cancelTimeout = null;
- const structured = await Promise.race([
- browser.runtime.sendMessage({ action: 'findWiki', url: url.href }),
- new Promise(resolve => { cancelTimeout = setTimeout(() => resolve(null), 500); })
- ]);
- clearTimeout(cancelTimeout);
- if (structured && structured.destinationUrl) {
- finalDestUrl = structured.destinationUrl;
- finalDestLabel = structured.wikiName || new URL(structured.destinationUrl).host;
- isIndieWiki = true;
- }
- } catch (e) { /* background unavailable – fall back to heuristic mirror */ }
-
- // Fast-path for askOnVisit=false using sessionStorage-only attempt tracking
- if (!askOnVisit) {
- const ATTEMPT_WINDOW_MS = 10 * 1000; // 10s window
- const ATTEMPT_THRESHOLD = 2; // either 2 navigations within the window OR 2 consecutive reloads independently trigger suppression
- const SUPPRESS_COOLDOWN_MS = 30 * 1000;
-
- const now = Date.now();
- const pageKey = url.href;
- const attemptsKey = '__flean_attempts:' + pageKey;
- const suppressKey = '__flean_suppressed:' + pageKey;
- const reloadKey = '__flean_reloads:' + pageKey;
-
- function readSessionArray(k) { try { return JSON.parse(window.sessionStorage.getItem(k) || '[]'); } catch (e) { return []; } }
- function writeSessionArray(k, arr) { try { window.sessionStorage.setItem(k, JSON.stringify(arr)); } catch (e) { /* ignore */ } }
-
- // Detect whether this page load is a browser reload (F5 / Cmd-R)
- let isReload = false;
+ // Helper: show suppression banner
+ function showSuppressionBanner () {
+ try {
+ if (document.getElementById('flean-suppress-banner')) return
+ const banner = document.createElement('div')
+ banner.id = 'flean-suppress-banner'
+ banner.style.position = 'fixed'
+ banner.style.top = '0'
+ banner.style.left = '0'
+ banner.style.right = '0'
+ banner.style.zIndex = '2147483646'
+ banner.style.background = '#fff3bf'
+ banner.style.color = '#222'
+ banner.style.borderBottom = '1px solid rgba(0,0,0,0.08)'
+ banner.style.padding = '10px 12px'
+ banner.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial'
+ banner.style.display = 'flex'
+ banner.style.alignItems = 'center'
+ banner.style.justifyContent = 'space-between'
+
+ const left = document.createElement('div')
+ left.innerHTML = 'You were not redirected because of multiple quick attempts to open this page. Configure settings for Flean'
+ banner.appendChild(left)
+
+ const closeBtn = document.createElement('button')
+ closeBtn.id = 'flean-suppress-close'
+ closeBtn.textContent = 'Dismiss'
+ closeBtn.style.background = 'transparent'
+ closeBtn.style.border = 'none'
+ closeBtn.style.cursor = 'pointer'
+ closeBtn.style.color = '#0b6cff'
+ banner.appendChild(closeBtn)
+
+ const container = document.body || document.documentElement
+ if (container) container.insertBefore(banner, container.firstChild)
+
+ const cfg = document.getElementById('flean-suppress-config')
+ if (cfg) {
+ cfg.addEventListener('click', async (e) => {
+ e.preventDefault(); e.stopPropagation()
try {
- const navEntry = performance.getEntriesByType('navigation')[0];
- isReload = navEntry ? navEntry.type === 'reload' : (performance.navigation && performance.navigation.type === 1);
- } catch (e) { /* ignore */ }
-
- // Track consecutive reloads separately; two in a row bypasses the redirect.
- // Intentionally resets to 0 on any non-reload navigation so only truly
- // back-to-back reloads of the same page count toward suppression.
- let reloadCount = 0;
+ if (browser.action && typeof browser.action.openPopup === 'function') {
+ await browser.action.openPopup(); return
+ }
+ } catch (e) { /* try next */ }
try {
- reloadCount = isReload ? (parseInt(window.sessionStorage.getItem(reloadKey) || '0', 10) + 1) : 0;
- window.sessionStorage.setItem(reloadKey, String(reloadCount));
- } catch (e) { /* ignore */ }
-
- const recent = readSessionArray(attemptsKey).filter(ts => (now - ts) <= ATTEMPT_WINDOW_MS);
- recent.push(now);
- writeSessionArray(attemptsKey, recent);
-
- const suppressedUntil = parseInt(window.sessionStorage.getItem(suppressKey) || '0', 10) || 0;
- if (now < suppressedUntil) {
- console.debug('Flean: redirect suppressed until', new Date(suppressedUntil).toISOString());
- showSuppressionBanner();
- return;
- }
-
- if (reloadCount >= ATTEMPT_THRESHOLD || recent.length >= ATTEMPT_THRESHOLD) {
- window.sessionStorage.setItem(suppressKey, String(now + SUPPRESS_COOLDOWN_MS));
- console.info('Flean: suppressing redirect for', pageKey, 'for', SUPPRESS_COOLDOWN_MS, 'ms (reloads=' + reloadCount + ', navigations=' + recent.length + ')');
- showSuppressionBanner();
- return;
- }
-
- // Redirect quickly
- console.log('Flean: askOnVisit=false, redirecting', url.href, '->', finalDestUrl);
- try { window.location.replace(finalDestUrl); } catch (e) { console.warn('Flean: failed to redirect', e); }
- return;
- }
-
- // Helper: show suppression banner
- function showSuppressionBanner() {
- try {
- if (document.getElementById('flean-suppress-banner')) return;
- const banner = document.createElement('div');
- banner.id = 'flean-suppress-banner';
- banner.style.position = 'fixed';
- banner.style.top = '0';
- banner.style.left = '0';
- banner.style.right = '0';
- banner.style.zIndex = '2147483646';
- banner.style.background = '#fff3bf';
- banner.style.color = '#222';
- banner.style.borderBottom = '1px solid rgba(0,0,0,0.08)';
- banner.style.padding = '10px 12px';
- banner.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial';
- banner.style.display = 'flex';
- banner.style.alignItems = 'center';
- banner.style.justifyContent = 'space-between';
-
- const left = document.createElement('div');
- left.innerHTML = 'You were not redirected because of multiple quick attempts to open this page. Configure settings for Flean';
- banner.appendChild(left);
-
- const closeBtn = document.createElement('button');
- closeBtn.id = 'flean-suppress-close';
- closeBtn.textContent = 'Dismiss';
- closeBtn.style.background = 'transparent';
- closeBtn.style.border = 'none';
- closeBtn.style.cursor = 'pointer';
- closeBtn.style.color = '#0b6cff';
- banner.appendChild(closeBtn);
-
- const container = document.body || document.documentElement;
- if (container) container.insertBefore(banner, container.firstChild);
-
- const cfg = document.getElementById('flean-suppress-config');
- if (cfg) cfg.addEventListener('click', async (e) => {
- e.preventDefault(); e.stopPropagation();
- try {
- if (browser.action && typeof browser.action.openPopup === 'function') {
- await browser.action.openPopup(); return;
- }
- } catch (e) { /* try next */ }
- try {
- if (browser.runtime && typeof browser.runtime.openOptionsPage === 'function') {
- browser.runtime.openOptionsPage(); return;
- }
- } catch (e) { /* try next */ }
- try { await browser.tabs.create({ url: browser.runtime.getURL('popup.html') }); } catch (e) { console.warn('Flean: could not open settings', e); }
- });
- closeBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); banner.remove(); });
- console.debug('Flean: suppression banner shown');
- } catch (e) {
- console.warn('Flean: failed to create suppression banner', e);
- }
+ if (browser.runtime && typeof browser.runtime.openOptionsPage === 'function') {
+ browser.runtime.openOptionsPage(); return
+ }
+ } catch (e) { /* try next */ }
+ try { await browser.tabs.create({ url: browser.runtime.getURL('popup.html') }) } catch (e) { console.warn('Flean: could not open settings', e) }
+ })
}
+ closeBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); banner.remove() })
+ console.debug('Flean: suppression banner shown')
+ } catch (e) {
+ console.warn('Flean: failed to create suppression banner', e)
+ }
+ }
- // If askOnVisit is true, show the overlay/interstitial so the user can decide.
- const style = document.createElement('style');
- style.textContent = `
+ // If askOnVisit is true, show the overlay/interstitial so the user can decide.
+ const style = document.createElement('style')
+ style.textContent = `
#flean-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.6); color:#fff; display:flex; align-items:center; justify-content:center; z-index:2147483647; }
#flean-card { background:#0b1220; color:#fff; padding:18px; border-radius:10px; max-width:520px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; box-shadow:0 8px 30px rgba(0,0,0,0.6); }
#flean-card h1 { font-size:18px; margin:0 0 8px; }
@@ -279,17 +281,17 @@
.flean-btn { background:#1f6feb; color:#fff; border:none; padding:8px 10px; border-radius:6px; cursor:pointer }
.flean-btn.secondary { background:#2d3748 }
.flean-link { color:#9bd1ff; text-decoration:underline; cursor:pointer }
- `;
-
- const overlay = document.createElement('div');
- overlay.id = 'flean-overlay';
- const overlayHeading = isIndieWiki
- ? `Open this wiki on its independent mirror?`
- : `Open this Fandom wiki on a Breezewiki mirror?`;
- const overlayBody = isIndieWiki
- ? `This page has an independent wiki. You can open the same article on ${finalDestLabel} (recommended), or continue to visit the Fandom page.`
- : `This page appears to be a Fandom wiki article. You can open the same article on ${finalDestLabel} (recommended), or continue to visit the Fandom page.`;
- overlay.innerHTML = `
+ `
+
+ const overlay = document.createElement('div')
+ overlay.id = 'flean-overlay'
+ const overlayHeading = isIndieWiki
+ ? 'Open this wiki on its independent mirror?'
+ : 'Open this Fandom wiki on a Breezewiki mirror?'
+ const overlayBody = isIndieWiki
+ ? `This page has an independent wiki. You can open the same article on ${finalDestLabel} (recommended), or continue to visit the Fandom page.`
+ : `This page appears to be a Fandom wiki article. You can open the same article on ${finalDestLabel} (recommended), or continue to visit the Fandom page.`
+ overlay.innerHTML = `
${overlayHeading}
${overlayBody}
@@ -300,66 +302,71 @@
- `;
-
- if (document.head) document.head.appendChild(style); else document.documentElement.appendChild(style);
- if (document.body) document.body.appendChild(overlay); else document.documentElement.appendChild(overlay);
-
- const openBtn = overlay.querySelector('#flean-open-mirror');
- const onceBtn = overlay.querySelector('#flean-visit-once');
- const allowBtn = overlay.querySelector('#flean-allow-site');
- const settingsLink = overlay.querySelector('#flean-open-popup');
-
- console.debug('Flean: overlay buttons', { openBtn: !!openBtn, onceBtn: !!onceBtn, allowBtn: !!allowBtn, settingsLink: !!settingsLink });
-
- overlay.addEventListener('click', (ev) => {
- try { console.debug('Flean: overlay click', ev.target && (ev.target.id || ev.target.className || ev.target.tagName)); } catch (e) { /* ignore */ }
- }, { capture: true });
-
- if (openBtn) openBtn.addEventListener('click', (e) => {
- e.preventDefault(); e.stopPropagation();
- console.debug('Flean: open mirror button clicked');
- try { window.location.replace(finalDestUrl); } catch (err) { console.warn('Flean: failed to open mirror', err); }
- });
-
- if (onceBtn) onceBtn.addEventListener('click', (e) => {
- e.preventDefault(); e.stopPropagation();
- console.debug('Flean: visit once clicked');
- overlay.remove();
- style.remove();
- });
-
- if (allowBtn) allowBtn.addEventListener('click', (e) => {
- e.preventDefault(); e.stopPropagation();
- console.debug('Flean: allow site clicked for', host);
- try {
- const SESSION_ALLOW_MS = 5 * 1000; // 5 seconds
- const until = Date.now() + SESSION_ALLOW_MS;
- try { window.sessionStorage.setItem('__flean_allow_until', String(until)); } catch (e) { /* ignore */ }
- console.info('Flean: session-allow for host', host, 'until', new Date(until).toISOString());
- } catch (err) { console.warn('Flean: failed to set session allow', err); }
- overlay.remove();
- style.remove();
- });
-
- if (settingsLink) {
- settingsLink.addEventListener('click', async (e) => {
- e.preventDefault(); e.stopPropagation();
- try {
- if (browser.action && typeof browser.action.openPopup === 'function') {
- await browser.action.openPopup(); return;
- }
- } catch (e) { /* try next */ }
- try {
- if (browser.runtime && typeof browser.runtime.openOptionsPage === 'function') {
- browser.runtime.openOptionsPage(); return;
- }
- } catch (e) { /* try next */ }
- try { await browser.tabs.create({ url: browser.runtime.getURL('popup.html') }); } catch (e) { console.warn('Flean: could not open settings', e); }
- });
- }
+ `
+
+ if (document.head) document.head.appendChild(style); else document.documentElement.appendChild(style)
+ if (document.body) document.body.appendChild(overlay); else document.documentElement.appendChild(overlay)
+
+ const openBtn = overlay.querySelector('#flean-open-mirror')
+ const onceBtn = overlay.querySelector('#flean-visit-once')
+ const allowBtn = overlay.querySelector('#flean-allow-site')
+ const settingsLink = overlay.querySelector('#flean-open-popup')
+
+ console.debug('Flean: overlay buttons', { openBtn: !!openBtn, onceBtn: !!onceBtn, allowBtn: !!allowBtn, settingsLink: !!settingsLink })
+
+ overlay.addEventListener('click', (ev) => {
+ try { console.debug('Flean: overlay click', ev.target && (ev.target.id || ev.target.className || ev.target.tagName)) } catch (e) { /* ignore */ }
+ }, { capture: true })
- } catch (err) {
- console.error('Flean content script error:', err);
+ if (openBtn) {
+ openBtn.addEventListener('click', (e) => {
+ e.preventDefault(); e.stopPropagation()
+ console.debug('Flean: open mirror button clicked')
+ try { window.location.replace(finalDestUrl) } catch (err) { console.warn('Flean: failed to open mirror', err) }
+ })
+ }
+
+ if (onceBtn) {
+ onceBtn.addEventListener('click', (e) => {
+ e.preventDefault(); e.stopPropagation()
+ console.debug('Flean: visit once clicked')
+ overlay.remove()
+ style.remove()
+ })
+ }
+
+ if (allowBtn) {
+ allowBtn.addEventListener('click', (e) => {
+ e.preventDefault(); e.stopPropagation()
+ console.debug('Flean: allow site clicked for', host)
+ try {
+ const SESSION_ALLOW_MS = 5 * 1000 // 5 seconds
+ const until = Date.now() + SESSION_ALLOW_MS
+ try { window.sessionStorage.setItem('__flean_allow_until', String(until)) } catch (e) { /* ignore */ }
+ console.info('Flean: session-allow for host', host, 'until', new Date(until).toISOString())
+ } catch (err) { console.warn('Flean: failed to set session allow', err) }
+ overlay.remove()
+ style.remove()
+ })
+ }
+
+ if (settingsLink) {
+ settingsLink.addEventListener('click', async (e) => {
+ e.preventDefault(); e.stopPropagation()
+ try {
+ if (browser.action && typeof browser.action.openPopup === 'function') {
+ await browser.action.openPopup(); return
+ }
+ } catch (e) { /* try next */ }
+ try {
+ if (browser.runtime && typeof browser.runtime.openOptionsPage === 'function') {
+ browser.runtime.openOptionsPage(); return
+ }
+ } catch (e) { /* try next */ }
+ try { await browser.tabs.create({ url: browser.runtime.getURL('popup.html') }) } catch (e) { console.warn('Flean: could not open settings', e) }
+ })
}
-})();
+ } catch (err) {
+ console.error('Flean content script error:', err)
+ }
+})()
diff --git a/mos/Flean Extension/Resources/scripts/wiki-data-manager.js b/mos/Flean Extension/Resources/scripts/wiki-data-manager.js
index e6d4cc4..9c6aa41 100644
--- a/mos/Flean Extension/Resources/scripts/wiki-data-manager.js
+++ b/mos/Flean Extension/Resources/scripts/wiki-data-manager.js
@@ -2,189 +2,189 @@
// Remote wiki data loading with local caching and heuristic fallback.
// Data source: indie-wiki-buddy (https://github.com/KevinPayravi/indie-wiki-buddy)
-const WIKI_DATA_URL = 'https://raw.githubusercontent.com/KevinPayravi/indie-wiki-buddy/main/data/sitesEN.json';
-const CACHE_KEY = 'wikiData';
-const CACHE_TS_KEY = 'wikiDataLastFetch';
-const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
+const WIKI_DATA_URL = 'https://raw.githubusercontent.com/KevinPayravi/indie-wiki-buddy/main/data/sitesEN.json'
+const CACHE_KEY = 'wikiData'
+const CACHE_TS_KEY = 'wikiDataLastFetch'
+const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
-const BASE64REGEX = /^[A-Za-z0-9+/]+=*$/;
+const BASE64REGEX = /^[A-Za-z0-9+/]+=*$/
/**
* Compress a JS value to a gzip+base64 string.
* Falls back to plain JSON string if CompressionStream is unavailable.
*/
-export async function compressJSON(value) {
- const json = JSON.stringify(value);
- if (typeof CompressionStream === 'undefined') return btoa(json);
- try {
- const bytes = new TextEncoder().encode(json);
- const stream = new CompressionStream('gzip');
- const writer = stream.writable.getWriter();
- writer.write(bytes);
- writer.close();
- const compressed = await new Response(stream.readable).arrayBuffer();
- const uint8 = new Uint8Array(compressed);
- let binary = '';
- for (let i = 0; i < uint8.length; i++) binary += String.fromCharCode(uint8[i]);
- return btoa(binary);
- } catch (e) {
- return btoa(json);
- }
+export async function compressJSON (value) {
+ const json = JSON.stringify(value)
+ if (typeof CompressionStream === 'undefined') return btoa(json)
+ try {
+ const bytes = new TextEncoder().encode(json)
+ const stream = new CompressionStream('gzip')
+ const writer = stream.writable.getWriter()
+ writer.write(bytes)
+ writer.close()
+ const compressed = await new Response(stream.readable).arrayBuffer()
+ const uint8 = new Uint8Array(compressed)
+ let binary = ''
+ for (let i = 0; i < uint8.length; i++) binary += String.fromCharCode(uint8[i])
+ return btoa(binary)
+ } catch (e) {
+ return btoa(json)
+ }
}
/**
* Decompress a gzip+base64 string back to the original value.
* Accepts both gzip-compressed and plain base64-encoded JSON (fallback).
*/
-export async function decompressJSON(value) {
- if (!value || !BASE64REGEX.test(value)) throw new Error('Invalid compressed data');
- if (typeof DecompressionStream === 'undefined') return JSON.parse(atob(value));
- try {
- const binary = atob(value);
- const bytes = new Uint8Array(binary.length);
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
- const stream = new DecompressionStream('gzip');
- const writer = stream.writable.getWriter();
- writer.write(bytes);
- writer.close();
- const decompressed = await new Response(stream.readable).arrayBuffer();
- return JSON.parse(new TextDecoder().decode(decompressed));
- } catch (e) {
- // May be plain base64 JSON (written by the fallback path above)
- try { return JSON.parse(atob(value)); } catch (e2) { throw e; }
- }
+export async function decompressJSON (value) {
+ if (!value || !BASE64REGEX.test(value)) throw new Error('Invalid compressed data')
+ if (typeof DecompressionStream === 'undefined') return JSON.parse(atob(value))
+ try {
+ const binary = atob(value)
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
+ const stream = new DecompressionStream('gzip')
+ const writer = stream.writable.getWriter()
+ writer.write(bytes)
+ writer.close()
+ const decompressed = await new Response(stream.readable).arrayBuffer()
+ return JSON.parse(new TextDecoder().decode(decompressed))
+ } catch (e) {
+ // May be plain base64 JSON (written by the fallback path above)
+ try { return JSON.parse(atob(value)) } catch (e2) { throw e }
+ }
}
/**
* Fetch fresh wiki data from the GitHub CDN, compress it, and store in browser.storage.local.
* Returns the raw data array, or throws on failure.
*/
-export async function fetchWikiData() {
- const response = await fetch(WIKI_DATA_URL);
- if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
- const data = await response.json();
- const compressed = await compressJSON(data);
- await browser.storage.local.set({ [CACHE_KEY]: compressed, [CACHE_TS_KEY]: Date.now() });
- return data;
+export async function fetchWikiData () {
+ const response = await fetch(WIKI_DATA_URL)
+ if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
+ const data = await response.json()
+ const compressed = await compressJSON(data)
+ await browser.storage.local.set({ [CACHE_KEY]: compressed, [CACHE_TS_KEY]: Date.now() })
+ return data
}
/**
* Get wiki data from cache, fetching fresh data if the cache is stale (>7 days) or absent.
* Returns the data array, or null if unavailable (network failure, etc.).
*/
-export async function getWikiData() {
- try {
- const stored = await browser.storage.local.get([CACHE_KEY, CACHE_TS_KEY]);
- const ts = stored[CACHE_TS_KEY] || 0;
- const compressed = stored[CACHE_KEY];
- if (compressed && (Date.now() - ts) < CACHE_TTL_MS) {
- try {
- return await decompressJSON(compressed);
- } catch (e) {
- console.warn('Flean: wiki data decompression failed, re-fetching', e);
- }
- }
- return await fetchWikiData();
- } catch (e) {
- console.warn('Flean: could not load wiki data, falling back to heuristics', e);
- return null;
+export async function getWikiData () {
+ try {
+ const stored = await browser.storage.local.get([CACHE_KEY, CACHE_TS_KEY])
+ const ts = stored[CACHE_TS_KEY] || 0
+ const compressed = stored[CACHE_KEY]
+ if (compressed && (Date.now() - ts) < CACHE_TTL_MS) {
+ try {
+ return await decompressJSON(compressed)
+ } catch (e) {
+ console.warn('Flean: wiki data decompression failed, re-fetching', e)
+ }
}
+ return await fetchWikiData()
+ } catch (e) {
+ console.warn('Flean: could not load wiki data, falling back to heuristics', e)
+ return null
+ }
}
/** Build an in-memory Map from normalised origin host → { wiki, originEntry }. */
-function buildIndex(wikiData) {
- const index = new Map();
- if (!Array.isArray(wikiData)) return index;
- for (const wiki of wikiData) {
- if (!wiki.origins || !wiki.destination_base_url) continue;
- for (const origin of wiki.origins) {
- if (!origin.origin_base_url) continue;
- const key = origin.origin_base_url.toLowerCase()
- .replace(/^https?:\/\//, '')
- .replace(/\/$/, '');
- index.set(key, { wiki, originEntry: origin });
- }
+function buildIndex (wikiData) {
+ const index = new Map()
+ if (!Array.isArray(wikiData)) return index
+ for (const wiki of wikiData) {
+ if (!wiki.origins || !wiki.destination_base_url) continue
+ for (const origin of wiki.origins) {
+ if (!origin.origin_base_url) continue
+ const key = origin.origin_base_url.toLowerCase()
+ .replace(/^https?:\/\//, '')
+ .replace(/\/$/, '')
+ index.set(key, { wiki, originEntry: origin })
}
- return index;
+ }
+ return index
}
-let _wikiIndex = null;
-let _wikiIndexPromise = null;
+let _wikiIndex = null
+let _wikiIndexPromise = null
/** Ensure the in-memory lookup index is built. */
-async function ensureIndex() {
- if (_wikiIndex !== null) return _wikiIndex;
- if (_wikiIndexPromise) return _wikiIndexPromise;
- _wikiIndexPromise = getWikiData().then(data => {
- _wikiIndex = buildIndex(data);
- _wikiIndexPromise = null;
- return _wikiIndex;
- }).catch(() => {
- _wikiIndex = new Map();
- _wikiIndexPromise = null;
- return _wikiIndex;
- });
- return _wikiIndexPromise;
+async function ensureIndex () {
+ if (_wikiIndex !== null) return _wikiIndex
+ if (_wikiIndexPromise) return _wikiIndexPromise
+ _wikiIndexPromise = getWikiData().then(data => {
+ _wikiIndex = buildIndex(data)
+ _wikiIndexPromise = null
+ return _wikiIndex
+ }).catch(() => {
+ _wikiIndex = new Map()
+ _wikiIndexPromise = null
+ return _wikiIndex
+ })
+ return _wikiIndexPromise
}
/**
* Invalidate the in-memory index so it is rebuilt on the next lookup.
* Call this after a successful data refresh.
*/
-export function invalidateIndex() {
- _wikiIndex = null;
+export function invalidateIndex () {
+ _wikiIndex = null
}
/**
* Given a URL string, find the best matching independent wiki destination.
* Returns { destinationUrl: string, wikiName: string } or null if no match found.
*/
-export async function findMatchingWiki(urlString) {
- try {
- const url = new URL(urlString);
- const host = url.host.toLowerCase();
- const index = await ensureIndex();
-
- // Try exact host, then without leading 'www.'
- const match = index.get(host) || (host.startsWith('www.') ? index.get(host.slice(4)) : undefined);
- if (!match) return null;
-
- const { wiki, originEntry } = match;
-
- // Extract the article name from the path using the origin's content path prefix.
- // indie-wiki-buddy templates use a single `$1` placeholder (e.g. "/wiki/$1").
- // We strip everything from `$1` onward to obtain just the path prefix.
- const originPathPrefix = (originEntry.origin_content_path || '/wiki/')
- .replace(/\$1.*$/, '');
- let article = url.pathname;
- // Case-insensitive prefix match is intentional: wiki path prefixes like /wiki/
- // are the same regardless of casing on every platform targeted by this data.
- if (article.toLowerCase().startsWith(originPathPrefix.toLowerCase())) {
- article = article.slice(originPathPrefix.length);
- }
- try { article = decodeURIComponent(article); } catch (e) { /* keep encoded */ }
-
- // Build destination URL based on platform
- const destBase = wiki.destination_base_url
- .replace(/^https?:\/\//, '')
- .replace(/\/$/, '');
- const platform = (wiki.destination_platform || 'mediawiki').toLowerCase();
- let destPath;
-
- if (wiki.destination_content_path) {
- destPath = wiki.destination_content_path
- .replace('$1', encodeURIComponent(article.replace(/ /g, '_')));
- } else if (platform === 'dokuwiki') {
- destPath = '/doku.php?id=' + encodeURIComponent(article.replace(/ /g, '_').toLowerCase());
- } else {
- // Default: MediaWiki-style /wiki/ArticleName
- destPath = '/wiki/' + encodeURIComponent(article.replace(/ /g, '_'));
- }
-
- const destinationUrl = `https://${destBase}${destPath}${url.search}${url.hash}`;
- const wikiName = wiki.destination || wiki.article || destBase;
- return { destinationUrl, wikiName };
- } catch (e) {
- return null;
+export async function findMatchingWiki (urlString) {
+ try {
+ const url = new URL(urlString)
+ const host = url.host.toLowerCase()
+ const index = await ensureIndex()
+
+ // Try exact host, then without leading 'www.'
+ const match = index.get(host) || (host.startsWith('www.') ? index.get(host.slice(4)) : undefined)
+ if (!match) return null
+
+ const { wiki, originEntry } = match
+
+ // Extract the article name from the path using the origin's content path prefix.
+ // indie-wiki-buddy templates use a single `$1` placeholder (e.g. "/wiki/$1").
+ // We strip everything from `$1` onward to obtain just the path prefix.
+ const originPathPrefix = (originEntry.origin_content_path || '/wiki/')
+ .replace(/\$1.*$/, '')
+ let article = url.pathname
+ // Case-insensitive prefix match is intentional: wiki path prefixes like /wiki/
+ // are the same regardless of casing on every platform targeted by this data.
+ if (article.toLowerCase().startsWith(originPathPrefix.toLowerCase())) {
+ article = article.slice(originPathPrefix.length)
}
+ try { article = decodeURIComponent(article) } catch (e) { /* keep encoded */ }
+
+ // Build destination URL based on platform
+ const destBase = wiki.destination_base_url
+ .replace(/^https?:\/\//, '')
+ .replace(/\/$/, '')
+ const platform = (wiki.destination_platform || 'mediawiki').toLowerCase()
+ let destPath
+
+ if (wiki.destination_content_path) {
+ destPath = wiki.destination_content_path
+ .replace('$1', encodeURIComponent(article.replace(/ /g, '_')))
+ } else if (platform === 'dokuwiki') {
+ destPath = '/doku.php?id=' + encodeURIComponent(article.replace(/ /g, '_').toLowerCase())
+ } else {
+ // Default: MediaWiki-style /wiki/ArticleName
+ destPath = '/wiki/' + encodeURIComponent(article.replace(/ /g, '_'))
+ }
+
+ const destinationUrl = `https://${destBase}${destPath}${url.search}${url.hash}`
+ const wikiName = wiki.destination || wiki.article || destBase
+ return { destinationUrl, wikiName }
+ } catch (e) {
+ return null
+ }
}
From 572e434efd111d33ad2052a016bcb90b038b93c3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 26 Mar 2026 20:16:52 +0000
Subject: [PATCH 5/6] Add ESLint + DeepSource config to suppress
JS-0002/JS-0125 false positives
Agent-Logs-Url: https://github.com/SillyLittleTech/Flean/sessions/0fe5a555-523e-412f-b8cc-ca77526e7dad
Co-authored-by: kiyarose <75678535+kiyarose@users.noreply.github.com>
---
.deepsource.toml | 14 ++++++++++++++
.eslintrc.json | 14 ++++++++++++++
.gitignore | 5 ++++-
eslint.config.js | 21 +++++++++++++++++++++
package.json | 8 ++++++++
5 files changed, 61 insertions(+), 1 deletion(-)
create mode 100644 .deepsource.toml
create mode 100644 .eslintrc.json
create mode 100644 eslint.config.js
create mode 100644 package.json
diff --git a/.deepsource.toml b/.deepsource.toml
new file mode 100644
index 0000000..267ba01
--- /dev/null
+++ b/.deepsource.toml
@@ -0,0 +1,14 @@
+version = 1
+
+[[analyzers]]
+name = "javascript"
+
+ [analyzers.meta]
+ environment = ["browser", "es2020"]
+
+ # JS-0002: console usage is intentional in browser extension background/content
+ # scripts where logging is the only debug channel available.
+ # JS-0125: `browser` is the WebExtension runtime global injected by the host
+ # browser (Safari/Firefox/Chrome) — it is not a project-level variable.
+ # Both are false positives for this codebase and are suppressed via .eslintrc.json
+ # (env.webextensions = true covers `browser`; no-console = off covers console calls).
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..ebbe0bc
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,14 @@
+{
+ "env": {
+ "browser": true,
+ "webextensions": true,
+ "es2020": true
+ },
+ "parserOptions": {
+ "ecmaVersion": 2020,
+ "sourceType": "module"
+ },
+ "rules": {
+ "no-console": "off"
+ }
+}
diff --git a/.gitignore b/.gitignore
index 52fe2f7..d6a4152 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,4 +59,7 @@ Carthage/Build/
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
-fastlane/test_output
+# Node / ESLint dev deps (only needed for local linting)
+node_modules/
+package-lock.json
+
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..fa256e8
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,21 @@
+// eslint.config.js — ESLint v9+ flat config
+// Mirrors .eslintrc.json for local development; DeepSource still reads .eslintrc.json.
+import globals from 'globals';
+
+export default [
+ {
+ files: ['**/*.js'],
+ languageOptions: {
+ ecmaVersion: 2020,
+ sourceType: 'module',
+ globals: {
+ ...globals.browser,
+ ...globals.webextensions,
+ browser: 'readonly'
+ }
+ },
+ rules: {
+ 'no-console': 'off'
+ }
+ }
+];
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..4314b9c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,8 @@
+{
+ "type": "module",
+ "devDependencies": {
+ "eslint": "^10.1.0",
+ "globals": "^17.4.0"
+ }
+}
+
From 8b689006814ffed914386bbc184d43ea963e72e7 Mon Sep 17 00:00:00 2001
From: "deepsource-autofix[bot]"
<62050782+deepsource-autofix[bot]@users.noreply.github.com>
Date: Thu, 26 Mar 2026 20:17:25 +0000
Subject: [PATCH 6/6] style: format code with PHP CS Fixer, StandardJS and
swift-format
This commit fixes the style issues introduced in 572e434 according to the output
from PHP CS Fixer, StandardJS and swift-format.
Details: https://github.com/SillyLittleTech/Flean/pull/2
---
eslint.config.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/eslint.config.js b/eslint.config.js
index fa256e8..90885cd 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,6 +1,6 @@
// eslint.config.js — ESLint v9+ flat config
// Mirrors .eslintrc.json for local development; DeepSource still reads .eslintrc.json.
-import globals from 'globals';
+import globals from 'globals'
export default [
{
@@ -18,4 +18,4 @@ export default [
'no-console': 'off'
}
}
-];
+]