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..90885cd --- /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/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/background.js b/ios/extention/Resources/background.js index 56cb3fc..0f1c608 100644 --- a/ios/extention/Resources/background.js +++ b/ios/extention/Resources/background.js @@ -1,6 +1,44 @@ +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) + } +} + +// 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) + } +} + browser.runtime.onMessage.addListener((request, sender, sendResponse) => { - console.log("Received request: ", request); + if (request.greeting === 'hello') { + return Promise.resolve({ farewell: 'goodbye' }) + } + + if (request.action === 'findWiki') { + return findMatchingWiki(request.url).catch(() => null) + } +}) - if (request.greeting === "hello") - return Promise.resolve({ farewell: "goodbye" }); -}); +initWikiData() +setupRefreshAlarm() diff --git a/ios/extention/Resources/content.js b/ios/extention/Resources/content.js index 238037e..c09fbff 100644 --- a/ios/extention/Resources/content.js +++ b/ios/extention/Resources/content.js @@ -1,236 +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}`; - - // If already on a mirror host, do nothing - if (host === selectedMirror || (mirrors || []).includes(host)) return; - - // 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 SUPPRESS_COOLDOWN_MS = 30 * 1000; - - const now = Date.now(); - const pageKey = url.href; - const attemptsKey = '__flean_attempts:' + pageKey; - const suppressKey = '__flean_suppressed:' + 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 */ } } - - 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 (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 + ')'); - showSuppressionBanner(); - return; - } - - // Redirect quickly - console.log('Flean: askOnVisit=false, redirecting', url.href, '->', mirrorUrl); - try { window.location.replace(mirrorUrl); } catch (e) { console.warn('Flean: failed to redirect', e); } - return; - } + // 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 + + // 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 + } - // Helper: show suppression banner - function showSuppressionBanner() { + // 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 (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', (e) => { - e.preventDefault(); e.stopPropagation(); - try { - if (browser && browser.runtime && typeof browser.runtime.openOptionsPage === 'function') { - browser.runtime.openOptionsPage(); - } - } catch (err) { console.warn('Flean: could not open options from banner', err); } - }); - 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; } @@ -239,72 +281,92 @@ .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'; - 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 = `
-

Open this Fandom wiki on a Breezewiki mirror?

-

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.

+

${overlayHeading}

+

${overlayBody}

- + Extension settings
- `; - - 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(mirrorUrl); } 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) { - const canOpenOptions = !!(browser && browser.runtime && typeof browser.runtime.openOptionsPage === 'function'); - if (!canOpenOptions) settingsLink.remove(); else settingsLink.addEventListener('click', (e) => { - e.preventDefault(); e.stopPropagation(); - try { browser.runtime.openOptionsPage(); } catch (err) { console.warn('Flean: could not open options page', err); settingsLink.remove(); } - }); - } + ` + + 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 }) - } catch (err) { - console.error('Flean content script error:', err); + 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) } + }) } -})(); + } catch (err) { + console.error('Flean content script error:', err) + } +})() diff --git a/ios/extention/Resources/manifest.json b/ios/extention/Resources/manifest.json index f676b25..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", @@ -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/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 @@
Flean
-
Redirect Fandom wiki pages to Breezewiki mirrors
+
Redirects to independent wikis when available; Breezewiki mirrors as fallback
- + +

Independent (self-hosted) wikis are always preferred when available. This mirror is only used when no independent wiki is found.

diff --git a/ios/extention/Resources/scripts/wiki-data-manager.js b/ios/extention/Resources/scripts/wiki-data-manager.js new file mode 100644 index 0000000..9c6aa41 --- /dev/null +++ b/ios/extention/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 + } +} diff --git a/mos/Flean Extension/Resources/background.js b/mos/Flean Extension/Resources/background.js index 56cb3fc..0f1c608 100644 --- a/mos/Flean Extension/Resources/background.js +++ b/mos/Flean Extension/Resources/background.js @@ -1,6 +1,44 @@ +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) + } +} + +// 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) + } +} + browser.runtime.onMessage.addListener((request, sender, sendResponse) => { - console.log("Received request: ", request); + if (request.greeting === 'hello') { + return Promise.resolve({ farewell: 'goodbye' }) + } + + if (request.action === 'findWiki') { + return findMatchingWiki(request.url).catch(() => null) + } +}) - if (request.greeting === "hello") - return Promise.resolve({ farewell: "goodbye" }); -}); +initWikiData() +setupRefreshAlarm() diff --git a/mos/Flean Extension/Resources/content.js b/mos/Flean Extension/Resources/content.js index 238037e..c09fbff 100644 --- a/mos/Flean Extension/Resources/content.js +++ b/mos/Flean Extension/Resources/content.js @@ -1,236 +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}`; - - // If already on a mirror host, do nothing - if (host === selectedMirror || (mirrors || []).includes(host)) return; - - // 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 SUPPRESS_COOLDOWN_MS = 30 * 1000; - - const now = Date.now(); - const pageKey = url.href; - const attemptsKey = '__flean_attempts:' + pageKey; - const suppressKey = '__flean_suppressed:' + 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 */ } } - - 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 (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 + ')'); - showSuppressionBanner(); - return; - } - - // Redirect quickly - console.log('Flean: askOnVisit=false, redirecting', url.href, '->', mirrorUrl); - try { window.location.replace(mirrorUrl); } catch (e) { console.warn('Flean: failed to redirect', e); } - return; - } + // 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 + + // 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 + } - // Helper: show suppression banner - function showSuppressionBanner() { + // 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 (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', (e) => { - e.preventDefault(); e.stopPropagation(); - try { - if (browser && browser.runtime && typeof browser.runtime.openOptionsPage === 'function') { - browser.runtime.openOptionsPage(); - } - } catch (err) { console.warn('Flean: could not open options from banner', err); } - }); - 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; } @@ -239,72 +281,92 @@ .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'; - 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 = `
-

Open this Fandom wiki on a Breezewiki mirror?

-

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.

+

${overlayHeading}

+

${overlayBody}

- + Extension settings
- `; - - 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(mirrorUrl); } 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) { - const canOpenOptions = !!(browser && browser.runtime && typeof browser.runtime.openOptionsPage === 'function'); - if (!canOpenOptions) settingsLink.remove(); else settingsLink.addEventListener('click', (e) => { - e.preventDefault(); e.stopPropagation(); - try { browser.runtime.openOptionsPage(); } catch (err) { console.warn('Flean: could not open options page', err); settingsLink.remove(); } - }); - } + ` + + 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 }) - } catch (err) { - console.error('Flean content script error:', err); + 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) } + }) } -})(); + } catch (err) { + console.error('Flean content script error:', err) + } +})() diff --git a/mos/Flean Extension/Resources/manifest.json b/mos/Flean Extension/Resources/manifest.json index acc7afd..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", @@ -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/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 @@
Flean
-
Redirect Fandom wiki pages to Breezewiki mirrors
+
Redirects to independent wikis when available; Breezewiki mirrors as fallback
- + +

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/scripts/wiki-data-manager.js b/mos/Flean Extension/Resources/scripts/wiki-data-manager.js new file mode 100644 index 0000000..9c6aa41 --- /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 + } +} 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; 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" + } +} +