LINUX DO Auto Nested View

Automatically redirect linux.do topic pages and topic links to nested view.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         LINUX DO Auto Nested View
// @name:zh-CN   LINUX DO 自动嵌套视图
// @namespace    https://github.com/kai-wei-kfuse/linux-do-auto-nested-view
// @version      1.5.4
// @description  Automatically redirect linux.do topic pages and topic links to nested view.
// @description:zh-CN 自动将 linux.do 主题页和主题链接切换到嵌套视图。
// @author       kai-wei-kfuse
// @license      MIT
// @match        https://linux.do/*
// @homepageURL  https://github.com/kai-wei-kfuse/linux-do-auto-nested-view
// @supportURL   https://github.com/kai-wei-kfuse/linux-do-auto-nested-view/issues
// @run-at       document-start
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        unsafeWindow
// ==/UserScript==

(function () {
  "use strict";

  const ENABLED_STORAGE_KEY = "linuxdo-auto-nested-view-enabled";
  const TITLE_STORAGE_PREFIX = "linuxdo-auto-nested-view-topic-title-";
  const PRIVATE_MESSAGE_STORAGE_PREFIX =
    "linuxdo-auto-nested-view-private-message-";
  const SITE_TITLE = "LINUX DO";
  const pageWindow =
    typeof unsafeWindow === "undefined" ? window : unsafeWindow;

  let lastHandledUrl = "";
  let observer = null;
  let menuCommandId = null;
  let autoConvertEnabled = loadAutoConvertEnabled();
  const rewrittenLinks = new WeakSet();

  function loadAutoConvertEnabled() {
    try {
      return GM_getValue(ENABLED_STORAGE_KEY, true) !== false;
    } catch {
      return true;
    }
  }

  function saveAutoConvertEnabled(enabled) {
    autoConvertEnabled = enabled;

    try {
      GM_setValue(ENABLED_STORAGE_KEY, enabled);
    } catch {
      // Ignore storage failures so the script still works with its default state.
    }
  }

  function registerToggleMenu() {
    if (typeof GM_registerMenuCommand !== "function") {
      return;
    }

    if (
      menuCommandId !== null &&
      typeof GM_unregisterMenuCommand === "function"
    ) {
      GM_unregisterMenuCommand(menuCommandId);
    }

    const status = autoConvertEnabled ? "开启" : "关闭";
    menuCommandId = GM_registerMenuCommand(
      `自动转换嵌套视图:${status}`,
      () => {
        saveAutoConvertEnabled(!autoConvertEnabled);
        registerToggleMenu();
        applyAutoConvertState();

        if (!autoConvertEnabled) {
          pageWindow.location.reload();
        }
      }
    );
  }

  function stopObserver() {
    if (!observer) {
      return;
    }

    observer.disconnect();
    observer = null;
  }

  function applyAutoConvertState() {
    lastHandledUrl = "";

    if (!autoConvertEnabled) {
      stopObserver();
      return;
    }

    startObserver();
    rewriteTopicLinks();
    redirectToNested();
    syncDocumentTitle();
  }

  function extractTopicId(pathname) {
    const nestedMatch = pathname.match(/^\/n\/topic\/(\d+)(?:\/.*)?$/);
    if (nestedMatch) {
      return nestedMatch[1];
    }

    const topicMatch = pathname.match(/^\/t\/[^/]+\/(\d+)(?:\/.*)?$/);
    if (topicMatch) {
      return topicMatch[1];
    }

    return null;
  }

  function isTopicPage(pathname) {
    return pathname.startsWith("/t/") && Boolean(extractTopicId(pathname));
  }

  function isNestedPage(pathname) {
    return pathname.startsWith("/n/topic/") && Boolean(extractTopicId(pathname));
  }

  function toNestedUrl(urlLike) {
    const url = new URL(urlLike, pageWindow.location.origin);
    const topicId = extractTopicId(url.pathname);
    if (!topicId) {
      return null;
    }

    url.pathname = `/n/topic/${topicId}`;
    return url.toString();
  }

  function getTopicIdFromUrl(urlLike) {
    try {
      const url = new URL(urlLike, pageWindow.location.origin);
      return extractTopicId(url.pathname);
    } catch {
      return null;
    }
  }

  function normalizeTopicTitle(text) {
    const title = text?.replace(/\s+/g, " ").trim();
    if (!title || title.toUpperCase() === "LINUXDO") {
      return null;
    }

    return title.replace(/\s+-\s+LINUX\s*DO$/i, "").trim() || null;
  }

  function extractTitleFromLink(link) {
    if (!link) {
      return null;
    }

    const titleNode = link.querySelector?.(
      ".title, .topic-title, [data-topic-title]"
    );
    return (
      normalizeTopicTitle(titleNode?.textContent) ||
      normalizeTopicTitle(link.getAttribute("title")) ||
      normalizeTopicTitle(link.getAttribute("aria-label")) ||
      normalizeTopicTitle(link.textContent)
    );
  }

  function rememberTopicTitle(link) {
    const topicId = getTopicIdFromUrl(link?.href);
    const title = extractTitleFromLink(link);
    if (!topicId || !title) {
      return;
    }

    try {
      sessionStorage.setItem(`${TITLE_STORAGE_PREFIX}${topicId}`, title);
    } catch {
      // Ignore storage failures; page DOM title detection can still recover.
    }
  }

  function loadRememberedTopicTitle(topicId) {
    try {
      return normalizeTopicTitle(
        sessionStorage.getItem(`${TITLE_STORAGE_PREFIX}${topicId}`)
      );
    } catch {
      return null;
    }
  }

  function rememberPrivateMessageTopic(link) {
    const topicId = getTopicIdFromUrl(link?.href);
    if (!topicId) {
      return;
    }

    try {
      sessionStorage.setItem(`${PRIVATE_MESSAGE_STORAGE_PREFIX}${topicId}`, "1");
    } catch {
      // Ignore storage failures; context checks still protect visible menu links.
    }
  }

  function isRememberedPrivateMessageUrl(urlLike) {
    const topicId = getTopicIdFromUrl(urlLike);
    if (!topicId) {
      return false;
    }

    try {
      return (
        sessionStorage.getItem(`${PRIVATE_MESSAGE_STORAGE_PREFIX}${topicId}`) ===
        "1"
      );
    } catch {
      return false;
    }
  }

  function isPrivateMessageContext(target) {
    if (!(target instanceof Element)) {
      return false;
    }

    return Boolean(
      target.closest(
        [
          "#quick-access-messages",
          ".quick-access-messages",
          ".user-menu-messages-list",
          ".user-messages-list",
          ".private-messages-list",
          ".messages-list",
          ".message-list",
          ".menu-panel.messages",
          ".menu-panel.private-messages",
          ".quick-access-panel.messages",
          ".quick-access-panel.private-messages",
          "[data-user-menu-tab='messages']",
          "[data-tab='messages']",
          "[data-section='messages']",
          "[data-name='messages']",
          "[data-type='private_message']",
          "[data-notification-type='private_message']",
          "[data-archetype='private_message']",
        ].join(",")
      )
    );
  }

  function shouldSkipNestedConversion(link) {
    if (!link) {
      return false;
    }

    if (isRememberedPrivateMessageUrl(link.href)) {
      return true;
    }

    if (!isPrivateMessageContext(link)) {
      return false;
    }

    rememberPrivateMessageTopic(link);
    return true;
  }

  function findTopicTitleInPage() {
    const metaTitle =
      normalizeTopicTitle(
        document.querySelector('meta[property="og:title"]')?.content
      ) ||
      normalizeTopicTitle(
        document.querySelector('meta[name="twitter:title"]')?.content
      );
    if (metaTitle) {
      return metaTitle;
    }

    const titleSelectors = [
      "h1 .fancy-title",
      "h1.topic-title",
      ".topic-title h1",
      ".topic-title",
      ".title-wrapper h1",
      "h1",
    ];

    for (const selector of titleSelectors) {
      const title = normalizeTopicTitle(
        document.querySelector(selector)?.textContent
      );
      if (title) {
        return title;
      }
    }

    return null;
  }

  function syncDocumentTitle() {
    if (!autoConvertEnabled || !isNestedPage(pageWindow.location.pathname)) {
      return;
    }

    const topicId = extractTopicId(pageWindow.location.pathname);
    const title = findTopicTitleInPage() || loadRememberedTopicTitle(topicId);
    if (!title) {
      return;
    }

    const nextTitle = `${title} - ${SITE_TITLE}`;
    if (document.title !== nextTitle) {
      document.title = nextTitle;
    }
  }

  function rewriteTopicLinks(root = document) {
    if (!autoConvertEnabled) {
      return;
    }

    const links = root.querySelectorAll('a[href]');
    for (const link of links) {
      if (shouldSkipNestedConversion(link)) {
        continue;
      }

      const nestedUrl = toNestedUrl(link.href);
      if (!nestedUrl) {
        continue;
      }

      if (link.href === nestedUrl && rewrittenLinks.has(link)) {
        continue;
      }

      rememberTopicTitle(link);
      link.href = nestedUrl;
      rewrittenLinks.add(link);
    }
  }

  function rewriteLinkElement(link) {
    if (!autoConvertEnabled) {
      return;
    }

    if (!link || !link.href) {
      return;
    }

    if (shouldSkipNestedConversion(link)) {
      return;
    }

    const nestedUrl = toNestedUrl(link.href);
    if (!nestedUrl || link.href === nestedUrl) {
      return;
    }

    rememberTopicTitle(link);
    link.href = nestedUrl;
    rewrittenLinks.add(link);
  }

  function findAnchorFromEventTarget(target) {
    if (!(target instanceof Element)) {
      return null;
    }

    return target.closest("a[href]");
  }

  function isNotificationArea(target) {
    if (!(target instanceof Element)) {
      return false;
    }

    return Boolean(
      target.closest(
        [
          "#quick-access-notifications",
          ".user-notifications-list",
          ".notifications-list",
          ".notification-history",
          ".menu-panel.notifications",
          ".notification",
          "[data-notification-id]",
        ].join(",")
      )
    );
  }

  function isBookmarkArea(target) {
    if (!(target instanceof Element)) {
      return false;
    }

    return Boolean(
      target.closest(
        [
          "#quick-access-bookmarks",
          ".quick-access-bookmarks",
          ".user-menu-bookmarks-list",
          ".bookmarks-list",
          ".menu-panel.bookmarks",
          ".quick-access-panel.bookmarks",
          "[data-user-menu-tab='bookmarks']",
          "[data-tab='bookmarks']",
          "[data-section='bookmarks']",
          "[data-name='bookmarks']",
        ].join(",")
      )
    );
  }

  function shouldForceNestedNavigation(target) {
    return isNotificationArea(target) || isBookmarkArea(target);
  }

  function forceMenuTopicNavigation(event) {
    if (!autoConvertEnabled) {
      return;
    }

    if (!shouldForceNestedNavigation(event.target)) {
      return;
    }

    const link = findAnchorFromEventTarget(event.target);
    if (!link) {
      return;
    }

    if (shouldSkipNestedConversion(link)) {
      return;
    }

    const nestedUrl = toNestedUrl(link.href);
    if (!nestedUrl) {
      return;
    }

    rememberTopicTitle(link);
    event.preventDefault();
    event.stopPropagation();
    event.stopImmediatePropagation?.();
    pageWindow.location.assign(nestedUrl);
  }

  function interceptNavigationEvent(event) {
    if (!autoConvertEnabled) {
      return;
    }

    const link = findAnchorFromEventTarget(event.target);
    if (!link) {
      return;
    }

    rewriteLinkElement(link);
  }

  function redirectToNested() {
    if (!autoConvertEnabled) {
      lastHandledUrl = "";
      return;
    }

    const { pathname, href } = pageWindow.location;

    if (href === lastHandledUrl) {
      syncDocumentTitle();
      return;
    }

    if (isNestedPage(pathname)) {
      const normalizedNestedUrl = toNestedUrl(href);
      if (normalizedNestedUrl && normalizedNestedUrl !== href) {
        lastHandledUrl = href;
        pageWindow.location.replace(normalizedNestedUrl);
        return;
      }

      lastHandledUrl = href;
      syncDocumentTitle();
      return;
    }

    if (!isTopicPage(pathname)) {
      lastHandledUrl = href;
      return;
    }

    if (isRememberedPrivateMessageUrl(href)) {
      lastHandledUrl = href;
      return;
    }

    const link = document.querySelector("a.nested-view-link");
    if (link && link.href) {
      lastHandledUrl = href;
      pageWindow.location.href = link.href;
      return;
    }

    const nestedUrl = toNestedUrl(href);
    if (nestedUrl && nestedUrl !== href) {
      lastHandledUrl = href;
      pageWindow.location.href = nestedUrl;
    }
  }

  function startObserver() {
    if (!autoConvertEnabled) {
      stopObserver();
      return;
    }

    if (observer) {
      observer.disconnect();
    }

    observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
          if (node.nodeType !== Node.ELEMENT_NODE) {
            continue;
          }

          if (node.matches?.("a[href]")) {
            rewriteTopicLinks(node.parentElement ?? document);
            continue;
          }

          rewriteTopicLinks(node);
        }
      }

      redirectToNested();
      syncDocumentTitle();
    });

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

  function hookHistoryMethod(methodName) {
    const original = pageWindow.history[methodName];
    pageWindow.history[methodName] = function (...args) {
      if (
        autoConvertEnabled &&
        args.length >= 3 &&
        args[2] != null &&
        !isRememberedPrivateMessageUrl(args[2])
      ) {
        const nestedUrl = toNestedUrl(args[2]);
        if (nestedUrl) {
          args[2] = nestedUrl;
        }
      }

      const result = original.apply(this, args);
      if (autoConvertEnabled) {
        setTimeout(redirectToNested, 0);
        setTimeout(syncDocumentTitle, 0);
      }
      return result;
    };
  }

  registerToggleMenu();
  hookHistoryMethod("pushState");
  hookHistoryMethod("replaceState");
  window.addEventListener("popstate", () => setTimeout(redirectToNested, 0));
  window.addEventListener("DOMContentLoaded", () => {
    rewriteTopicLinks();
    redirectToNested();
    syncDocumentTitle();
  });
  window.addEventListener("click", forceMenuTopicNavigation, true);
  window.addEventListener("pointerdown", interceptNavigationEvent, true);
  window.addEventListener("mousedown", interceptNavigationEvent, true);
  window.addEventListener("click", interceptNavigationEvent, true);
  window.addEventListener("auxclick", interceptNavigationEvent, true);

  applyAutoConvertState();
})();