Printables Bambu Studio Button

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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