GitZip Lite

Download selected files and folders from GitHub repositories.

// ==UserScript==
// @name         GitZip Lite
// @icon         https://www.google.com/s2/favicons?sz=64&domain=github.com
// @namespace    https://github.com/tizee-tampermonkey-scripts/tampermonkey-gitzip-lite
// @version      1.6.3
// @description  Download selected files and folders from GitHub repositories.
// @author       tizee
// @match        https://github.com/*/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @require      https://unpkg.com/[email protected]/dist/powerglitch.min.js
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @connect      api.github.com
// @connect      raw.githubusercontent.com
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  const itemCollectSelector =
    "div.js-navigation-item, table tbody tr.react-directory-row > td[class$='cell-large-screen']";
  const tokenKey = "githubApiToken";

  const { parseRepoURL, getGitURL, getInfoURL } = {
    parseRepoURL: (repoUrl) => {
      const repoExp = new RegExp(
        "^https://github.com/([^/]+)/([^/]+)(/(tree|blob)/([^/]+)(/(.*))?)?"
      );
      const matches = repoUrl.match(repoExp);

      if (!matches || matches.length === 0) return null;

      const author = matches[1];
      const project = matches[2];
      const branch = matches[5];
      const type = matches[4];
      const path = matches[7] || "";

      const rootUrl = branch
        ? `https://github.com/${author}/${project}/tree/${branch}`
        : `https://github.com/${author}/${project}`;

      if (!type && repoUrl.length - rootUrl.length > 1) {
        return null;
      }

      return {
        author,
        project,
        branch,
        type,
        path,
        inputUrl: repoUrl,
        rootUrl,
      };
    },
    getGitURL: (author, project, type, sha) => {
      if (type === "blob" || type === "tree") {
        const pluralType = type + "s";
        return `https://api.github.com/repos/${author}/${project}/git/${pluralType}/${sha}`;
      }
      return null;
    },
    getInfoURL: (author, project, path, branch) => {
      let url = `https://api.github.com/repos/${author}/${project}/contents/${path}`;
      if (branch) {
        url += `?ref=${branch}`;
      }
      return url;
    },
  };

  // --- GitZip Functions ---

  function base64toBlob(base64Data, contentType) {
    contentType = contentType || "";
    const sliceSize = 1024;
    const byteCharacters = atob(base64Data);
    const bytesLength = byteCharacters.length;
    const slicesCount = Math.ceil(bytesLength / sliceSize);
    const byteArrays = new Array(slicesCount);

    for (let sliceIndex = 0; sliceIndex < slicesCount; ++sliceIndex) {
      const begin = sliceIndex * sliceSize;
      const end = Math.min(begin + sliceSize, bytesLength);

      const bytes = new Array(end - begin);
      for (let offset = begin, i = 0; offset < end; ++i, ++offset) {
        bytes[i] = byteCharacters[offset].charCodeAt(0);
      }
      byteArrays[sliceIndex] = new Uint8Array(bytes);
    }
    return new Blob(byteArrays, { type: contentType });
  }

  function callAjax(url, token) {
    return new Promise(function (resolve, reject) {
      GM_xmlhttpRequest({
        method: "GET",
        url: url,
        headers: {
          Authorization: token ? "token " + token : undefined,
          Accept: "application/json",
        },
        onload: function (response) {
          if (response.status >= 200 && response.status < 300) {
            try {
              const jsonResponse = JSON.parse(response.responseText);
              resolve({ response: jsonResponse });
            } catch (e) {
              console.debug("Error parsing JSON:", e);
              reject(e);
            }
          } else {
            console.debug("Request failed with status:", response.status);
            logMessage("ERROR", `Request failed with status: ${response.status}`);
            reject(response);
          }
        },
        onerror: function (error) {
          logMessage("ERROR", error);
          reject(error);
        },
      });
    });
  }

  // New dedicated function for binary downloads
  function downloadFile(url, token) {
    return new Promise(function (resolve, reject) {
      GM_xmlhttpRequest({
        method: "GET",
        url: url,
        responseType: "arraybuffer",
        headers: {
          Authorization: token ? "token " + token : undefined,
          Accept: "application/octet-stream",
        },
        onload: function (response) {
          if (response.status >= 200 && response.status < 300) {
            resolve(new Uint8Array(response.response));
          } else {
            reject(new Error(`Download failed: ${response.status}`));
          }
        },
        onerror: reject,
      });
    });
  }

  // --- End GitZip Functions ---

  function addCheckboxes() {
    const fileRows = document.querySelectorAll(itemCollectSelector);
    fileRows.forEach((row) => {
      if (row.querySelector(".gitziplite-check-wrap")) return;

      // Ensure the row is relatively positioned
      row.style.position = "relative";

      const checkboxContainer = document.createElement("div");
      checkboxContainer.classList.add("gitziplite-check-wrap");
      checkboxContainer.style.position = "absolute";
      checkboxContainer.style.left = "4px";
      checkboxContainer.style.top = "50%";
      checkboxContainer.style.transform = "translateY(-50%)";
      checkboxContainer.style.display = "flex";
      checkboxContainer.style.alignItems = "center";
      checkboxContainer.style.height = "100%";
      checkboxContainer.style.display = "none";

      const checkbox = document.createElement("input");
      checkbox.type = "checkbox";
      checkbox.classList.add("gitziplite-checkbox");

      checkboxContainer.appendChild(checkbox);

      // Find the first element to insert before.  Handles both file and directory rows.
      const insertBeforeElement = row.firstChild;
      if (insertBeforeElement) {
        row.insertBefore(checkboxContainer, insertBeforeElement);
      } else {
        row.appendChild(checkboxContainer); // Fallback if no children exist
      }

      // Add event listeners for hover
      row.addEventListener("mouseenter", () => {
        checkboxContainer.style.display = "flex";
      });

      row.addEventListener("mouseleave", () => {
        if (!checkbox.checked) {
          checkboxContainer.style.display = "none";
        }
      });

      row.addEventListener("dblclick", () => {
        console.debug("double click", row, checkbox);
        if (checkbox.checked) {
          checkboxContainer.style.display = "none";
        } else {
          checkboxContainer.style.display = "flex";
        }
        checkbox.checked = !checkbox.checked;
        checkbox.dispatchEvent(new Event("change"));
      });

      // Add event listener for checkbox change
      checkbox.addEventListener("change", () => {
        let link;
        if (row.tagName === "TD") {
          link = row.querySelector("a[href]");
        } else {
          link = row.querySelector("a[href]");
        }

        if (link) {
          const title = link.textContent.trim();
          const command = checkbox.checked ? "SELECT" : "UNSELECT";
          logMessage(command, title);
        }
      });
    });
  }

  let logWindow;
  let logToggleButton;
  let downloadButton;
  let mainContainer;
  let stickerButton;

  // Add global styles
  GM_addStyle(`
    /* Container Styles */
    .gitziplite-container {
        position: fixed;
        bottom: 1rem;
        right: 1rem;
        z-index: 1000;
        width: 480px;
        background-color: rgba(28, 28, 30, 0.95);
        border-radius: 16px;
        box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
        padding: 1.25rem;
        backdrop-filter: blur(20px);
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
        border: 1px solid rgba(255, 255, 255, 0.08);
        display: none; /* Hide window by default */
    }

    /* Sidebar sticker button */
    .gitziplite-sticker-button {
        position: fixed;
        right: 0;
        top: 30%;
        background-color: rgba(28, 28, 30, 0.95);
        color: white;
        border-radius: 8px 0 0 8px;
        padding: 10px;
        cursor: pointer;
        z-index: 999;
        box-shadow: -2px 0 8px rgba(0, 0, 0, 0.2);
        transition: all 0.2s ease;
        border: 1px solid rgba(255, 255, 255, 0.08);
        border-right: none;
    }

    .gitziplite-sticker-button:hover {
        background-color: rgba(40, 40, 45, 0.95);
        transform: translateX(-2px);
    }

    /* Hide button for the container */
    .gitziplite-hide-button {
        position: absolute;
        top: -14px;
        right: -14px;
        width: 28px;
        height: 28px;
        border-radius: 14px;
        background-color: rgba(28, 28, 30, 0.95);
        border: 1px solid rgba(255, 255, 255, 0.08);
        color: white;
        font-size: 16px;
        cursor: pointer;
        display: flex;
        align-items: center;
        justify-content: center;
        transition: background-color 0.2s ease;
        z-index: 1001;
        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
    }

    .gitziplite-hide-button:hover {
        background-color: rgba(40, 40, 45, 0.95);
    }

    /* Log Window Styles */
    .gitziplite-log {
        width: 100%;
        height: 16rem;
        margin-bottom: 0.75rem;
        overflow-y: auto;
        border-radius: 12px;
        background-color: rgba(0, 0, 0, 0.25);
        color: #E4E4E4;
        font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace;
        font-size: 12px;
        line-height: 1.5;
        padding: 0.75rem;
        scrollbar-width: thin;
        scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
        border: 1px solid rgba(255, 255, 255, 0.06);
    }

    /* Scrollbar Styles */
    .gitziplite-log::-webkit-scrollbar {
        width: 6px;
        height: 6px;
    }

    .gitziplite-log::-webkit-scrollbar-track {
        background: transparent;
    }

    .gitziplite-log::-webkit-scrollbar-thumb {
        background: rgba(255, 255, 255, 0.2);
        border-radius: 3px;
    }

    .gitziplite-log::-webkit-scrollbar-thumb:hover {
        background: rgba(255, 255, 255, 0.3);
    }

    /* Log Entry Styles */
    .gitziplite-log-entry {
        padding: 0.25rem 0;
        display: flex;
        align-items: center;
        gap: 0.5rem;
        opacity: 0;
        transform: translateY(10px);
        animation: gitziplite-fadeIn 0.2s ease-out forwards;
    }

    .gitziplite-log-timestamp {
        color: #8E8E93;
        min-width: 5.5rem;
        font-feature-settings: "tnum";
        font-variant-numeric: tabular-nums;
    }

    .gitziplite-log-command {
        min-width: 5rem;
        padding: 0.125rem 0.5rem;
        border-radius: 6px;
        font-weight: 500;
        text-align: center;
        backdrop-filter: blur(8px);
    }

    .gitziplite-log-content {
        color: #E4E4E4;
        flex: 1;
    }

    /* Button Container */
    .gitziplite-buttons {
        display: flex;
        gap: 0.75rem;
        justify-content: space-between;
        align-items: center;
    }

    /* Button Styles */
    .gitziplite-button {
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
        font-size: 13px;
        font-weight: 510;
        padding: 0.625rem 1rem;
        border-radius: 8px;
        cursor: pointer;
        transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
        border: none;
        outline: none;
        white-space: nowrap;
        user-select: none;
        position: relative;
        box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
    }

    .gitziplite-button-primary {
        background-color: #0A84FF;
        color: white;
    }

    .gitziplite-button-primary:hover {
        background-color: #007AFF;
        transform: translateY(-1px);
        box-shadow: 0 4px 12px rgba(10, 132, 255, 0.3);
    }

    .gitziplite-button-primary:active {
        transform: translateY(0);
        background-color: #0062CC;
        box-shadow: 0 1px 2px rgba(10, 132, 255, 0.2);
    }

    .gitziplite-button-secondary {
        background-color: rgba(255, 255, 255, 0.1);
        color: #FFFFFF;
        border: 1px solid rgba(255, 255, 255, 0.1);
    }

    .gitziplite-button-secondary:hover {
        background-color: rgba(255, 255, 255, 0.15);
        transform: translateY(-1px);
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
    }

    .gitziplite-button-secondary:active {
        transform: translateY(0);
        background-color: rgba(255, 255, 255, 0.05);
        box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
    }

    /* Animation */
    @keyframes gitziplite-fadeIn {
        to {
            opacity: 1;
            transform: translateY(0);
        }
    }
  `);

  function createDownloadButton() {
    // Create sticker button for the sidebar
    stickerButton = document.createElement("div");
    stickerButton.className = "gitziplite-sticker-button";
    stickerButton.innerHTML = `
        <div style="display: flex; flex-direction: column; align-items: center;">
            <svg width="16" height="16" viewBox="0 0 16 16" style="margin-bottom: 8px;">
                <path fill="currentColor" d="M8 12l-4.5-4.5 1.5-1.5L7 8.25V2h2v6.25L11 6l1.5 1.5L8 12zm-6 2v-2h12v2H2z"></path>
            </svg>
            <div style="writing-mode: vertical-lr; transform: rotate(180deg); font-size: 12px; letter-spacing: 1px; margin-top: 5px;">GitZip</div>
        </div>
    `;
    stickerButton.setAttribute("title", "Show GitZip Download Window");
    stickerButton.addEventListener("click", () => {
      mainContainer.style.display = "block";
      stickerButton.style.display = "none";
    });
    document.body.appendChild(stickerButton);

    // Main container
    mainContainer = document.createElement("div");
    mainContainer.className = "gitziplite-container";

    // Hide button
    const hideButton = document.createElement("button");
    hideButton.className = "gitziplite-hide-button";
    hideButton.innerHTML = "✕";
    hideButton.setAttribute("title", "Hide Download Window");
    hideButton.addEventListener("click", () => {
      mainContainer.style.display = "none";
      stickerButton.style.display = "block";
    });
    mainContainer.appendChild(hideButton);

    // Log Window Container
    logWindow = document.createElement("div");
    logWindow.setAttribute("aria-label", "Log Window");
    logWindow.className = "gitziplite-log";
    logWindow.style.display = "none";

    // Button Container
    const buttonContainer = document.createElement("div");
    buttonContainer.className = "gitziplite-buttons";

    // Log Toggle Button
    logToggleButton = document.createElement("button");
    logToggleButton.textContent = "Show Log";
    logToggleButton.className = "gitziplite-button gitziplite-button-secondary";
    logToggleButton.addEventListener("click", () => {
      logWindow.style.display =
        logWindow.style.display === "none" ? "block" : "none";
      logToggleButton.textContent =
        logWindow.style.display === "none" ? "Show Log" : "Hide Log";
    });

    // Download Button
    downloadButton = document.createElement("button");
    downloadButton.textContent = "Download Selected";
    downloadButton.className = "gitziplite-button gitziplite-button-primary";
    downloadButton.addEventListener("click", downloadSelected);

    // Assemble the UI
    buttonContainer.appendChild(logToggleButton);
    buttonContainer.appendChild(downloadButton);
    mainContainer.appendChild(logWindow);
    mainContainer.appendChild(buttonContainer);
    document.body.appendChild(mainContainer);

    // Hide the window by default
    mainContainer.style.display = "none";
    stickerButton.style.display = "block";
  }

  function logMessage(command, content) {
    const now = new Date();
    const timestamp = `${String(now.getHours()).padStart(2, "0")}:${String(
      now.getMinutes()
    ).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;

    const commandColors = {
      ERROR: { bg: "#FF453A20", color: "#FF453A" },
      SUCCESS: { bg: "#32D74B20", color: "#32D74B" },
      PROCESS: { bg: "#0A84FF20", color: "#0A84FF" },
      SELECT: { bg: "#FFD60A20", color: "#FFD60A" },
      UNSELECT: { bg: "#FFD60A20", color: "#FFD60A" },
      INFO: { bg: "#64D2FF20", color: "#64D2FF" },
    };

    const colorScheme =
      commandColors[command.toUpperCase()] || commandColors.INFO;

    const logEntry = document.createElement("div");
    logEntry.className = "gitziplite-log-entry";
    logEntry.innerHTML = `
        <span class="gitziplite-log-timestamp">${timestamp}</span>
        <span class="gitziplite-log-command" style="background: ${colorScheme.bg}; color: ${colorScheme.color}">
            ${command}
        </span>
        <span class="gitziplite-log-content">${content}</span>
    `;

    logWindow.appendChild(logEntry);
    logWindow.scrollTop = logWindow.scrollHeight;
  }

  /**
   * Collects selected files and folders from the DOM.
   * @returns {{files: [], folders: []}} - An object containing arrays of selected files and folders.
   */
  function collectSelectedItems() {
    const selectedFiles = [];
    const selectedFolders = [];
    const checkboxes = document.querySelectorAll(
      ".gitziplite-checkbox:checked"
    );

    checkboxes.forEach((checkbox) => {
      const row = checkbox.parentNode.parentNode; // Direct parent access
      if (!row) {
        console.warn("Could not find a parent row for a selected checkbox.");
        return; // Skip to the next checkbox
      }
      console.debug(row);
      let link;

      if (row.tagName === "TD") {
        link = row.querySelector("a[href]");
      } else {
        link = row.querySelector("a[href]");
      }

      if (link) {
        const href = link.href;
        const title = link.textContent.trim();
        const resolved = parseRepoURL(href);
        if (resolved && resolved.type === "blob") {
          selectedFiles.push({ href: href, title: title });
        } else if (resolved && resolved.type === "tree") {
          selectedFolders.push({ href: href, title: title });
        }
      }
    });

    return { files: selectedFiles, folders: selectedFolders };
  }

  /**
   * Zips the given contents and triggers a download.
   * @param {Array<{path: string, content: string}>} allContents - Array of file contents to zip.
   * @param {object} resolvedUrl - Parsed URL information of the repository.
   */
  function zipAndDownload(allContents, resolvedUrl) {
    if (allContents.length === 1) {
      // Handle single file download
      const singleItem = allContents[0];
      console.debug(singleItem);
      if (singleItem.isBinary) {
        // Create Blob directly from Uint8Array
        const blob = new Blob([singleItem.content], {
          type: "application/octet-stream",
        });
        saveAs(blob, singleItem.path);
      } else {
        // Handle base64 encoded text files
        const blob = base64toBlob(singleItem.content, "");
        saveAs(blob, singleItem.path);
      }
    } else {
      // Handle zip archive creation
      try {
        const currDate = new Date();
        const dateWithOffset = new Date(
          currDate.getTime() - currDate.getTimezoneOffset() * 60000
        );
        window.JSZip.defaults.date = dateWithOffset;

        const zip = new window.JSZip();
        allContents.forEach((item) => {
          if (item.isBinary) {
            // Add binary file as Uint8Array
            zip.file(item.path, item.content, {
              createFolders: true,
              binary: true,
              date: dateWithOffset,
            });
          } else {
            // Add base64 encoded file
            zip.file(item.path, item.content, {
              createFolders: true,
              base64: true,
              date: dateWithOffset,
            });
          }
        });

        zip.generateAsync({ type: "blob" }).then((content) => {
          saveAs(
            content,
            [resolvedUrl.project]
              .concat(resolvedUrl.path.split("/"))
              .join("-") + ".zip"
          );
        });
      } catch (error) {
        console.debug("Error zipping files:", error);
        logMessage("ERROR", "zipping files.");
      }
    }
  }

  async function downloadSelected() {
    const { files: selectedFiles, folders: selectedFolders } =
      collectSelectedItems();

    if (selectedFiles.length === 0 && selectedFolders.length === 0) {
      logMessage("ERROR", "No files or folders selected.");
      return;
    }

    const resolvedUrl = parseRepoURL(window.location.href);
    if (!resolvedUrl) {
      logMessage("ERROR", "Could not resolve repository URL.");
      return;
    }

    const githubToken = GM_getValue(tokenKey);

    const allContents = [];

    async function processFolder(folder, pathPrefix = "") {
      logMessage("PROCESS", `${folder.title}`);
      const folderResolvedUrl = parseRepoURL(folder.href);
      const apiUrl = getInfoURL(
        folderResolvedUrl.author,
        folderResolvedUrl.project,
        folderResolvedUrl.path,
        folderResolvedUrl.branch
      );

      try {
        const xmlResponse = await callAjax(apiUrl, githubToken);
        const folderContents = xmlResponse.response;

        for (const item of folderContents) {
          const itemPath = pathPrefix + "/" + item.name;
          if (item.type === "file") {
            logMessage("PROCESS", `${itemPath}`);
            const fileInfoUrl = getInfoURL(
              folderResolvedUrl.author,
              folderResolvedUrl.project,
              folderResolvedUrl.path + "/" + item.name,
              folderResolvedUrl.branch
            );
            const fileXmlResponse = await callAjax(fileInfoUrl, githubToken);
            const fileContent = fileXmlResponse.response;
            allContents.push({
              path: itemPath,
              content: fileContent.content,
            });
          } else if (item.type === "dir") {
            await processFolder(
              { href: folder.href + "/" + item.name, title: item.name },
              itemPath
            );
          }
        }
      } catch (error) {
        console.debug("Error fetching folder:", folder.title, error);
        logMessage("ERROR", `Error fetching folder: ${folder.title}`);
      }
    }

    for (const folder of selectedFolders) {
      await processFolder(folder, folder.title);
    }

    for (const file of selectedFiles) {
      logMessage("PROCESS", `${file.title}`);
      const fileResolvedUrl = parseRepoURL(file.href);
      const infoUrl = getInfoURL(
        fileResolvedUrl.author,
        fileResolvedUrl.project,
        fileResolvedUrl.path,
        fileResolvedUrl.branch
      );
      logMessage("PROCESS", `${infoUrl}`);
      console.debug(`file info url: ${infoUrl}`);
      try {
        const xmlResponse = await callAjax(infoUrl, githubToken);
        const fileContent = xmlResponse.response;

        if (fileContent.encoding === "base64" && fileContent.content) {
          allContents.push({
            path: file.title,
            content: fileContent.content,
            isBinary: false,
          });
        } else if (fileContent.download_url) {
          // Handle binary file with dedicated download function
          const binaryData = await downloadFile(
            fileContent.download_url,
            githubToken
          );
          allContents.push({
            path: file.title,
            content: binaryData,
            isBinary: true,
          });
        }
      } catch (error) {
        console.debug("Error fetching file:", file.title, error);
        logMessage("ERROR", `fetching file: ${file.title}`);
        return;
      }
    }

    zipAndDownload(allContents, resolvedUrl);
    logMessage("SUCCESS", "Download complete.");
  }

  // Register menu command for setting token
  GM_registerMenuCommand("Set GitHub API Token", () => {
    const token = prompt("Enter your GitHub API token:");
    if (token) {
      GM_setValue(tokenKey, token);
      alert("Token saved successfully!");
    }
  });

  function onDomLoaded() {
    addCheckboxes();
    createDownloadButton();
  }

  function onUrlChange() {
    addCheckboxes();
  }

  // Initialize
  onDomLoaded();
  // Glitch Animation
  PowerGlitch.glitch(logToggleButton, {
    playMode: "click",
    timing: {
      duration: 400,
      easing: "ease-in-out",
    },
    shake: {
      velocity: 20,
      amplitudeX: 0,
      amplitudeY: 0.1,
    },
  });
  PowerGlitch.glitch(downloadButton, {
    playMode: "click",
    timing: {
      duration: 400,
      easing: "ease-in-out",
    },
  });

  // Observe GitHub repository page URL changes (e.g., navigating into a new directory)
  const observer = new MutationObserver(onUrlChange);
  observer.observe(document.body, { childList: true, subtree: true });
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址