Printables Bambu Studio Button

Adds a "Bambu Studio" button and replaces PrusaSlicer buttons on Printables.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Printables Bambu Studio Button
// @namespace    http://wol.ph/
// @version      2025-10-03
// @description  Adds a "Bambu Studio" button and replaces PrusaSlicer buttons on Printables.
// @author       wolph
// @match        https://www.printables.com/model/*
// @run-at       document-idle
// @icon         https://icons.duckduckgo.com/ip2/printables.com.ico
// @license      MIT
// ==/UserScript==

(() => {
  'use strict';

  // ------------------------------
  // Config / constants
  // ------------------------------
  const BAMBULAB_ICON = 'https://icons.duckduckgo.com/ip2/bambulab.com.ico';
  const SLICE_SELECTOR = '.btn.slicer-download, button.slicer-download, .slicer-download';

  // Track which buttons we've already patched
  const seen = new WeakSet();

  // ------------------------------
  // Utilities
  // ------------------------------
  /** @param {Element} el */
  const $closestCard = (el) => el.closest('.download-item');

  /** Replace the icon(s) inside a Slice button with the Bambu favicon (don’t touch the text). */
  function swapIcon(btn) {
    const imgs = btn.querySelectorAll('img');
    if (imgs.length) {
      imgs.forEach((img) => {
        img.src = BAMBULAB_ICON;
        img.alt = 'Bambu Studio';
        img.width = 14;
        img.height = 14;
        img.classList.add('bambu-icon');
        img.style.borderRadius = '2px';
      });
    } else {
      // If there is no <img>, prepend one without changing text
      const img = document.createElement('img');
      img.src = BAMBULAB_ICON;
      img.alt = 'Bambu Studio';
      img.width = 14;
      img.height = 14;
      img.style.marginRight = '0.35rem';
      img.style.verticalAlign = 'text-bottom';
      img.className = 'bambu-icon';
      btn.prepend(img);
    }
    // Tooltip only; do NOT modify visible text (prevents "Slice Slice")
    btn.title = 'Open in Bambu Studio';
  }

  /** Remove duplicate "Slice" text nodes (some themes render two). Keep the first, blank out the rest. */
  function dedupeSliceLabel(btn) {
    const textNodes = Array.from(btn.childNodes)
      .filter((n) => n.nodeType === Node.TEXT_NODE)
      .filter((n) => /\bSlice\b/i.test(n.textContent || ''));

    for (let i = 1; i < textNodes.length; i++) {
      textNodes[i].textContent = ''; // blank out extras
    }
  }

  /** Patch one Slice button: swap icon + de-dupe label (idempotent). */
  function patchSliceButton(btn) {
    if (seen.has(btn)) return;
    seen.add(btn);
    swapIcon(btn);
    dedupeSliceLabel(btn);
  }

  /** Scan current DOM for Slice buttons. */
  function scan() {
    document.querySelectorAll(SLICE_SELECTOR).forEach((btn) => {
      if (!(btn instanceof HTMLElement)) return;
      // Only touch buttons that live inside a download-item card to avoid false positives
      if (!$closestCard(btn)) return;
      patchSliceButton(btn);
    });
  }

  // ------------------------------
  // Protocol / navigation rewriting
  // ------------------------------

  /**
   * Rewrite any Prusa custom-protocol navigation to Bambu Studio.
   * - prusaslicer://open?file=...  -> bambustudio://open?file=...
   * - prusa://open?url=...         -> bambustudio://open?file=...
   * Falls back to returning the original URL if it’s not a Prusa link.
   * @param {string|URL} raw
   * @returns {string|URL}
   */
  function rewritePrusaToBambu(raw) {
    try {
      if (raw == null) return raw;
      let s = String(raw);

      // Only handle prusa*/ custom schemes
      const m = s.match(/^prusa(?:slicer)?:\/\/([^#?\/]+)(.*)$/i);
      if (!m) return raw;

      // Normalize path to 'open'
      const path = (m[1] || 'open').toLowerCase() === 'slice' ? 'open' : 'open';

      // Extract query (if present)
      let qs = '';
      const qIndex = s.indexOf('?');
      if (qIndex >= 0) qs = s.slice(qIndex + 1);

      // Map common param names to "file"
      const params = new URLSearchParams(qs);
      const knownKeys = ['file', 'url', 'path', 'u'];
      let fileUrl = '';
      for (const k of knownKeys) {
        if (params.has(k)) { fileUrl = params.get(k) || ''; break; }
      }
      // If still empty, attempt raw decode of the entire query
      if (!fileUrl && qs) fileUrl = qs;

      // Build bambustudio deep link
      const out = `bambustudio://open?file=${encodeURIComponent(fileUrl)}`;
      return out;
    } catch {
      return raw;
    }
  }

  // Intercept <a> clicks to prusa*:// and rewrite to bambu
  document.addEventListener('click', (ev) => {
    const target = /** @type {HTMLElement|null} */ (ev.target instanceof Element ? ev.target : null);
    const anchor = target?.closest?.('a[href^="prusaslicer://"], a[href^="prusa://"]');
    if (anchor) {
      const href = anchor.getAttribute('href');
      if (href) {
        ev.preventDefault();
        ev.stopPropagation();
        const rewritten = rewritePrusaToBambu(href);
        // Open via location to trigger protocol handler
        window.location.href = String(rewritten);
      }
    }
  }, true);

  // Intercept programmatic window.open("prusaslicer://...")
  (function patchWindowOpen() {
    const original = window.open;
    window.open = function patchedOpen(url, name, specs, replace) {
      const rewritten = rewritePrusaToBambu(url);
      return original.call(this, rewritten, name, specs, replace);
    };
  })();

  // Intercept programmatic anchor.click() commonly used by sites to trigger downloads
  (function patchAnchorClick() {
    const OriginalClick = HTMLAnchorElement.prototype.click;
    HTMLAnchorElement.prototype.click = function patchedAnchorClick() {
      try {
        const href = this.getAttribute('href') || this.href || '';
        // If site tries to open Prusa directly, rewrite to Bambu
        if (/^prusa(?:slicer)?:\/\//i.test(href)) {
          const rewritten = rewritePrusaToBambu(href);
          // Prefer navigating instead of default click to avoid duplicate handlers
          window.location.href = String(rewritten);
          return;
        }
        // If a direct model file (.3mf) is being "clicked" programmatically, use Bambu Studio
        if (/\.(?:3mf)(?:$|\?)/i.test(href) || /\/download\b/i.test(href)) {
          const deep = `bambustudio://open?file=${encodeURIComponent(href)}`;
          window.location.href = deep;
          return;
        }
      } catch {
        // ignore and fall through
      }
      return OriginalClick.call(this);
    };
  })();

  // Some frameworks use location.assign/replace — intercept those as well
  (function patchLocationMethods() {
    const proto = /** @type {Location} */ (window.location).__proto__ || Location.prototype;
    const origAssign = proto.assign;
    const origReplace = proto.replace;

    proto.assign = function patchedAssign(url) {
      const rewritten = rewritePrusaToBambu(url);
      return origAssign.call(this, rewritten);
    };
    proto.replace = function patchedReplace(url) {
      const rewritten = rewritePrusaToBambu(url);
      return origReplace.call(this, rewritten);
    };
  })();

  // ------------------------------
  // Observe dynamic content
  // ------------------------------
  const mo = new MutationObserver(() => scan());
  mo.observe(document.documentElement, { childList: true, subtree: true });
  scan();
})();