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

})();