AO3: Auto Pseud

Assign pseuds based on fandoms when commenting and bookmarking works

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         AO3: Auto Pseud
// @version      1.0.2
// @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
// @grant        none
// @license      MIT
// @run-at       document-end
// @namespace https://greasyfork.org/users/1498004
// ==/UserScript==

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

    const html = (strings, ...values) =>
        strings.reduce((out, s, i) => out + s + (i < values.length ? values[i] : ""), "");

    // ============================================================
    // CONSTANTS
    // ============================================================

    const STORAGE_KEY = "ao3_auto_pseud_config";

    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(?:\/.*)?$/;

    // ============================================================
    // UTILITY FUNCTIONS
    // ============================================================

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

    /**
     * Find which pseud ID matches the given work fandoms.
     * @param {string[]} workFandoms
     * @returns {string|null} pseud ID if match found
     */
    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;
        }
    }

    // ============================================================
    // STORAGE
    // ============================================================

    /**
     * Load config from localStorage with defaults applied.
     * @returns {{ pseuds: Object, enableComments: boolean, enableBookmarks: boolean }}
     */
    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 };
        }
    }

    /**
     * Persist config to localStorage.
     * @param {Object} config
     */
    function saveConfig(config) {
        try {
            localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
        } catch (e) {
            console.error("[AO3: Auto Pseud] Error saving config:", e);
        }
    }

    /** @param {string} pseudName @returns {string[]} */
    function getStoredFandoms(pseudName) {
        const config = getConfig();
        return config.pseuds[pseudName]?.fandoms || [];
    }

    /** @param {string} pseudName @param {string[]} fandoms */
    function saveFandomsForPseud(pseudName, fandoms) {
        const config = getConfig();
        if (!config.pseuds[pseudName]) {
            config.pseuds[pseudName] = {};
        }
        config.pseuds[pseudName].fandoms = fandoms;
        saveConfig(config);
    }

    /** @param {string} pseudName @param {string} pseudId */
    function savePseudNameMapping(pseudName, pseudId) {
        const config = getConfig();
        if (!config.pseuds[pseudName]) {
            config.pseuds[pseudName] = {};
        }
        config.pseuds[pseudName].id = pseudId;
        saveConfig(config);
    }

    // ============================================================
    // CORE LOGIC
    // ============================================================

    /**
     * Extract fandom tag names from the current work page or a specific blurb.
     * Uses multiple fallback strategies for different AO3 page layouts.
     * @param {string|null} workId
     * @returns {string[]}
     */
    function getWorkFandoms(workId = null) {
        if (workId) {
            // Try standard AO3 blurb class .work-<id>
            let blurb = document.querySelector(`.work-${workId}`);
            if (!blurb) {
                // Fallback: search all li elements for 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) {
                // Fallback: try by id attribute
                blurb =
                    document.getElementById(`work-${workId}`) ||
                    document.getElementById(`bookmark_${workId}`);
            }
            if (blurb) {
                let fandomTags = blurb.querySelectorAll("h5.fandoms.heading a.tag");
                if (fandomTags.length === 0) {
                    fandomTags = blurb.querySelectorAll("a.tag");
                }
                const fandoms = Array.from(fandomTags).map((tag) => tag.textContent.trim());
                return fandoms;
            }
        }

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

    /**
     * Build pseud name→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→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 comment form pseud select to the matching pseud for current work's fandoms.
     */
    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;
            }
        });
    }

    /**
     * Switch bookmark form pseud select to the matching pseud.
     * Resolves work ID from the form's action attribute to get correct fandoms.
     */
    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;
            }
        });
    }

    // ============================================================
    // DOM / UI
    // ============================================================

    /**
     * Display a modal explaining fandom-pseud association.
     * Exposed globally for onclick handler in injected HTML.
     */
    function showFandomHelpModal() {
        // Remove any existing modal/background
        const oldModal = document.getElementById("modal-wrap");
        if (oldModal?.parentNode) oldModal.parentNode.removeChild(oldModal);
        const oldBg = document.getElementById("modal-background");
        if (oldBg?.parentNode) 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 = html`
            <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);
        const actionBtn = modalWrap.querySelector(".action");
        if (actionBtn) actionBtn.addEventListener("click", closeHandler);
    }

    // Expose for onclick handler in injected HTML
    window.showFandomHelpModal = showFandomHelpModal;

    /**
     * Inject fandom association fieldset into the pseud edit form.
     * Adds autocomplete input for fandoms plus enable/disable checkboxes.
     */
    function addFandomFieldset() {
        const form = document.querySelector("form.edit_pseud");
        if (!form) {
            console.error("[AO3: Auto Pseud] Could not find pseud edit form");
            return;
        }

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

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

        const fandomDt = document.createElement("dt");
        fandomDt.className = "fandom";
        fandomDt.innerHTML = html`
            <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 = html`
            <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");
        if (!submitDt) {
            console.error("[AO3: Auto Pseud] Could not find dt.landmark in form");
            return;
        }
        submitDt.parentNode.insertBefore(fandomDt, submitDt);
        submitDt.parentNode.insertBefore(fandomDd, submitDt);
    }

    /**
     * Intercept pseud edit form submission to persist fandom list and pseud ID.
     */
    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;
            }
        });
    }

    /**
     * Persist enableComments/enableBookmarks toggles on checkbox 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);
        }
    }

    // ============================================================
    // EVENT HANDLERS
    // ============================================================

    /**
     * Watch comment fieldsets for pseud select injection, then auto-switch.
     * Handles both static and dynamically-added comment forms (e.g. replies).
     */
    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 present
                const existingSelect = fieldset.querySelector('select[name="comment[pseud_id]"]');
                if (existingSelect) {
                    switchCommentPseud();
                }
            }
        });
    }

    /**
     * Watch bookmark fieldsets for pseud select injection, then auto-switch.
     */
    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 present
                const existingSelect = fieldset.querySelector('select[name="bookmark[pseud_id]"]');
                if (existingSelect) {
                    switchBookmarkPseud();
                }
            }
        });
    }

    /**
     * Global observer: detect dynamically-added comment fieldsets (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 });
    }

    /**
     * Global observer: detect dynamically-added bookmark fieldsets and forms.
     * Handles both fieldset-based and #bookmark-form-based layouts.
     */
    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 });
    }

    // ============================================================
    // INITIALIZATION
    // ============================================================

    /**
     * Initialize pseud edit page: inject fandom fieldset, intercept form submit.
     */
    function initPseudEditPage() {
        addFandomFieldset();
        interceptFormSubmit();

        const enableCommentsCheckbox = document.querySelector("#enable_comments");
        const enableBookmarksCheckbox = document.querySelector("#enable_bookmarks");

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

    /**
     * Initialize work page: switch pseuds and wire up observers.
     */
    function initWorksPage() {
        switchCommentPseud();
        switchBookmarkPseud();
        observeCommentFieldsets();
        observeBookmarkFieldsets();
        observeForNewCommentFieldsets();
        observeForNewBookmarkFieldsets();
    }

    /**
     * Initialize bookmarks page: switch bookmark pseud and wire up observers.
     */
    function initBookmarksPage() {
        switchBookmarkPseud();
        observeBookmarkFieldsets();
        observeForNewBookmarkFieldsets();
    }

    /**
     * Route to the correct page initializer based on current URL.
     */
    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();
        }
    }

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", initializeScript);
    } else {
        initializeScript();
    }
})();