GitHub Personal Events

This plugin is used to display personal events on the GitHub homepage

当前为 2024-07-19 提交的版本,查看 最新版本

// ==UserScript==
// @name         GitHub Personal Events
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  This plugin is used to display personal events on the GitHub homepage
// @author       ahaostudy
// @license      MIT
// @match        https://github.com/
// @icon         https://www.google.com/s2/favicons?sz=64&domain=github.com
// @grant        GM_addStyle
// @require      https://update.gf.qytechs.cn/scripts/34138/223779/markedjs.js
// @require      https://update.gf.qytechs.cn/scripts/454265/1113258/Axios.js
// ==/UserScript==

(function () {
  "use strict";

  window.onload = () => {
    init();
  };
})();

async function init() {
  const username = document
    .querySelector(
      "#switch_dashboard_context_left_column-button > span.Button-content > span > span:nth-child(2)"
    )
    .innerHTML.trim();

  GM_addStyle(`
    .color-fg-todo {
      fill: white;
      background-color: var(--fgColor-open);
    }
    
    .color-fg-push {
      fill: white;
      background-color: var(--fgColor-accent);
    }
  `);

  loadPullRequests(username);
}

let conduitFeedFrameCount = 0;

/**
 * Listen PR list loading, execute handler when page loads new PR list (only execute once)
 * @param {Function} handler
 */
function listenConduitTurboFrame(handler) {
  const interval = setInterval(() => {
    const len = document.querySelectorAll("turbo-frame").length;
    if (conduitFeedFrameCount !== len) {
      clearInterval(interval);
      conduitFeedFrameCount = len;
      handler();
    }
  }, 50);
}

/**
 * Load the user's PR list to the homepage
 * @param {String} username
 */
async function loadPullRequests(username) {
  const pullRequests = await fetchPullRequests(username);
  listenConduitTurboFrame(() => {
    const moreButton = document.querySelector(
      "#conduit-feed-frame > form > button"
    );
    if (moreButton) {
      moreButton.addEventListener("click", () => {
        loadPullRequests(username);
      });
      renderPullRequests(pullRequests, false);
    } else renderPullRequests(pullRequests, true);
  });
}

let lastPRDateTime = new Date().toISOString();

/**
 * Fetch the PRs of the specified user and construct HTML elements, return the list of constructed HTML elements
 * @param {String} username
 * @returns PullRequest Elements
 */
async function fetchPullRequests(username) {
  try {
    const apiURL = `https://api.github.com/search/issues?q=is:pr+author:${username}+created:<${lastPRDateTime}&sort=created&order=desc`;
    console.log("Fetch", apiURL);
    const response = await axios.get(apiURL, {
      headers: {
        Accept: "application/vnd.github.v3+json",
        // TODO: how to get user token dynamically
        // Authorization: "Bearer xxx",
      },
    });
    if (response.status === 200) {
      const prs = response.data.items;
      console.log(prs);
      let pullRequestElements = new Array();
      for (let pr of prs) {
        pullRequestElements.push(createPullReqnestElement(pr));
      }
      return pullRequestElements;
    } else {
      throw new Error(`Failed to fetch PullRequests: ${response.statusText}`);
    }
  } catch (error) {
    throw new Error(`Failed to fetch PullRequests: ${error.message}`);
  }
}

/**
 * Render multiple PR nodes to the List on the homepage in chronological order
 * @param {Array<Element>} pullRequestElements
 * @param {Boolean} showMore
 */
function renderPullRequests(pullRequestElements, showMore) {
  const conduitFeedFrame = document.querySelector("#conduit-feed-frame");
  const articles = conduitFeedFrame.querySelectorAll("article");
  let index = 0;
  for (let children of articles) {
    const dateTime = getDateTimeFromElement(children);
    if (dateTime !== null) {
      for (let i = index; i < pullRequestElements.length; i++) {
        const element = pullRequestElements[i];
        const prDateTime = getDateTimeFromElement(element);
        if (prDateTime.getTime() > dateTime.getTime()) {
          children.parentNode.insertBefore(element, children);
          lastPRDateTime = prDateTime.toISOString();
          index = i + 1;
        } else break;
      }
    }
  }
  if (showMore) {
    let earlierDateTime = new Date();
    earlierDateTime.setMonth(earlierDateTime.getMonth() - 3);
    const parentNode = articles[articles.length - 1].parentNode;
    for (let i = index; i < pullRequestElements.length; i++) {
      const element = pullRequestElements[i];
      const prDateTime = getDateTimeFromElement(element);
      if (prDateTime.getTime() > earlierDateTime.getTime()) {
        parentNode.appendChild(element);
        lastPRDateTime = prDateTime.toISOString();
        index = i + 1;
      } else break;
    }
  }
}

/**
 * Build HTML node based on PR information
 * @param {Object} pr
 * @returns Element
 */
function createPullReqnestElement(pr) {
  const merged = pr?.state == "closed";
  const stateSvg = merged
    ? `<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-feed-merged circle feed-item-heading-icon feed-next color-fg-done position-absolute"> <path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm.25-11.25A1.75 1.75 0 1 0 6 6.428v3.144a1.75 1.75 0 1 0 1 0V8.236A2.99 2.99 0 0 0 9 9h.571a1.75 1.75 0 1 0 0-1H9a2 2 0 0 1-1.957-1.586A1.75 1.75 0 0 0 8.25 4.75Z"></path> </svg>`
    : `<svg height="16" class="octicon octicon-feed-open circle feed-item-heading-icon feed-next color-fg-todo position-absolute" viewBox="-3 -3 22 22" version="1.1" width="16" aria-hidden="true"><path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z"></path></svg>`;
  let body = pr?.body || "";
  if (body.length >= 1410) {
    body = body.substring(0, 1410) + "...";
  }

  const avatar = new Image(pr?.user?.avatar_url, pr?.user?.login);
  const userLink = new Link(pr?.user?.login, pr?.user?.html_url);
  const joinText = "contributed to";
  const repoLink = new Link(
    pr?.repository_url.replace(`https://api.github.com/repos/`, ""),
    pr?.repository_url
  );
  const cardHeader = new CardHeader(
    avatar,
    stateSvg,
    pr?.created_at,
    userLink,
    joinText,
    repoLink
  );

  const title = new Link(pr?.title, pr?.html_url, pr?.number);
  const stateGap = new CardPullRequestGap(merged);
  const cardBody = new CardBody(title, body, stateGap);

  const card = new Card(cardHeader, cardBody);
  return createElementFromHTML(card.html());
}

/**
 * Build HTML node from HTML string
 * @param {String} html
 * @returns HTML element
 */
function createElementFromHTML(html) {
  const container = document.createElement("div");
  container.innerHTML = html;
  return container.firstElementChild;
}

/**
 * Extract time information from PR node
 * @param {Element} element
 * @returns Date
 */
function getDateTimeFromElement(element) {
  const relativeTimeElement = element.querySelector("relative-time");
  if (!relativeTimeElement) return null;
  return new Date(relativeTimeElement.getAttribute("datetime"));
}

class Link {
  /**
   * @param {String} text
   * @param {String} href
   * @param {String} patch
   */
  constructor(text, href, patch) {
    this.text = text;
    this.href = href;
    this.patch = patch || "";
  }
}

class Image {
  /**
   * @param {String} src
   * @param {String} alt
   */
  constructor(src, alt) {
    this.src = src;
    this.alt = alt;
  }
}

class CardHeader {
  /**
   * @param {Image} avatar
   * @param {String} icon
   * @param {String} time
   * @param {Link} link1
   * @param {String} joinText
   * @param {Link} link2
   */
  constructor(avatar, icon, time, link1, joinText, link2) {
    this.avatar = avatar;
    this.icon = icon;
    this.time = time;
    this.link1 = link1;
    this.joinText = joinText;
    if (link2) {
      this.link2 = link2;
    } else {
      this.link2 = new Link("", "");
    }
  }

  html() {
    const template = `
      <div class="px-3">
        <header class="mt-1 mb-2 width-full d-flex flex-justify-between">
          <!-- avatar -->
          <div class="mr-2">
            <div class="position-relative">
              <a class="Link d-block">
                <img
                  src="${this.avatar.src}"
                  alt="${this.avatar.alt} profile"
                  size="40"
                  height="40"
                  width="40"
                  class="feed-item-user-avatar avatar circle box-shadow-none"
                />
              </a>
              ${this.icon}
            </div>
          </div>

          <!-- title -->
          <div class="flex-1 ml-1 mb-1">
            <h5
              class="text-normal color-fg-muted d-flex flex-items-center flex-row flex-nowrap width-fit"
            >
              <span class="flex-1">
                <span class="flex-shrink-0">
                  <a
                    href="${this.link1.href}"
                    class="Link--primary Link text-bold"
                    >${this.link1.text}</a
                  >
                  ${this.joinText}
                </span>
                <span class="overflow-auto">
                  <span class="Truncate">
                    <span class="Truncate-text">
                      <a
                        href="${this.link2.href}"
                        class="Link--primary Link text-bold"
                        >${this.link2.text}</a
                      >
                    </span>
                  </span>
                </span>
              </span>
            </h5>
            <div class="d-flex">
              <h6
                style="margin-top: 0rem"
                class="text-small text-normal color-fg-muted"
              >
                <relative-time
                  tense="past"
                  datetime="${this.time}"
                  title="${this.time}"
                  >${this.time}</relative-time
                >
              </h6>
            </div>
          </div>
        </header>
      </div>
    `;
    return template;
  }
}

class CardBody {
  /**
   * @param {Link} title
   * @param {String} markdown
   * @param {Object} gap must contain html() method
   */
  constructor(title, markdown, gap) {
    this.title = title || new Link();
    this.markdown = markdown || "";
    this.gap = gap;
  }

  html() {
    const titleTemplate = `
      <h3 class="lh-condensed mt-2 mb-2">
        <a
          href="${this.title?.href}"
          class="Link--primary Link text-bold"
        >
          ${this.title.text}
          <span class="f3-light color-fg-muted"
            >${this.title.patch ? "#" + this.title.patch : ""}</span
          >
        </a>
      </h3>
    `;
    const markdownTempalte = this.markdown
      ? `
        <section class="dashboard-break-word comment-body markdown-body m-0 p-3 color-bg-subtle mb-0 rounded-1">
          ${marked.parse(this.markdown)}
        </section>`
      : "";
    const template = `
      <div class="mt-1 mb-1">
        <div class="px-3">
          <div>
            ${titleTemplate}
            ${this.gap ? this.gap.html() : ""}
            ${markdownTempalte}
          </div>
        </div>
      </div>
    `;
    return template;
  }
}

class Card {
  /**
   * @param {CardHeader} header
   * @param {CardBody} body
   */
  constructor(header, body) {
    this.header = header;
    this.body = body;
  }

  html() {
    const template = `
      <article class="js-feed-item-component js-feed-item-view js-feed-item-next-component d-flex flex-column width-full flex-items-baseline pt-2 pb-2">
        <div class="feed-item-content d-flex flex-column pt-2 pb-2 border color-border-default rounded-2 color-shadow-small width-full height-fit">
          <div class="rounded-2 py-1">
            ${this.header.html()}
            ${this.body.html()}
          </div>
        </div>
      </article>
    `;
    return template;
  }
}

class CardPullRequestGap {
  /**
   * @param {Boolean} state
   */
  constructor(state) {
    this.state = state;
  }

  html() {
    const icon = this.state
      ? `<svg height="14" class="octicon octicon-git-merge" viewBox="0 0 16 16" version="1.1" width="14" aria-hidden="true"><path d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z"></path></svg>`
      : `<svg height="16" class="octicon octicon-git-pull-request" viewBox="0 0 16 16" version="1.1" width="16" aria-hidden="true"><path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z"></path></svg>`;
    const template = `
      <section width="full" class="f6 color-fg-muted mt-2 mb-2">
        <span class="State State--${
          this.state ? "merged" : "open"
        } State--small mr-2">
          ${icon}
          ${this.state ? "Merged" : "Open"}
        </span>
      </section>
    `;
    return template;
  }
}

QingJ © 2025

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