SlicerBridge

Adds Open in Slicer buttons to Printables and Thingiverse

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         SlicerBridge
// @namespace    https://github.com/LukysGaming/SlicerBridge
// @version      9
// @description  Adds Open in Slicer buttons to Printables and Thingiverse
// @author       LukysGaming
// @match        https://www.printables.com/model/*
// @match        https://www.thingiverse.com/thing:*
// @run-at       document-idle
// @license      MPL 2.0
// @grant        GM_xmlhttpRequest
// @connect      api.printables.com
// @connect      www.printables.com
// @connect      printables.com
// @connect      www.thingiverse.com
// @connect      thingiverse.com
// @connect      *.thingiverse.com
// @connect      cdn.thingiverse.com
// @connect      *
// ==/UserScript==


// ─────────────────────────────────────────────
// PRINTABLES SCRIPT
// ─────────────────────────────────────────────

if (location.hostname === 'www.printables.com') {

    (function () {
        'use strict';
    const PROTOCOL       = 'slicerbridge';
    const PRINTABLES_API = 'https://api.printables.com/graphql/';
    const BTN_LABEL      = '⬡ Open';
    const BTN_LABEL_ALL  = '⬡ Open ALL';

    const C = '#7aa2f7';
    const C_DIM = 'rgba(122,162,247,0.45)';
    const C_DIM_TEXT = 'rgba(122,162,247,0.7)';
    const C_CANCEL = '#f7768e';
    const C_BG = '#16161e';

    const BTN_STYLE = [
        'display:inline-flex', 'align-items:center', 'justify-content:center', 'gap:5px',
        'padding:6px 12px', 'font-size:12px', 'font-family:inherit', 'font-weight:600',
        'border:1px solid ' + C, 'border-radius:6px', 'background:transparent',
        'color:' + C, 'cursor:pointer', 'transition:background 0.15s,color 0.15s',
        'white-space:nowrap', 'width:100%', 'box-sizing:border-box',
    ].join(';');

    // ── Utilities ──────────────────────────────────────────────────────────────

    function getModelId() {
        const m = location.pathname.match(/\/model\/([^/]+)/);
        if (!m) return null;

        const numeric = m[1].match(/^(\d+)/);
        return numeric ? numeric[1] : null;
    }


    function normalizeName(str) {
        return (str || '').trim().replace(/\s+/g, ' ');
    }
    function fileExt(file) {
        const name = (file?.name || '').toLowerCase();
        const m = name.match(/\.([a-z0-9]+)$/);
        return m ? m[1] : '';
    }

    function compatibilityInfo(files) {
        const exts = [...new Set(files.map(fileExt).filter(Boolean))];

        if (exts.length <= 1) {
            return { ok: true, exts };
        }

        return {
            ok: false,
            exts,
            reason: `Cannot open mixed file types together: ${exts.map(e => '.' + e).join(' + ')}. Use Select files and open one type at a time.`,
        };
    }

    function setButtonDisabled(btn, disabled, reason = '') {
        btn.disabled = disabled;

        if (disabled) {
            btn.title = reason;
            btn.style.opacity = '0.35';
            btn.style.cursor = 'not-allowed';
            btn.style.borderColor = C_DIM;
            btn.style.color = C_DIM_TEXT;
            btn.style.background = 'transparent';
        } else {
            btn.style.opacity = '1';
            btn.style.cursor = 'pointer';
            btn.style.borderColor = C;
            btn.style.color = C;
        }
    }

    // ── API ────────────────────────────────────────────────────────────────────

    async function fetchModelFiles(modelId) {
        const query = `
          query ModelFiles($id: ID!) {
            model: print(id: $id) {
              stls { id name fileSize folder }
            }
          }
        `;
        try {
            const resp = await fetch(PRINTABLES_API, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ operationName: 'ModelFiles', query, variables: { id: modelId } }),
            });
            const json = await resp.json();
            if (json?.errors) console.warn('[SlicerBridge] GraphQL errors:', json.errors);

            return json?.data?.model?.stls ?? [];
        } catch (e) {
            console.warn('[SlicerBridge] fetchModelFiles failed:', e);
            return [];
        }
    }

    const DOWNLOAD_MUTATION = `
      mutation GetDownloadLink($id: ID!, $modelId: ID!, $fileType: DownloadFileTypeEnum!, $source: DownloadSourceEnum!) {
        getDownloadLink(id: $id, printId: $modelId, fileType: $fileType, source: $source) {
          ok
          errors { field messages __typename }
          output { link count ttl __typename }
          __typename
        }
      }
    `;

    async function resolveDownloadUrl(fileId, modelId, fileName) {
        const ext = (fileName || '').toLowerCase().split('.').pop();
        let firstChoice = 'stl';
        if (ext === '3mf') firstChoice = 'project';
        else if (['gcode', 'bgcode'].includes(ext)) firstChoice = 'gcode';
        else if (!['stl', 'obj', 'step', 'stp'].includes(ext)) firstChoice = 'other';

        const typesToTry = [...new Set([firstChoice, 'stl', 'project', 'other', 'gcode'])];

        for (const t of typesToTry) {
            try {
                const resp = await fetch(PRINTABLES_API, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    credentials: 'include',
                    body: JSON.stringify({
                        operationName: 'GetDownloadLink',
                        query: DOWNLOAD_MUTATION,
                        variables: { id: String(fileId), modelId: String(modelId), fileType: t, source: 'model_detail' },
                    }),
                });
                const json = await resp.json();
                const link = json?.data?.getDownloadLink?.output?.link;

                if (link) return link;
            } catch (e) {
                // Ignore silent errors
            }
        }
        console.warn(`[SlicerBridge] nepodařilo se získat link pro ${fileName}`);
        return null;
    }

    // VRÁCENO DO PŮVODNÍHO STAVU BEZ DÁVKOVÁNÍ
    async function buildMultiUri(files, modelId) {
        console.log(`[SlicerBridge] Resolving ${files.length} download URL(s)...`);

        const resolved = await Promise.all(
            files.map(s => resolveDownloadUrl(s.id, modelId, s.name))
        );

        const params = new URLSearchParams();

        for (let i = 0; i < files.length; i++) {
            if (resolved[i]) {
                params.append('file', resolved[i]);
                params.append('name', files[i].name || `model_${i + 1}.stl`);
            } else {
                console.warn(`[SlicerBridge] Skipping ${files[i].name} — no URL`);
            }
        }

        if (![...params.keys()].length) return null;

        return `${PROTOCOL}://multi?${params.toString()}`;
    }


    // ── Button factory ─────────────────────────────────────────────────────────

    function makeButton(label, onClickAsync) {
        const btn = document.createElement('button');
        btn.textContent = label;
        btn.setAttribute('style', BTN_STYLE);
        btn.addEventListener('mouseenter', () => { if (!btn.disabled) { btn.style.background = C; btn.style.color = C_BG; } });
        btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; btn.style.color = C; });
        btn.addEventListener('click', async e => {
            e.preventDefault();
            e.stopPropagation();

            if (btn.disabled) return;

            const prev = btn.textContent;
            btn.textContent = '⏳…';
            btn.disabled = true;

            try {
                await onClickAsync();
            } finally {
                btn.textContent = prev;
                btn.disabled = false;
            }
        });
        return btn;
    }

    // ── Panel ──────────────────────────────────────────────────────────────────

    let panel = null;
    let openAllBtn = null;
    let selectToggleBtn = null;
    let openSelectedBtn = null;

    function getOrCreatePanel() {
        if (panel) return panel;

        panel = document.createElement('div');
        panel.id = 'sb-panel';
        panel.style.cssText = [
            'position:fixed', 'display:flex', 'flex-direction:column', 'gap:6px',
            'width:140px', 'z-index:9999', 'top:120px', 'right:20px',
        ].join(';');

        document.body.appendChild(panel);

        function alignPanel() {
            const container = document.querySelector('[data-testid="model-files"]');

            // Kontrolujeme clientHeight, abychom panel schovali na Details a dalších tabech
            if (!container || container.clientHeight === 0) {
                panel.style.display = 'none';
                return;
            }
            panel.style.display = 'flex';

            const rect = container.getBoundingClientRect();
            const vw = window.innerWidth;
            const vh = window.innerHeight;
            const gap = 12;

            if (rect.bottom < 0 || rect.top > vh) {
                panel.style.visibility = 'hidden';
                return;
            }
            panel.style.visibility = 'visible';

            if (vw - rect.right > 140 + gap * 2) {
                panel.style.left  = (rect.right + gap) + 'px';
                panel.style.right = '';
            } else {
                panel.style.left  = '';
                panel.style.right = '20px';
            }
            const containerBottom = rect.bottom;
            const clampedTop = Math.min(Math.max(80, rect.top), Math.max(80, containerBottom - 100));
            panel.style.top = clampedTop + 'px';
        }

        requestAnimationFrame(() => requestAnimationFrame(alignPanel));
        window.addEventListener('scroll', alignPanel, { passive: true });
        window.addEventListener('resize', alignPanel, { passive: true });

        document.body.addEventListener('click', () => {
            setTimeout(alignPanel, 50);
        });

        return panel;
    }

    // ── DOM helpers ────────────────────────────────────────────────────────────

    function getFolderNameFromDataHref(folderItem) {
        const href = folderItem?.dataset?.href || '';
        const m = href.match(/#folder:[^:]+:(.+)/);
        return m ? decodeURIComponent(m[1]) : null;
    }

    function findFolderHeaderInfos() {
        const folderItems = [...document.querySelectorAll('.folder-item')];
        if (folderItems.length) {
            const results = folderItems.map(item => {
                const header = item.querySelector('header');
                if (!header) return null;
                const nameEl     = header.querySelector('.folder-name, [class*="folder-name"]');
                const nameFromEl = nameEl ? normalizeName(nameEl.textContent) : null;
                const nameFromHref = normalizeName(getFolderNameFromDataHref(item));
                return { header, folderName: nameFromEl || nameFromHref };
            }).filter(Boolean);
            if (results.length) return results;
        }
        const ariaHeaders = [...document.querySelectorAll('header[aria-label*="folder" i]')];
        return ariaHeaders.map(header => {
            const nameEl     = header.querySelector('.folder-name, [class*="folder-name"]');
            const nameFromEl = nameEl ? normalizeName(nameEl.textContent) : null;
            const ariaMatch  = (header.getAttribute('aria-label') || '').match(/folder\s+(.+)/i);
            const nameFromAria = ariaMatch ? normalizeName(ariaMatch[1]) : null;
            return { header, folderName: nameFromEl || nameFromAria };
        });
    }

    function getFileNameFromItem(itemEl) {
        const el = itemEl.querySelector('.name-on-desktop .shrink, .name-on-mobile .shrink, .shrink');
        return el ? normalizeName(el.textContent) : null;
    }

    function fileFromItem(itemEl, allFiles) {
        const rawName = getFileNameFromItem(itemEl);
        if (!rawName) return null;

        const domName = rawName.toLowerCase();
        return allFiles.find(f => {
            const apiName = f.name.toLowerCase();
            return apiName === domName || apiName.startsWith(domName + '.');
        }) || null;
    }

    // ── Restyle native "Slice" buttons ─────────────────────────────────────────

    function restyleSliceButtons() {
        for (const btn of document.querySelectorAll('button.slicer-download')) {
            if (btn.dataset.sbStyled) continue;
            btn.dataset.sbStyled = '1';
            btn.style.cssText += [
                'border:1px solid ' + C + ' !important', 'border-right:none !important',
                'color:' + C + ' !important', 'background:transparent !important',
                'transition:background 0.15s,color 0.15s',
            ].join(';');
            const img = btn.querySelector('img');
            if (img) img.style.display = 'none';
            for (const node of [...btn.childNodes]) {
                if (node.nodeType === Node.TEXT_NODE) node.remove();
            }
            for (const span of btn.querySelectorAll('span:not([class*="arrow"])')) span.remove();
            const label = document.createElement('span');
            label.textContent = '⬡ Slice';
            label.style.cssText = 'font-weight:600;font-size:12px;pointer-events:none;color:' + C;
            btn.insertBefore(label, btn.firstChild);
            btn.addEventListener('mouseenter', () => { btn.style.background = C; label.style.color = C_BG; });
            btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; label.style.color = C; });
        }

        for (const wrapper of document.querySelectorAll('.slicer-download-wrapper, .btn-ordered')) {
            if (wrapper.dataset.sbStyled) continue;
            wrapper.dataset.sbStyled = '1';
            const arrow = [...wrapper.querySelectorAll('button')].find(
                b => !b.classList.contains('slicer-download') &&
                    b.querySelector('i, svg, .fa-chevron-down, [class*="chevron"], [class*="arrow"]')
            );
            if (!arrow) continue;
            arrow.style.cssText += [
                'border:1px solid ' + C + ' !important', 'border-left:1px solid rgba(122,162,247,0.3) !important',
                'color:' + C + ' !important', 'background:transparent !important',
                'transition:background 0.15s,color 0.15s',
            ].join(';');
            arrow.addEventListener('mouseenter', () => { arrow.style.background = 'rgba(122,162,247,0.15)'; });
            arrow.addEventListener('mouseleave', () => { arrow.style.background = 'transparent'; });
        }
    }

    // ── Select mode ────────────────────────────────────────────────────────────

    let selectModeActive = false;

    function updateOpenSelectedBtn(modelId) {
        if (!openSelectedBtn) return;
        const count = document.querySelectorAll('.sb-corner-cb:checked').length;
        openSelectedBtn.textContent   = `⬡ Open ${count}`;
        openSelectedBtn.disabled      = count === 0;
        openSelectedBtn.style.display = count > 0 ? 'flex' : 'none';
    }

    function enterSelectMode(modelId, allFiles) {
        selectModeActive = true;
        if (selectToggleBtn) {
            selectToggleBtn.textContent       = '✕ Cancel';
            selectToggleBtn.style.borderColor = C_CANCEL;
            selectToggleBtn.style.color       = C_CANCEL;
            selectToggleBtn.style.background  = 'transparent';
        }

        for (const item of document.querySelectorAll('.download-item')) {
            if (item.querySelector('.sb-file-checkbox')) continue;

            const fileIcon = item.querySelector('.file-icon');
            if (fileIcon && !fileIcon.querySelector('.sb-corner-cb')) {
                const existingPos = getComputedStyle(fileIcon).position;
                if (existingPos === 'static') fileIcon.style.position = 'relative';

                const corner = document.createElement('input');
                corner.type      = 'checkbox';
                corner.className = 'sb-corner-cb sb-file-checkbox';
                corner.style.cssText = [
                    'position:absolute', 'top:4px', 'right:4px', 'width:18px', 'height:18px',
                    'margin:0', 'cursor:pointer', 'accent-color:' + C, 'z-index:10',
                    'border-radius:4px', 'box-shadow:0 0 0 2px rgba(0,0,0,0.6)', 'flex-shrink:0',
                ].join(';');

                corner.addEventListener('change', () => {
                    const rowCb = item.querySelector('.sb-row-checkbox');
                    if (rowCb) rowCb.checked = corner.checked;
                    updateOpenSelectedBtn(modelId);
                    item.style.background = corner.checked ? 'rgba(122,162,247,0.08)' : '';
                });

                fileIcon.appendChild(corner);
            }

            if (!item.querySelector('.sb-row-checkbox')) {
                const rowCb = document.createElement('input');
                rowCb.type      = 'checkbox';
                rowCb.className = 'sb-row-checkbox';
                rowCb.style.cssText = 'display:none';

                rowCb.addEventListener('change', () => {
                    const cornerCb = item.querySelector('.sb-corner-cb');
                    if (cornerCb) cornerCb.checked = rowCb.checked;
                    updateOpenSelectedBtn(modelId);
                    item.style.background = rowCb.checked ? 'rgba(122,162,247,0.08)' : '';
                });

                item.appendChild(rowCb);
            }

            if (!item._sbRowHandler) {
                const rowHandler = e => {
                    if (e.target.closest('button, a, input, select, [role="button"]')) return;
                    const rowCb = item.querySelector('.sb-row-checkbox');
                    if (!rowCb) return;
                    rowCb.checked = !rowCb.checked;
                    rowCb.dispatchEvent(new Event('change'));
                };
                item._sbRowHandler = rowHandler;
                item.addEventListener('click', rowHandler);
                item.style.cursor = 'pointer';
            }
        }

        if (openSelectedBtn) {
            openSelectedBtn.style.display = 'none';
        } else {
            openSelectedBtn = makeButton('⬡ Open 0', async () => {
                const selectedFiles = [...document.querySelectorAll('.sb-file-checkbox:checked')]
                    .map(cb => fileFromItem(cb.closest('.download-item'), allFiles))
                    .filter(Boolean);

                if (!selectedFiles.length) return;

                const uri = await buildMultiUri(selectedFiles, modelId);
                if (uri) location.href = uri;
                else alert('[SlicerBridge] Could not resolve download URLs.\nAre you logged in to Printables?');
            });
            openSelectedBtn.style.display = 'none';
            getOrCreatePanel().appendChild(openSelectedBtn);
        }

        updateOpenSelectedBtn(modelId);
    }

    function exitSelectMode() {
        selectModeActive = false;

        if (selectToggleBtn) {
            selectToggleBtn.textContent       = '☰ Select files';
            selectToggleBtn.style.borderColor = C_DIM;
            selectToggleBtn.style.color       = C_DIM_TEXT;
            selectToggleBtn.style.background  = 'transparent';
        }

        for (const item of document.querySelectorAll('.download-item')) {
            if (item._sbRowHandler) {
                item.removeEventListener('click', item._sbRowHandler);
                delete item._sbRowHandler;
            }
            item.style.cursor     = '';
            item.style.background = '';

            item.querySelector('.sb-corner-cb')?.remove();
            item.querySelector('.sb-row-checkbox')?.remove();

            const fileIcon = item.querySelector('.file-icon');
            if (fileIcon) fileIcon.style.position = '';
        }

        if (openSelectedBtn) openSelectedBtn.style.display = 'none';
    }

    // ── Folder buttons ─────────────────────────────────────────────────────────

    function inject(allFiles, modelId) {
        if (!allFiles.length) return;

        const byFolder = {};
        for (const f of allFiles) {
            const key = normalizeName(f.folder) || '__root__';
            if (!byFolder[key]) byFolder[key] = [];
            byFolder[key].push(f);
        }

        for (const { header, folderName } of findFolderHeaderInfos()) {
            if (header.querySelector('.sb-open-btn')) continue;
            if (!folderName) continue;

            let stls = byFolder[folderName] ?? byFolder[
                Object.keys(byFolder).find(k => k.toLowerCase() === folderName.toLowerCase())
                ];

            if (!stls?.length) {
                console.log(`[SlicerBridge] No match for folder: "${folderName}". API has:`, Object.keys(byFolder));
                continue;
            }

            const btn = makeButton(BTN_LABEL, async () => {
                const folderCompat = compatibilityInfo(stls);
                if (!folderCompat.ok) {
                    alert('[SlicerBridge] ' + folderCompat.reason);
                    return;
                }

                const uri = await buildMultiUri(stls, modelId);
                if (uri) location.href = uri;
                else alert('[SlicerBridge] Could not resolve download URLs.\nAre you logged in to Printables?');
            });

            btn.classList.add('sb-open-btn');
            btn.style.width = 'auto';
            btn.style.marginLeft = '8px';

            const folderCompat = compatibilityInfo(stls);
            if (!folderCompat.ok) {
                setButtonDisabled(btn, true, folderCompat.reason);
                btn.title = folderCompat.reason;
                // DŮLEŽITÉ: neměnit text na "Mixed types", protože to rozhazuje layout
                btn.textContent = BTN_LABEL;
            } else {
                setButtonDisabled(btn, false);
                btn.title = `Open ${stls.length} file(s) in your slicer via SlicerBridge`;
            }

            const sizeEl = header.querySelector('.folder-size, [class*="folder-size"]');
            const nameEl = header.querySelector('.folder-name, [class*="folder-name"]');

            if (sizeEl)      header.insertBefore(btn, sizeEl);
            else if (nameEl) nameEl.after(btn);
            else             header.appendChild(btn);

            console.log(`[SlicerBridge] Injected folder button: "${folderName}" (${stls.length} files)`);
        }

        const p = getOrCreatePanel();

        if (!p.querySelector('.sb-open-all-btn')) {
            openAllBtn = makeButton(BTN_LABEL_ALL, async () => {
                const allCompat = compatibilityInfo(allFiles);
                if (!allCompat.ok) {
                    alert('[SlicerBridge] ' + allCompat.reason);
                    return;
                }

                const uri = await buildMultiUri(allFiles, modelId);
                if (uri) location.href = uri;
                else alert('[SlicerBridge] Could not resolve download URLs.\nAre you logged in to Printables?');
            });

            openAllBtn.classList.add('sb-open-all-btn');

            const allCompat = compatibilityInfo(allFiles);
            if (!allCompat.ok) {
                setButtonDisabled(openAllBtn, true, allCompat.reason);
                openAllBtn.title = allCompat.reason;
                openAllBtn.textContent = BTN_LABEL_ALL;
            } else {
                setButtonDisabled(openAllBtn, false);
                openAllBtn.title = `Open all ${allFiles.length} files in your slicer via SlicerBridge`;
            }

            p.appendChild(openAllBtn);
        }

        if (!p.querySelector('.sb-select-toggle')) {
            const toggleBtn = document.createElement('button');
            selectToggleBtn = toggleBtn;
            toggleBtn.textContent = '☰ Select files';
            toggleBtn.className   = 'sb-select-toggle';
            toggleBtn.setAttribute(
                'style',
                BTN_STYLE + ';border-color:' + C_DIM + ';color:' + C_DIM_TEXT + ';font-weight:500'
            );

            toggleBtn.addEventListener('mouseenter', () => {
                if (selectModeActive) return;
                toggleBtn.style.borderColor = C;
                toggleBtn.style.color       = C;
                toggleBtn.style.background  = 'transparent';
            });

            toggleBtn.addEventListener('mouseleave', () => {
                if (selectModeActive) return;
                toggleBtn.style.borderColor = C_DIM;
                toggleBtn.style.color       = C_DIM_TEXT;
                toggleBtn.style.background  = 'transparent';
            });

            toggleBtn.addEventListener('click', e => {
                e.preventDefault();
                e.stopPropagation();
                selectModeActive ? exitSelectMode() : enterSelectMode(modelId, allFiles);
            });

            p.appendChild(toggleBtn);
        }

        restyleSliceButtons();
    }

    // ── Entry point ────────────────────────────────────────────────────────────

    async function start(modelId) {
        const allFiles = await fetchModelFiles(modelId);
        if (!allFiles.length) {
            console.log('[SlicerBridge] No files found for model', modelId);
            return;
        }
        console.log(`[SlicerBridge] Fetched ${allFiles.length} file(s)`);

        inject(allFiles, modelId);

        let debounceTimer = null;
        const obs = new MutationObserver(() => {
            clearTimeout(debounceTimer);
            debounceTimer = setTimeout(() => {
                inject(allFiles, modelId);
                restyleSliceButtons();
                if (selectModeActive) enterSelectMode(modelId, allFiles);
            }, 500);
        });
        obs.observe(document.body, { childList: true, subtree: true });
        setTimeout(() => { obs.disconnect(); clearTimeout(debounceTimer); }, 30_000);
    }

    const modelId = getModelId();
    if (modelId) {
        if (document.readyState === 'complete') setTimeout(() => start(modelId), 800);
        else window.addEventListener('load', () => setTimeout(() => start(modelId), 800));
    }


    })();

}


// ─────────────────────────────────────────────
// THINGIVERSE SCRIPT
// ─────────────────────────────────────────────

if (location.hostname === 'www.thingiverse.com') {

    (function () {
        'use strict';
    const PROTOCOL = 'slicerbridge';

    const C = '#2b52fe';
    const C_BG = '#ffffff';
    const C_DIM = 'rgba(122,162,247,0.45)';
    const C_DIM_TEXT = 'rgba(122,162,247,0.65)';

    const BTN_STYLE = [
        'display:inline-flex',
        'align-items:center',
        'justify-content:center',
        'gap:5px',
        'padding:8px 14px',
        'font-size:13px',
        'font-family:inherit',
        'font-weight:700',
        'border:1px solid #2b52fe',
        'border-radius:8px',
        'background:#2b52fe',
        'color:#ffffff',
        'cursor:pointer',
        'white-space:nowrap',
        'box-sizing:border-box',
        'line-height:1.2',
    ].join(';');

    function log(...a) {
        console.log('[SlicerBridge Thingiverse]', ...a);
    }

    function normalizeName(s) {
        return (s || '').trim().replace(/\s+/g, ' ');
    }

    function getThingId() {
        const m = location.pathname.match(/\/thing:(\d+)/);
        return m ? m[1] : null;
    }
    function gmRequest(opts) {
        const fn =
            typeof GM_xmlhttpRequest !== 'undefined'
                ? GM_xmlhttpRequest
                : (typeof GM !== 'undefined' && GM.xmlHttpRequest)
                    ? GM.xmlHttpRequest
                    : null;

        if (!fn) {
            throw new Error('GM_xmlhttpRequest is not available. Check @grant metadata and reload the page.');
        }

        return new Promise((resolve, reject) => {
            fn({
                ...opts,
                anonymous: false,
                withCredentials: true,
                onload: resolve,
                onerror: reject,
                ontimeout: reject,
            });
        });
    }

    async function resolveThingiverseUrl(file) {
        const url = file.url;

        console.log('[SlicerBridge Thingiverse] Resolve start:', file.name, url);

        const attempts = [
            {
                method: 'HEAD',
                headers: { 'Accept': '*/*' },
            },
            {
                method: 'GET',
                headers: { 'Accept': '*/*', 'Range': 'bytes=0-0' },
            },
            {
                method: 'GET',
                headers: { 'Accept': '*/*' },
            },
        ];

        for (const a of attempts) {
            try {
                const r = await gmRequest({
                    method: a.method,
                    url,
                    headers: a.headers,
                    responseType: 'blob',
                });

                const finalUrl = r.finalUrl || r.responseURL || url;

                console.log('[SlicerBridge Thingiverse] Resolve attempt:', {
                    file: file.name,
                    method: a.method,
                    status: r.status,
                    finalUrl,
                    headers: r.responseHeaders,
                });

                if (
                    r.status >= 200 &&
                    r.status < 400 &&
                    finalUrl &&
                    !finalUrl.includes('/download:')
                ) {
                    return finalUrl;
                }
            } catch (e) {
                console.warn('[SlicerBridge Thingiverse] Resolve failed attempt:', file.name, a.method, e);
            }
        }

        throw new Error(`Could not resolve real download URL for ${file.name}`);
    }


    function extOf(file) {
        const m = (file?.name || '').toLowerCase().match(/\.([a-z0-9]+)$/);
        return m ? m[1] : '';
    }
    async function openAllNative(files) {
        const compat = compatibilityInfo(files);
        if (!compat.ok) {
            alert('[SlicerBridge] ' + compat.reason);
            return;
        }

        const nativeButtons = files
            .map(f => ({
                file: f,
                btn: findNativeOpenButton(f.row),
            }))
            .filter(x => x.btn);

        if (!nativeButtons.length) {
            alert('[SlicerBridge] No native Thingiverse Open buttons found.');
            return;
        }

        const ok = confirm(
            `[SlicerBridge] Open ${nativeButtons.length} file(s) in slicer?\n\n` +
            `Thingiverse requires browser-authorized opening, so files will be opened one by one.`
        );

        if (!ok) return;

        for (let i = 0; i < nativeButtons.length; i++) {
            console.log('[SlicerBridge Thingiverse] Open ALL native click:', nativeButtons[i].file.name);
            nativeButtons[i].btn.click();

            // delay so browser/protocol handler doesn't drop clicks
            await sleep(1200);
        }
    }

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    function compatibilityInfo(files) {
        const exts = [...new Set(files.map(extOf).filter(Boolean))];
        if (exts.length <= 1) return { ok: true, exts };

        return {
            ok: false,
            exts,
            reason: `Cannot open mixed file types together: ${exts.map(e => '.' + e).join(' + ')}. Open one type at a time.`,
        };
    }

    function setDisabled(btn, disabled, reason = '') {
        btn.disabled = disabled;

        if (disabled) {
            btn.title = reason;
            btn.style.opacity = '0.5';
            btn.style.cursor = 'not-allowed';
            btn.style.borderColor = '#2b52fe';
            btn.style.color = '#ffffff';
            btn.style.background = '#2b52fe';
            btn.style.filter = 'grayscale(0.45)';
        } else {
            btn.title = '';
            btn.style.opacity = '1';
            btn.style.cursor = 'pointer';
            btn.style.borderColor = '#2b52fe';
            btn.style.color = '#ffffff';
            btn.style.background = '#2b52fe';
            btn.style.filter = 'none';
        }
    }


    function makeButton(label, onClick) {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.textContent = label;
        btn.className = 'sb-tv-btn';
        btn.setAttribute('style', BTN_STYLE);

        btn.addEventListener('mouseenter', () => {
            if (btn.disabled) return;
            btn.style.background = '#1f43d8';
            btn.style.color = '#ffffff';
        });

        btn.addEventListener('mouseleave', () => {
            if (btn.disabled) return;
            btn.style.background = '#2b52fe';
            btn.style.color = '#ffffff';
        });

        btn.addEventListener('click', async e => {
            e.preventDefault();
            e.stopPropagation();

            if (btn.disabled) return;

            const old = btn.textContent;
            btn.textContent = '⏳ Resolving...';
            btn.disabled = true;

            try {
                await onClick();
            } finally {
                btn.textContent = old;
                btn.disabled = false;
            }
        });

        return btn;
    }



    async function buildUri(files) {
        const params = new URLSearchParams();

        for (const f of files) {
            const resolvedUrl = await resolveThingiverseUrl(f);

            params.append('file', resolvedUrl);
            params.append('name', f.name);
        }

        return `${PROTOCOL}://multi?${params.toString()}`;
    }


    async function openFiles(files) {
        if (!files.length) return;

        const compat = compatibilityInfo(files);
        if (!compat.ok) {
            alert('[SlicerBridge] ' + compat.reason);
            return;
        }

        try {
            const uri = await buildUri(files);
            location.href = uri;
        } catch (e) {
            console.error('[SlicerBridge Thingiverse] Download resolve failed:', e);
            alert('[SlicerBridge] Could not resolve Thingiverse download URL.\n\n' + e.message);
        }
    }


    function extractFileName(row, id) {
        const candidates = [
            row.querySelector('h5'),
            row.querySelector('h4'),
            row.querySelector('a[title]'),
            row.querySelector('[class*="ItemList__listItemHeader"]'),
            row,
        ].filter(Boolean);

        for (const el of candidates) {
            let text = normalizeName(el.getAttribute?.('title') || el.textContent || '');
            const m = text.match(/([^\\/:*?"<>|\n\r]+\.(stl|3mf|obj|step|stp|gcode|bgcode|zip))/i);
            if (m) return m[1];
        }

        return `thingiverse_${id}.stl`;
    }
    function isVisible(el) {
        if (!el) return false;
        const r = el.getBoundingClientRect();
        const style = getComputedStyle(el);

        return (
            r.width > 0 &&
            r.height > 0 &&
            style.display !== 'none' &&
            style.visibility !== 'hidden' &&
            style.opacity !== '0'
        );
    }
    function getFileRows() {
        const imgs = [...document.querySelectorAll('[data-item-id]')]
            .filter(isVisible);

        const rows = imgs
            .map(img =>
                img.closest('[class*="DownloadFilesList__downloadFilesListItem"]') ||
                img.closest('[class*="ItemList__listItem"]') ||
                img.closest('li') ||
                img.closest('div')
            )
            .filter(Boolean)
            .filter(isVisible);

        const seen = new Set();

        return rows.filter(row => {
            const id = row.querySelector('[data-item-id]')?.getAttribute('data-item-id');
            if (!id || seen.has(id)) return false;

            // musí to být reálný file row — má viditelný download/open button nebo filename
            const text = normalizeName(row.textContent).toLowerCase();
            if (!/\.(stl|3mf|obj|step|stp|gcode|bgcode|zip)/i.test(text)) return false;

            seen.add(id);
            return true;
        });
    }


    function scrapeFiles() {
        const rows = getFileRows();

        return rows.map(row => {
            const id = row.querySelector('[data-item-id]')?.getAttribute('data-item-id');
            return {
                id,
                row,
                name: extractFileName(row, id),
                url: `https://www.thingiverse.com/download:${id}`,
            };
        }).filter(f => f.id && f.name);
    }

    function findDownloadAllButton() {
        return document.querySelector(
            '.TabContentFiles__filesTabHeader--RVBxf button[aria-label="Open download modal"]'
        ) || [...document.querySelectorAll('button, a')]
            .find(el => {
                const txt = normalizeName(el.textContent).toLowerCase();
                const aria = normalizeName(el.getAttribute('aria-label') || '').toLowerCase();

                return (
                    txt.includes('download all files') ||
                    aria.includes('open download modal')
                );
            });
    }








    function findRowActionArea(row) {
        const buttons = [...row.querySelectorAll('button, a')];

        const downloadBtn = buttons.find(b =>
            /^download$/i.test(normalizeName(b.textContent))
        );

        if (downloadBtn?.parentElement) return downloadBtn.parentElement;

        const openBtn = buttons.find(b =>
            /open in|cura|slicer/i.test(normalizeName(b.textContent))
        );

        if (openBtn?.parentElement) return openBtn.parentElement;

        return row.querySelector('[class*="ItemList__listItemContent"]') || row;
    }

    function removeOldInjected() {
        document.querySelectorAll('.sb-tv-open-btn, .sb-tv-open-all-btn').forEach(el => el.remove());

        document.querySelectorAll('[data-sb-tv-injected]').forEach(el => {
            el.removeAttribute('data-sb-tv-injected');
        });
    }
    async function openAllResolved(files) {
        if (!files.length) return;

        const compat = compatibilityInfo(files);
        if (!compat.ok) {
            alert('[SlicerBridge] ' + compat.reason);
            return;
        }

        const ok = confirm(`[SlicerBridge] Open ${files.length} file(s) in one slicer session?`);
        if (!ok) return;

        try {
            const params = new URLSearchParams();

            for (const file of files) {
                console.log('[SlicerBridge Thingiverse] Resolving:', file.name);

                const realUrl = await resolveThingiverseUrl(file);

                console.log('[SlicerBridge Thingiverse] Resolved:', file.name, realUrl);

                params.append('file', realUrl);
                params.append('name', file.name);
            }

            const uri = `slicerbridge://multi?${params.toString()}`;
            console.log('[SlicerBridge Thingiverse] Open ALL URI:', uri);

            location.href = uri;

        } catch (e) {
            console.error('[SlicerBridge Thingiverse] Open ALL failed:', e);
            alert(
                '[SlicerBridge] Could not resolve Thingiverse downloads.\n\n' +
                e.message +
                '\n\nPer-file Open in Slicer can still use the native Thingiverse button.'
            );
        }
    }

    function injectHeader(files) {
        if (!location.pathname.includes('/files')) {
            document.querySelectorAll('.sb-tv-open-all-btn').forEach(el => el.remove());
            return;
        }

        const dlAll = findDownloadAllButton();
        if (!dlAll) return;

        const header =
            dlAll.closest('.TabContentFiles__filesTabHeader--RVBxf') ||
            dlAll.parentElement;

        if (!header) return;
        if (header.querySelector('.sb-tv-open-all-btn')) return;

        const btn = makeButton('⬡ Open ALL in Slicer', () => openAllResolved(files));

        btn.classList.add('sb-tv-open-all-btn');

        btn.setAttribute(
            'style',
            [
                'display:inline-flex!important',
                'align-items:center!important',
                'justify-content:center!important',
                'height:48px!important',
                'min-width:180px!important',
                'padding:0 16px!important',
                'margin-right:10px!important',
                'border:1px solid #2b52fe!important',
                'border-radius:10px!important',
                'background:#2b52fe!important',
                'color:#ffffff!important',
                'font-size:13px!important',
                'font-weight:700!important',
                'font-family:inherit!important',
                'cursor:pointer!important',
                'white-space:nowrap!important',
                'opacity:1!important',
                'visibility:visible!important',
            ].join(';')
        );

        const compat = compatibilityInfo(files);
        if (!compat.ok) {
            setDisabled(btn, true, compat.reason);
        } else {
            setDisabled(btn, true);
            btn.title = `Open all ${files.length} file(s) in one slicer session`;
        }

        header.insertBefore(btn, dlAll);

        log('Injected Open ALL');
    }


    function findNativeOpenButton(row) {
        return [...row.querySelectorAll('button, a')]
            .filter(isVisible)
            .find(el => {
                const txt = normalizeName(el.textContent).toLowerCase();
                return /^open in /.test(txt);
            });
    }

    function hideNativeOpenGroup(nativeBtn) {
        if (!nativeBtn) return;

        nativeBtn.style.display = 'none';

        const group = nativeBtn.parentElement;
        if (!group) return;

        [...group.querySelectorAll('button, a')].forEach(el => {
            if (el === nativeBtn) return;
            if (el.classList.contains('sb-tv-open-btn')) return;

            const txt = normalizeName(el.textContent).toLowerCase();
            const aria = normalizeName(el.getAttribute('aria-label') || '').toLowerCase();
            const title = normalizeName(el.getAttribute('title') || '').toLowerCase();

            const looksLikeDots =
                txt === '...' ||
                txt === '…' ||
                txt.includes('⋯') ||
                aria.includes('more') ||
                title.includes('more') ||
                el.querySelector('svg') ||
                el.innerHTML.includes('ellipsis');

            if (looksLikeDots) {
                el.style.display = 'none';
                el.style.visibility = 'hidden';
                el.style.pointerEvents = 'none';
            }
        });
    }





    function injectRows(files) {
        let count = 0;

        for (const file of files) {
            const row = file.row;
            if (!row) continue;

            if (row.querySelector('.sb-tv-open-btn')) continue;

            const nativeBtn = findNativeOpenButton(row);

            if (!nativeBtn) {
                console.log('[SlicerBridge Thingiverse] Native open button not found for:', file.name);
                continue;
            }

            const actionArea = nativeBtn.parentElement;
            if (!actionArea) continue;

            const btn = makeButton('⬡ Open in Slicer', () => nativeBtn.click());

            btn.classList.add('sb-tv-open-btn');
            btn.style.height = '34px';
            btn.style.minWidth = '160px';
            btn.style.marginRight = '8px';
            btn.title = `Open ${file.name} via SlicerBridge`;

            actionArea.insertBefore(btn, nativeBtn);
            hideNativeOpenGroup(nativeBtn);

            row.dataset.sbTvInjected = '1';
            count++;
        }

        if (count) log(`Injected ${count} Open in Slicer button(s)`);
    }




    let debounceTimer = null;

    function inject() {
        if (!getThingId()) return;

        if (!location.pathname.includes('/files')) {
            document.querySelectorAll('.sb-tv-open-all-btn').forEach(el => {
                if (!location.pathname.includes('/files')) el.remove();
            });
            return;
        }

        const files = scrapeFiles();

        if (!files.length) {
            document.querySelectorAll('.sb-tv-open-all-btn').forEach(el => {
                if (!location.pathname.includes('/files')) el.remove();
            });
            return;
        }

        if (!window.__sbTvLoggedFiles) {
            log(`Found ${files.length} file(s)`, files.map(f => f.name));
            window.__sbTvLoggedFiles = true;
        }

        injectHeader(files);
        injectRows(files);
    }



    function scheduleInject() {
        clearTimeout(debounceTimer);
        debounceTimer = setTimeout(inject, 500);
    }

    // Initial attempts
    setTimeout(inject, 800);
    setTimeout(inject, 1800);
    setTimeout(inject, 3500);

    // React/SPA re-render handling
    const obs = new MutationObserver(scheduleInject);
    obs.observe(document.body, { childList: true, subtree: true });


    })();

}