AO3: Auto Pseud

Assign pseuds based on fandoms when commenting and bookmarking works

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         AO3: Auto Pseud
// @version      1.0.1
// @description  Assign pseuds based on fandoms when commenting and bookmarking works
// @author       BlackBatCat
// @match        *://archiveofourown.org/users/*/pseuds/*/edit
// @match        *://archiveofourown.org/users/*/pseuds/*/bookmarks*
// @match        *://archiveofourown.org/works*
// @match        *://archiveofourown.org/chapters/*
// @match        *://archiveofourown.org/collections/*/bookmarks
// @license      MIT
// @run-at       document-end
// @namespace https://greasyfork.org/users/1498004
// ==/UserScript==

console.log("[AO3: Auto Pseud] loaded.");
(function () {
  "use strict";

  // Storage key
  const STORAGE_KEY = "ao3_auto_pseud_config";

  // Page detection regex
  const WORKS_PAGE_REGEX =
    /^https?:\/\/archiveofourown\.org\/(?:.*\/)?(works|chapters)(\/|$)/;
  const PSEUD_EDIT_REGEX =
    /^https?:\/\/archiveofourown\.org\/users\/.*\/pseuds\/.*\/edit$/;
  const BOOKMARKS_PAGE_REGEX =
    /^https?:\/\/archiveofourown\.org\/(?:collections\/.*\/)?(?:users\/.*\/(?:pseuds\/.*\/)?)?bookmarks(?:\/.*)?$/;

  // Get the entire config object
  function getConfig() {
    try {
      const config = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
      if (!config.pseuds) config.pseuds = {};
      if (config.enableComments === undefined) config.enableComments = false;
      if (config.enableBookmarks === undefined) config.enableBookmarks = false;
      return config;
    } catch (e) {
      console.error("[AO3: Auto Pseud] Error loading config:", e);
      return { pseuds: {}, enableComments: false, enableBookmarks: false };
    }
  }

  // Save the entire config object
  function saveConfig(config) {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
    } catch (e) {
      console.error("[AO3: Auto Pseud] Error saving config:", e);
    }
  }

  // Get current pseud name from URL
  function getCurrentPseudName() {
    const urlParts = window.location.pathname.split("/");
    const pseudIndex = urlParts.indexOf("pseuds");
    return pseudIndex !== -1 ? urlParts[pseudIndex + 1] : null;
  }

  // Get stored fandoms for current pseud
  function getStoredFandoms(pseudName) {
    const config = getConfig();
    return config.pseuds[pseudName]?.fandoms || [];
  }

  // Save fandoms for current pseud
  function saveFandomsForPseud(pseudName, fandoms) {
    const config = getConfig();
    if (!config.pseuds[pseudName]) {
      config.pseuds[pseudName] = {};
    }
    config.pseuds[pseudName].fandoms = fandoms;
    saveConfig(config);
  }

  // Get pseud ID for a given pseud name
  function getPseudIdByName(pseudName) {
    const config = getConfig();
    return config.pseuds[pseudName]?.id || null;
  }

  // Save pseud name to ID mapping
  function savePseudNameMapping(pseudName, pseudId) {
    const config = getConfig();
    if (!config.pseuds[pseudName]) {
      config.pseuds[pseudName] = {};
    }
    config.pseuds[pseudName].id = pseudId;
    saveConfig(config);
  }

  // Find which pseud should be used for given fandoms
  function findMatchingPseud(workFandoms) {
    try {
      const config = getConfig();

      for (const [pseudName, pseudData] of Object.entries(config.pseuds)) {
        const pseudFandoms = pseudData.fandoms || [];
        const pseudId = pseudData.id;

        if (!pseudId) continue;

        for (const workFandom of workFandoms) {
          if (pseudFandoms.includes(workFandom)) {
            return pseudId;
          }
        }
      }

      return null;
    } catch (e) {
      console.error("[AO3: Auto Pseud] Error finding matching pseud:", e);
      return null;
    }
  }

  // ==================== PSEUD EDIT PAGE ====================

  // Show the fandom help modal
  function showFandomHelpModal() {
    // Remove any existing modal/background
    const oldModal = document.getElementById("modal-wrap");
    if (oldModal) oldModal.parentNode.removeChild(oldModal);
    const oldBg = document.getElementById("modal-background");
    if (oldBg) oldBg.parentNode.removeChild(oldBg);

    // AO3 native modal uses overlay first, then modal-wrap
    const background = document.createElement("div");
    background.id = "modal-background";
    background.className = "modal-closer";
    background.style.display = "block";
    background.style.position = "fixed";
    background.style.top = "0";
    background.style.left = "0";
    background.style.width = "100%";
    background.style.height = "100%";
    background.style.backgroundColor = "rgba(0,0,0,0.5)";
    background.style.zIndex = "1000";
    document.body.appendChild(background);

    const modalWrap = document.createElement("div");
    modalWrap.id = "modal-wrap";
    modalWrap.className = "modal-closer";
    modalWrap.style.display = "block";
    modalWrap.style.position = "fixed";
    modalWrap.style.top = "50%";
    modalWrap.style.left = "50%";
    modalWrap.style.transform = "translate(-50%, -50%)";
    modalWrap.style.zIndex = "1001";
    modalWrap.innerHTML = `
      <div id="modal" style="display: inline-block;">
        <div class="content userstuff">
          <p>Associate fandoms with this pseud. When you comment on or bookmark works in these fandoms, this pseud will be suggested automatically.</p>
        </div>
        <div class="footer">
          <span class="title">Associate fandoms with this pseud</span>
          <a class="action modal-closer" href="#">Close</a>
        </div>
      </div>
    `;
    document.body.appendChild(modalWrap);

    // Add close handler (click overlay or close link)
    function closeHandler(e) {
      if (e.target === background || e.target.classList.contains("action")) {
        if (background.parentNode)
          background.parentNode.removeChild(background);
        if (modalWrap.parentNode) modalWrap.parentNode.removeChild(modalWrap);
      }
    }
    background.addEventListener("click", closeHandler);
    modalWrap.querySelector(".action").addEventListener("click", closeHandler);
  }

  // Make it global for onclick
  window.showFandomHelpModal = showFandomHelpModal;

  // Add fandom fieldset to the page
  function addFandomFieldset() {
    const form = document.querySelector("form.edit_pseud");
    if (!form) {
      console.error("[AO3: Auto Pseud] Could not find pseud edit form");
      return;
    }

    const submitDd = form.querySelector("dd.submit.actions");
    if (!submitDd) {
      console.error("[AO3: Auto Pseud] Could not find submit button");
      return;
    }

    const pseudName = getCurrentPseudName();
    const storedFandoms = pseudName ? getStoredFandoms(pseudName) : [];
    const fandomValue = storedFandoms.join(", ");

    // Create the fandom dt and dd elements
    const fandomDt = document.createElement("dt");
    fandomDt.className = "fandom";
    fandomDt.innerHTML = `
            <label for="pseud_fandom_autocomplete" title="fandoms">Fandoms</label>
            <a class="help symbol question" title="Associate fandoms with this pseud for automatic selection when commenting and bookmarking" href="#" onclick="window.showFandomHelpModal(); return false;">
                <span class="symbol question"><span>?</span></span>
            </a>
        `;

    const config = getConfig();
    const fandomDd = document.createElement("dd");
    fandomDd.className = "fandom";
    fandomDd.setAttribute("title", "fandoms");
    fandomDd.innerHTML = `
            <input type="text" name="pseud[fandom_string]" id="pseud_fandom" 
                   value="${fandomValue}" class="autocomplete" 
                   data-autocomplete-method="/autocomplete/fandom" 
                   data-autocomplete-hint-text="Start typing for suggestions!" 
                   data-autocomplete-no-results-text="(No suggestions found)" 
                   data-autocomplete-min-chars="1" 
                   data-autocomplete-searching-text="Searching..." 
                   title="fandoms">
            <div style="margin-top: 0.5em;">
                <label><input type="checkbox" id="enable_comments" ${
                  config.enableComments ? "checked" : ""
                }> Assign pseud to comments</label><br>
                <label><input type="checkbox" id="enable_bookmarks" ${
                  config.enableBookmarks ? "checked" : ""
                }> Assign pseud to bookmarks</label>
            </div>
        `;

    const submitDt = form.querySelector("dt.landmark");
    submitDt.parentNode.insertBefore(fandomDt, submitDt);
    submitDt.parentNode.insertBefore(fandomDd, submitDt);
  }

  // Intercept form submission to save fandoms
  function interceptFormSubmit() {
    const form = document.querySelector("form.edit_pseud");
    if (!form) return;

    const formAction = form.getAttribute("action");
    const pseudId = formAction ? formAction.split("/").pop() : null;

    form.addEventListener("submit", function (e) {
      const pseudName = getCurrentPseudName();
      const fandomInput = document.querySelector("#pseud_fandom");

      if (pseudName && fandomInput) {
        const fandoms = fandomInput.value
          .split(",")
          .map((f) => f.trim())
          .filter((f) => f.length > 0);

        saveFandomsForPseud(pseudName, fandoms);

        if (pseudId) {
          savePseudNameMapping(pseudName, pseudId);
        }

        fandomInput.disabled = true;
        const autocompleteInput = document.querySelector(
          "#pseud_fandom_autocomplete",
        );
        if (autocompleteInput) autocompleteInput.disabled = true;
      }
    });
  }

  // Save options when checkboxes change
  function saveOptions() {
    const enableCommentsCheckbox = document.querySelector("#enable_comments");
    const enableBookmarksCheckbox = document.querySelector("#enable_bookmarks");

    if (enableCommentsCheckbox && enableBookmarksCheckbox) {
      const config = getConfig();
      config.enableComments = enableCommentsCheckbox.checked;
      config.enableBookmarks = enableBookmarksCheckbox.checked;
      saveConfig(config);
    }
  }

  // Initialize the pseud edit page
  function initPseudEditPage() {
    addFandomFieldset();
    interceptFormSubmit();

    // Add listeners for options checkboxes
    const enableCommentsCheckbox = document.querySelector("#enable_comments");
    const enableBookmarksCheckbox = document.querySelector("#enable_bookmarks");

    if (enableCommentsCheckbox) {
      enableCommentsCheckbox.addEventListener("change", saveOptions);
    }
    if (enableBookmarksCheckbox) {
      enableBookmarksCheckbox.addEventListener("change", saveOptions);
    }
  }

  // ==================== WORKS PAGE ====================

  // Get fandoms from the current work page
  function getWorkFandoms(workId = null) {
    if (workId) {
      // Try to find the blurb by .work-<id> (standard AO3 class)
      let blurb = document.querySelector(`.work-${workId}`);
      if (!blurb) {
        // Try to find a <li> with class containing work-<id>
        blurb = Array.from(
          document.querySelectorAll("li.bookmark, li.blurb, li.group"),
        ).find((el) => el.className && el.className.includes(`work-${workId}`));
      }
      if (!blurb) {
        // Try to find by id (sometimes used)
        blurb =
          document.getElementById(`work-${workId}`) ||
          document.getElementById(`bookmark_${workId}`);
      }
      if (blurb) {
        // Try standard AO3 structure
        let fandomTags = blurb.querySelectorAll("h5.fandoms.heading a.tag");
        if (fandomTags.length === 0) {
          // Try fallback: any a.tag inside blurb
          fandomTags = blurb.querySelectorAll("a.tag");
        }
        const fandoms = Array.from(fandomTags).map((tag) =>
          tag.textContent.trim(),
        );
        return fandoms;
      }
      // If no blurb found, fall through to page-wide selectors
    }

    // On bookmark list pages, don't auto-select to avoid using wrong fandoms
    if (
      BOOKMARKS_PAGE_REGEX.test(window.location.href) &&
      !window.location.href.includes("/edit")
    ) {
      return [];
    }
    // Check for fandoms in work page structure (dd.fandom.tags)
    let fandomTags = document.querySelectorAll("dd.fandom.tags a.tag");
    if (fandomTags.length === 0) {
      // Check for fandoms in bookmark page structure (h5.fandoms.heading a.tag)
      fandomTags = document.querySelectorAll("h5.fandoms.heading a.tag");
    }
    return Array.from(fandomTags).map((tag) => tag.textContent.trim());
  }

  // Build pseud name-to-ID map from comment form select options
  function buildPseudMapFromCommentForm() {
    const commentSelect = document.querySelector(
      'select[name="comment[pseud_id]"]',
    );
    if (!commentSelect) return;

    const options = commentSelect.querySelectorAll("option");
    options.forEach((option) => {
      const pseudId = option.value;
      const pseudName = option.textContent.trim();
      savePseudNameMapping(pseudName, pseudId);
    });
  }

  // Build pseud name-to-ID map from bookmark form select options
  function buildPseudMapFromBookmarkForm() {
    const bookmarkSelect = document.querySelector(
      'select[name="bookmark[pseud_id]"]',
    );
    if (!bookmarkSelect) return;

    const options = bookmarkSelect.querySelectorAll("option");
    options.forEach((option) => {
      const pseudId = option.value;
      const pseudName = option.textContent.trim();
      savePseudNameMapping(pseudName, pseudId);
    });
  }

  // Switch the comment form pseud to the matching one
  function switchCommentPseud() {
    const config = getConfig();
    if (!config.enableComments) return;

    const workFandoms = getWorkFandoms();
    if (workFandoms.length === 0) return;

    buildPseudMapFromCommentForm();

    const matchingPseudId = findMatchingPseud(workFandoms);
    if (!matchingPseudId) return;

    const commentSelects = document.querySelectorAll(
      'select[name="comment[pseud_id]"]',
    );
    commentSelects.forEach((select) => {
      const option = select.querySelector(`option[value="${matchingPseudId}"]`);
      if (option) {
        select.value = matchingPseudId;
      }
    });
  }

  // Observe comment fieldsets for pseud selects
  function observeCommentFieldsets() {
    const commentLegends = document.querySelectorAll("fieldset legend");
    commentLegends.forEach((legend) => {
      const legendText = legend.textContent.trim();
      if (legendText === "Comment" || legendText === "Post Comment") {
        const fieldset = legend.closest("fieldset");
        if (!fieldset) return;
        const observer = new MutationObserver((mutations) => {
          for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
              if (
                node.nodeType === 1 &&
                node.matches('select[name="comment[pseud_id]"]')
              ) {
                switchCommentPseud();
                observer.disconnect();
                return;
              }
            }
          }
        });
        observer.observe(fieldset, { childList: true, subtree: true });
        // Also check if select is already there
        const existingSelect = fieldset.querySelector(
          'select[name="comment[pseud_id]"]',
        );
        if (existingSelect) {
          switchCommentPseud();
        }
      }
    });
  }

  // Observe bookmark fieldsets for pseud selects
  function observeBookmarkFieldsets() {
    const bookmarkLegends = document.querySelectorAll("fieldset legend");
    bookmarkLegends.forEach((legend) => {
      if (legend.textContent.trim() === "Bookmark") {
        const fieldset = legend.closest("fieldset");
        if (!fieldset) return;
        const observer = new MutationObserver((mutations) => {
          for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
              if (
                node.nodeType === 1 &&
                node.matches('select[name="bookmark[pseud_id]"]')
              ) {
                switchBookmarkPseud();
                observer.disconnect();
                return;
              }
            }
          }
        });
        observer.observe(fieldset, { childList: true, subtree: true });
        // Also check if select is already there
        const existingSelect = fieldset.querySelector(
          'select[name="bookmark[pseud_id]"]',
        );
        if (existingSelect) {
          switchBookmarkPseud();
        }
      }
    });
  }

  // Observe for new comment fieldsets being added to the page (e.g. reply forms)
  function observeForNewCommentFieldsets() {
    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
          if (node.nodeType !== 1) continue;
          // Collect the node itself if it's a fieldset, plus any nested fieldsets
          const candidates = node.matches("fieldset") ? [node] : [];
          node.querySelectorAll("fieldset").forEach((f) => candidates.push(f));
          for (const fieldset of candidates) {
            const legend = fieldset.querySelector("legend");
            if (!legend) continue;
            const legendText = legend.textContent.trim();
            if (legendText === "Comment" || legendText === "Post Comment") {
              switchCommentPseud();
              return;
            }
          }
        }
      }
    });
    observer.observe(document.body, { childList: true, subtree: true });
  }

  // Observe for new bookmark fieldsets being added to the page
  function observeForNewBookmarkFieldsets() {
    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
          if (node.nodeType === 1 && node.matches("fieldset")) {
            const legend = node.querySelector("legend");
            if (legend && legend.textContent.trim() === "Bookmark") {
              observeBookmarkFieldsets();
            }
          }
          // Handle AO3 collection bookmarks: <div id="bookmark-form">
          if (node.nodeType === 1 && node.id === "bookmark-form") {
            // Watch for the select being added inside #bookmark-form
            const formObserver = new MutationObserver((mutations) => {
              for (const mutation of mutations) {
                for (const added of mutation.addedNodes) {
                  if (
                    added.nodeType === 1 &&
                    added.matches('select[name="bookmark[pseud_id]"]')
                  ) {
                    switchBookmarkPseud();
                    formObserver.disconnect();
                    return;
                  }
                }
              }
            });
            formObserver.observe(node, { childList: true, subtree: true });
            // Also check if select is already there
            const existingSelect = node.querySelector(
              'select[name="bookmark[pseud_id]"]',
            );
            if (existingSelect) {
              switchBookmarkPseud();
            }
          }
        }
      }
    });
    observer.observe(document.body, { childList: true, subtree: true });
  }

  function switchBookmarkPseud() {
    const config = getConfig();
    if (!config.enableBookmarks) {
      return;
    }

    const bookmarkSelects = document.querySelectorAll(
      'select[name="bookmark[pseud_id]"]',
    );
    if (bookmarkSelects.length === 0) {
      return;
    }
    bookmarkSelects.forEach((select) => {
      const form = select.closest('form[action^="/works/"]');
      let workId = null;
      if (form) {
        const match = form.getAttribute("action").match(/\/works\/(\d+)/);
        if (match) workId = match[1];
      }
      if (!workId) {
        return;
      }
      const workFandoms = getWorkFandoms(workId);
      if (workFandoms.length === 0) {
        return;
      }
      buildPseudMapFromBookmarkForm();
      const matchingPseudId = findMatchingPseud(workFandoms);
      if (!matchingPseudId) {
        return;
      }
      const option = select.querySelector(`option[value="${matchingPseudId}"]`);
      if (option) {
        select.value = matchingPseudId;
      }
    });
  }

  // Initialize works page functionality
  function initWorksPage() {
    switchCommentPseud();
    switchBookmarkPseud();
    observeCommentFieldsets();
    observeBookmarkFieldsets();
    observeForNewCommentFieldsets();
    observeForNewBookmarkFieldsets();
  }

  // Initialize bookmarks page functionality
  function initBookmarksPage() {
    switchBookmarkPseud();
    observeBookmarkFieldsets();
    observeForNewBookmarkFieldsets();
  }

  // Determine which page we're on and initialize accordingly
  function initializeScript() {
    const currentUrl = window.location.href;
    if (PSEUD_EDIT_REGEX.test(currentUrl)) {
      initPseudEditPage();
    } else if (WORKS_PAGE_REGEX.test(currentUrl)) {
      initWorksPage();
    } else if (BOOKMARKS_PAGE_REGEX.test(currentUrl)) {
      initBookmarksPage();
    }
  }

  // Wait for page to be ready
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initializeScript);
  } else {
    initializeScript();
  }
})();