SlicerBridge

Adds Open in Slicer buttons to Printables and Thingiverse

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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 });


    })();

}