• úvod
  • témata
  • události
  • tržiště
  • přihlásit
  • diskuze
  • nástěnka
  • přihlásit
    přihlásit se
    registrace
    ztracené heslo?
    TOMTampermonkey 🐒 - máte užitečný skript? a mohli bychom ho vidět?
    Home | Tampermonkey
    https://www.tampermonkey.net/

    Tutorial:
    How to install and use Tampermonkey
    https://www.youtube.com/watch?v=8tyjJD65zws


    kód samotného skriptu vkládejte takto (viz také https://nyx.cz/help - sekce "Blok kódu"):
    První tag: ```js
    Ukončení tagu: ```

    Mobilní browsery (kromě Yandexu) podporující extensions včetně tampermonkey:
    Vivaldi:
    https://play.google.com/store/apps/details?id=com.vivaldi.browser
    Edge Beta:
    https://play.google.com/store/apps/details?id=com.microsoft.emmx.beta
    FDroid:
    Fennec F-Droid | F-Droid - Free and Open Source Android App Repository
    https://f-droid.org/packages/org.mozilla.fennec_fdroid/
    rozbalit záhlaví
    TOM
    TOM --- ---
    TOM: je tam bug, anebo má někdo jenom averzi k MyHeritage? (bez premium účtu si nemám palec dolů jak vysvětlit)

    MujRozhlas.cz stahování audioknih (případně samostatných/jednotlivých souborů)
    umí to i kapitoly, které už vypršely (pokud část z nich jde přehrát)
    mělo by fungovat i na Androidu
    // ==UserScript==
    // @name         mujRozhlas Chapter Downloader
    // @namespace    https://mujrozhlas.cz/
    // @version      1.7
    // @description  Adds a panel to mujrozhlas.cz pages that downloads every chapter/episode of the current audiobook, serial or show. MP3 is used when Český rozhlas offers it; otherwise the HLS stream (AAC) is stitched in the browser into one .aac file without transcoding. Saves into a per-book folder (a folder you pick once, desktop Chromium) or straight into Downloads.
    // @match        https://www.mujrozhlas.cz/*
    // @match        https://mujrozhlas.cz/*
    // @grant        GM_xmlhttpRequest
    // @grant        GM_download
    // @grant        GM_info
    // @connect      api.mujrozhlas.cz
    // @connect      croaod.cz
    // @connect      rozhlas.cz
    // @connect      portal.rozhlas.cz
    // @connect      mujrozhlas.cz
    // @run-at       document-idle
    // ==/UserScript==
    
    (function () {
        'use strict';
    
        // ---------------------- Configuration ----------------------
        const API = 'https://api.mujrozhlas.cz';
        const SEGMENT_PARALLELISM = 4;      // HLS segments fetched at once per episode
        const PAGE_LIMIT = 100;             // API page size when listing a show's episodes
        const MAX_EPISODES = 1000;          // safety cap for huge shows
        const BASE_FOLDER = 'MujRozhlas';   // books land in <picked folder>\MujRozhlas\<book>\ (unless the picked folder is MujRozhlas itself)
        const PREFIX_PART_NUMBER = true;    // "03 - Title.aac" instead of "Title.aac"
        const RETAIN_MB = 400;              // keep finished files in memory (for "save again") up to this total
        const URL_POLL_MS = 500;            // the site is a single-page app; poll for URL changes
        const ID = 'mrdl';                  // DOM id prefix
        const LS_METHOD = 'mrdl_method';             // auto | folder | plain – how files are saved (see "Saving strategies")
        const LS_ASCII_NAMES = 'mrdl_ascii_names';   // "1" – apply the folder-name rule to file names too
    
        // ---------------------- Runtime state ----------------------
        const state = {
            context: null,      // {bundle, id, label}
            episodes: [],       // see normalizeEpisode()
            running: false,
            abort: null,        // AbortController while running
            loaded: false,
            gen: 0,             // bumped on every page change; stale async loads check it and bail out
            loadAbort: null,    // AbortController of the running list load
            stale: false,       // URL changed while a download was running
            saver: null,        // {kind: 'folder'|'gm'|'anchor', ...} chosen for the current run
            plainForced: false, // user chose "save without a folder" after a folder method failed
            folderStored: false,// a picked base folder is remembered in IndexedDB
            retained: [],       // [{ep, blob}] built files kept for "save again" / instant retry
        };
    
        // ---------------------- Utilities --------------------------
        const $ = (sel, root) => (root || document).querySelector(sel);
        let panelEl = null;                                   // the script's own panel (all UI lookups are scoped to it)
        const ui = (sel) => (panelEl ? panelEl.querySelector(sel) : null);
        const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
    
        function sanitizeFilename(s) {
            return (s || '')
                .replace(/[\\/:*?"<>|\x00-\x1f]/g, '-')
                .replace(/\s+/g, ' ')
                .trim()
                .replace(/[. ]+$/, '')
                .slice(0, 150) || 'audio';
        }
    
        // Folder names: ASCII only, per the user's transliteration table.
        const TRANSLIT = {
            'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'é': 'e', 'è': 'e', 'ê': 'e', 'ě': 'e', 'à': 'a',
            'Ä': 'AE', 'Ö': 'OE', 'Ü': 'UE', 'É': 'E', 'È': 'E', 'Ê': 'E', 'Ě': 'E', 'À': 'A',
            'á': 'a', 'č': 'c', 'ď': 'd', 'í': 'i', 'ň': 'n', 'ó': 'o', 'ř': 'r', 'š': 's', 'ť': 't', 'ů': 'u', 'ú': 'u', 'ý': 'y', 'ž': 'z', 'ł': 'l', 'đ': 'd',
            'Á': 'A', 'Č': 'C', 'Ď': 'D', 'Í': 'I', 'Ň': 'N', 'Ó': 'O', 'Ř': 'R', 'Š': 'S', 'Ť': 'T', 'Ů': 'U', 'Ú': 'U', 'Ý': 'Y', 'Ž': 'Z', 'Ł': 'L', 'Đ': 'D',
            'ß': 'ss',
        };
        function folderName(s) {
            let out = '';
            for (const ch of (s || '')) {
                if (TRANSLIT[ch] !== undefined) out += TRANSLIT[ch];
                else if (/[,. !§'´ˇ;()\-]/.test(ch)) out += '_';
                else if (/[A-Za-z0-9_]/.test(ch)) out += ch;
                else {
                    // anything else non-ASCII: strip combining marks, keep ASCII letters, otherwise "_"
                    const plain = ch.normalize('NFD').replace(/[̀-ͯ]/g, '');
                    out += /^[A-Za-z0-9]$/.test(plain) ? plain : '_';
                }
            }
            return out.replace(/_+/g, '_').replace(/^_|_$/g, '').slice(0, 100) || 'mujRozhlas';
        }
    
        function fmtDuration(sec) {
            if (!sec && sec !== 0) return '';
            sec = Math.round(sec);
            const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
            return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(s).padStart(2, '0');
        }
    
        function fmtBytes(n) {
            if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' kB';
            return (n / 1024 / 1024).toFixed(1) + ' MB';
        }
    
        function log(msg) {
            console.log('[mujRozhlas DL] ' + msg);
        }
    
        function esc(s) {
            return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
        }
    
        // ---------------------- HTTP layer -------------------------
        // Plain fetch first (api.mujrozhlas.cz, croaod.cz and portal.rozhlas.cz all send CORS headers),
        // GM_xmlhttpRequest as a fallback should CORS ever be withdrawn.
        async function httpGet(url, { type = 'text', signal, onProgress } = {}) {
            try {
                const r = await fetch(url, { signal, headers: type === 'json' ? { Accept: 'application/json' } : {} });
                if (!r.ok) throw new Error('HTTP ' + r.status);
                if (type === 'json') return await r.json();
                if (type === 'text') return await r.text();
                // arraybuffer with progress
                if (onProgress && r.body && typeof r.body.getReader === 'function') {
                    const total = Number(r.headers.get('content-length')) || 0;
                    const reader = r.body.getReader();
                    const chunks = [];
                    let got = 0;
                    for (;;) {
                        const { done, value } = await reader.read();
                        if (done) break;
                        chunks.push(value);
                        got += value.length;
                        onProgress(got, total);
                    }
                    const out = new Uint8Array(got);
                    let off = 0;
                    for (const c of chunks) { out.set(c, off); off += c.length; }
                    return out.buffer;
                }
                return await r.arrayBuffer();
            } catch (e) {
                if (signal && signal.aborted) throw e;
                if (typeof GM_xmlhttpRequest !== 'function') throw e;
                log('fetch failed for ' + url + ' (' + e.message + '), retrying via GM_xmlhttpRequest');
                return gmGet(url, { type, signal, onProgress });
            }
        }
    
        function gmGet(url, { type, signal, onProgress }) {
            return new Promise((resolve, reject) => {
                const req = GM_xmlhttpRequest({
                    method: 'GET',
                    url,
                    responseType: type === 'json' ? 'json' : (type === 'text' ? 'text' : 'arraybuffer'),
                    headers: type === 'json' ? { Accept: 'application/json' } : {},
                    onprogress: (p) => { if (onProgress && p.lengthComputable) onProgress(p.loaded, p.total); },
                    onload: (r) => {
                        if (r.status < 200 || r.status >= 300) return reject(new Error('HTTP ' + r.status));
                        if (type === 'json' && typeof r.response === 'string') {
                            try { return resolve(JSON.parse(r.response)); } catch (e) { return reject(e); }
                        }
                        resolve(type === 'text' ? r.responseText : r.response);
                    },
                    onerror: () => reject(new Error('network error')),
                    ontimeout: () => reject(new Error('timeout')),
                });
                if (signal) signal.addEventListener('abort', () => { try { req.abort(); } catch (_) { /* ignore */ } reject(new DOMException('Aborted', 'AbortError')); });
            });
        }
    
        // ---------------------- Page context -----------------------
        // mujrozhlas.cz pushes a dataLayer object ("var dl = {...}") into an inline script:
        //   siteEntityBundle: serial | serialPart | episode | show | custom_hp | ...
        //   contentId:        UUID of the serial / episode (absent on show pages)
        //   contentShow:      "<show UUID>: <show title>"
        //   siteDocumentPath: path the object describes
        // The site is a single-page app: after in-page navigation that inline script (and parts of the
        // DOM such as the title) stay stale, so when it does not describe the current path the page
        // HTML is fetched again and parsed instead.
        function parseContext(html, fallbackLabel) {
            const m = (re) => (html.match(re) || [])[1];
            const raw = m(/"siteEntityBundle":"([^"]*)"/) || '';
            const contentId = m(/"contentId":"([0-9a-f-]{36})"/);
            const showId = m(/"contentShow":"([0-9a-f-]{36})/);
            const docPath = (m(/"siteDocumentPath":"((?:[^"\\]|\\.)*)"/) || '').replace(/\\\//g, '/');
            let label = fallbackLabel || '';
            const rawLabel = m(/"siteEntityLabel":"((?:[^"\\]|\\.)*)"/);
            if (rawLabel) { try { label = JSON.parse('"' + rawLabel + '"'); } catch (_) { label = rawLabel; } }
            let bundle = 'page', id = null;
            if (raw === 'serial' && contentId) { bundle = 'serial'; id = contentId; }
            else if (raw === 'show' && showId) { bundle = 'show'; id = showId; }
            else if ((raw === 'serialPart' || raw === 'episode') && contentId) { bundle = 'episode'; id = contentId; }
            return { bundle, id, label, docPath, raw };
        }
    
        async function detectContext(signal) {
            const path = location.pathname;
            const titleLabel = document.title.replace(/\s*•.*$/, '').trim();
            for (const s of document.scripts) {
                const t = s.textContent;
                if (!t || t.indexOf('siteEntityBundle') === -1) continue;
                const ctx = parseContext(t, titleLabel);
                if (ctx.docPath === path) return ctx;
            }
            // Stale or missing: fetch the server-rendered HTML of the current URL.
            try {
                const html = await httpGet(location.href, { type: 'text', signal });
                const lbl = ((html.match(/<title>([^<]*)<\/title>/) || [])[1] || titleLabel).replace(/\s*•.*$/, '').trim();
                const ctx = parseContext(html, lbl);
                if (ctx.raw) return ctx;
            } catch (e) { log('page fetch failed: ' + e.message); }
            return { bundle: 'page', id: null, label: titleLabel, docPath: path, raw: '' };
        }
    
        function dataEntriesOnPage() {
            const out = [];
            for (const el of document.querySelectorAll('[data-entry]')) {
                try {
                    const j = JSON.parse(el.getAttribute('data-entry'));
                    if (j && j.uuid && j.type) out.push(j);
                } catch (_) { /* ignore */ }
            }
            return out;
        }
    
        // ---------------------- API ------------------------------
        async function apiList(url, signal, cap) {
            const items = [];
            let next = url;
            while (next && items.length < cap) {
                const j = await httpGet(next, { type: 'json', signal });
                items.push(...(j.data || []));
                next = j.links && j.links.next;
                if (next) setStatus(`Loading… ${items.length} episodes so far`);
            }
            return items;
        }
    
        function normalizeEpisode(res) {
            const a = res.attributes || {};
            const links = Array.isArray(a.audioLinks) ? a.audioLinks : [];
            const byVariant = {};
            for (const l of links) if (l && l.url && l.variant && !byVariant[l.variant]) byVariant[l.variant] = l;
            let kind = null, link = null;
            if (byVariant.mp3) { kind = 'mp3'; link = byVariant.mp3; }
            else if (byVariant.hls) { kind = 'hls'; link = byVariant.hls; }
            else if (byVariant.dash && /manifest\.mpd$/.test(byVariant.dash.url)) {
                // same stream directory also serves the HLS playlist
                kind = 'hls';
                link = Object.assign({}, byVariant.dash, { url: byVariant.dash.url.replace(/manifest\.mpd$/, 'playlist.m3u8') });
            }
            const now = Date.now();
            let status = 'ready';
            if (!link) status = (a.since && Date.parse(a.since) > now) ? 'not yet published' : 'no audio';
            else if (link.playableTill && Date.parse(link.playableTill) < now) status = 'expired';
            const rel = res.relationships || {};
            return {
                uuid: res.id,
                title: a.title || a.shortTitle || res.id,
                part: a.part || null,
                since: a.since || '',
                duration: link ? link.duration : null,
                bitrate: link ? link.bitrate : null,
                kind,
                url: link ? link.url : null,
                status,
                serialUuid: rel.serial && rel.serial.data && rel.serial.data.id || null,
                selected: status === 'ready',
                progress: '',
            };
        }
    
        async function loadEpisodes(signal) {
            const ctx = state.context;
            let raw = [];
            if (ctx.bundle === 'serial' && ctx.id) {
                raw = await apiList(`${API}/serials/${ctx.id}/episodes?page[limit]=500`, signal, MAX_EPISODES);
                raw.sort((x, y) => (x.attributes.part || 0) - (y.attributes.part || 0));
            } else if (ctx.bundle === 'show' && ctx.id) {
                raw = await apiList(`${API}/shows/${ctx.id}/episodes?page[limit]=${PAGE_LIMIT}`, signal, MAX_EPISODES);
            } else if (ctx.bundle === 'episode' && ctx.id) {
                const j = await httpGet(`${API}/episodes/${ctx.id}`, { type: 'json', signal });
                raw = j.data ? [j.data] : [];
                // A chapter of a serial: list the whole serial so every chapter can be taken at once.
                const serialUuid = normalizeEpisode(raw[0] || {}).serialUuid;
                if (serialUuid) {
                    state.context = Object.assign({}, ctx, { bundle: 'serial', id: serialUuid });
                    return loadEpisodes(signal);
                }
            } else {
                // any other page: take whatever episodes are listed on it
                const seen = new Set();
                const uuids = dataEntriesOnPage().filter((e) => e.type === 'episode' && !seen.has(e.uuid) && seen.add(e.uuid)).map((e) => e.uuid);
                for (const u of uuids.slice(0, MAX_EPISODES)) {
                    try {
                        const j = await httpGet(`${API}/episodes/${u}`, { type: 'json', signal });
                        if (j.data) raw.push(j.data);
                    } catch (e) { log('episode ' + u + ' failed: ' + e.message); }
                }
            }
            state.episodes = raw.map(normalizeEpisode);
            state.loaded = true;
        }
    
        // ---------------------- Stream assembly --------------------
        // Strip the ID3v2 timestamp tag HLS packs in front of every AAC segment so the
        // concatenation is a clean ADTS stream.
        function stripId3(u8) {
            let off = 0;
            while (u8.length - off >= 10 && u8[off] === 0x49 && u8[off + 1] === 0x44 && u8[off + 2] === 0x33) {
                const flags = u8[off + 5];
                const size = ((u8[off + 6] & 0x7f) << 21) | ((u8[off + 7] & 0x7f) << 14) | ((u8[off + 8] & 0x7f) << 7) | (u8[off + 9] & 0x7f);
                off += 10 + size + ((flags & 0x10) ? 10 : 0);
            }
            return off ? u8.subarray(off) : u8;
        }
    
        async function resolveMediaPlaylist(url, signal) {
            let text = await httpGet(url, { type: 'text', signal });
            let base = url;
            for (let hop = 0; hop < 3 && /#EXT-X-STREAM-INF/.test(text); hop++) {
                const lines = text.split(/\r?\n/);
                let next = null;
                for (let i = 0; i < lines.length; i++) {
                    if (lines[i].startsWith('#EXT-X-STREAM-INF')) {
                        for (let k = i + 1; k < lines.length; k++) if (lines[k] && !lines[k].startsWith('#')) { next = lines[k].trim(); break; }
                        break;
                    }
                }
                if (!next) break;
                base = new URL(next, base).href;
                text = await httpGet(base, { type: 'text', signal });
            }
            return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#')).map((l) => new URL(l, base).href);
        }
    
        async function pool(items, n, fn) {
            const results = new Array(items.length);
            let idx = 0;
            async function worker() {
                for (;;) {
                    const i = idx++;
                    if (i >= items.length) return;
                    results[i] = await fn(items[i], i);
                }
            }
            await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
            return results;
        }
    
        async function fetchWithRetry(url, opts, tries = 3) {
            let err;
            for (let t = 0; t < tries; t++) {
                try { return await httpGet(url, opts); } catch (e) {
                    err = e;
                    if (opts.signal && opts.signal.aborted) throw e;
                    await sleep(500 * (t + 1));
                }
            }
            throw err;
        }
    
        async function buildHls(ep, signal, setProgress) {
            const segs = await resolveMediaPlaylist(ep.url, signal);
            if (!segs.length) throw new Error('empty playlist');
            let done = 0, bytes = 0;
            const parts = await pool(segs, SEGMENT_PARALLELISM, async (u) => {
                const buf = await fetchWithRetry(u, { type: 'arraybuffer', signal });
                const u8 = stripId3(new Uint8Array(buf));
                done++; bytes += u8.length;
                setProgress(`${done}/${segs.length} segments, ${fmtBytes(bytes)}`, done / segs.length);
                return u8;
            });
            return new Blob(parts, { type: 'audio/aac' });
        }
    
        async function buildMp3(ep, signal, setProgress) {
            const buf = await fetchWithRetry(ep.url, {
                type: 'arraybuffer', signal,
                onProgress: (got, total) => setProgress(fmtBytes(got) + (total ? ' / ' + fmtBytes(total) : ''), total ? got / total : 0),
            });
            return new Blob([buf], { type: 'audio/mpeg' });
        }
    
        const asciiNamesWanted = () => localStorage.getItem(LS_ASCII_NAMES) === '1';
    
        function fileNameFor(ep) {
            const ext = ep.kind === 'mp3' ? 'mp3' : 'aac';
            let base = ep.title;
            if (PREFIX_PART_NUMBER && ep.part) base = String(ep.part).padStart(2, '0') + ' - ' + base;
            return (asciiNamesWanted() ? folderName(base) : sanitizeFilename(base)) + '.' + ext;
        }
    
        function currentFolderName() {
            return folderName(state.context && state.context.label);
        }
    
        // ---------------------- Saving strategies ------------------
        // 1. "folder": File System Access API (desktop Chromium). The user picks a base folder once
        //    (remembered in IndexedDB); the script creates BASE_FOLDER\<book folder> inside it and writes
        //    files directly - no download prompts at all, already-present files are skipped. Chromium
        //    refuses Downloads / Desktop / Documents / home *themselves* ("contains system files"), so
        //    the user must pick or create a sub-folder, e.g. Downloads\MujRozhlas.
        // 2. "gm": Tampermonkey GM_download with a Blob, plain file name. Used for "Downloads directly"
        //    when available because extension-started downloads do not trigger Chromium's
        //    "allow multiple downloads" prompt. (Sub-folders in the name are NOT used: Chromium forks
        //    silently drop them, so that route was removed.)
        // 3. "anchor": plain <a download> click. Works everywhere (incl. Firefox/Kiwi on Android) but
        //    Chromium asks once per site to allow multiple downloads.
    
        const hasFolderApi = typeof window.showDirectoryPicker === 'function';
        const savedMethod = () => { const m = localStorage.getItem(LS_METHOD); return m === 'folder' || m === 'plain' ? m : 'auto'; };   // older stored values (e.g. 'gm') fall back to auto
    
        // -- IndexedDB persistence of the picked directory handle --
        function idb() {
            return new Promise((resolve, reject) => {
                const req = indexedDB.open('mrdl', 1);
                req.onupgradeneeded = () => req.result.createObjectStore('kv');
                req.onsuccess = () => resolve(req.result);
                req.onerror = () => reject(req.error);
            });
        }
        async function idbGet(key) {
            try {
                const db = await idb();
                return await new Promise((resolve, reject) => {
                    const r = db.transaction('kv').objectStore('kv').get(key);
                    r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error);
                });
            } catch (_) { return undefined; }
        }
        async function idbSet(key, val) {
            try {
                const db = await idb();
                await new Promise((resolve, reject) => {
                    const tx = db.transaction('kv', 'readwrite');
                    tx.objectStore('kv').put(val, key);
                    tx.oncomplete = resolve; tx.onerror = () => reject(tx.error);
                });
            } catch (_) { /* ignore */ }
        }
    
        // Must be called from a user gesture (the Download button click).
        async function pickBaseFolder(forcePick) {
            let handle = forcePick ? null : await idbGet('baseDir');
            if (handle) {
                try {
                    let p = await handle.queryPermission({ mode: 'readwrite' });
                    if (p !== 'granted') p = await handle.requestPermission({ mode: 'readwrite' });
                    if (p !== 'granted') handle = null;
                } catch (_) { handle = null; }
            }
            if (!handle) {
                handle = await window.showDirectoryPicker({ id: 'mrdl-base', mode: 'readwrite', startIn: 'downloads' });
                await idbSet('baseDir', handle);
                state.folderStored = true;
            }
            return handle;
        }
    
        async function makeFolderSaver(forcePick) {
            const picked = await pickBaseFolder(forcePick);
            const base = picked.name.toLowerCase() === BASE_FOLDER.toLowerCase() ? picked : await picked.getDirectoryHandle(BASE_FOLDER, { create: true });
            const sub = await base.getDirectoryHandle(currentFolderName(), { create: true });
            return {
                kind: 'folder',
                label: `folder "${picked.name}${base === picked ? '' : '/' + BASE_FOLDER}/${sub.name}"`,
                async exists(name) {
                    try { const f = await (await sub.getFileHandle(name)).getFile(); return f.size > 0; } catch (_) { return false; }
                },
                async save(blob, name) {
                    const fh = await sub.getFileHandle(name, { create: true });
                    const w = await fh.createWritable();
                    await w.write(blob);
                    await w.close();
                },
            };
        }
    
        function gmDownloadMode() {
            try { return (typeof GM_info !== 'undefined' && GM_info && GM_info.downloadMode) || ''; } catch (_) { return ''; }
        }
    
        const gmSaver = {
            kind: 'gm',
            label: 'Tampermonkey download into Downloads (no folder)',
            exists: async () => false,
            save(blob, name) {
                return new Promise((resolve, reject) => {
                    try {
                        GM_download({
                            url: blob,
                            name,
                            conflictAction: 'uniquify',
                            onload: () => resolve(),
                            onerror: (e) => reject(new Error('Tampermonkey download failed: ' + ((e && e.error) || 'error'))),
                            ontimeout: () => reject(new Error('Tampermonkey download timed out')),
                        });
                    } catch (e) { reject(e); }
                });
            },
        };
    
        const anchorSaver = {
            kind: 'anchor',
            label: 'browser download (no folder)',
            exists: async () => false,
            async save(blob, name) {
                const url = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = name;
                a.style.display = 'none';
                document.body.appendChild(a);
                a.click();
                setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 60000);
            },
        };
    
        const ANCHOR_WARNING = 'Chromium browsers (Chrome, Edge, Vivaldi, Brave…) show an "allow multiple downloads" prompt near the address bar when the 2nd file is saved. ' +
            'Click Allow, otherwise the 2nd and later files are silently dropped. The choice is remembered per site. ' +
            'Dropped files can be saved again from the list afterwards.';
    
        const gmAvailable = () => typeof GM_download === 'function' && gmDownloadMode() !== 'disabled';
        const plainSaver = () => (gmAvailable() ? gmSaver : anchorSaver);
    
        // Which strategy to use. "auto": a folder picked earlier wins, otherwise plain downloads.
        async function chooseSaver(forcePick) {
            if (state.plainForced && !forcePick) return plainSaver();
            let method = forcePick ? 'folder' : savedMethod();
            if (method === 'auto') method = (hasFolderApi && await idbGet('baseDir')) ? 'folder' : 'plain';
            if (method === 'folder' && hasFolderApi) {
                try { return await makeFolderSaver(forcePick); }
                catch (e) {
                    if (e && e.name === 'AbortError') throw new Error(`Folder selection cancelled. Browsers refuse the Downloads folder itself - pick or create a sub-folder such as Downloads\\${BASE_FOLDER}.`);
                    throw new Error('Cannot use the picked folder: ' + e.message);
                }
            }
            return plainSaver();
        }
    
        // ---------------------- Download run -----------------------
        function retain(ep, blob) {
            state.retained = state.retained.filter((r) => r.ep.uuid !== ep.uuid);
            state.retained.push({ ep, blob });
            let total = state.retained.reduce((n, r) => n + r.blob.size, 0);
            while (state.retained.length > 1 && total > RETAIN_MB * 1024 * 1024) {
                const dropped = state.retained.shift();
                total -= dropped.blob.size;
            }
        }
    
        // Thrown when a folder-capable method (Tampermonkey / picked folder) cannot save: the run is
        // stopped and the user told what to fix, instead of silently dropping files into Downloads.
        class SaveSetupError extends Error {}
    
        async function saveOrExplain(blob, name) {
            try {
                await state.saver.save(blob, name);
            } catch (e) {
                if (state.saver.kind === 'folder') throw new SaveSetupError(e.message);
                if (state.saver.kind === 'gm') {
                    log('Tampermonkey download failed (' + e.message + '), using a plain browser download');
                    state.saver = anchorSaver;
                    showWarning(ANCHOR_WARNING);
                    setMethod();
                    await anchorSaver.save(blob, name);
                    return;
                }
                throw e;
            }
        }
    
        async function downloadEpisode(ep, signal) {
            const setProgress = (txt, frac) => { ep.progress = txt; renderRow(ep, frac); };
            const name = fileNameFor(ep);
            if (await state.saver.exists(name)) {
                ep.status = 'done';
                setProgress('already in folder, skipped', 1);
                return;
            }
            let blob = (state.retained.find((r) => r.ep.uuid === ep.uuid) || {}).blob;   // built earlier, save failed
            if (!blob) {
                setProgress('starting…', 0);
                blob = ep.kind === 'mp3' ? await buildMp3(ep, signal, setProgress) : await buildHls(ep, signal, setProgress);
                retain(ep, blob);
            }
            setProgress('saving…', 1);
            await saveOrExplain(blob, name);
            ep.status = 'done';
            setProgress('saved ' + fmtBytes(blob.size), 1);
        }
    
        async function downloadSelected(forcePick) {
            if (state.running) return;
            const todo = state.episodes.filter((e) => e.selected && e.status === 'ready');
            if (!todo.length) { setStatus('Nothing selected.'); return; }
            try {
                state.saver = await chooseSaver(forcePick);   // inside the click's user activation
            } catch (e) {
                setStatus(e.message);
                return;
            }
            setMethod();
            if (state.saver.kind === 'anchor') showWarning(ANCHOR_WARNING); else showWarning('');
            state.running = true;
            state.abort = new AbortController();
            renderButtons();
            let ok = 0, fail = 0, setupProblem = null;
            for (let i = 0; i < todo.length; i++) {
                const ep = todo[i];
                if (state.abort.signal.aborted) break;
                setStatus(`Downloading ${i + 1} / ${todo.length}: ${ep.title}`);
                try {
                    await downloadEpisode(ep, state.abort.signal);
                    ok++;
                    // give the browser a moment to start the save before the next file
                    await sleep(400);
                } catch (e) {
                    if (state.abort.signal.aborted) { ep.progress = 'cancelled'; renderRow(ep, 0); break; }
                    if (e instanceof SaveSetupError) {
                        ep.progress = 'built, waiting to be saved';
                        renderRow(ep, 1);
                        setupProblem = e.message;
                        break;
                    }
                    fail++;
                    ep.progress = 'failed: ' + e.message;
                    renderRow(ep, 0);
                    log('failed ' + ep.title + ': ' + e.message);
                }
            }
            const cancelled = state.abort.signal.aborted;
            state.running = false;
            state.abort = null;
            renderButtons();
            if (setupProblem) {
                state.saver = null;
                setMethod();
                showWarning(setupProblem, true);
                setStatus(`Stopped after ${ok} file(s) - see the note above.`);
                return;
            }
            let msg = cancelled ? `Cancelled after ${ok} file(s).` : `Finished: ${ok} saved${fail ? ', ' + fail + ' failed' : ''}.`;
            if (!cancelled && state.saver.kind === 'anchor' && ok > 1) msg += ' If the browser blocked some downloads, use "save again" on the affected rows.';
            setStatus(msg);
            if (state.stale) resetForNewPage();
        }
    
        async function saveAgain(uuid) {
            const r = state.retained.find((x) => x.ep.uuid === uuid);
            if (!r) { setStatus('That file is no longer held in memory – download it again.'); return; }
            try {
                if (!state.saver) state.saver = await chooseSaver(false);
                await saveOrExplain(r.blob, fileNameFor(r.ep));
                setStatus('Saved again: ' + fileNameFor(r.ep));
            } catch (e) { setStatus('Save failed: ' + e.message); }
        }
    
        function cancel() {
            if (state.abort) state.abort.abort();
        }
    
        // ---------------------- SPA navigation ---------------------
        function resetForNewPage() {
            state.gen++;
            if (state.loadAbort) { try { state.loadAbort.abort(); } catch (_) { /* ignore */ } state.loadAbort = null; }
            state.stale = false;
            state.context = null;
            state.episodes = [];
            state.loaded = false;
            state.retained = [];
            state.saver = null;
            const list = ui('#' + ID + '-list');
            if (list) list.innerHTML = '';
            showWarning('');
            setMethod();
            if (panelEl && panelEl.classList.contains('open')) openPanel();   // reload for the new page
        }
    
        let lastHref = location.href;
        setInterval(() => {
            if (location.href === lastHref) return;
            lastHref = location.href;
            if (state.running) { state.stale = true; setStatus('Page changed – the list will refresh when this download finishes.'); }
            else resetForNewPage();
        }, URL_POLL_MS);
    
        // ---------------------- UI ------------------------------
        const CSS = `
    #${ID}-fab{position:fixed;right:16px;bottom:16px;z-index:2147483000;background:#e2001a;color:#fff;border:0;border-radius:28px;
      padding:12px 18px;font:600 15px/1 system-ui,Segoe UI,Roboto,sans-serif;box-shadow:0 4px 14px rgba(0,0,0,.35);cursor:pointer}
    #${ID}-fab:hover{background:#b80015}
    #${ID}-panel{position:fixed;right:12px;bottom:12px;z-index:2147483001;width:min(600px,calc(100vw - 24px));max-height:min(85vh,calc(100vh - 24px));
      display:none;flex-direction:column;background:#fff;color:#111;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.45);
      font:14px/1.4 system-ui,Segoe UI,Roboto,sans-serif;overflow:hidden}
    #${ID}-panel.open{display:flex}
    #${ID}-head{display:flex;align-items:center;gap:8px;padding:10px 12px;background:#e2001a;color:#fff;font-weight:600}
    #${ID}-head .t{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
    #${ID}-head button{background:transparent;border:0;color:#fff;font-size:20px;cursor:pointer;padding:0 4px;line-height:1}
    #${ID}-tools{display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px;border-bottom:1px solid #ddd;align-items:center}
    #${ID}-tools button{border:1px solid #bbb;background:#f6f6f6;color:#111;border-radius:6px;padding:7px 11px;font-size:13px;cursor:pointer}
    #${ID}-tools button.primary{background:#e2001a;border-color:#e2001a;color:#fff;font-weight:600}
    #${ID}-tools button:disabled{opacity:.5;cursor:default}
    #${ID}-opts{display:flex;flex-wrap:wrap;gap:4px 14px;padding:6px 12px;border-bottom:1px solid #ddd;font-size:12px;color:#333;align-items:center}
    #${ID}-opts label{display:flex;align-items:center;gap:5px;cursor:pointer}
    #${ID}-opts .m{color:#666}
    #${ID}-opts a{color:#0b57d0;cursor:pointer}
    #${ID}-warn{display:none;padding:8px 12px;background:#fff4d6;color:#5a3d00;border-bottom:1px solid #f0d58c;font-size:12px;line-height:1.35}
    #${ID}-warn.show{display:block}
    #${ID}-warn button{margin-top:6px;border:1px solid #c9a74a;background:#fff;color:#5a3d00;border-radius:6px;padding:5px 9px;font-size:12px;cursor:pointer}
    #${ID}-list{overflow:auto;flex:1;padding:4px 0}
    .${ID}-row{display:grid;grid-template-columns:auto 1fr auto;gap:4px 10px;align-items:center;padding:6px 12px;border-bottom:1px solid #eee}
    .${ID}-row .ttl{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
    .${ID}-row .meta{grid-column:2/4;color:#555;font-size:12px}
    .${ID}-row .meta a{color:#0b57d0;cursor:pointer;margin-left:6px}
    .${ID}-row .badge{font-size:11px;padding:2px 6px;border-radius:4px;background:#eee;color:#333;white-space:nowrap}
    .${ID}-row .badge.mp3{background:#d9f2d9;color:#155715}
    .${ID}-row .badge.hls{background:#dbe8ff;color:#113a7a}
    .${ID}-row .badge.off{background:#f6d9d9;color:#7a1111}
    .${ID}-row .bar{grid-column:2/4;height:4px;background:#eee;border-radius:2px;overflow:hidden}
    .${ID}-row .bar i{display:block;height:100%;width:0;background:#e2001a;transition:width .2s}
    .${ID}-row.done .ttl{color:#155715}
    #${ID}-status{padding:8px 12px;border-top:1px solid #ddd;font-size:12px;color:#333;background:#fafafa}
    `;
    
        function buildUI() {
            if (document.getElementById(ID + '-fab')) return;   // already built (script loaded twice)
            const style = document.createElement('style');
            style.textContent = CSS;
            document.head.appendChild(style);
    
            const fab = document.createElement('button');
            fab.id = ID + '-fab';
            fab.textContent = '⬇ Download chapters';
            fab.addEventListener('click', openPanel);
            document.body.appendChild(fab);
    
            const panel = document.createElement('div');
            panel.id = ID + '-panel';
            panelEl = panel;
            panel.innerHTML = `
    <div id="${ID}-head"><span class="t"></span><button type="button" title="Close">×</button></div>
    <div id="${ID}-tools">
      <button type="button" data-act="all">Select all</button>
      <button type="button" data-act="none">Select none</button>
      <span style="flex:1"></span>
      <button type="button" data-act="cancel" style="display:none">Cancel</button>
      <button type="button" data-act="dl" class="primary">Download selected</button>
    </div>
    <div id="${ID}-opts">
      <label><input type="checkbox" data-opt="ascii" ${asciiNamesWanted() ? 'checked' : ''}> ASCII file names (same rule as the folder)</label>
      <label>save to <select data-opt="method">
        <option value="auto">folder picked earlier, otherwise Downloads</option>
        ${hasFolderApi ? `<option value="folder">folder I pick once → ${BASE_FOLDER}\\&lt;book&gt;\\ (works everywhere)</option>` : ''}
        <option value="plain">Downloads directly, no sub-folder</option>
      </select></label>${hasFolderApi ? `<a data-act="pick">choose folder…</a>` : ''}
      <span class="m" data-role="method"></span>
    </div>
    <div id="${ID}-warn"></div>
    <div id="${ID}-list"></div>
    <div id="${ID}-status">Loading…</div>`;
            document.body.appendChild(panel);
            $('#' + ID + '-head button', panel).addEventListener('click', () => panel.classList.remove('open'));
            $('#' + ID + '-tools', panel).addEventListener('click', onToolClick);
            $('#' + ID + '-warn', panel).addEventListener('click', (ev) => {
                if (ev.target.closest('button[data-act="plain"]')) { state.plainForced = true; showWarning(''); downloadSelected(false); }
            });
            $('#' + ID + '-opts', panel).addEventListener('click', (ev) => {
                const a = ev.target.closest('a[data-act="pick"]');
                if (a) {
                    ev.preventDefault();
                    pickBaseFolder(true).then((h) => {
                        localStorage.setItem(LS_METHOD, 'folder');
                        const sel = ui('select[data-opt="method"]'); if (sel) sel.value = 'folder';
                        state.plainForced = false;
                        setMethod();
                        setStatus(`Folder chosen: ${h.name}. Files will go to ${h.name}${h.name.toLowerCase() === BASE_FOLDER.toLowerCase() ? '' : '/' + BASE_FOLDER}/<book>/.`);
                    }).catch((e) => setStatus(e && e.name === 'AbortError' ? 'Folder selection cancelled.' : 'Folder selection failed: ' + e.message));
                }
            });
            $('#' + ID + '-opts', panel).addEventListener('change', (ev) => {
                if (ev.target.dataset.opt === 'method') { localStorage.setItem(LS_METHOD, ev.target.value); state.plainForced = false; setMethod(); }
                if (ev.target.dataset.opt === 'ascii') { localStorage.setItem(LS_ASCII_NAMES, ev.target.checked ? '1' : '0'); renderList(); }
            });
            $('#' + ID + '-list', panel).addEventListener('change', (ev) => {
                const cb = ev.target;
                if (cb.type !== 'checkbox') return;
                const ep = state.episodes.find((e) => e.uuid === cb.dataset.uuid);
                if (ep) ep.selected = cb.checked;
            });
            $('#' + ID + '-list', panel).addEventListener('click', (ev) => {
                const a = ev.target.closest('a[data-again]');
                if (a) { ev.preventDefault(); saveAgain(a.dataset.again); }
            });
            const sel = ui('select[data-opt="method"]');
            if (sel) sel.value = [...sel.options].some((o) => o.value === savedMethod()) ? savedMethod() : 'auto';
            setMethod();
        }
    
        function setStatus(s) {
            const el = ui('#' + ID + '-status');
            if (el) el.textContent = s;
        }
    
        function showWarning(s, offerPlain) {
            const el = ui('#' + ID + '-warn');
            if (!el) return;
            el.textContent = s;
            if (s && offerPlain) {
                const b = document.createElement('button');
                b.type = 'button';
                b.dataset.act = 'plain';
                b.textContent = 'Save straight to Downloads instead (no folder)';
                el.appendChild(document.createElement('br'));
                el.appendChild(b);
            }
            el.classList.toggle('show', !!s);
        }
    
        // Describe which saving method the next run will use.
        function setMethod() {
            const el = ui(`#${ID}-opts [data-role="method"]`);
            if (!el) return;
            const folder = state.context ? currentFolderName() : '…';
            const m = savedMethod();
            let txt;
            if (state.saver) txt = 'Saving via ' + state.saver.label + '.';
            else if (state.plainForced || m === 'plain') txt = 'Files go straight to the default download folder (no sub-folder).';
            else if (m === 'folder' || (m === 'auto' && hasFolderApi && state.folderStored)) txt = `Files go to <picked folder>\\${BASE_FOLDER}\\${folder}\\ - pick Downloads\\${BASE_FOLDER} once (the browser refuses Downloads itself) and choose "Allow on every visit" when asked.`;
            else if (hasFolderApi) txt = 'No folder picked yet, so files go straight to the default download folder. Use "choose folder…" once to get a book folder.';
            else txt = 'Files go straight to the default download folder (no sub-folder possible in this browser).';
            el.textContent = txt;
        }
    
        async function openPanel() {
            if (!panelEl) return;
            panelEl.classList.add('open');
            if (state.loaded) return;
            state.loaded = true;   // guard against double load while fetching
            const gen = state.gen;
            const ctl = new AbortController();
            state.loadAbort = ctl;
            setStatus('Loading…');
            ui('#' + ID + '-head .t').textContent = 'mujRozhlas';
            if (hasFolderApi) idbGet('baseDir').then((h) => { state.folderStored = !!h; setMethod(); });
            try {
                const ctx = await detectContext(ctl.signal);
                if (gen !== state.gen) return;           // page changed meanwhile – a newer load owns the panel
                state.context = ctx;
                ui('#' + ID + '-head .t').textContent = state.context.label || 'mujRozhlas';
                await loadEpisodes(ctl.signal);
                if (gen !== state.gen) return;
                renderList();
                setMethod();
                const ready = state.episodes.filter((e) => e.status === 'ready').length;
                setStatus(`${state.episodes.length} episode(s) found, ${ready} downloadable.`);
            } catch (e) {
                if (gen !== state.gen) return;
                state.loaded = false;
                setStatus('Failed to load episode list: ' + e.message);
                log(e.stack || e);
            } finally {
                if (state.loadAbort === ctl) state.loadAbort = null;
            }
        }
    
        function onToolClick(ev) {
            const btn = ev.target.closest('button[data-act]');
            if (!btn) return;
            const act = btn.dataset.act;
            if (act === 'all' || act === 'none') {
                for (const e of state.episodes) if (e.status === 'ready') e.selected = act === 'all';
                renderList();
            } else if (act === 'dl') {
                downloadSelected(false);
            } else if (act === 'cancel') {
                cancel();
            }
        }
    
        function renderButtons() {
            const dl = ui(`#${ID}-tools [data-act="dl"]`), cn = ui(`#${ID}-tools [data-act="cancel"]`);
            if (dl) dl.disabled = state.running;
            if (cn) cn.style.display = state.running ? '' : 'none';
        }
    
        function renderList() {
            const list = ui('#' + ID + '-list');
            list.innerHTML = '';
            if (!state.episodes.length) {
                list.innerHTML = '<div style="padding:16px;color:#666">No episodes found on this page.</div>';
                return;
            }
            for (const ep of state.episodes) {
                const row = document.createElement('div');
                row.className = ID + '-row';
                row.dataset.row = ep.uuid;
                list.appendChild(row);
                renderRow(ep, 0);
            }
        }
    
        function renderRow(ep, frac) {
            const row = ui(`.${ID}-row[data-row="${ep.uuid}"]`);
            if (!row) return;
            const ready = ep.status === 'ready' || ep.status === 'done';
            const badge = ep.status === 'done' ? 'done' : (ready ? (ep.kind === 'mp3' ? 'MP3' : 'AAC (HLS)') : ep.status);
            const badgeCls = ep.status === 'done' ? 'mp3' : (ready ? ep.kind : 'off');
            const again = ep.status === 'done' && state.retained.some((r) => r.ep.uuid === ep.uuid) ? `<a data-again="${ep.uuid}">save again</a>` : '';
            row.classList.toggle('done', ep.status === 'done');
            row.innerHTML = `
    <input type="checkbox" data-uuid="${ep.uuid}" ${ep.selected && ep.status === 'ready' ? 'checked' : ''} ${ep.status === 'ready' ? '' : 'disabled'}>
    <div class="ttl" title="${esc(ep.title)}">${ep.part ? ep.part + '. ' : ''}${esc(ep.title)}</div>
    <span class="badge ${badgeCls}">${badge}</span>
    <div class="meta" title="${esc(fileNameFor(ep))}">${ep.duration ? fmtDuration(ep.duration) : ''}${ep.bitrate ? ' · ' + ep.bitrate + ' kbps' : ''}${ep.since ? ' · ' + ep.since.slice(0, 10) : ''}${ep.progress ? ' · ' + esc(ep.progress) : ''}${again}</div>
    <div class="bar"><i style="width:${Math.round((frac || 0) * 100)}%"></i></div>`;
        }
    
        // ---------------------- Boot -------------------------------
        if (document.body) buildUI();
        else document.addEventListener('DOMContentLoaded', buildUI);
    })();
    
    TOM
    TOM --- ---
    TOM: tady k tomu ještě powershell (zawrappovaný), který ten JSON převede na SVG a/nebo PPTX

    TOM
    TOM --- ---
    Export JSON rodokmenu z největší DB na světě MyHeritage.com
    články o rodokmenech here:
    [TOM @ Za Paywallem? 🔐🗝️ Odemčeno. 🔑🔓 klub se 4 pravidly v záhlaví]

    // ==UserScript==
    // @name         MyHeritage Family Tree Exporter
    // @version      1.1.0
    // @description  Exports the complete MyHeritage family tree (people, families, lineage codes, photos) into a ZIP with a JSON file and a MyHeritage_attachments folder.
    // @match        https://www.myheritage.com/family-trees/*
    // @match        https://www.myheritage.cz/family-trees/*
    // @include      /^https:\/\/www\.myheritage\.[a-z.]+\/family-trees\//
    // @grant        none
    // @run-at       document-idle
    // ==/UserScript==
    
    /*
     * HOW IT WORKS (findings from reverse-engineering the "family view" page)
     * ----------------------------------------------------------------------
     * The family-view page keeps its data model in `window.newTree`:
     *   newTree.genealogy._individualsCollection._allIndividuals  -> {id: Individual}
     *   newTree.genealogy._familiesCollection._allFamilies        -> {id: Family}
     * Only a pruned neighbourhood (~3 generations) around the *pivot* person is loaded.
     * The "View family branch" button on a card runs
     *   newTree.dataAcquisition.setRootIndividualID(id);
     *   newTree.dataAcquisition.requestTreeData(false);
     * which reloads the model around a new pivot WITHOUT a page reload.
     * The exporter drives that call over every person until the whole tree
     * (newTree.genealogy.getNumberOfIndividualsInTree()) has been collected.
     *
     * Photos: card data only carries a 96 px signed thumbnail. The full-size
     * profile photo comes from
     *   POST /FP/API/Profile/get-profile-photo.php  body: individualID=<id>&s=<siteID>
     * which returns XML with <profilePhotoFullUrl>. Those URLs are signed and expire,
     * so images are downloaded at export time into MyHeritage_attachments/<id>.<ext>.
     *
     * Lineage code (same convention MyHeritage uses for its colour coding):
     *   ""  = home person, "f"/"m" = father/mother, "ff","fm","mf","mm" = grandparents,
     *   "c" = descendant of the home person, "" also for spouses/in-laws/siblings.
     * Colours are NOT written into the JSON; the post-processing tool maps lineage -> colour.
     * MyHeritage's own palette is included as `referencePalette` for convenience.
     *
     * Output: one ZIP (Downloads folder) containing
     *   <name>.json
     *   MyHeritage_attachments/<individualId>.jpg
     * The ZIP is written by a small built-in "store" writer (no compression, no external
     * library) so the script has no @require and is not affected by the site's CSP.
     */
    
    (function () {
        'use strict';
    
        const LOG_PREFIX = '[MH-Export]';
        const ATTACH_DIR = 'MyHeritage_attachments';
        const JUMP_TIMEOUT_MS = 15000;      // a normal pivot loads in ~1 s; longer = the server refused it
        // The site rate-limits tree requests (HTTP 429, shown as "An error occurred. Please try again later.").
        // The pause between pivots is adaptive: it doubles after every refusal and slowly relaxes after successes.
        const JUMP_DELAY_MIN_MS = 2000, JUMP_DELAY_MAX_MS = 20000;
        const RETRY_WAITS_MS = [30000, 60000, 120000, 240000];   // extra back-off before retrying a refused pivot
        const SETTLE_MS = 350;
    
        // ------------------------------------------------------------------ utils
        const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
        const log = (...a) => console.log(LOG_PREFIX, ...a);
    
        async function waitFor(cond, timeoutMs, stepMs = 100) {
            const t0 = Date.now();
            while (Date.now() - t0 < timeoutMs) {
                try { if (cond()) return true; } catch (e) { /* not ready */ }
                await sleep(stepMs);
            }
            return false;
        }
    
        function str(v) { return v === undefined || v === null ? '' : String(v); }
        function numOrNull(v) { const n = parseInt(v, 10); return isNaN(n) ? null : n; }
        function safeFileName(s) { return str(s).replace(/[\\/:*?"<>|]+/g, '_').replace(/\s+/g, ' ').trim(); }
        function timestamp() {
            const d = new Date(), p = (n) => String(n).padStart(2, '0');
            return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '_' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
        }
    
        // ------------------------------------------------------------ zip writer
        // Minimal ZIP (method 0 = store). JPEGs do not compress anyway; JSON is small.
        const CRC_TABLE = (() => {
            const t = new Uint32Array(256);
            for (let n = 0; n < 256; n++) {
                let c = n;
                for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
                t[n] = c >>> 0;
            }
            return t;
        })();
        function crc32(bytes) {
            let c = 0xFFFFFFFF;
            for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
            return (c ^ 0xFFFFFFFF) >>> 0;
        }
        class SimpleZip {
            constructor() { this.entries = []; }
            file(name, data) { this.entries.push({ name, data }); }
            async generateAsync() {
                const enc = new TextEncoder();
                const now = new Date();
                const dosTime = ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xFFFF;
                const dosDate = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xFFFF;
                const parts = [], central = [];
                let offset = 0;
                const u16 = (v) => [v & 0xFF, (v >>> 8) & 0xFF];
                const u32 = (v) => [v & 0xFF, (v >>> 8) & 0xFF, (v >>> 16) & 0xFF, (v >>> 24) & 0xFF];
                for (const e of this.entries) {
                    const bytes = typeof e.data === 'string' ? enc.encode(e.data) : new Uint8Array(await e.data.arrayBuffer());
                    const nameBytes = enc.encode(e.name);
                    const crc = crc32(bytes);
                    const common = [...u16(20), ...u16(0x0800), ...u16(0), ...u16(dosTime), ...u16(dosDate), ...u32(crc), ...u32(bytes.length), ...u32(bytes.length), ...u16(nameBytes.length)];
                    const local = new Uint8Array([...u32(0x04034B50), ...common, ...u16(0), ...nameBytes]);
                    parts.push(local, bytes);
                    central.push(new Uint8Array([...u32(0x02014B50), ...u16(20), ...common, ...u16(0), ...u16(0), ...u16(0), ...u16(0), ...u32(0), ...u32(offset), ...nameBytes]));
                    offset += local.length + bytes.length;
                }
                const centralSize = central.reduce((s, c) => s + c.length, 0);
                const eocd = new Uint8Array([...u32(0x06054B50), ...u16(0), ...u16(0), ...u16(this.entries.length), ...u16(this.entries.length), ...u32(centralSize), ...u32(offset), ...u16(0)]);
                return new Blob([...parts, ...central, eocd], { type: 'application/zip' });
            }
        }
    
        function treeReady() {
            const nt = window.newTree;
            return !!(nt && nt.genealogy && nt.dataAcquisition && nt.canvas &&
                nt.genealogy._individualsCollection && nt.genealogy._individualsCollection.getCount() > 0);
        }
    
        // ------------------------------------------------------------- collector
        class TreeCollector {
            constructor() {
                this.individuals = new Map();   // id -> record
                this.families = new Map();      // id -> record
                this.pivoted = new Set();
                this.pruned = new Set();        // ids whose branches were cut in some view
                this.complete = new Set();      // ids seen as a NON-pruned card: all their immediate relatives were in that view
                this.failed = new Set();        // pivots the server kept refusing
                this.siteId = str(window.newTree.genealogy.getSiteID());
                this.treeId = str(window.newTree.genealogy.getTreeID());
                this.homeId = str(window.newTree.dataAcquisition.getHomeIndividualID());
                this.treeSize = numOrNull(window.newTree.genealogy.getNumberOfIndividualsInTree()) || 0;
            }
    
            // real ids are positive integers; the page uses "0" for a missing spouse and "addme_N" for placeholders
            static isRealId(id) { return /^\d+$/.test(str(id)) && parseInt(id, 10) > 0; }
    
            mergeCurrentView() {
                const g = window.newTree.genealogy;
                const inds = Object.values(g._individualsCollection._allIndividuals || {}).filter(Boolean);
                for (const i of inds) {
                    const id = str(i._ID);
                    if (!TreeCollector.isRealId(id)) continue;
                    // "addme_N" ids are the page's "add parents / add partner" placeholders, not real families
                    const parentFamilies = (i._familiesIAmAChildIn || [])
                        .map((f) => typeof f === 'object' ? { id: str(f.id), relation: str(f.relation) } : { id: str(f), relation: '' })
                        .filter((f) => TreeCollector.isRealId(f.id));
                    const spouseFamilies = (i._familiesIAmAParentIn || [])
                        .map((f) => str(typeof f === 'object' ? f.id : f))
                        .filter(TreeCollector.isRealId);
                    let profileUrl = '';
                    try { profileUrl = new URL(i.getProfilePageURL(), location.origin).href; } catch (e) { /* ignore */ }
    
                    const prev = this.individuals.get(id);
                    const rec = {
                        id,
                        name: str(i._displayName),
                        firstName: str(i._firstName),
                        lastName: str(i._lastName),
                        gender: str(i._gender),          // "M" / "F" / ""
                        birthDate: str(i._birthDate),     // localized text as shown on the card
                        birthYear: numOrNull(i._birthYear),
                        deathDate: str(i._deathDate),
                        deathYear: numOrNull(i._deathYear),
                        isAlive: !!i._isAlive,
                        parentFamilies,
                        spouseFamilies,
                        thumbUrl: str(i._smallThumbURL),
                        hasPhoto: !!i._smallThumbURL,
                        profileUrl,
                        lineage: '',
                        generation: null,
                        photo: null
                    };
                    if (prev) {
                        // union of family links (a pruned view may hide some of them)
                        const pf = new Map(prev.parentFamilies.map((x) => [x.id, x]));
                        for (const x of parentFamilies) pf.set(x.id, x);
                        rec.parentFamilies = [...pf.values()];
                        rec.spouseFamilies = [...new Set([...prev.spouseFamilies, ...spouseFamilies])];
                        if (!rec.thumbUrl && prev.thumbUrl) { rec.thumbUrl = prev.thumbUrl; rec.hasPhoto = true; }
                    }
                    this.individuals.set(id, rec);
                }
    
                const fams = Object.values(g._familiesCollection._allFamilies || {}).filter(Boolean);
                for (const f of fams) {
                    const id = str(f._ID);
                    if (!TreeCollector.isRealId(id)) continue;   // skip "addme_N" placeholders
                    const prev = this.families.get(id);
                    const children = (f._childrenIDs || []).map(str).filter(TreeCollector.isRealId);
                    const rec = {
                        id,
                        husband: TreeCollector.isRealId(f._husbandID) ? str(f._husbandID) : '',
                        wife: TreeCollector.isRealId(f._wifeID) ? str(f._wifeID) : '',
                        status: str(f._status),
                        children
                    };
                    if (prev) {
                        rec.husband = rec.husband || prev.husband;
                        rec.wife = rec.wife || prev.wife;
                        rec.status = rec.status || prev.status;
                        rec.children = [...new Set([...prev.children, ...children])];
                    }
                    this.families.set(id, rec);
                }
    
                // cards whose branch is cut in this view (ridge icon) vs. cards shown with all their relatives
                try {
                    const cards = Object.values(window.newTree.canvas._personCardsCollection._allPersonCards || {}).filter(Boolean);
                    for (const c of cards) {
                        if (typeof c.getIsPruned !== 'function' || !c.getTargetPerson) continue;
                        const p = c.getTargetPerson();
                        if (!p || !TreeCollector.isRealId(p._ID)) continue;
                        if (c.getIsPruned()) this.pruned.add(str(p._ID)); else this.complete.add(str(p._ID));
                    }
                } catch (e) { log('pruned-card scan failed', e); }
                const root = str(g.getRootIndividualID());
                if (TreeCollector.isRealId(root)) this.complete.add(root);
            }
    
            remainingToCheck() {
                let n = 0;
                for (const id of this.individuals.keys()) if (!this.pivoted.has(id) && !this.complete.has(id)) n++;
                return n;
            }
    
            nextPivot(thorough) {
                // 1) cut branches first (that is where unknown people hide)
                for (const id of this.pruned) if (!this.pivoted.has(id) && !this.complete.has(id)) return id;
                if (!thorough && this.treeSize && this.individuals.size >= this.treeSize) return null;
                // 2) then only people never shown with their full neighbourhood - NOT everyone (each pivot is a server request)
                for (const id of this.individuals.keys()) if (!this.pivoted.has(id) && !this.complete.has(id)) return id;
                return null;
            }
    
            // The page calls dataAcquisition.treeLayoutLoaded(...) when a pivot's data has been
            // applied to the model. Hooking it gives an event-driven completion signal, which also
            // works in a background tab where setTimeout polling would be throttled to ~1/min.
            static installLoadedHook() {
                const da = window.newTree.dataAcquisition;
                if (da.__mhHooked) return;
                const orig = da.treeLayoutLoaded;
                da.__mhWaiters = [];
                const fire = (ok) => { const w = da.__mhWaiters; da.__mhWaiters = []; w.forEach((f) => { try { f(ok); } catch (e) { /* ignore */ } }); };
                da.treeLayoutLoaded = function () {
                    const r = orig.apply(this, arguments);
                    fire(true);
                    return r;
                };
                // a refused layout request (HTTP 429 with an empty body when the rate limit hits, or any other
                // HTTP error) should fail fast instead of waiting for the timeout
                try {
                    const oOpen = XMLHttpRequest.prototype.open, oSend = XMLHttpRequest.prototype.send;
                    XMLHttpRequest.prototype.open = function (m, u) { this.__mhUrl = String(u || ''); return oOpen.apply(this, arguments); };
                    XMLHttpRequest.prototype.send = function () {
                        if (/get-tree-layout/.test(this.__mhUrl)) {
                            this.addEventListener('loadend', () => { if (this.status >= 400 || this.status === 0) fire(false); });
                        }
                        return oSend.apply(this, arguments);
                    };
                } catch (e) { log('xhr hook failed', e); }
                da.__mhHooked = true;
            }
    
            async jumpTo(id) {
                const da = window.newTree.dataAcquisition, g = window.newTree.genealogy;
                TreeCollector.installLoadedHook();
                try { da.setOpenSpiderAfterPruneJump(false); } catch (e) { /* ignore */ }
                const loaded = new Promise((res) => {
                    da.__mhWaiters.push((ok) => res(ok));
                    setTimeout(() => res(false), JUMP_TIMEOUT_MS);
                });
                da.setRootIndividualID(id);
                da.requestTreeData(false);
                // success = treeLayoutLoaded fired. There is deliberately NO fallback on the root id: the page
                // sets genealogy's root id BEFORE the request, so after a 429 it already "matches" while the
                // model still holds the previous pivot's people.
                const ok = await loaded;
                await sleep(SETTLE_MS);   // let the canvas rebuild its card collection
                return ok;
            }
    
            // ------------------------------------------------ derived fields
            computeLineageAndGeneration() {
                const byId = this.individuals;
                const fam = this.families;
                const parentsOf = (id) => {
                    const out = [];
                    const p = byId.get(id);
                    if (!p) return out;
                    for (const pf of p.parentFamilies) {
                        const f = fam.get(pf.id);
                        if (!f) continue;
                        if (f.husband) out.push({ id: f.husband, code: 'f' });
                        if (f.wife) out.push({ id: f.wife, code: 'm' });
                    }
                    return out;
                };
                const childrenOf = (id) => {
                    const out = [];
                    const p = byId.get(id);
                    if (!p) return out;
                    for (const fid of p.spouseFamilies) {
                        const f = fam.get(fid);
                        if (f) out.push(...f.children);
                    }
                    return out;
                };
                const spousesOf = (id) => {
                    const out = [];
                    const p = byId.get(id);
                    if (!p) return out;
                    for (const fid of p.spouseFamilies) {
                        const f = fam.get(fid);
                        if (!f) continue;
                        if (f.husband && f.husband !== id) out.push(f.husband);
                        if (f.wife && f.wife !== id) out.push(f.wife);
                    }
                    return out;
                };
    
                // lineage: ancestors of home get f/m path, descendants get "c"
                const home = this.homeId;
                if (byId.has(home)) {
                    byId.get(home).lineage = '';
                    const q = [{ id: home, code: '' }];
                    const seen = new Set([home]);
                    while (q.length) {
                        const { id, code } = q.shift();
                        for (const par of parentsOf(id)) {
                            if (seen.has(par.id) || !byId.has(par.id)) continue;
                            seen.add(par.id);
                            const c = code + par.code;
                            byId.get(par.id).lineage = c;
                            q.push({ id: par.id, code: c });
                        }
                    }
                    const dq = [home];
                    const dseen = new Set([home]);
                    while (dq.length) {
                        const id = dq.shift();
                        for (const ch of childrenOf(id)) {
                            if (dseen.has(ch) || !byId.has(ch)) continue;
                            dseen.add(ch);
                            byId.get(ch).lineage = 'c';
                            dq.push(ch);
                        }
                    }
                }
    
                // generation relative to home (ancestors positive, descendants negative, spouses equal)
                if (byId.has(home)) {
                    byId.get(home).generation = 0;
                    const q = [home];
                    while (q.length) {
                        const id = q.shift();
                        const gen = byId.get(id).generation;
                        const visit = (nid, ngen) => {
                            const n = byId.get(nid);
                            if (!n || n.generation !== null) return;
                            n.generation = ngen;
                            q.push(nid);
                        };
                        for (const par of parentsOf(id)) visit(par.id, gen + 1);
                        for (const ch of childrenOf(id)) visit(ch, gen - 1);
                        for (const sp of spousesOf(id)) visit(sp, gen);
                    }
                }
            }
    
            // ------------------------------------------------------ photos
            async fetchFullPhotoUrl(id) {
                try {
                    const r = await fetch('/FP/API/Profile/get-profile-photo.php', {
                        method: 'POST', credentials: 'include',
                        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                        body: 'individualID=' + encodeURIComponent(id) + '&s=' + encodeURIComponent(this.siteId)
                    });
                    const xml = new DOMParser().parseFromString(await r.text(), 'text/xml');
                    const full = xml.querySelector('profilePhotoFullUrl');
                    const norm = xml.querySelector('profilePhotoUrl');
                    return (full && full.textContent) || (norm && norm.textContent) || '';
                } catch (e) {
                    log('get-profile-photo failed for', id, e);
                    return '';
                }
            }
    
            async downloadPhoto(rec, zip, useFullSize) {
                let url = useFullSize ? await this.fetchFullPhotoUrl(rec.id) : '';
                let source = url ? 'profile-full' : '';
                if (!url) { url = rec.thumbUrl; source = 'card-thumbnail'; }
                if (!url) return null;
                const r = await fetch(url);
                if (!r.ok) throw new Error('HTTP ' + r.status);
                const blob = await r.blob();
                const ext = blob.type === 'image/png' ? 'png' : blob.type === 'image/gif' ? 'gif' : blob.type === 'image/webp' ? 'webp' : 'jpg';
                let width = null, height = null;
                try { const bm = await createImageBitmap(blob); width = bm.width; height = bm.height; if (bm.close) bm.close(); } catch (e) { /* ignore */ }
                const file = ATTACH_DIR + '/' + rec.id + '.' + ext;
                if (zip) zip.file(file, blob);
                return { file, width, height, bytes: blob.size, source, sourceUrl: url };
            }
    
            // ------------------------------------------------------ output
            toJson() {
                const palette = (window.treeColorCodingService && window.treeColorCodingService._colors) || null;
                return {
                    format: 'myheritage-tree-export/1',
                    exportedAt: new Date().toISOString(),
                    source: {
                        url: location.href.split('?')[0],
                        siteId: this.siteId,
                        treeId: this.treeId,
                        title: str(window.familyTreeTitle || document.title),
                        treeSizeReported: this.treeSize
                    },
                    homeIndividualId: this.homeId,
                    // note: no empty-string keys anywhere in this file - PowerShell 5.1's ConvertFrom-Json rejects them
                    lineageLegend: {
                        'none': 'empty string = home person, spouses, siblings and in-laws (no lineage)',
                        'f/m...': 'ancestor path from home person: f = father, m = mother, e.g. "fm" = father\'s mother',
                        'c': 'descendant of the home person'
                    },
                    referencePalette: palette,   // MyHeritage colour coding: lineage -> [fill, border]
                    attachmentsDir: ATTACH_DIR,
                    individuals: [...this.individuals.values()].sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true })),
                    families: [...this.families.values()].sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }))
                };
            }
        }
    
        // ----------------------------------------------------------------- UI
        function buildUi() {
            const css = `
            #mhx_btn{position:fixed;right:18px;bottom:18px;z-index:99999;background:#d14900;color:#fff;border:0;border-radius:24px;
                padding:10px 18px;font:600 14px/1 Arial,sans-serif;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.35)}
            #mhx_btn:hover{background:#b33f00}
            #mhx_panel{position:fixed;right:18px;bottom:64px;z-index:99999;width:360px;background:#fff;border:1px solid #ccc;border-radius:8px;
                box-shadow:0 4px 16px rgba(0,0,0,.3);font:13px/1.4 Arial,sans-serif;color:#222;display:none}
            #mhx_panel h3{margin:0;padding:10px 14px;background:#f4f4f4;border-bottom:1px solid #ddd;font-size:14px}
            #mhx_panel .body{padding:10px 14px}
            #mhx_panel label{display:block;margin:4px 0}
            #mhx_panel .row{display:flex;gap:8px;margin-top:8px}
            #mhx_panel button{background:#d14900;color:#fff;border:0;border-radius:16px;padding:6px 14px;cursor:pointer}
            #mhx_panel button.sec{background:#888}
            #mhx_panel button:disabled{background:#bbb;cursor:default}
            #mhx_log{margin-top:8px;height:140px;overflow:auto;background:#fafafa;border:1px solid #e5e5e5;padding:6px;font:12px/1.3 Consolas,monospace;white-space:pre-wrap}
            #mhx_bar{height:6px;background:#eee;border-radius:3px;margin-top:8px;overflow:hidden}
            #mhx_bar i{display:block;height:100%;width:0;background:#2d7559;transition:width .2s}`;
            const style = document.createElement('style');
            style.textContent = css;
            document.head.appendChild(style);
    
            const btn = document.createElement('button');
            btn.id = 'mhx_btn';
            btn.textContent = 'Export tree';
            document.body.appendChild(btn);
    
            const panel = document.createElement('div');
            panel.id = 'mhx_panel';
            panel.innerHTML = `
                <h3>MyHeritage tree export</h3>
                <div class="body">
                    <label><input type="checkbox" id="mhx_photos" checked> Download photos (full size) into ${ATTACH_DIR}/</label>
                    <label><input type="checkbox" id="mhx_thorough" checked> Thorough crawl (also visit people whose branches were always cut off)</label>
                    <div class="row">
                        <button id="mhx_run">Start export</button>
                        <button id="mhx_close" class="sec">Close</button>
                    </div>
                    <div id="mhx_bar"><i></i></div>
                    <div id="mhx_log"></div>
                </div>`;
            document.body.appendChild(panel);
    
            const logBox = panel.querySelector('#mhx_log');
            const bar = panel.querySelector('#mhx_bar i');
            let lastWasStatus = false;
            const ui = {
                log(msg) { logBox.textContent += msg + '\n'; logBox.scrollTop = logBox.scrollHeight; lastWasStatus = false; log(msg); },
                // status() rewrites the previous status line instead of adding a new one
                status(msg) {
                    if (lastWasStatus) {
                        const lines = logBox.textContent.split('\n');
                        lines.splice(-2, 1);               // drop the previous status line (last element is '' after the trailing \n)
                        logBox.textContent = lines.join('\n');
                    }
                    logBox.textContent += msg + '\n';
                    logBox.scrollTop = logBox.scrollHeight;
                    lastWasStatus = true;
                },
                progress(p) { bar.style.width = Math.max(0, Math.min(100, p)) + '%'; },
                options() { return { photos: panel.querySelector('#mhx_photos').checked, thorough: panel.querySelector('#mhx_thorough').checked }; },
                busy(b) { panel.querySelector('#mhx_run').disabled = b; }
            };
            btn.addEventListener('click', () => { panel.style.display = panel.style.display === 'block' ? 'none' : 'block'; });
            panel.querySelector('#mhx_close').addEventListener('click', () => { panel.style.display = 'none'; });
            panel.querySelector('#mhx_run').addEventListener('click', async () => {
                ui.busy(true);
                try { await runExport(ui.options(), ui); }
                catch (e) { ui.log('ERROR: ' + (e && e.message ? e.message : e)); console.error(e); }
                finally { ui.busy(false); }
            });
            return ui;
        }
    
        // --------------------------------------------------------------- main
        async function runExport(opts, ui, testOnly) {
            if (!treeReady()) throw new Error('Tree model not ready (newTree missing). Open the family view first.');
            const col = new TreeCollector();
            ui.log(`Site ${col.siteId}, tree ${col.treeId}, home person ${col.homeId}, ${col.treeSize} people reported.`);
    
            // 1) crawl
            //    phase 1: discover people (one log line per pivot while the count still grows)
            //    phase 2: every remaining person is visited once so that no family link is missed;
            //             the count no longer changes, so this phase is one status line updated in place
            col.mergeCurrentView();
            col.pivoted.add(str(window.newTree.genealogy.getRootIndividualID()));
            let jumps = 0, phase = 1;
            const allFound = () => col.treeSize > 0 && col.individuals.size >= col.treeSize;
            let phase2Total = 0, delay = JUMP_DELAY_MIN_MS, refused = 0;
            for (;;) {
                const next = col.nextPivot(opts.thorough);
                if (!next) break;
                let ok = false;
                for (let attempt = 0; attempt <= RETRY_WAITS_MS.length; attempt++) {
                    ok = await col.jumpTo(next);
                    jumps++;
                    if (ok) { delay = Math.max(JUMP_DELAY_MIN_MS, Math.round(delay * 0.9)); break; }
                    refused++;
                    delay = Math.min(JUMP_DELAY_MAX_MS, delay * 2);
                    if (attempt < RETRY_WAITS_MS.length) {
                        const w = RETRY_WAITS_MS[attempt];
                        ui.log(`  server refused pivot ${next} (rate limit) - pausing ${w / 1000} s, retry ${attempt + 1}/${RETRY_WAITS_MS.length}; spacing now ${delay / 1000} s`);
                        await sleep(w);
                    }
                }
                col.pivoted.add(next);
                if (!ok) { col.failed.add(next); ui.log(`  warning: pivot ${next} skipped after ${RETRY_WAITS_MS.length} retries`); }
                else col.mergeCurrentView();
                if (phase === 1) {
                    ui.progress(col.treeSize ? (col.individuals.size / col.treeSize) * 40 : 0);
                    ui.log(`  pivot ${next}: ${col.individuals.size}/${col.treeSize} people, ${col.families.size} families`);
                    if (allFound()) {
                        phase = 2;
                        phase2Total = col.remainingToCheck();
                        ui.log(`All ${col.individuals.size} people found after ${jumps} jumps.`);
                        if (opts.thorough && phase2Total) ui.log(`Checking ${phase2Total} people whose branches were cut off in every view so far ...`);
                    }
                } else {
                    const left = col.remainingToCheck();
                    ui.progress(40 + (phase2Total ? (1 - left / phase2Total) : 1) * 20);
                    ui.status(`  ${left} people left to check, ${col.families.size} families`);
                }
                await sleep(delay);
            }
            ui.log(`Crawl done: ${jumps} requests (${refused} refused), ${col.individuals.size} people, ${col.families.size} families.`);
            if (col.treeSize && col.individuals.size < col.treeSize) {
                ui.log(`  note: ${col.treeSize - col.individuals.size} people were not reachable through family links.`);
            }
            if (col.failed.size) ui.log(`  note: ${col.failed.size} pivots failed - some family links may be missing: ${[...col.failed].join(', ')}`);
    
            // 2) derived fields
            col.computeLineageAndGeneration();
    
            // 3) photos
            const zip = testOnly ? null : new SimpleZip();
            const withPhoto = [...col.individuals.values()].filter((r) => r.hasPhoto);
            if (opts.photos) {
                ui.log(`Downloading ${withPhoto.length} photos ...`);
                let n = 0;
                for (const rec of withPhoto) {
                    try { rec.photo = await col.downloadPhoto(rec, zip, true); }
                    catch (e) { ui.log(`  photo ${rec.id} (${rec.name}) failed: ${e.message}`); }
                    n++;
                    ui.progress(60 + (n / withPhoto.length) * 35);
                }
            }
    
            // 4) back to the home person so the page looks as before
            try { await col.jumpTo(col.homeId); } catch (e) { /* ignore */ }
    
            const json = col.toJson();
            if (testOnly) return json;
    
            // 5) zip + download
            const base = 'MyHeritage_' + safeFileName(json.source.title).replace(/ /g, '_') + '_' + timestamp();
            zip.file(base + '.json', JSON.stringify(json, null, 2));
            ui.log('Packing ZIP ...');
            const blob = await zip.generateAsync();
            const a = document.createElement('a');
            a.href = URL.createObjectURL(blob);
            a.download = base + '.zip';
            document.body.appendChild(a);
            a.click();
            setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 5000);
            ui.progress(100);
            ui.log(`Done: ${a.download} (${(blob.size / 1024).toFixed(0)} KB).`);
            return json;
        }
    
        // expose for testing from the console: mhExport.run({photos:true, thorough:true}, null, true)
        window.mhExport = {
            run: (opts, ui, testOnly) => runExport(opts || { photos: true, thorough: true }, ui || { log, status: log, progress() {} }, testOnly),
            TreeCollector
        };
    
        waitFor(treeReady, 60000, 250).then((ok) => {
            if (!ok) { log('tree model never became ready; exporter button not shown'); return; }
            buildUi();
            log('exporter ready');
        });
    })();
    
    TOM
    TOM --- ---
    otravné heureka "Ověřeno zákazníky" panely, co překrývají obsah stránky (a CTRE si s nimi neporadí)? say no more!

    // ==UserScript==
    // @name         Heureka Sliding Widget Remover
    // @version      1.0
    // @description  Hides the heureka.cz "shopping comparison" sliding side tab on any site that injects it. Extensible: add more ID prefixes to ID_PREFIXES.
    // @author       Claude Opus 4.7
    // @match        *://*/*
    // @run-at       document-start
    // @grant        none
    // ==/UserScript==
    
    (function () {
        'use strict';
    
        // ----- CONFIG -------------------------------------------------------------
        // ID prefixes to hide. Add more entries for other widgets you spot.
        // Tip: open DevTools, find the offending element, copy its id attribute,
        //      and use a long-enough prefix to avoid false positives.
        const ID_PREFIXES = [
            'heurekaTab',     // visible tab handle (e.g. heurekaTabright / heurekaTableft)
            'heurekaIfrBox',  // sliding iframe container
            'iframe-widget'   // the iframe itself (belt-and-suspenders; parent is already hidden)
        ];
        // --------------------------------------------------------------------------
    
        const selector = ID_PREFIXES.map(p => `[id^="${p}"]`).join(',');
    
        // Inject a single <style> rule. Because this runs at document-start,
        // the rule is in place BEFORE the widget script injects its DOM,
        // so the elements are invisible the moment they appear. No observer,
        // no polling, no removal loop required.
        const style = document.createElement('style');
        style.textContent = `${selector} { display: none !important; }`;
        (document.head || document.documentElement).appendChild(style);
    })();
    TOM
    TOM --- ---
    PDFko na scribd.com chce údaje kreditky pro "free" download?
    snadná pomoc ;)
    vlevo nahoře se zobrazí poloprůhledné tlačítko PDF download
    // ==UserScript==
    // @name         PDF Grabber (Direct + Image-Viewer Fallback)
    // @namespace    userscripts
    // @version      1.1.0
    // @description  Floating button. Tries to find a direct PDF on the page; if none, falls back to harvesting a paged image viewer (Scribd-style). Two output modes: 'print' (browser Save-as-PDF; preserves text layer; default) or 'pdf' (image-only PDF via jsPDF).
    // @author       whoever
    // @match        *://*.scribd.com/*
    // @grant        GM_xmlhttpRequest
    // @grant        GM_download
    // @grant        unsafeWindow
    // @connect      *
    // @require      https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js
    // @run-at       document-idle
    // ==/UserScript==
    
    (function () {
        'use strict';
    
        // ============================================================
        // CONFIG
        // ============================================================
        const CONFIG = {
            // 'print'  = build a clean iframe of all pages and call window.print()
            //            => preserves text layer (selectable text in resulting PDF)
            //            => requires user to pick "Save as PDF" in the print dialog
            // 'pdf'    = stitch image_layer images into a single PDF via jsPDF
            //            => fully automatic, no dialog
            //            => image-only, NO text in result
            // 'images' = save each image as a separate file
            OUTPUT_MODE: 'print',
    
            // Selectors for the paged image viewer.
            IMAGE_VIEWER_PAGE_SELECTOR: '.outer_page',
            INNER_PAGE_SELECTOR       : '.newpage',
            IMAGE_VIEWER_IMG_SELECTOR : '.absimg, .image_layer img',
    
            // CSS selectors of in-page promo / ad blocks to strip before printing.
            PRINT_HIDE_SELECTORS: [
                // Scribd's between-page promo div uses an obfuscated class name.
                '[class*="vxxhmpejhe"]',
                // Generic ad slots
                '[id*="ad-"]', '[id*="ad_"]', '[class*="advert"]',
            ],
    
            PAGE_LOAD_WAIT_MS: 800,
            FINAL_SETTLE_MS: 1500,
    
            BUTTON_OPACITY_IDLE: 0.25,
            BUTTON_OPACITY_HOVER: 1.0,
            BUTTON_POSITION: { top: '8px', left: '8px' },
    
            FILENAME: '',
            DEBUG: true,
        };
        // ============================================================
    
        const log = (...a) => CONFIG.DEBUG && console.log('[PDF-Grabber]', ...a);
        const warn = (...a) => console.warn('[PDF-Grabber]', ...a);
    
        // ---------- 1. UI ----------
        function buildButton() {
            if (document.getElementById('__pdf_grabber_btn__')) return;
            const btn = document.createElement('button');
            btn.id = '__pdf_grabber_btn__';
            btn.textContent = '⤓ PDF';
            btn.title = 'PDF Grabber — click to download';
            Object.assign(btn.style, {
                position: 'fixed',
                top: CONFIG.BUTTON_POSITION.top,
                left: CONFIG.BUTTON_POSITION.left,
                zIndex: 2147483647,
                padding: '6px 10px',
                fontSize: '12px',
                fontFamily: 'sans-serif',
                background: 'rgba(0,0,0,0.6)',
                color: '#fff',
                border: '1px solid rgba(255,255,255,0.4)',
                borderRadius: '4px',
                cursor: 'pointer',
                opacity: String(CONFIG.BUTTON_OPACITY_IDLE),
                transition: 'opacity 0.2s',
            });
            btn.addEventListener('mouseenter', () => btn.style.opacity = String(CONFIG.BUTTON_OPACITY_HOVER));
            btn.addEventListener('mouseleave', () => btn.style.opacity = String(CONFIG.BUTTON_OPACITY_IDLE));
            btn.addEventListener('click', onClick);
            document.body.appendChild(btn);
        }
    
        function setBtn(text, disabled) {
            const btn = document.getElementById('__pdf_grabber_btn__');
            if (!btn) return;
            btn.textContent = text;
            btn.disabled = !!disabled;
            btn.style.opacity = disabled ? '0.6' : String(CONFIG.BUTTON_OPACITY_IDLE);
        }
        function resetBtnLater() {
            setTimeout(() => setBtn('⤓ PDF', false), 3000);
        }
    
        // ---------- 2. Click handler ----------
        async function onClick() {
            try {
                setBtn('… searching', true);
    
                // (A) Direct PDF on the page — the genuinely universal case.
                const direct = findDirectPdf();
                if (direct) {
                    log('Direct PDF found:', direct);
                    setBtn('… downloading');
                    downloadDirect(direct);
                    setBtn('✓ done');
                    return resetBtnLater();
                }
    
                // (B) Image-viewer fallback (Scribd-shaped).
                log('No direct PDF. Trying image-viewer fallback.');
                if (!hasImageViewer()) {
                    alert('PDF Grabber: no PDF and no recognised image viewer on this page.');
                    setBtn('⤓ PDF', false);
                    return;
                }
    
                // Prevent Scribd from evicting off-screen pages while we scroll.
                disablePageEviction();
    
                setBtn('… loading pages', true);
                await scrollAllPages();
    
                if (CONFIG.OUTPUT_MODE === 'print') {
                    setBtn('… preparing print');
                    await openPrintIframe();
                    // Print dialog blocks until user closes it.
                    setBtn('✓ done');
                    return resetBtnLater();
                }
    
                // 'pdf' or 'images' modes — image-only path
                setBtn('… collecting');
                const urls = collectImageUrls();
                log(`Collected ${urls.length} image URLs.`);
                if (!urls.length) {
                    alert('PDF Grabber: image viewer detected but 0 images collected. Page selectors may need adjusting.');
                    setBtn('⤓ PDF', false);
                    return;
                }
    
                if (CONFIG.OUTPUT_MODE === 'images') {
                    setBtn('… downloading');
                    await downloadEachImage(urls);
                } else {
                    setBtn(`… fetching 0/${urls.length}`);
                    const blobs = await fetchAllAsDataURLs(urls, (i, n) => setBtn(`… fetching ${i}/${n}`));
                    setBtn('… building PDF');
                    await buildPdf(blobs);
                }
                setBtn('✓ done');
                resetBtnLater();
            } catch (err) {
                console.error('[PDF-Grabber] fatal:', err);
                alert('PDF Grabber error: ' + (err.message || err));
                setBtn('⚠ error', false);
                resetBtnLater();
            }
        }
    
        // ---------- 3. Direct PDF detection ----------
        function findDirectPdf() {
            if (/\.pdf(\?|#|$)/i.test(location.pathname + location.search)) return location.href;
            const sels = [
                'embed[src*=".pdf" i]', 'embed[type="application/pdf"]',
                'object[data*=".pdf" i]', 'object[type="application/pdf"]',
                'iframe[src*=".pdf" i]',
            ];
            for (const s of sels) {
                const el = document.querySelector(s);
                if (el) {
                    const u = el.getAttribute('src') || el.getAttribute('data');
                    if (u) return new URL(u, location.href).href;
                }
            }
            const a = document.querySelector('a[href*=".pdf" i]');
            if (a) return new URL(a.getAttribute('href'), location.href).href;
            return null;
        }
    
        function downloadDirect(url) {
            const a = document.createElement('a');
            a.href = url; a.download = filename('.pdf');
            a.target = '_blank'; a.rel = 'noopener';
            document.body.appendChild(a); a.click(); a.remove();
        }
    
        // ---------- 4. Image-viewer detection & loading ----------
        function hasImageViewer() {
            return document.querySelector(CONFIG.IMAGE_VIEWER_PAGE_SELECTOR) != null;
        }
    
        function disablePageEviction() {
            const dm = unsafeWindow.DocumentManager || window.DocumentManager;
            if (!dm) return;
            try {
                if (typeof dm._removeUnusedPages === 'function') dm._removeUnusedPages = function () {};
                const orig = dm._loadAdjacentPages;
                if (typeof orig === 'function') {
                    dm._loadAdjacentPages = function () {
                        const prev = this.mobile;
                        this.mobile = false;
                        try { return orig.apply(this, arguments); }
                        finally { this.mobile = prev; }
                    };
                }
                log('Page eviction disabled.');
            } catch (e) { warn('Could not disable page eviction:', e); }
        }
    
        async function scrollAllPages() {
            const dm = unsafeWindow.DocumentManager || window.DocumentManager;
            const total = countPages();
            log('Page count:', total);
    
            if (dm && typeof dm.gotoPage === 'function') {
                log('Using DocumentManager.gotoPage()');
                for (let i = 1; i <= total; i++) {
                    try { dm.gotoPage(i); } catch (e) { warn('gotoPage failed:', e); }
                    await sleep(CONFIG.PAGE_LOAD_WAIT_MS);
                }
                try { dm.gotoPage(1); } catch (_) {}
            } else {
                log('No DocumentManager — scrolling manually.');
                const pages = document.querySelectorAll(CONFIG.IMAGE_VIEWER_PAGE_SELECTOR);
                for (const p of pages) {
                    p.scrollIntoView({ block: 'center', behavior: 'instant' });
                    await sleep(CONFIG.PAGE_LOAD_WAIT_MS);
                }
                window.scrollTo(0, 0);
            }
            await sleep(CONFIG.FINAL_SETTLE_MS);
        }
    
        function countPages() {
            const ids = [...document.querySelectorAll('[id^="outer_page_"]')]
                .map(e => parseInt(e.id.replace(/\D+/g, ''), 10))
                .filter(n => !isNaN(n));
            if (ids.length) return Math.max(...ids);
            return document.querySelectorAll(CONFIG.IMAGE_VIEWER_PAGE_SELECTOR).length;
        }
    
        function collectImageUrls() {
            const imgs = [...document.querySelectorAll(CONFIG.IMAGE_VIEWER_IMG_SELECTOR)];
            const urls = [], seen = new Set();
            for (const img of imgs) {
                const u = img.currentSrc || img.src || img.getAttribute('orig');
                if (!u || u.startsWith('data:') || seen.has(u)) continue;
                seen.add(u); urls.push(u);
            }
            return urls;
        }
    
        // ---------- 5. PRINT MODE: build clean iframe, then print ----------
        async function openPrintIframe() {
            const pages = [...document.querySelectorAll(CONFIG.IMAGE_VIEWER_PAGE_SELECTOR)];
            if (!pages.length) throw new Error('No outer_page elements found.');
    
            // Determine print page size from the inner .newpage of the first page.
            const firstInner = pages[0].querySelector(CONFIG.INNER_PAGE_SELECTOR);
            const pageW = parsePx(firstInner && firstInner.style.width)  || 902;
            const pageH = parsePx(firstInner && firstInner.style.height) || 1275;
            log(`Print page size: ${pageW} × ${pageH}px`);
    
            // Create offscreen iframe.
            const iframe = document.createElement('iframe');
            iframe.id = '__pdf_grabber_print_iframe__';
            Object.assign(iframe.style, {
                position: 'fixed', top: '0', left: '0',
                width: '0', height: '0', border: 'none', opacity: '0',
                pointerEvents: 'none', zIndex: '-1',
            });
            document.body.appendChild(iframe);
    
            const doc = iframe.contentDocument;
            doc.open();
            doc.write('<!DOCTYPE html><html><head><meta charset="utf-8"><title>');
            doc.write(escapeHtml(filename('')));
            doc.write('</title>');
    
            // Copy ALL stylesheets and inline styles from parent so Scribd's
            // positioning rules (.text_layer transform, .absimg, fonts, etc.)
            // are preserved.
            document.querySelectorAll('head style, head link[rel="stylesheet"]').forEach(el => {
                doc.write(el.outerHTML);
            });
    
            // Print-specific CSS: page size, page breaks, hide promos.
            const hideSelectors = CONFIG.PRINT_HIDE_SELECTORS.join(',\n');
            doc.write(`
                <style id="__pdf_grabber_print_css__">
                    @page { size: ${pageW}px ${pageH}px; margin: 0; }
                    html, body { margin: 0 !important; padding: 0 !important; background: #fff !important; }
                    /* Strip Scribd's between-page promo blocks and ads */
                    ${hideSelectors} { display: none !important; }
                    /* But keep the page containers visible even if they share a class pattern */
                    .outer_page {
                        display: block !important;
                        width: ${pageW}px !important;
                        height: ${pageH}px !important;
                        margin: 0 !important;
                        padding: 0 !important;
                        border: none !important;
                        box-shadow: none !important;
                        page-break-after: always !important;
                        break-after: page !important;
                        overflow: hidden !important;
                        position: relative !important;
                    }
                    .outer_page:last-child {
                        page-break-after: auto !important;
                        break-after: auto !important;
                    }
                    .newpage {
                        display: block !important;
                        transform: none !important;
                        transform-origin: top left !important;
                        width: ${pageW}px !important;
                        height: ${pageH}px !important;
                        position: relative !important;
                    }
                    .absimg { display: block !important; }
                </style>
            `);
    
            doc.write('</head><body>');
    
            // Clone every page into the iframe.
            for (const p of pages) {
                const clone = p.cloneNode(true);
                clone.classList.remove('not_visible');
                clone.classList.remove('blurred_page');
                const inner = clone.querySelector(CONFIG.INNER_PAGE_SELECTOR);
                if (inner) inner.style.display = 'block';
                doc.write(clone.outerHTML);
            }
    
            doc.write('</body></html>');
            doc.close();
    
            // Wait for images to actually load in the iframe.
            await waitForIframeReady(iframe, 30000);
    
            // Print.
            try {
                iframe.contentWindow.focus();
                iframe.contentWindow.print();
            } catch (e) {
                warn('iframe.print() threw, falling back to window.print():', e);
                window.print();
            }
    
            // Clean up after a short delay.
            await sleep(1500);
            try { iframe.remove(); } catch (_) {}
        }
    
        function waitForIframeReady(iframe, timeoutMs) {
            return new Promise(resolve => {
                const start = Date.now();
                const tick = () => {
                    const doc = iframe.contentDocument;
                    let ready = false;
                    if (doc && doc.readyState === 'complete') {
                        const imgs = [...doc.querySelectorAll('img')];
                        ready = imgs.every(img => img.complete && img.naturalWidth > 0);
                    }
                    if (ready) return resolve();
                    if (Date.now() - start > timeoutMs) {
                        warn('Iframe not fully loaded within timeout, printing anyway.');
                        return resolve();
                    }
                    setTimeout(tick, 200);
                };
                tick();
            });
        }
    
        // ---------- 6. IMAGE-ONLY modes (unchanged from v1.0) ----------
        async function downloadEachImage(urls) {
            for (let i = 0; i < urls.length; i++) {
                const url = urls[i];
                const ext = (url.match(/\.(jpg|jpeg|png|webp)(\?|$)/i) || [, 'jpg'])[1];
                const name = `${filename('')}_page_${String(i + 1).padStart(3, '0')}.${ext}`;
                try {
                    if (typeof GM_download === 'function') {
                        GM_download({ url, name, saveAs: false });
                    } else {
                        const a = document.createElement('a');
                        a.href = url; a.download = name;
                        document.body.appendChild(a); a.click(); a.remove();
                    }
                } catch (e) { warn('download failed:', url, e); }
                await sleep(150);
            }
        }
    
        async function fetchAllAsDataURLs(urls, onProgress) {
            const out = [];
            for (let i = 0; i < urls.length; i++) {
                try { out.push(await fetchAsDataURL(urls[i])); }
                catch (e) { warn('fetch failed for', urls[i], e); out.push(null); }
                onProgress && onProgress(i + 1, urls.length);
            }
            return out;
        }
    
        function fetchAsDataURL(url) {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET', url, responseType: 'blob',
                    onload: (resp) => {
                        if (resp.status >= 400) return reject(new Error('HTTP ' + resp.status));
                        const reader = new FileReader();
                        reader.onloadend = () => resolve({ dataUrl: reader.result, blob: resp.response });
                        reader.onerror = () => reject(reader.error);
                        reader.readAsDataURL(resp.response);
                    },
                    onerror: (e) => reject(new Error('network error: ' + (e && e.error))),
                    ontimeout: () => reject(new Error('timeout')),
                });
            });
        }
    
        async function buildPdf(items) {
            const jsPDF = (window.jspdf && window.jspdf.jsPDF) ||
                          (unsafeWindow.jspdf && unsafeWindow.jspdf.jsPDF);
            if (!jsPDF) throw new Error('jsPDF not loaded — check @require URL.');
            let pdf = null;
            for (let i = 0; i < items.length; i++) {
                const item = items[i];
                if (!item) continue;
                const { w, h } = await imageSize(item.dataUrl);
                if (!pdf) pdf = new jsPDF({ orientation: w > h ? 'l' : 'p', unit: 'px', format: [w, h] });
                else pdf.addPage([w, h], w > h ? 'l' : 'p');
                const fmt = /^data:image\/png/i.test(item.dataUrl) ? 'PNG' : 'JPEG';
                pdf.addImage(item.dataUrl, fmt, 0, 0, w, h, undefined, 'FAST');
            }
            if (!pdf) throw new Error('No images successfully fetched.');
            pdf.save(filename('.pdf'));
        }
    
        function imageSize(dataUrl) {
            return new Promise((resolve, reject) => {
                const img = new Image();
                img.onload = () => resolve({ w: img.naturalWidth, h: img.naturalHeight });
                img.onerror = reject;
                img.src = dataUrl;
            });
        }
    
        // ---------- 7. Helpers ----------
        function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
        function parsePx(v) { return v ? parseFloat(String(v).replace('px', '')) : 0; }
        function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
        function filename(suffix) {
            let base = CONFIG.FILENAME || document.title || 'document';
            base = base.replace(/[\\/:*?"<>|]+/g, '_').replace(/\s+/g, '_').slice(0, 120);
            return base + suffix;
        }
    
        // ---------- 8. Boot ----------
        if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', buildButton);
        else buildButton();
    })();
    
    TOM
    TOM --- ---
    odstraní Shorts
    vhodné nenápadně instalovat na PC vašich dětí, pokud na nějaký mají přístup ;)
    // ==UserScript==
    // @name         Remove YouTube Shorts
    // @namespace    https://gist.github.com/sjoller/68a53ecd76f8cb6ea74e47b7a407f3f7
    // @updateURL    https://gist.github.com/sjoller/68a53ecd76f8cb6ea74e47b7a407f3f7/raw/87e1c0ce81325514f43d6666eee46de4b08e60f4/hideYTShorts.user.js
    // @downloadURL  https://gist.github.com/sjoller/68a53ecd76f8cb6ea74e47b7a407f3f7/raw/87e1c0ce81325514f43d6666eee46de4b08e60f4/hideYTShorts.user.js
    // @version      1.5
    // @description  Remove all YouTube Shorts from YouTube, including autoloaded content, and remove Shorts menu items from both the sidebar and top menu.
    // @match        *://www.youtube.com/*
    // @grant        none
    // ==/UserScript==
    
    (function() {
        'use strict';
    
        // If the current URL is a Shorts page, redirect to the homepage.
        if (window.location.pathname.startsWith('/shorts')) {
            window.location.replace("https://www.youtube.com");
        }
    
        // Function to remove Shorts video items.
        function removeShorts() {
            // Look for any link that contains "/shorts" in its href.
            const shortsLinks = document.querySelectorAll('a[href*="/shorts"]');
            shortsLinks.forEach(link => {
                // Attempt to locate a suitable container element for the Shorts item.
                let container = link.closest('ytd-reel-shelf-renderer') ||
                                link.closest('ytd-rich-item-renderer') ||
                                link.closest('ytd-video-renderer') ||
                                link.closest('div');
                if (container) {
                    container.remove();
                }
            });
        }
    
        // Function to remove Shorts menu items.
        function removeShortsMenuItems() {
            // Remove left sidebar Shorts element.
            // Select the guide entry whose anchor has a title attribute of "Shorts".
            const leftMenuShorts = document.querySelectorAll('ytd-guide-entry-renderer a[title="Shorts"]');
            leftMenuShorts.forEach(link => {
                let menuItem = link.closest('ytd-guide-entry-renderer');
                if (menuItem) {
                    menuItem.remove();
                }
            });
    
            // Remove left mini sidebar Shorts element.
            // Select the guide entry whose anchor has a title attribute of "Shorts".
            const leftMiniMenuShorts = document.querySelectorAll('ytd-mini-guide-entry-renderer a[title="Shorts"]');
            leftMiniMenuShorts.forEach(link => {
                let menuItem = link.closest('ytd-mini-guide-entry-renderer');
                if (menuItem) {
                    menuItem.remove();
                }
            });
    
            // Remove top menu Shorts element that is an anchor.
            const topMenuShorts = document.querySelectorAll('a[href="/shorts"]');
            topMenuShorts.forEach(link => {
                let menuItem = link.closest('tp-yt-paper-item') || link;
                if (menuItem) {
                    menuItem.remove();
                }
            });
    
            // Remove top menu Shorts chip element.
            // This targets the chip element by finding a yt-formatted-string with a title of "Shorts".
            const chipShorts = document.querySelectorAll('yt-chip-cloud-chip-renderer yt-formatted-string[title="Shorts"]');
            chipShorts.forEach(el => {
                let chip = el.closest('yt-chip-cloud-chip-renderer');
                if (chip) {
                    chip.remove();
                }
            });
        }
    
        // Run functions initially.
        removeShorts();
        removeShortsMenuItems();
    
        // Set up a MutationObserver to catch dynamically loaded content.
        const observer = new MutationObserver(() => {
            removeShorts();
            removeShortsMenuItems();
        });
        observer.observe(document.body, { childList: true, subtree: true });
    
        // Listen for scroll events to catch additional autoloaded content.
        let scrollTimeout;
        window.addEventListener("scroll", function() {
            clearTimeout(scrollTimeout);
            scrollTimeout = setTimeout(() => {
                removeShorts();
                removeShortsMenuItems();
            }, 300);
        });
    })();
    MRTVY_KENNY
    MRTVY_KENNY --- ---
    schovavani uz ohodnocenych filmu v zebriccich csfd

    // ==UserScript==
    // @name         ČSFD: Hide Rated Movies
    // @namespace    http://tampermonkey.net/
    // @version      1.2
    // @description  Hides movies you have already rated on ČSFD charts.
    // @author       Gemini
    // @match        https://www.csfd.cz/zebricky/*
    // @grant        none
    // ==/UserScript==
    
    (function() {
        'use strict';
    
        const hideRated = () => {
            const articles = document.querySelectorAll('article');
    
            articles.forEach(article => {
                // If the article contains an element with the 'rating-mine' class, hide it
                if (article.querySelector('.rating-mine')) {
                    article.style.display = 'none';
                }
            });
        };
    
        // Initial execution
        hideRated();
    
        // Handle infinite scroll and dynamic loading
        const observer = new MutationObserver(hideRated);
        observer.observe(document.body, { childList: true, subtree: true });
    })();
    STEVIE
    STEVIE --- ---
    Strašně me sralo, že se všechny taby jmenují Gemini a nevidím co v nich je v Chrome:

    // ==UserScript==
    // @name Gemini Chat Title Renamer
    // @namespace http://tampermonkey.net/
    // @version 1.1
    // @description Automatically renames the tab to the current Gemini conversation title
    // @author You
    // @match https://gemini.google.com/*
    // @grant none
    // ==/UserScript==

    (function() {
    'use strict';

    function updateTitle() {
    // 1. Attempt to find the specific title element at the top of the chat
    // Google sometimes changes these class names, so we try a specific one and a generic fallback
    const titleElement = document.querySelector('.conversation-title') ||
    document.querySelector('h1[data-test-id="conversation-title"]');

    // 2. If we found the element, get the text
    if (titleElement) {
    const chatTitle = titleElement.innerText.trim();

    // 3. Update the tab name only if it's different and not empty
    if (chatTitle && chatTitle.length > 0 && document.title !== chatTitle) {
    document.title = chatTitle;
    }
    }
    }

    // 4. Run this check every 1 second (1000ms) to handle switching between chats
    setInterval(updateTitle, 1000);
    })();
    DRAGON
    DRAGON --- ---
    SUCZKER: skoda ze tam nemaj STL file :))
    SUCZKER
    SUCZKER --- ---
    Stahování 3D modelů nábytku ze stránek IKEA:

    GitHub - apinanaivot/IKEA-3D-Model-Download-Button: Adds ability to download models from IKEA website
    https://github.com/apinanaivot/IKEA-3D-Model-Download-Button

    PICAZSOO
    PICAZSOO --- ---
    PICAZSOO: Nedalo mi to a skript jsem si otestoval i v Edge Beta s Tampermonkey. Funguje tam taky. Ty user scripty jsou fakt skvělá věc.
    PICAZSOO
    PICAZSOO --- ---
    A možná prosím opravte info v záhlaví, že jediný android browser, co umí user scripty je Edge Beta? Když Firefox for Android (teda přinejmenším Fennec z F-Droidu) podporuje jak Tampermonkey, tak Violentmonkey.

    Za mě je to nejdospělejší prohlížeč na telefon. Možná pomalejší než Chrome nebo Edge, ale na Galaxy S24 rozdíl v praxi nevidím.
    PICAZSOO
    PICAZSOO --- ---
    Velmi dlouho jsem na svém android telefonu používal Operu Mobile. Důvod byl, že jako jediný prohlížeč podporovala text reflow při změně zoomu ( typicky situace, kdy nějaká mobile-unfriendly stránka má moc široký odstavce a malý text a člověk je nucenej po zoomu scrolovat doprava a doleva. Např. některý klasický diskuzní fóra.) Tohle opera vždycky řešila úplně perfektně.

    Na PC ale používám Firefox a už mě nebavilo, jak to nefunguje dohromady z hlediska nějakýho sdílení záložek a historie atd. Takže jsem začal používat Fennec [1], který má slušnou podporu rozšíření a používal jsem tam rozšíření Text reflow on zoom (text wrap) [2]. Fungovalo to skoro stejně dobře jako ta funkcionalita v Opeře, ale mělo to pár much - některý textový prvky to newrapuje, vůbec to nesahá na dynamicky načtený nový prvky (typicky načtení dalších komentářů v diskuzi na redditu atd.) a nepodporovalo to double-tap-to-zoom (jenom pinch-to-zoom).

    Naštěstí je ten plugin open source a MIT licensed a funguje i jako user script. Tak jsem s pomocí Gemini odstranil mouchy, co mě trápily a teď ho používám skrz Violentmonkey [3] a jsem extrémně spokojenej.

    [1] https://f-droid.org/packages/org.mozilla.fennec_fdroid/
    [2] https://addons.mozilla.org/en-US/android/addon/text-reflow-on-zoom-mobile/
    [3] https://addons.mozilla.org/cs/firefox/addon/violentmonkey/

    Skript zde (permanentně zapnutý):
    // ==UserScript==
    // @name         Text reflow on zoom for mobile (text wrap) - Mixed Content Fix
    // @name:ru      Text reflow on zoom for mobile (text wrap) - Mixed Content Fix
    // @description  Fits all text to the screen width after a pinch or double-tap zoom gesture (fixes mixed text/quote blocks)
    // @description:ru Подгонка текста под ширину экрана после жеста увеличения на телефоне
    // @version      1.0.12
    // @author       emvaized
    // @license      MIT
    // @homepageURL  https://github.com/emvaized/text-reflow-on-zoom-mobile/
    // @namespace    text_reflow_on_pinch_zoom
    // @match        *://*/*
    // @grant        none
    // @run-at       document-start
    // ==/UserScript==
    
    (function() {
        'use strict';
    
        // UPDATED SELECTOR: Uses text()[normalize-space()] to find ANY valid text node, not just the first one.
        const xpathSelector = `
        //p |
        //li |
        //h1 | //h2 | //h3 | //h4 | //h5 | //h6 |
        //pre |
        //a[text()[normalize-space()]] |
        //div[b or em or i] |
        //div[text()[normalize-space()]] |
        //div[span[text()[normalize-space()]]]`;
    
        let isCssInjected = false;
        let isPinching = false;
        let zoomTarget, targetDyOffsetRatio;
        let resizeDebounceTimer;
        let mutationDebounceTimer;
        
        // Track viewport width to ignore height-only changes (keyboard)
        let lastViewportWidth = window.visualViewport ? window.visualViewport.width : window.innerWidth;
    
        // Track all text elements queried by the selector
        const allTextElements = new Set();
    
        /**
         * Reflows text to fit screen.
         * @param {boolean} shouldRestoreScroll - If true, scrolls back to the zoom target. False for passive updates (infinite scroll).
         */
        function reflowText(shouldRestoreScroll = false) {
            if (!isCssInjected) {
                const styleContent = `.text-reflow-userscript { word-wrap: break-word !important; overflow-wrap:break-word !important; max-width:var(--text-reflow-max-width) !important; }
                .text-reflow-scroll-padding {scroll-margin-left: 1vw !important;}`;
                const styleElement = document.createElement('style');
                styleElement.textContent = styleContent;
                document.head.appendChild(styleElement);
                isCssInjected = true;
            }
    
            // Calculate new width based on the visual viewport (zoomed area)
            const maxAllowedWidth = Math.round(window.visualViewport.width * 0.96);
            document.documentElement.style.setProperty('--text-reflow-max-width', `${maxAllowedWidth}px`);
    
            // Update our width tracker
            lastViewportWidth = window.visualViewport.width;
    
            // Select elements likely to contain text
            const xpathResult = document.evaluate(xpathSelector, document, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
    
            // Refresh text element set
            allTextElements.clear();
    
            for (let i = 0, n = xpathResult.snapshotLength, el; i < n; i++) {
                el = xpathResult.snapshotItem(i);
                if (!el.offsetParent) continue;
                // Double check text content to be safe
                if (!el.textContent.trim()) continue;
    
                // Process only top-level text elements
                let isTopLevel = true;
                let parent = el.parentElement;
                while (parent) {
                    if (elementIsTextElement(parent)) {
                        isTopLevel = false;
                        break;
                    }
                    parent = parent.parentElement;
                }
    
                if (isTopLevel) {
                    el.classList.add('text-reflow-userscript');
                    allTextElements.add(el);
                }
             }
    
            /// Scroll initial target element into view (ONLY if this is a zoom-triggered reflow)
            if (shouldRestoreScroll && zoomTarget && targetDyOffsetRatio) {
                try {
                    // Scroll to element vertically, according to new page layout
                    const targetOffset = targetDyOffsetRatio * window.innerHeight;
                    const rect = zoomTarget.getBoundingClientRect();
                    const targetTop = rect.top + window.pageYOffset;
                    const scrollToPosition = targetTop - targetOffset;
    
                    window.scrollTo({
                        top: scrollToPosition,
                        behavior: 'instant'
                    });
    
                    // Scroll element into view horizontally if it's text
                    if (zoomTarget.nodeName !== 'IMG' && zoomTarget.nodeName !== 'VIDEO' && zoomTarget.nodeName !== 'IFRAME'){
                        zoomTarget.classList.add('text-reflow-scroll-padding');
                        zoomTarget.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });
                        zoomTarget.classList.remove('text-reflow-scroll-padding');
                    }
                } catch (e) {
                    // Fail silently if target is detached from DOM
                }
                
                zoomTarget = null;
                targetDyOffsetRatio = null;
            }
        }
    
        function elementIsTextElement(element) {
            return allTextElements.has(element);
        }
    
        // --- Interaction Handlers ---
    
        function handleTouchStart(event) {
            if (!event.touches) return;
    
            // Logic for tracking the target element
            let midpointX, midpointY;
    
            if (event.touches.length >= 2) {
                isPinching = true;
                const touch1 = event.touches[0];
                const touch2 = event.touches[1];
                midpointX = (touch1.clientX + touch2.clientX) / 2;
                midpointY = (touch1.clientY + touch2.clientY) / 2;
            } else {
                midpointX = event.touches[0].clientX;
                midpointY = event.touches[0].clientY;
            }
    
            let possibleZoomTarget;
            const elementsFromPoint = document.elementsFromPoint(midpointX, midpointY);
    
            for (let i = 0, n = elementsFromPoint.length, element; i < n; i++) {
                element = elementsFromPoint[i];
                if (elementIsTextElement(element)) {
                    possibleZoomTarget = element;
                    break;
                }
            }
    
            if (!possibleZoomTarget) possibleZoomTarget = elementsFromPoint[0];
    
            if (possibleZoomTarget) {
                zoomTarget = possibleZoomTarget;
                const targetRect = zoomTarget.getBoundingClientRect();
                targetDyOffsetRatio = targetRect.top / window.innerHeight;
            }
        }
    
        function handleTouchEnd(event) {
            if (isPinching && (event.touches && event.touches.length === 0)) {
                isPinching = false;
                reflowText(true);
            }
        }
    
        function handleViewportResize() {
            if (isPinching) return;
            
            const currentWidth = window.visualViewport.width;
            if (Math.abs(currentWidth - lastViewportWidth) < 1) {
                return;
            }
    
            clearTimeout(resizeDebounceTimer);
            resizeDebounceTimer = setTimeout(() => {
                reflowText(true);
            }, 400); 
        }
    
        // --- Dynamic Content Handling ---
        function initObserver() {
            if (!document.body) return;
    
            const observer = new MutationObserver((mutations) => {
                if (isPinching) return;
    
                let hasAddedNodes = false;
                for (const mutation of mutations) {
                    if (mutation.addedNodes.length > 0) {
                        hasAddedNodes = true;
                        break;
                    }
                }
    
                if (hasAddedNodes) {
                    clearTimeout(mutationDebounceTimer);
                    mutationDebounceTimer = setTimeout(() => {
                        reflowText(false);
                    }, 500);
                }
            });
    
            observer.observe(document.body, { childList: true, subtree: true });
        }
    
        // --- Listeners ---
        window.addEventListener('touchstart', handleTouchStart, { passive: true });
        window.addEventListener('touchend', handleTouchEnd);
        
        if (window.visualViewport) {
            window.visualViewport.addEventListener('resize', handleViewportResize);
        }
    
        if (document.body) {
            initObserver();
        } else {
            window.addEventListener('DOMContentLoaded', initObserver);
        }
    
    })();


    a tady je ještě novější verze, u které lze podržením tří prstů na displeji text-wrap globálně vypnout a zapnout podle potřeby (když třeba zlobí na některých stránkách).

    // ==UserScript==
    // @name         Text reflow on zoom for mobile (text wrap) - Sync Fix
    // @name:ru      Text reflow on zoom for mobile (text wrap) - Sync Fix
    // @description  Fits all text to the screen width after a pinch or double-tap zoom gesture (Global 3-finger toggle)
    // @description:ru Подгонка текста под ширину экрана после жеста увеличения на телефоне (Глобальный Вкл/Выкл 3 пальцами)
    // @version      1.0.20
    // @author       emvaized
    // @license      MIT
    // @homepageURL  https://github.com/emvaized/text-reflow-on-zoom-mobile/
    // @namespace    text_reflow_on_pinch_zoom
    // @match        *://*/*
    // @grant        GM_setValue
    // @grant        GM_getValue
    // @grant        GM_addValueChangeListener
    // @run-at       document-start
    // ==/UserScript==
    
    (function() {
        'use strict';
    
        const ENABLED_CLASS = 'userscript-mobile-text-reflow-enabled';
    
        // v1.0.12 Selector
        const xpathSelector = `
        //p |
        //a[normalize-space(text())] |
        //h1 | //h2 | //h3 | //h4 | //h5 | //h6 |
        //li |
        //pre |
        //div[b or em or i] |
        //div[text()[normalize-space()]] |
        //div[span[text()[normalize-space()]]]`;
    
        let isReflowEnabled = GM_getValue('reflowEnabled', true);
        
        let isCssInjected = false;
        let isPinching = false;
        
        let zoomTarget, targetDyOffsetRatio;
        let resizeDebounceTimer;
        let mutationDebounceTimer;
        
        let longPressTimer;
        const LONG_PRESS_DURATION = 800; 
        const MOVE_THRESHOLD = 15;
        let startTouches = [];
        
        let lastViewportWidth = window.visualViewport ? window.visualViewport.width : window.innerWidth;
    
        const allTextElements = new Set();
        let notificationEl = null;
    
        // Apply initial state
        updateHtmlClass();
    
        // 1. Listen for background changes (if browser allows)
        if (typeof GM_addValueChangeListener !== 'undefined') {
            GM_addValueChangeListener('reflowEnabled', (name, oldVal, newVal, remote) => {
                if (isReflowEnabled !== newVal) {
                    isReflowEnabled = newVal;
                    updateHtmlClass();
                    if (isReflowEnabled) reflowText(false); 
                }
            });
        }
    
        // 2. FORCE SYNC on tab switch (Fixes frozen background tabs)
        document.addEventListener("visibilitychange", () => {
            if (document.visibilityState === 'visible') {
                const globalState = GM_getValue('reflowEnabled', true);
                if (isReflowEnabled !== globalState) {
                    isReflowEnabled = globalState;
                    updateHtmlClass();
                    if (isReflowEnabled) reflowText(false);
                }
            }
        });
    
        function updateHtmlClass() {
            if (isReflowEnabled) {
                document.documentElement.classList.add(ENABLED_CLASS);
            } else {
                document.documentElement.classList.remove(ENABLED_CLASS);
                document.documentElement.style.setProperty('--text-reflow-max-width', 'unset');
            }
        }
    
        function showNotification(text) {
            if (!notificationEl) {
                notificationEl = document.createElement('div');
                Object.assign(notificationEl.style, {
                    position: 'fixed',
                    top: '50%',
                    left: '50%',
                    backgroundColor: 'rgba(0, 0, 0, 0.9)',
                    color: 'white',
                    padding: '16px 24px',
                    borderRadius: '12px',
                    zIndex: '2147483647',
                    fontFamily: 'sans-serif',
                    fontSize: '18px',
                    fontWeight: 'bold',
                    pointerEvents: 'none',
                    opacity: '0',
                    transition: 'opacity 0.2s ease',
                    textAlign: 'center',
                    boxShadow: '0 4px 15px rgba(0,0,0,0.5)',
                    userSelect: 'none' 
                });
                document.body.appendChild(notificationEl);
            }
    
            const scale = window.visualViewport ? (1 / window.visualViewport.scale) : 1;
            notificationEl.style.transform = `translate(-50%, -50%) scale(${scale})`;
            
            notificationEl.textContent = text;
            notificationEl.style.opacity = '1';
    
            setTimeout(() => {
                notificationEl.style.opacity = '0';
            }, 2000);
        }
    
        function reflowText(shouldRestoreScroll = false) {
            if (!isReflowEnabled) return;
    
            if (!isCssInjected) {
                const styleContent = `html.${ENABLED_CLASS} .text-reflow-userscript { word-wrap: break-word !important; overflow-wrap:break-word !important; max-width:var(--text-reflow-max-width) !important; }
                .text-reflow-scroll-padding {scroll-margin-left: 1vw !important;}`;
                const styleElement = document.createElement('style');
                styleElement.textContent = styleContent;
                document.head.appendChild(styleElement);
                isCssInjected = true;
            }
    
            const maxAllowedWidth = Math.round(window.visualViewport.width * 0.96);
            document.documentElement.style.setProperty('--text-reflow-max-width', `${maxAllowedWidth}px`);
    
            lastViewportWidth = window.visualViewport.width;
    
            const xpathResult = document.evaluate(xpathSelector, document, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
    
            allTextElements.clear();
    
            for (let i = 0, n = xpathResult.snapshotLength, el; i < n; i++) {
                el = xpathResult.snapshotItem(i);
                if (!el.offsetParent) continue;
                if (!el.textContent.trim()) continue;
    
                let isTopLevel = true;
                let parent = el.parentElement;
                while (parent) {
                    if (elementIsTextElement(parent)) {
                        isTopLevel = false;
                        break;
                    }
                    parent = parent.parentElement;
                }
    
                if (isTopLevel) {
                    el.classList.add('text-reflow-userscript');
                    allTextElements.add(el);
                }
             }
    
            if (shouldRestoreScroll && zoomTarget && targetDyOffsetRatio) {
                try {
                    const targetOffset = targetDyOffsetRatio * window.innerHeight;
                    const rect = zoomTarget.getBoundingClientRect();
                    const targetTop = rect.top + window.pageYOffset;
                    const scrollToPosition = targetTop - targetOffset;
    
                    window.scrollTo({
                        top: scrollToPosition,
                        behavior: 'instant'
                    });
    
                    if (zoomTarget.nodeName !== 'IMG' && zoomTarget.nodeName !== 'VIDEO' && zoomTarget.nodeName !== 'IFRAME'){
                        zoomTarget.classList.add('text-reflow-scroll-padding');
                        zoomTarget.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });
                        zoomTarget.classList.remove('text-reflow-scroll-padding');
                    }
                } catch (e) {
                    // Fail silently
                }
                zoomTarget = null;
                targetDyOffsetRatio = null;
            }
        }
    
        function elementIsTextElement(element) {
            return allTextElements.has(element);
        }
    
        // --- Interaction Handlers ---
    
        function handleTouchStart(event) {
            if (!event.touches) return;
    
            // Toggle Gesture (3 Fingers)
            if (event.touches.length === 3) {
                startTouches = [
                    {x: event.touches[0].clientX, y: event.touches[0].clientY},
                    {x: event.touches[1].clientX, y: event.touches[1].clientY},
                    {x: event.touches[2].clientX, y: event.touches[2].clientY}
                ];
    
                clearTimeout(longPressTimer);
                longPressTimer = setTimeout(() => {
                    isReflowEnabled = !isReflowEnabled;
                    GM_setValue('reflowEnabled', isReflowEnabled);
                    updateHtmlClass();
                    showNotification(isReflowEnabled ? "Reflow: ON" : "Reflow: OFF");
                    if (isReflowEnabled) reflowText(true);
                    longPressTimer = null;
                }, LONG_PRESS_DURATION);
                return; 
            }
    
            let midpointX, midpointY;
    
            if (event.touches.length >= 2) {
                isPinching = true;
                const touch1 = event.touches[0];
                const touch2 = event.touches[1];
                midpointX = (touch1.clientX + touch2.clientX) / 2;
                midpointY = (touch1.clientY + touch2.clientY) / 2;
            } else {
                midpointX = event.touches[0].clientX;
                midpointY = event.touches[0].clientY;
            }
    
            if (isReflowEnabled) {
                let possibleZoomTarget;
                const elementsFromPoint = document.elementsFromPoint(midpointX, midpointY);
    
                for (let i = 0, n = elementsFromPoint.length, element; i < n; i++) {
                    element = elementsFromPoint[i];
                    if (elementIsTextElement(element)) {
                        possibleZoomTarget = element;
                        break;
                    }
                }
    
                if (!possibleZoomTarget) possibleZoomTarget = elementsFromPoint[0];
    
                if (possibleZoomTarget) {
                    zoomTarget = possibleZoomTarget;
                    const targetRect = zoomTarget.getBoundingClientRect();
                    targetDyOffsetRatio = targetRect.top / window.innerHeight;
                }
            }
        }
    
        function handleTouchMove(event) {
            if (longPressTimer && event.touches.length === 3 && startTouches.length === 3) {
                for (let i = 0; i < 3; i++) {
                    const dx = event.touches[i].clientX - startTouches[i].x;
                    const dy = event.touches[i].clientY - startTouches[i].y;
                    if (Math.abs(dx) > MOVE_THRESHOLD || Math.abs(dy) > MOVE_THRESHOLD) {
                        clearTimeout(longPressTimer);
                        longPressTimer = null;
                        return;
                    }
                }
                return;
            }
            
            if (longPressTimer && event.touches.length !== 3) {
                 clearTimeout(longPressTimer);
                 longPressTimer = null;
            }
        }
    
        function handleTouchEnd(event) {
            if (longPressTimer) {
                 clearTimeout(longPressTimer);
                 longPressTimer = null;
            }
    
            if (isPinching && (event.touches && event.touches.length === 0)) {
                isPinching = false;
                if (isReflowEnabled) reflowText(true);
            }
        }
    
        function handleViewportResize() {
            if (isPinching || !isReflowEnabled) return;
            
            const currentWidth = window.visualViewport.width;
            if (Math.abs(currentWidth - lastViewportWidth) < 1) {
                return;
            }
    
            clearTimeout(resizeDebounceTimer);
            resizeDebounceTimer = setTimeout(() => {
                reflowText(true);
            }, 400); 
        }
    
        function initObserver() {
            if (!document.body) return;
    
            const observer = new MutationObserver((mutations) => {
                if (isPinching || !isReflowEnabled) return;
    
                let hasAddedNodes = false;
                for (const mutation of mutations) {
                    if (mutation.addedNodes.length > 0) {
                        hasAddedNodes = true;
                        break;
                    }
                }
    
                if (hasAddedNodes) {
                    clearTimeout(mutationDebounceTimer);
                    mutationDebounceTimer = setTimeout(() => {
                        reflowText(false);
                    }, 500);
                }
            });
    
            observer.observe(document.body, { childList: true, subtree: true });
        }
    
        window.addEventListener('touchstart', handleTouchStart, { passive: true });
        window.addEventListener('touchmove', handleTouchMove, { passive: true });
        window.addEventListener('touchend', handleTouchEnd);
        
        if (window.visualViewport) {
            window.visualViewport.addEventListener('resize', handleViewportResize);
        }
    
        if (document.body) {
            initObserver();
        } else {
            window.addEventListener('DOMContentLoaded', initObserver);
        }
    
    })();

    Třeba se to bude někomu hodit. Obecně jsem u těhle user scriptů s tím kóděním přes gemini a chatGpt udělal dobrou zkušenost. Programovat umím, ale na tyhle věci mi nezbývá čas.
    MUXX
    MUXX --- ---
    Na reddit jsem nedavno objevil bajecnou vec - https://gosinkit.com/
    Neni to tampermonkey, ale stoji to za to.
    STARF
    STARF --- ---
    pokud pouzivate Reddit, posty s vice obrazkama je standardne nutny proklikat mysi... tenhle script prida carousel scroll pomoci sipek na klavesnici
    prevzato z: https://github.com/TheFantasticLoki/Tampermonkey-Scripts/blob/master/Reddit%20Image%20Gallery%20Arrow%20Navigation.user.js

    // ==UserScript==
    // @name         Reddit Image Gallery Arrow Navigation
    // @namespace    https://reddit.com/
    // @version      1.9.2
    // @description  Navigate Reddit image galleries using arrow keys, made with the help of ChatGPT, Thanks ChatGPT!
    // @author       TheFantasticLoki
    // @match        https://*.reddit.com/*
    // @grant        none
    // @license      MIT
    // @homepage     https://github.com/TheFantasticLoki/Tampermonkey-Scripts
    // @homepageURL  https://github.com/TheFantasticLoki/Tampermonkey-Scripts
    // @supportURL   https://github.com/TheFantasticLoki/Tampermonkey-Scripts/issues
    // @downloadURL  https://github.com/TheFantasticLoki/Tampermonkey-Scripts/raw/refs/heads/master/Reddit%20Image%20Gallery%20Arrow%20Navigation.user.js
    // @updateURL    https://github.com/TheFantasticLoki/Tampermonkey-Scripts/raw/refs/heads/master/Reddit%20Image%20Gallery%20Arrow%20Navigation.user.js
    // ==/UserScript==
    
    (function () {
        'use strict';
    
        // Set the debug variable to control logging
        const debug = false;
    
        let activeGallery = null;
    
        const simulateClick = (button) => {
            if (button) {
                try {
                    const event = new MouseEvent('click', {
                        bubbles: true,
                        cancelable: true,
                        view: window,
                    });
                    button.dispatchEvent(event);
                    if (debug) console.log('Dispatched click event successfully');
                } catch (err) {
                    if (debug) console.error('Error dispatching click event:', err);
                }
            } else {
                if (debug) console.log('Button not interactable or not found.');
            }
        };
    
        const handleKeyDown = (e) => {
            if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') {
                return;
            }
    
            if (!activeGallery) {
                if (debug) console.log('No active gallery to navigate.');
                return;
            }
    
            if (e.key === 'ArrowLeft') {
                if (debug) console.log('Left arrow pressed');
                const prevButton = activeGallery.querySelector('button[aria-label="Previous page"]');
                if (debug) console.log('Previous Button:', prevButton);
                simulateClick(prevButton);
            }
    
            if (e.key === 'ArrowRight') {
                if (debug) console.log('Right arrow pressed');
                const nextButton = activeGallery.querySelector('button[aria-label="Next page"]');
                if (debug) console.log('Next Button:', nextButton);
                simulateClick(nextButton);
            }
        };
    
        const handleMouseEnter = (e) => {
            const galleryCarousel = e.currentTarget;
            if (galleryCarousel && galleryCarousel.shadowRoot) {
                activeGallery = galleryCarousel.shadowRoot;
                if (debug) console.log('Mouse entered gallery-carousel. Active gallery set:', activeGallery);
            }
        };
    
        const handleMouseLeave = () => {
            activeGallery = null;
            if (debug) console.log('Mouse left gallery-carousel. Active gallery cleared.');
        };
    
        const detectPopup = () => {
            const lightbox = document.querySelector('#shreddit-media-lightbox');
            if (lightbox) {
                const gallery = lightbox.querySelector('gallery-carousel');
                if (gallery && gallery.shadowRoot) {
                    activeGallery = gallery.shadowRoot;
                    if (debug) console.log('Gallery popup detected. Active gallery set:', activeGallery);
                }
            }
        };
    
        const observeDOMChanges = () => {
            const observer = new MutationObserver((mutations) => {
                mutations.forEach((mutation) => {
                    if (mutation.addedNodes) {
                        mutation.addedNodes.forEach((node) => {
                            if (node.id === 'shreddit-media-lightbox') {
                                if (debug) console.log('Lightbox detected:', node);
                                detectPopup();
                            }
                        });
                    }
                });
            });
    
            observer.observe(document.body, { childList: true, subtree: true });
        };
    
        const attachHoverListeners = () => {
            document.querySelectorAll('gallery-carousel').forEach((carousel) => {
                carousel.addEventListener('mouseenter', handleMouseEnter);
                carousel.addEventListener('mouseleave', handleMouseLeave);
            });
        };
    
        const observeFeed = () => {
            const feedObserver = new MutationObserver(() => {
                attachHoverListeners(); // Reattach listeners as new galleries load
            });
    
            feedObserver.observe(document.body, { childList: true, subtree: true });
        };
    
        // Initial setup
        document.addEventListener('keydown', handleKeyDown);
        observeDOMChanges();
        observeFeed();
    
        // Attach listeners for initial galleries
        attachHoverListeners();
    
        if (debug) console.log('Script initialized.');
    })();
    TOM
    TOM --- ---
    TOM: zapomněl jsem dodat, že nemá GUI - ovládá se jenom klávesovou zkratkou Ctrl+Shift+mezerník
    TOM
    TOM --- ---
    pauzne/pustí video na všech tabech/stránkách prohlížeče, kde je skript aktivní
    asterisk (*://*/*) případně změňte na youtube apod.
    hodí se např. pokud potřebujete dabing z nekvalitního videa a video obraz z jiného (kombinace youtube + netflix)

    // ==UserScript==
    // @name Pause/Play an all tabs
    // @namespace http://tampermonkey.net/
    // @version 1.0
    // @description Pause/play all videos across all tabs with Ctrl+Shift+Space
    // @match *://*/*
    // @grant GM_setValue
    // @grant GM_getValue
    // @grant GM_addValueChangeListener
    // @run-at document-idle
    // ==/UserScript==
    
    (function() {
    'use strict';
    
    // ============ CONFIGURATION ============
    const CONFIG = {
    debug: true, // Console logging
    storageKey: 'globalVideoToggle'
    };
    
    const log = (...args) => CONFIG.debug && console.log('[VideoToggle]', ...args);
    
    // ============ VIDEO CONTROL ============
    function toggleVideos(action) {
    const videos = document.querySelectorAll('video');
    let affected = 0;
    
    videos.forEach(video => {
    try {
    if (action === 'pause') {
    if (!video.paused) {
    video.pause();
    affected++;
    }
    } else {
    if (video.paused) {
    video.play().catch(() => {}); // Ignore autoplay restrictions
    affected++;
    }
    }
    } catch (e) {
    log('Error controlling video:', e);
    }
    });
    
    log(`${action.toUpperCase()}: ${affected}/${videos.length} videos`);
    }
    
    // ============ KEYBOARD SHORTCUT ============
    document.addEventListener('keydown', (e) => {
    // Ctrl+Shift+Space
    if (e.ctrlKey && e.shiftKey && e.code === 'Space') {
    e.preventDefault();
    e.stopPropagation();
    
    // Toggle based on previous state
    const current = GM_getValue(CONFIG.storageKey, { action: 'play' });
    const newAction = current.action === 'pause' ? 'play' : 'pause';
    
    log(`Shortcut triggered -> ${newAction}`);
    
    // Broadcast to all tabs (including this one via the listener)
    GM_setValue(CONFIG.storageKey, {
    action: newAction,
    timestamp: Date.now() // Ensures value always changes
    });
    
    // Apply locally (listener may not fire for local changes in some versions)
    toggleVideos(newAction);
    }
    }, true); // Capture phase - catches event before websites can intercept
    
    // ============ CROSS-TAB LISTENER ============
    GM_addValueChangeListener(CONFIG.storageKey, (name, oldValue, newValue, remote) => {
    if (remote) { // Only react to changes from OTHER tabs
    log(`Remote signal received -> ${newValue.action}`);
    toggleVideos(newValue.action);
    }
    });
    
    // ============ INIT ============
    log('Loaded. Press Ctrl+Shift+Space to pause/play all videos across tabs.');
    })();
    TOM
    TOM --- ---
    brute force scraper titulků ze streamu
    běží minimálně na stránce arte.tv (konkrétní tip zde), jinde jsem to nepotřeboval/netestoval
    skript nemá GUI, přehrávané titulky se stáhnou po skončení videa automaticky do nastavené dwl složky prohlížeče

    // ==UserScript==
    // @name         Arte.tv Auto Subtitle Scraper
    // @namespace    http://tampermonkey.net/
    // @version      3.0
    // @description  Automatically scrape and save subtitles on tab close/navigation
    // @match        https://www.arte.tv/*
    // @match        https://*.arte.tv/*
    // @grant        none
    // @run-at       document-idle
    // ==/UserScript==
    (function() {
        'use strict';
        // ============ CONFIGURATION ============
        const CONFIG = {
            // Console logging
            debug: true,
            // Start scraping automatically
            autoStart: true,
            // Auto-save when leaving page
            saveOnExit: true,
            // Auto-save on URL/navigation change
            saveOnUrlChange: true,
            // Minimum subtitles before auto-saving
            minSubtitles: 10,
            // Manual save shortcut
            keyboardShortcut: 'ctrl+shift+s',
            // Filename prefix
            filenamePrefix: 'arte_subtitles_',
        };
        // Note: Download folder is controlled by browser settings, not this script.
        // Files will save to your browser's default download location.
        // ============ STATE ============
        const log = (...args) => CONFIG.debug && console.log('[SubScraper]', ...args);
        let capturedSubs = [];
        let isScanning = false;
        let hasAutoSaved = false;
        // ============ FILTERS ============
        const UI_GARBAGE = [
            'Pause', 'Play', 'Mute', 'Unmute',
            'Forward 10 seconds', 'Back 10 seconds',
            'Rewind 10 seconds', 'Skip 10 seconds',
            'Settings', 'Fullscreen', 'Exit fullscreen',
            'Subtitles', 'Audio', 'Quality', 'Captions',
            'Volume', 'Speed', 'Next', 'Previous',
            'Share', 'Download', 'More', 'Less'
        ];
        function isUIGarbage(text) {
            if (!text || text.length < 2) return true;
            // Percentages like "1.36%"
            if (/^\d+\.?\d*%$/.test(text)) return true;
            // Timestamps like "12:34" or "1:23:45"
            if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(text)) return true;
            // Known UI text
            if (UI_GARBAGE.some(ui => text.includes(ui))) return true;
            // Very short or pure numbers
            if (text.length < 3 || /^\d+$/.test(text)) return true;
            return false;
        }
        // ============ SCRAPING ============
        function startScraping() {
            if (isScanning) {
                log('Already scanning');
                return;
            }
            isScanning = true;
            log('Auto-scraping started');
            const observer = new MutationObserver((mutations) => {
                if (!isScanning) return;
                mutations.forEach(mutation => {
                    mutation.addedNodes.forEach(node => {
                        let text = '';
                        if (node.nodeType === Node.TEXT_NODE) {
                            text = node.textContent.trim();
                        } else if (node.nodeType === Node.ELEMENT_NODE) {
                            text = node.textContent.trim();
                        }
                        if (text && !isUIGarbage(text)) {
                            captureSubtitle(text);
                        }
                    });
                });
            });
            // Monitor subtitle areas
            const targets = document.querySelectorAll('.avp, video, [class*="player"], [class*="subtitle"], [class*="caption"]');
            targets.forEach(target => {
                observer.observe(target, {
                    childList: true,
                    subtree: true,
                    characterData: true
                });
            });
            log(`Monitoring ${targets.length} elements`);
        }
        function captureSubtitle(text) {
            // Avoid duplicates
            if (capturedSubs.some(s => s.text === text)) return;
            const video = document.querySelector('video');
            const timestamp = video ? video.currentTime : capturedSubs.length * 2;
            capturedSubs.push({
                start: timestamp,
                end: timestamp + 2, // Default 2 second duration
                text: text
            });
            log(`[${capturedSubs.length}] ${text.substring(0, 50)}`);
        }
        // ============ OVERLAP CORRECTION ============
        function fixOverlaps(subs) {
            // Sort by start time
            subs.sort((a, b) => a.start - b.start);
            // Fix overlaps: if current subtitle ends after next starts,
            // adjust current end to match next start
            for (let i = 0; i < subs.length - 1; i++) {
                if (subs[i].end > subs[i + 1].start) {
                    log(`Overlap fixed: [${i}] ${formatTime(subs[i].end)} -> ${formatTime(subs[i + 1].start)}`);
                    subs[i].end = subs[i + 1].start;
                }
            }
            return subs;
        }
        // ============ DEDUPLICATION ============
        function removeDuplicates(subs) {
            const unique = [];
            const seen = new Set();
            subs.forEach(sub => {
                const key = `${sub.start.toFixed(1)}_${sub.text}`;
                if (!seen.has(key)) {
                    seen.add(key);
                    unique.push(sub);
                }
            });
            return unique;
        }
        // ============ SRT GENERATION ============
        function generateSRT() {
            if (capturedSubs.length === 0) {
                log('No subtitles to save');
                return null;
            }
            // Process subtitles
            let processed = removeDuplicates(capturedSubs);
            processed = fixOverlaps(processed);
            log(`Generating SRT: ${capturedSubs.length} raw -> ${processed.length} final`);
            // Convert to SRT format
            let srt = '';
            processed.forEach((sub, idx) => {
                srt += `${idx + 1}\n`;
                srt += `${formatTime(sub.start)} --> ${formatTime(sub.end)}\n`;
                srt += `${sub.text}\n\n`;
            });
            return srt;
        }
        function formatTime(seconds) {
            const h = Math.floor(seconds / 3600);
            const m = Math.floor((seconds % 3600) / 60);
            const s = Math.floor(seconds % 60);
            const ms = Math.floor((seconds % 1) * 1000);
            return `${pad(h)}:${pad(m)}:${pad(s)},${pad(ms, 3)}`;
        }
        function pad(num, size = 2) {
            let s = num + '';
            while (s.length < size) s = '0' + s;
            return s;
        }
        // ============ SAVE ============
        function saveSubtitles(reason = 'manual') {
            if (capturedSubs.length < CONFIG.minSubtitles) {
                log(`Not saving: only ${capturedSubs.length} subtitles (min: ${CONFIG.minSubtitles})`);
                return false;
            }
            const srt = generateSRT();
            if (!srt) return false;
            const filename = `${CONFIG.filenamePrefix}${Date.now()}.srt`;
            try {
                const blob = new Blob([srt], { type: 'text/plain;charset=utf-8' });
                const url = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = filename;
                // For auto-save on exit, we need to click synchronously
                document.body.appendChild(a);
                a.click();
                document.body.removeChild(a);
                // Clean up after a delay
                setTimeout(() => URL.revokeObjectURL(url), 100);
                log(`✓ Saved: ${filename} (${capturedSubs.length} lines, reason: ${reason})`);
                return true;
            } catch (e) {
                console.error('[SubScraper] Save failed:', e);
                return false;
            }
        }
        // ============ AUTO-SAVE TRIGGERS ============
        // Tab close / page refresh / browser close
        window.addEventListener('beforeunload', (e) => {
            if (CONFIG.saveOnExit && !hasAutoSaved) {
                hasAutoSaved = true;
                saveSubtitles('page-exit');
            }
        });
        // URL change (SPA navigation)
        if (CONFIG.saveOnUrlChange) {
            let currentUrl = window.location.href;
            setInterval(() => {
                if (window.location.href !== currentUrl) {
                    log('URL changed detected');
                    currentUrl = window.location.href;
                    if (!hasAutoSaved) {
                        hasAutoSaved = true;
                        saveSubtitles('url-change');
                    }
                }
            }, 1000);
        }
        // ============ KEYBOARD SHORTCUT ============
        document.addEventListener('keydown', (e) => {
            // Ctrl+Shift+S (or Cmd+Shift+S on Mac)
            if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 's') {
                e.preventDefault();
                log('Manual save triggered');
                saveSubtitles('keyboard');
            }
        });
        // ============ INITIALIZATION ============
        function init() {
            if (!document.body) {
                setTimeout(init, 100);
                return;
            }
            log('Arte.tv Auto Subtitle Scraper v3.0');
            log(`Config: autoStart=${CONFIG.autoStart}, saveOnExit=${CONFIG.saveOnExit}`);
            log(`Keyboard shortcut: ${CONFIG.keyboardShortcut.toUpperCase()} for manual save`);
            if (CONFIG.autoStart) {
                // Wait for video player to load
                setTimeout(() => {
                    const video = document.querySelector('video');
                    if (video) {
                        log('Video detected, starting scraper');
                        startScraping();
                    } else {
                        log('No video found, waiting...');
                        setTimeout(init, 2000);
                    }
                }, 2000);
            }
        }
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', init);
        } else {
            init();
        }
        // ============ DEBUG INFO ============
        // Expose stats for console inspection
        window.subScraperStats = () => {
            return {
                captured: capturedSubs.length,
                autoSaved: hasAutoSaved,
                config: CONFIG
            };
        };
        log('Tip: Run window.subScraperStats() in console to see current status');
    })();
    TOM
    TOM --- ---
    POSEIDON: ¯\_(°-°)_/¯ napsal jsem podle aktuální situace, nevím jestli/kdy/proč se může gcp zobrazit
    tvoje verze bude tím pádem určitě spolehlivější
    POSEIDON
    POSEIDON --- ---
    TOM: /gcp/ uz neni potreba?
    // ==UserScript==
    // @name         AliExpress Redirect Script
    // @namespace    http://tampermonkey.net/
    // @version      1.0
    // @description  Redirect from AliExpress /gcp/ URLs to /item/ URLs using productIds param
    // @author       Poseidon
    // @match        https://www.aliexpress.com/*
    // @grant        none
    // ==/UserScript==
    
    (function() {
        'use strict';
    
        // Get current URL
        const url = new URL(window.location.href);
    
        if (url.pathname.includes("/gcp/") || url.pathname.includes("/ssr/")) {
            if (url.searchParams.has("productIds")){
                // Get the value of productIds param
                const productIds = url.searchParams.get("productIds");
                // Split by colon and take the first ID
                const productId = productIds.split(':')[0];
    
                // Create the new URL
                const newUrl = `https://www.aliexpress.com/item/${productId}.html`;
    
                // Redirect to the new URL
                window.location.replace(newUrl);
            }
        }
    })();
    Kliknutím sem můžete změnit nastavení reklam