Greasy Fork is available in English.

SlicerBridge

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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

})();