SlicerBridge

Adds "Open in Slicer" button next to each folder on Printables model pages

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      5
// @description  Adds "Open in Slicer" button next to each folder on Printables model pages
// @author       LukysGaming
// @match        https://www.printables.com/model/*
// @grant        none
// @run-at       document-idle
// @license      MPL 2.0
// ==/UserScript==

(function () {
    'use strict';

    const PROTOCOL       = 'slicerbridge';
    const PRINTABLES_API = 'https://api.printables.com/graphql/';
    const BTN_LABEL      = '⬡ Open in Slicer';
    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\/(\d+)/);
        return m ? m[1] : null;
    }

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

    // ── 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 urls = [], names = [];
        for (let i = 0; i < files.length; i++) {
            if (resolved[i]) { urls.push(resolved[i]); names.push(files[i].name); }
            else console.warn(`[SlicerBridge] Skipping ${files[i].name} — no URL`);
        }
        if (!urls.length) return null;
        return `${PROTOCOL}://multi?files=${encodeURIComponent(urls.join('|'))}&names=${encodeURIComponent(names.join('|'))}`;
    }

    // ── 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();
            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 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.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 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');
            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));
    }

})();