AO3 BetterShip

save include/exclude tags for fast search; hide non-top-tag works

// ==UserScript==
// @name         AO3 BetterShip
// @namespace    viasyla
// @version      1
// @description  save include/exclude tags for fast search; hide non-top-tag works
// @author       viasyla
// @match        *://archiveofourown.org/*
// @match        *://www.archiveofourown.org/*
// @icon         https://archiveofourown.org/favicon.ico
// @license      MIT
// ==/UserScript==


/* START CONFIG */
const relpad = 3;
const charpad = 5;
/* END CONFIG */

const relationships = [];
const characters = [];

(function () {
    const style = document.createElement("style");
    style.textContent = `
        .workhide {
            display: flex;
            justify-content: space-between;
            align-items: center;
            font-size: 0.8em;
            margin: 0.15em 0;
            width: 100%;
        }
        [data-ospp-visibility="false"] > :not(.header),
        [data-ospp-visibility="false"] > .header > :not(h4) { display: none!important; }
        [data-ospp-visibility="false"] > div.workhide { display: flex!important; }
        [data-ospp-visibility="false"] > .header,
        [data-ospp-visibility="false"] > .header > h4 {
            margin: 0!important; min-height: auto; font-size: .9em; font-style: italic; }
        [data-ospp-visibility="false"] { opacity: .6; }

        .saved-filters-collapser { cursor: pointer; user-select: none; }
        .saved-filters, .saved-filters > div { margin-bottom: 0.6em; }
        .saved-filters { border-style: solid; border-width: 1px; padding: 0.6em; }
        .saved-filters textarea { min-height: 5em; width: 100%; margin-bottom: .3em;}
        .saved-filters div label { padding-left: 3px; }
        .prev-search span { color: #000; }
        .prev-search .temp { background: #ACEA72; }
        .prev-search .global { background: #93D2ED; }
        .prev-search .fandom { background: #B9AAED; }
        .sf-label { display: block; margin-top: .5em; }

        .ao3-saved-filters-section .sf-row {
            display: flex;
            align-items: center;
            gap: 0em;
            margin-bottom: 0.2em;
            width: 100%;
            box-sizing: border-box;
        }
        .ao3-saved-filters-section .checkbox {
            display: inline-flex;
            align-items: center;
            margin: 0;
        }

        .ao3-saved-filters-section .indicator {
            margin-right: 0.18em;
            padding: 0;
        }
        .ao3-saved-filters-section .sf-title {
            font-weight: normal;
            font-size: 1em;
            white-space: nowrap;
        }
        .ao3-saved-filters-section .action.js-save-filter {
            margin-left: auto;
            flex-shrink: 0;
        }
        .ao3-filter-shortcut-btn {
            width: 100% !important;
            box-sizing: border-box;
            margin: 0 0 0.7em 0;
            min-height: 1.286em;
            font-size: 100%;
            text-align: center;
        }
    `;
    document.head.appendChild(style);

    const fandomLinkElement = document.querySelector("h2.heading a");
    if (!fandomLinkElement) return;

    const fandomLink = fandomLinkElement.href;
    const tagName = fandomLinkElement.innerText;
    const isCharacterTag = !(tagName.includes("/") || tagName.includes("&"));

    if (isCharacterTag) {
        characters.push(tagName);
    } else if (!isCharacterTag) {
        relationships.push(tagName);
    }

    fetchFandomData(fandomLink.slice(fandomLink.indexOf("tags")), isCharacterTag)
    .then(fandomDataAvailable => {
        if (!fandomDataAvailable) return;
        insertFilterCheckboxes();

        document.getElementById('ospp-relationship-filter').addEventListener('change', applyTagFilter);
        document.getElementById('ospp-character-filter').addEventListener('change', applyTagFilter);

        applyTagFilter();
    });


    const isCharacterSearch = isCharacterTag;
    const isRelationshipSearch = !isCharacterTag;

    function insertFilterCheckboxes() {
        if (document.getElementById('ospp-relationship-filter')) return;
        const actions = document.querySelector('dd.submit.actions');
        if (!actions) return;
        const parent = actions.parentNode;
        const dd = document.createElement('dd');
        dd.style.marginBottom = '0.6em';

        // Relationship
        const relLabel = document.createElement('label');
        relLabel.setAttribute('for', 'ospp-relationship-filter');
        const relCheckbox = document.createElement('input');
        relCheckbox.type = 'checkbox';
        relCheckbox.id = 'ospp-relationship-filter';
        relCheckbox.disabled = !isRelationshipSearch;
        relCheckbox.checked = true && isRelationshipSearch;
        const relIndicator = document.createElement('span');
        relIndicator.className = 'indicator';
        relIndicator.setAttribute('aria-hidden', 'true');
        const relText = document.createElement('span');
        relText.textContent = 'Top Relationship';
        relText.style.fontWeight = "normal";

        relLabel.appendChild(relCheckbox);
        relLabel.appendChild(relIndicator);
        relLabel.appendChild(relText);

        // Character
        const charLabel = document.createElement('label');
        charLabel.setAttribute('for', 'ospp-character-filter');
        const charCheckbox = document.createElement('input');
        charCheckbox.type = 'checkbox';
        charCheckbox.id = 'ospp-character-filter';
        charCheckbox.disabled = !isCharacterSearch;
        charCheckbox.checked = true && isCharacterSearch;
        const charIndicator = document.createElement('span');
        charIndicator.className = 'indicator';
        charIndicator.setAttribute('aria-hidden', 'true');
        const charText = document.createElement('span');
        charText.textContent = 'Top Character';
        charText.style.fontWeight = "normal";

        charLabel.appendChild(charCheckbox);
        charLabel.appendChild(charIndicator);
        charLabel.appendChild(charText);

        relLabel.style.marginRight = "1.5em";
        relLabel.style.display = 'block';
        relLabel.style.marginBottom = '-1em';

        dd.appendChild(relLabel);
        dd.appendChild(document.createElement("br"));
        dd.appendChild(charLabel);

        parent.insertBefore(dd, actions);
    }


    function applyTagFilter() {
        const relChecked = document.getElementById('ospp-relationship-filter').checked && isRelationshipSearch;
        const charChecked = document.getElementById('ospp-character-filter').checked && isCharacterSearch;
        document.querySelectorAll(".index .blurb").forEach((blurb) => {
            const tags = blurb.querySelector("ul.tags");
            if (!tags) return;
            const relTags = Array.from(tags.querySelectorAll(".relationships")).slice(0, relpad).map(el => el.innerText);
            const charTags = Array.from(tags.querySelectorAll(".characters")).slice(0, charpad).map(el => el.innerText);
            let visible = true;
            if (relChecked) {
                visible = relTags.some(tag => relationships.includes(tag));
            }
            if (charChecked) {
                visible = visible && charTags.some(tag => characters.includes(tag));
            }

            blurb.setAttribute("data-ospp-visibility", visible ? "true" : "false");
            if (!visible && !blurb.querySelector('.workhide')) {
                const buttonDiv = document.createElement("div");
                buttonDiv.className = "workhide";
                buttonDiv.innerHTML = `
                <div class="left">Your preferred tag is not prioritised in this work.</div>
                <div class="right"><button type="button" class="showwork">Show Work</button></div>
            `;
                blurb.insertAdjacentElement("beforeend", buttonDiv);
            }
            if (visible && blurb.querySelector('.workhide')) {
                blurb.querySelector('.workhide').remove();
            }
        });
    }

    document.addEventListener("click", (event) => {
        if (event.target.matches(".showwork")) {
            const blurb = event.target.closest(".blurb");
            const button = event.target;
            const isHidden = blurb.getAttribute("data-ospp-visibility") === "false";
            blurb.setAttribute("data-ospp-visibility", isHidden ? "true" : "false");
            button.textContent = isHidden ? "Hide Work" : "Show Work";
        }
    });

    async function fetchFandomData(link, isCharacterTag) {
        try {
            const response = await fetch("/" + link + " .parent");
            const html = await response.text();
            const parser = new DOMParser();
            const doc = parser.parseFromString(html, "text/html");
            const pElements = doc.querySelectorAll("p");
            if ((pElements[2] && pElements[2].textContent.includes("Additional Tags Category")) ||
                (pElements[3] && pElements[3].textContent.includes("Additional Tags Category"))) {
                return false;
            }
            const synonymSources = doc.querySelectorAll("ul.tags.commas.index.group, ul.tags.tree.index");
            synonymSources.forEach((ul, index) => {
                if (index === 0) return;
                ul.querySelectorAll(":scope > li").forEach(li => {
                    processListItem(li, isCharacterTag);
                });
            });
            return true;
        } catch (error) {
            console.error('Error fetching fandom data:', error);
            return false;
        }
    }
    function processListItem(li, isCharacterTag) {
        const a = li.querySelector('a');
        if (!a) return;
        const synonym = a.textContent.trim();
        if (isCharacterTag) {
            if (!characters.includes(synonym)) characters.push(synonym);
        } else {
            if (!relationships.includes(synonym)) relationships.push(synonym);
        }
        const nestedUL = li.querySelector(':scope > ul');
        if (nestedUL) {
            for (const nested of nestedUL.children) {
                processListItem(nested, isCharacterTag);
            }
        }
    }


    const TAG_OWNERSHIP_PERCENT = 70;
    const works = document.querySelector('#main.works-index');
    const form = document.querySelector('form#work-filters');
    if (!works || !form) return;
    function getFandomName() {
        const fandomLabel = document.querySelector('#include_fandom_tags label');
        const heading = works.querySelector('.heading');
        if (!fandomLabel || !heading) return null;
        const fandom = fandomLabel.textContent;
        const fandomCount = parseInt(fandom.substring(fandom.lastIndexOf('(') + 1, fandom.lastIndexOf(')')));
        let tagCount = heading.textContent;
        tagCount = tagCount.substring(0, tagCount.indexOf(' Works'));
        tagCount = parseInt(tagCount.substring(tagCount.lastIndexOf(' ') + 1));
        const fandomName = fandom.substring(0, fandom.lastIndexOf('(')).trim();
        if (!fandomName || !fandomCount || !tagCount) return null;
        return (fandomCount / tagCount * 100 > TAG_OWNERSHIP_PERCENT) ? fandomName : null;
    }
    const fandomName = getFandomName();
    const tempKey = 'temp-filter';
    const tempGlobalKey = 'temp-global-filter';
    const tempFandomKey = 'temp-fandom-filter';
    const tempExcludeGlobalKey = 'temp-exclude-global-filter';
    const tempExcludeFandomKey = 'temp-exclude-fandom-filter';
    const globalKey = 'global-filter';
    const fandomKey = fandomName ? 'filter-' + fandomName : '';
    const excludeGlobalKey = 'exclude-global-filter';
    const excludeFandomKey = fandomName ? 'exclude-filter-' + fandomName : '';
    const prevGlobal = localStorage[globalKey] || '';
    const prevFandom = fandomKey ? localStorage[fandomKey] || '' : '';
    const prevExcludeGlobal = localStorage[excludeGlobalKey] || '';
    const prevExcludeFandom = excludeFandomKey ? localStorage[excludeFandomKey] || '' : '';

    const search = document.querySelector('#work_search_query');
    if (!search) return;
    const dd = search.closest('dd');
    const dt = dd && dd.previousElementSibling && dd.previousElementSibling.tagName === 'DT'
        ? dd.previousElementSibling
        : null;
    const realSearch = document.createElement('textarea');
    realSearch.style.display = 'none';
    realSearch.name = search.name;
    search.removeAttribute('name');
    search.insertAdjacentElement('afterend', realSearch);

    const container = document.createElement('div');
    container.className = 'saved-filters';

    function makeFilterSection(opts) {
        const div = document.createElement('div');
        div.className = opts.className + ' ao3-saved-filters-section';

        const textarea = document.createElement('textarea');
        textarea.value = opts.value;
        textarea.id = opts.id;
        textarea.placeholder = opts.placeholder || "tag11;tag12,tag13\ntag21;tag22";
        textarea.style.boxSizing = "border-box";
        textarea.style.marginTop = ".3em";
        textarea.style.width = "100%";

        const row = document.createElement('div');
        row.className = 'sf-row';
        row.style.display = 'flex';
        row.style.alignItems = 'center';
        row.style.gap = '0em';
        row.style.marginBottom = '.2em';

        const typeClass = opts.type === 'exclude' ? ' exclude' : '';
        const checkboxLabel = document.createElement('label');
        checkboxLabel.className = 'checkbox' + typeClass;
        checkboxLabel.style.position = 'relative';
        checkboxLabel.style.margin = '0';

        const checkbox = document.createElement('input');
        checkbox.type = 'checkbox';
        checkbox.className = 'js-enabled-checkbox';
        checkbox.checked = (localStorage[opts.enabledKey] !== 'false');

        const indicator = document.createElement('span');
        indicator.className = 'indicator';
        indicator.setAttribute('aria-hidden', 'true');

        checkboxLabel.appendChild(checkbox);
        checkboxLabel.appendChild(indicator);

        const titleSpan = document.createElement('span');
        titleSpan.className = 'sf-title';
        titleSpan.textContent = opts.label;
        titleSpan.style.fontWeight = 'normal';

        const saveBtn = document.createElement('button');
        saveBtn.type = 'button';
        saveBtn.className = 'action js-save-filter';
        saveBtn.textContent = 'Save';
        saveBtn.style.marginLeft = 'auto';
        saveBtn.addEventListener('click', function () {
            localStorage[opts.storeKey] = textarea.value;
            localStorage[opts.enabledKey] = checkbox.checked + '';
        });

        row.appendChild(checkboxLabel);
        row.appendChild(titleSpan);
        row.appendChild(saveBtn);

        div.appendChild(row);
        div.appendChild(textarea);

        return div;
    }

    // global include
    container.appendChild(makeFilterSection({
        label: 'Global', className: 'global-filter',
        value: prevGlobal, enabledKey: globalKey + '-on', storeKey: globalKey, id: 'sf-global-include', type: 'include'
    }));
    // fandom include
    if (fandomKey) {
        container.appendChild(makeFilterSection({
            label: 'Fandom', className: 'fandom-filter',
            value: prevFandom, enabledKey: fandomKey + '-on', storeKey: fandomKey, id: 'sf-fandom-include', type: 'include'
        }));
    }
    // global exclude
    container.appendChild(makeFilterSection({
        label: 'Global', className: 'exclude-global-filter',
        value: prevExcludeGlobal, enabledKey: excludeGlobalKey + '-on', storeKey: excludeGlobalKey, id: 'sf-global-exclude', type: 'exclude'
    }));
    // fandom exclude
    if (excludeFandomKey) {
        container.appendChild(makeFilterSection({
            label: 'Fandom', className: 'exclude-fandom-filter',
            value: prevExcludeFandom, enabledKey: excludeFandomKey + '-on', storeKey: excludeFandomKey, id: 'sf-fandom-exclude', type: 'exclude'
        }));
    }

    const collapser = document.createElement('dt');
    collapser.className = 'saved-filters-collapser filter-toggle collapsed';

    const btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'expander';
    btn.setAttribute('aria-expanded', 'false');
    btn.setAttribute('aria-controls', 'saved_filters_panel');
    btn.textContent = 'Saved Filters';

    collapser.appendChild(btn);

    container.id = 'saved_filters_panel';
    container.classList.add('expandable', 'hidden');

    if (dt && dt.parentNode) {
        dt.parentNode.insertBefore(collapser, dt);
        dt.parentNode.insertBefore(container, dt);
    }

    btn.addEventListener('click', function () {
    const expanded = btn.getAttribute('aria-expanded') === 'true';
    btn.setAttribute('aria-expanded', expanded ? 'false' : 'true');
    collapser.classList.toggle('expanded', !expanded);
    collapser.classList.toggle('collapsed', expanded);
    container.classList.toggle('hidden', expanded);
    });

    function insertPrevSearch() {
        const heading = works.querySelector('.heading');
        if (!heading) return;
        const url = decodeURIComponent(window.location.href);
        const m = url.match(/work_search_query=([^&]*)/);
        if (!m) return;
        let ps = m[1].replace(/\+/g, ' ');
        let html = 'Your filter was: ';
        html += ps + '.';
        const div = document.createElement('div');
        div.className = 'prev-search';
        div.innerHTML = html;
        heading.insertAdjacentElement('afterend', div);
    }
    insertPrevSearch();

    function parseTags(str) {
        if (!str) return [];
        return str.split(/[\n;,]+/).map(s => s.trim()).filter(Boolean);
    }
    function quoted(tag) {
        if (!tag) return '';
        tag = tag.trim();
        if (/^".*"$/.test(tag)) return tag;
        return `"${tag.replace(/^"+|"+$/g, '')}"`;
    }
    function assembleFilters() {
        let val = search.value || '';
        let includeFilters = [];
        let excludeFilters = [];
        container.querySelectorAll('.ao3-saved-filters-section').forEach(section => {
            const textarea = section.querySelector('textarea');
            const enabled = section.querySelector('.js-enabled-checkbox').checked;
            const className = section.className;
            if (!enabled || !textarea.value.trim()) return;
            if (className.includes('exclude-global-filter')) {
                excludeFilters = excludeFilters.concat(parseTags(textarea.value));
            } else if (className.includes('exclude-fandom-filter')) {
                excludeFilters = excludeFilters.concat(parseTags(textarea.value));
            } else if (className.includes('global-filter')) {
                includeFilters = includeFilters.concat(parseTags(textarea.value));
            } else if (className.includes('fandom-filter')) {
                includeFilters = includeFilters.concat(parseTags(textarea.value));
            }
        });
        // include
        if (includeFilters.length) {
            includeFilters.forEach(tag => {
                let qtag = quoted(tag);
                if (qtag && !val.includes(qtag)) val += ' ' + qtag;
            });
        }
        // exclude
        if (excludeFilters.length) {
            excludeFilters.forEach(tag => {
                let qtag = quoted(tag);
                if (qtag && !val.includes('-' + qtag)) val += ' -' + qtag;
            });
        }
        return val.trim();
    }

    form.addEventListener('submit', function (e) {
        const savedFiltersPanel = document.getElementById('saved_filters_panel');
        const isExpanded = !savedFiltersPanel.classList.contains('hidden');
        if (isExpanded) {
            realSearch.value = assembleFilters();
        } else {
            realSearch.value = search.value;
        }
    });

    const actionsDd = document.querySelector('dd.submit.actions');
    if (actionsDd) {
        const sortBtn = actionsDd.querySelector('input[type="submit"][value="Sort and Filter"]');
        if (sortBtn) {
            const filterBtn = document.createElement('input');
            filterBtn.type = 'button';
            filterBtn.value = '🔍 Apply Saved Filters';
            filterBtn.className = sortBtn.className;
            filterBtn.name = '';
            filterBtn.classList.add('ao3-filter-shortcut-btn');

            actionsDd.insertBefore(filterBtn, sortBtn);

            filterBtn.addEventListener('click', function() {
                realSearch.value = assembleFilters();
                form.submit();
            });
        }
    }

    document.querySelectorAll('input[type="submit"][value="Sort and Filter"]').forEach(btn => {
        btn.value = '🔍 Sort and Filter';
    });


})();