Adds Open in Slicer buttons to Printables and Thingiverse
// ==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 });
})();
}