Adds "Open in Slicer" button next to each folder on Printables model pages
// ==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));
}
})();