Font Awesome Pro Downloader

Adds download and copy buttons for Font Awesome Pro icons.

// ==UserScript==
// @name         Font Awesome Pro Downloader
// @description  Adds download and copy buttons for Font Awesome Pro icons.
// @icon         https://fontawesome.com/images/favicon/icon.svg
// @version      1.6
// @author       afkarxyz
// @namespace    https://github.com/afkarxyz/userscripts/
// @supportURL   https://github.com/afkarxyz/userscripts/issues
// @license      MIT
// @match        https://fontawesome.com/*
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function () {
  "use strict";

  const CACHE_KEY_PREFIX = "FA_SVG_CACHE_";
  const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000;

  const DOWNLOAD_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" width="12" height="12"><path d="M378.1 198.6L249.5 341.4c-6.1 6.7-14.7 10.6-23.8 10.6l-3.5 0c-9.1 0-17.7-3.8-23.8-10.6L69.9 198.6c-3.8-4.2-5.9-9.8-5.9-15.5C64 170.4 74.4 160 87.1 160l72.9 0 0-128c0-17.7 14.3-32 32-32l64 0c17.7 0 32 14.3 32 32l0 128 72.9 0c12.8 0 23.1 10.4 23.1 23.1c0 5.7-2.1 11.2-5.9 15.5zM64 352l0 64c0 17.7 14.3 32 32 32l256 0c17.7 0 32-14.3 32-32l0-64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 64c0 53-43 96-96 96L96 512c-53 0-96-43-96-96l0-64c0-17.7 14.3-32 32-32s32 14.3 32 32z"/></svg>`;
  const COPY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="12" height="12"><path d="M192 0c-41.8 0-77.4 26.7-90.5 64L64 64C28.7 64 0 92.7 0 128L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-320c0-35.3-28.7-64-64-64l-37.5 0C269.4 26.7 233.8 0 192 0zm0 64a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM72 272a24 24 0 1 1 48 0 24 24 0 1 1 -48 0zm104-16l128 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-128 0c-8.8 0-16-7.2-16-16s7.2-16 16-16zM72 368a24 24 0 1 1 48 0 24 24 0 1 1 -48 0zm88 0c0-8.8 7.2-16 16-16l128 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-128 0c-8.8 0-16-7.2-16-16z"/></svg>`;
  const SUCCESS_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="12" height="12"><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>`;

  const processedIcons = new WeakSet();

  function showSuccessAnimation(button) {
    const originalContent = button.innerHTML;
    const parser = new DOMParser();
    const successSvg = parser.parseFromString(SUCCESS_ICON, "image/svg+xml");

    button.innerHTML = "";
    button.appendChild(successSvg.documentElement);

    setTimeout(() => {
      button.innerHTML = originalContent;
    }, 250);
  }

  function clearExpiredCache() {
    const currentTime = Date.now();
    for (let i = 0; i < localStorage.length; i++) {
      const key = localStorage.key(i);
      if (key.startsWith(CACHE_KEY_PREFIX)) {
        try {
          const cachedItem = JSON.parse(localStorage.getItem(key));
          if (currentTime - cachedItem.timestamp > CACHE_DURATION) {
            localStorage.removeItem(key);
          }
        } catch (error) {
          console.error("Error clearing cache:", error);
        }
      }
    }
  }

  function getCachedSVG(url) {
    clearExpiredCache();
    const cacheKey = CACHE_KEY_PREFIX + btoa(url);
    const cachedItem = localStorage.getItem(cacheKey);

    if (cachedItem) {
      try {
        const parsedItem = JSON.parse(cachedItem);
        return parsedItem.content;
      } catch (error) {
        console.error("Error parsing cached SVG:", error);
        return null;
      }
    }
    return null;
  }

  function cacheSVG(url, svgContent) {
    const cacheKey = CACHE_KEY_PREFIX + btoa(url);
    const cacheItem = {
      content: svgContent,
      timestamp: Date.now(),
    };

    try {
      localStorage.setItem(cacheKey, JSON.stringify(cacheItem));
    } catch (error) {
      console.error("Error caching SVG:", error);
    }
  }

  async function fetchAndCacheSVG(url, iconStyle, iconName) {
    const cachedSVG = getCachedSVG(url);
    if (cachedSVG) return cachedSVG;

    try {
      const response = await fetch(url);
      if (!response.ok) {
        if (response.status === 403 || response.status === 404) {
          let fallbackUrl = null;

          if (iconStyle === "duotone-solid") {
            fallbackUrl = url.replace("duotone-solid", "duotone");
          } else if (iconStyle === "sharp-thin") {
            fallbackUrl = url.replace("sharp-thin", "sharp-light");
          } else if (
            !["solid", "regular", "light", "thin", "brands"].includes(iconStyle)
          ) {
            fallbackUrl = url.replace(`/${iconStyle}/`, "/solid/");
          }

          if (fallbackUrl) {
            console.log(`Retrying with fallback style: ${fallbackUrl}`);
            const retryResponse = await fetch(fallbackUrl);
            if (!retryResponse.ok)
              throw new Error("Network response was not ok.");
            let svgContent = await retryResponse.text();
            svgContent = svgContent.replace(/<!--[\s\S]*?-->/g, "");
            cacheSVG(url, svgContent);
            return svgContent;
          }
        }
        throw new Error("Network response was not ok.");
      }
      let svgContent = await response.text();
      svgContent = svgContent.replace(/<!--[\s\S]*?-->/g, "");
      cacheSVG(url, svgContent);
      return svgContent;
    } catch (error) {
      console.error("Error fetching SVG:", error);
      throw error;
    }
  }

  async function copySVG(url, button, iconStyle, iconName) {
    try {
      const svgContent = await fetchAndCacheSVG(url, iconStyle, iconName);
      await navigator.clipboard.writeText(svgContent);
      showSuccessAnimation(button);
    } catch (error) {
      const errorMessage = `Failed to copy SVG: ${error.message}`;
      console.error(errorMessage);
      alert(errorMessage);
    }
  }

  async function downloadSVG(url, filename, button, iconStyle, iconName) {
    try {
      const svgContent = await fetchAndCacheSVG(url, iconStyle, iconName);
      const blob = new Blob([svgContent], { type: "image/svg+xml" });
      const a = document.createElement("a");
      a.href = URL.createObjectURL(blob);
      a.download = filename;
      a.style.display = "none";
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(a.href);
      showSuccessAnimation(button);
    } catch (error) {
      console.error("Failed to download SVG:", error);
      alert("Failed to download SVG. Check the console for details.");
    }
  }

  function getSelectedVersion() {
    const selectElement = document.getElementById(
      "choose_aversionoffontawesome"
    );
    if (selectElement?.value) return selectElement.value.trim();

    const styleLink = document.querySelector(
      'link[rel="stylesheet"][href*="fontawesome.com/releases"]'
    );
    const versionMatch = styleLink
      ?.getAttribute("href")
      ?.match(/releases\/v([\d.]+)/);

    return versionMatch?.[1] || "7.0.0";
  }

  function parseVersion(versionString) {
    const parts = versionString.split(".").map(Number);
    return {
      major: parts[0] || 0,
      minor: parts[1] || 0,
      patch: parts[2] || 0,
      full: versionString,
      isV7OrHigher: parts[0] >= 7,
      isV6OrLower: parts[0] <= 6,
    };
  }

  function getUrlPath(version) {
    const versionInfo = parseVersion(version);
    return versionInfo.isV7OrHigher ? "svgs-full" : "svgs";
  }

  function createButton(icon, title, className) {
    const button = document.createElement("span");
    button.className = `fa-icon-button ${className}`;
    button.title = title;
    const parser = new DOMParser();
    const svgDoc = parser.parseFromString(icon, "image/svg+xml");
    button.appendChild(svgDoc.documentElement);
    return button;
  }

  function getIconStyle(iconElement, version = null) {
    const classes = new Set(iconElement.classList);
    const versionInfo = version
      ? parseVersion(version)
      : parseVersion(getSelectedVersion());

    if (classes.has("fa-brands")) return "brands";

    if (versionInfo.isV7OrHigher) {
      const newStyleMap = {
        "fa-chisel": "chisel-regular",
        "fa-etch": "etch-solid",
        "fa-jelly": "jelly-regular",
        "fa-jelly-duo": "jelly-duo-regular",
        "fa-jelly-fill": "jelly-fill-regular",
        "fa-notdog": "notdog-solid",
        "fa-notdog-duo": "notdog-duo-solid",
        "fa-slab": "slab-regular",
        "fa-slab-press": "slab-press-regular",
        "fa-thumbprint": "thumbprint-light",
        "fa-whiteboard": "whiteboard-semibold",
      };

      for (const [className, styleName] of Object.entries(newStyleMap)) {
        if (classes.has(className)) {
          return styleName;
        }
      }
    }

    const baseStyleMap = {
      "fa-solid": "solid",
      "fa-light": "light",
      "fa-thin": "thin",
      "fa-regular": "regular",
    };

    let style = "";

    if (classes.has("fa-duotone")) {
      style = "duotone";
      for (const [className, styleName] of Object.entries(baseStyleMap)) {
        if (classes.has(className)) {
          return styleName === "solid" ? "duotone" : `duotone-${styleName}`;
        }
      }
      return "duotone";
    }

    if (classes.has("fa-sharp")) {
      if (classes.has("fa-duotone")) {
        style = "sharp-duotone";
        for (const [className, styleName] of Object.entries(baseStyleMap)) {
          if (classes.has(className)) {
            return `sharp-duotone-${styleName}`;
          }
        }
        return "sharp-duotone-solid";
      } else {
        style = "sharp";
        for (const [className, styleName] of Object.entries(baseStyleMap)) {
          if (classes.has(className)) {
            return `sharp-${styleName}`;
          }
        }
        return "sharp-solid";
      }
    }

    for (const [className, styleName] of Object.entries(baseStyleMap)) {
      if (classes.has(className)) {
        return styleName;
      }
    }

    return "solid";
  }

  function processIcon(icon) {
    if (processedIcons.has(icon)) return;

    const iconElement = icon.querySelector("i");
    const iconNameElement = icon.querySelector(".icon-name");

    if (!iconElement || !iconNameElement?.textContent) return;

    const iconName = iconNameElement.textContent.trim();

    if (!iconElement || !iconName) {
      return;
    }

    const version = getSelectedVersion();
    const iconStyle = getIconStyle(iconElement, version);
    const urlPath = getUrlPath(version);
    const url = `https://site-assets.fontawesome.com/releases/v${version}/${urlPath}/${iconStyle}/${iconName}.svg`;
    const filename =
      iconStyle === "brands"
        ? `${iconName}.svg`
        : `${iconName}_${iconStyle}.svg`;

    const container = document.createElement("div");
    container.className = "fa-buttons-container";

    const downloadButton = createButton(
      DOWNLOAD_ICON,
      "Download SVG",
      "fa-download-button"
    );
    const copyButton = createButton(COPY_ICON, "Copy SVG", "fa-copy-button");

    downloadButton.addEventListener("click", () =>
      downloadSVG(url, filename, downloadButton, iconStyle, iconName)
    );
    copyButton.addEventListener("click", () =>
      copySVG(url, copyButton, iconStyle, iconName)
    );

    container.appendChild(copyButton);
    container.appendChild(downloadButton);

    let tagContainer = icon.querySelector(".tag");
    if (!tagContainer) {
      tagContainer = document.createElement("div");
      tagContainer.className = "tag";
    }

    tagContainer.innerHTML = "";

    tagContainer.appendChild(container);

    const buttonElement = icon.querySelector("button");
    if (buttonElement && !buttonElement.parentNode.querySelector(".tag")) {
      buttonElement.parentNode.insertBefore(
        tagContainer,
        buttonElement.nextSibling
      );
    }

    processedIcons.add(icon);
  }

  function processAllIcons(icons) {
    Array.from(icons).forEach((icon) => {
      if (!processedIcons.has(icon)) {
        processIcon(icon);
      }
    });
  }

  function setupMutationObserver() {
    const observer = new MutationObserver(() => {
      const icons = document.querySelectorAll("article.wrap-icon");
      processAllIcons(icons);
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });
  }

  const style = document.createElement("style");
  style.textContent = `
        .fa-buttons-container {
            display: inline-flex;
            gap: 3px;
            align-items: center;
            justify-content: center;
            min-width: 50px;
            position: absolute;
            top: 50%;
            transform: translateY(-50%);
        }
        .tag {
            display: flex;
            justify-content: center;
            position: relative;
            min-height: 28px;
            padding: 0 4px !important;
            margin: 0 !important;
            background: transparent !important;
        }
        .fa-icon-button {
            display: flex;
            align-items: center;
            justify-content: center;
            width: 20px;
            height: 20px;
            border-radius: 4px;
            background-color: var(--fa-yellow);
            color: var(--fa-navy);
            cursor: pointer;
            padding: 3px;
        }
        .fa-icon-button svg {
            width: 100%;
            height: 100%;
        }
        .fa-icon-button svg path {
            fill: var(--fa-navy);
        }
    `;

  document.head.appendChild(style);

  const initialIcons = document.querySelectorAll("article.wrap-icon");
  processAllIcons(initialIcons);
  setupMutationObserver();
})();