AO3: Import & Export Script Storage

View & delete AO3 script settings (from localStorage) with hints which script they're from. Import & export settings to file for safekeeping or to transfer to another device.

Versión del día 10/08/2025. Echa un vistazo a la versión más reciente.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         AO3: Import & Export Script Storage
// @namespace    https://greasyfork.org/en/users/906106-escctrl
// @version      1.0
// @description  View & delete AO3 script settings (from localStorage) with hints which script they're from. Import & export settings to file for safekeeping or to transfer to another device.
// @author       escctrl
// @license      MIT
// @match        *://*.archiveofourown.org/
// @grant        none
// ==/UserScript==

'use strict';

// utility to reduce verboseness
const q = (selector, node=document) => node.querySelector(selector);
const qa = (selector, node=document) => node.querySelectorAll(selector);
const ins = (n, l, html) => n.insertAdjacentHTML(l, html);

// if no other script has created it yet, write out a "Userscripts" option to the main navigation
if (qa('#scriptconfig').length === 0) {
    qa('#header nav[aria-label="Site"] li.search')[0] // insert as last li before search
        .insertAdjacentHTML('beforebegin', `<li class="dropdown" id="scriptconfig">
            <a class="dropdown-toggle" href="/" data-toggle="dropdown" data-target="#">Userscripts</a>
            <ul class="menu dropdown-menu"></ul></li>`);
}
// then add this script's config option to navigation dropdown
ins(q('#scriptconfig .dropdown-menu'), 'beforeend', `<li><a href="#" id="open_storage">Import & Export Script Storage
        <span class="switch"><input type="checkbox"><span class="slider round"></span></span></a></li>`);

ins(q("head"), 'beforeend', `<style tyle="text/css"> #storagelist th, #storagelist td { vertical-align: middle; }
    /* The switch - the box around the slider */
        .switch { position: relative; display: inline-block; width: 2em; height: 1em; vertical-align: -0.3em; }
    /* Hide default HTML checkbox */
        .switch input { opacity: 0; width: 0; height: 0; }
    /* The slider */
        .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; -webkit-transition: .4s; transition: .4s; }
        .slider:before { position: absolute; content: ""; height: 0.8em; width: 0.8em; left: 0.2em; bottom: 0.1em; background-color: white; -webkit-transition: .4s; transition: .4s; }
        input:checked + .slider { background-color: currentColor; }
        input:focus + .slider { box-shadow: 0 0 1px currentColor; }
        input:checked + .slider:before { -webkit-transform: translateX(0.8em); -ms-transform: translateX(0.8em); transform: translateX(0.8em); }
    /* Rounded sliders */
        .slider.round { border-radius: 1em; }
        .slider.round:before { border-radius: 50%; }
    .fadeOut { opacity: 0; transition: opacity 400ms; }
    </style>`);

// on either click on link or change of checkbox (depending where exactly user clicked), switch the localStorage view on/off
q('#open_storage').addEventListener('click', (e)=>{
    e.preventDefault();
    if (!q('#storagecontainer')) {
        if (e.currentTarget.tagName==="A") q('input', e.currentTarget).checked = true; // make sure the slider reflects the status
        showLocalStorage();
    }
    else {
        if (e.currentTarget.tagName==="A") q('input', e.currentTarget).checked = false; // make sure the slider reflects the status
        hideLocalStorage();
    }
});

function showLocalStorage() {
    // Read LocalStorage and create a table from it
    let storage_list = [];
    for (let i = 0; i<localStorage.length; i++) {
        let key = localStorage.key(i);
        if (key =="accepted_tos") continue;
        storage_list.push([key, localStorage.getItem(key), getScriptNotes(key)]);
    }
    let stdcontent = q('div.splash');

    if (storage_list.length > 0) {
        // sort by name!
        storage_list.sort((a, b)=>{
            return a[0].localeCompare(b[0]);
        });

        let table = `<div id="storagecontainer">
        <p>Select items in the table and <button type="button" id="storagedelete">Delete</button> them, <button type="button" id="storagedl">Export</button> them to a file, or <button type="button" id="storageul">Import</button> settings from such a file. <input type="file" accept="application/json" id="storagefile" style="display: none" /></p>
        <table id="storagelist"><thead><tr><th><input type="checkbox" id="storageselall"></th><th>Key</th><th>Source</th><th>Content</th><th>Length</th></tr></thead><tbody>`;
        storage_list.forEach((v, i) => {
            table += `<tr><td><input type="checkbox" class="storagesel"></td><td class='storagekey'>${v[0]}</td><td>${v[2]}</td><td>${v[1].slice(0, 30)}${(v[1].length>30) ? "..." : ""}</td><td style="text-align: right;">${v[1].length} char${(v[1].length==1) ? "" : "s"}</td></tr>`;
        });
        table += `</tbody></table><p style="margin-top: 1em;">A Source listed as <i>unknown</i> means that you have or had a script installed which wrote this data,
            but the Import/Export script doesn't know its name. You can check your installed scripts to figure out which script it's from.</p></div>`;

        stdcontent.style.display = 'none';
        ins(stdcontent, 'beforebegin', table);
    }
    else {
        stdcontent.style.display = 'none';
        ins(stdcontent, 'beforebegin', `<div id="storagecontainer"><p>There is no data for AO3 stored in your browser's localStorage. <button type="button" id="storageul">Import</button> settings from a file. <input type="file" accept="application/json" id="storagefile" style="display: none" /></p></div>`);
    }
    // event listeners for various buttons and checkboxes
    q('#storagecontainer').addEventListener('click', (e)=>{
        if (e.target.tagName==="BUTTON" && e.target.id === 'storagedelete') {
            let boxes = qa('.storagesel');
            for (let box of boxes) {
                if (!box.checked) continue;
                let row = box.closest('tr');
                let key = q('.storagekey', row).textContent;
                localStorage.removeItem(key);
                row.classList.add('fadeOut');
                setTimeout(() => row.remove(), 400);
            }
        }
        else if (e.target.tagName==="BUTTON" && e.target.id === 'storagedl') {
            downloadFile(storage_list);
        }
        else if (e.target.tagName==="BUTTON" && e.target.id === 'storageul') {
            q('#storagefile').click(); // we show a pretty button but trigger the usual file browse button
        }
        else if (e.target.tagName==="INPUT" && e.target.id === 'storagesel') {
            q('#storagefile').click(); // we show a pretty button but trigger the usual file browse button
        }
    });
    q('#storagefile').addEventListener('change', uploadFile); // react when a file was selected
    q('#storageselall').addEventListener('change', (e)=>{
        qa('.storagesel').forEach((b) => { b.checked = e.target.checked; });
    });
}

function hideLocalStorage() {
    q('#storagecontainer').remove();
    q('div.splash').style.display = 'block';
}

function downloadFile(storage_list) {
    const a = document.createElement('a'); // Create <a> element

    // collect the content we're supposed to export
    let export_keys = [];
    let boxes = qa('.storagesel');
    for (let box of boxes) {
        if (!box.checked) continue;
        let row = box.closest('tr');
        export_keys.push(q('.storagekey', row).textContent);
    }
    if (export_keys.length === 0) {
        alertError("Please select at least one setting to export and try again.");
        return;
    }
    let export_arr = [];
    for (let s of storage_list) {
        if (export_keys.includes(s[0])) export_arr.push([ s[0], s[1] ]);
    }

    const blob = new Blob([JSON.stringify(export_arr)], {type: 'application/json'}); // Create a blob with our settings
    const url = URL.createObjectURL(blob); // Create an object URL from blob
    a.setAttribute('href', url); // Set <a> element link with that blob URL
    a.setAttribute('download', `ao3-script-storage-${new Date().toISOString().replaceAll(/[^\d\w]/g,'')}.json`); // Set download filename
    a.click(); // Start downloading
}

function uploadFile() {
    const uploaded = this.files[0]; // the selected file
    if (!uploaded) alertError("No file selected. Please try again.");
    else if (uploaded.type !== 'application/json') alertError("Unsupported file type. Please try again with a JSON file.");
    else {
        const reader = new FileReader();
        reader.onload = () => { overwriteStorage(reader.result); };
        reader.onerror = () => { alertError("Error reading the file. Please try again."); };
        reader.readAsText(uploaded); // read the file
    }
}

function overwriteStorage(content) {
    try {
        content = JSON.parse(content); // parse the content back into a [[key, val], ...] array
    } catch (error) { // JSON syntax error
        alertError("File does not contain valid JSON. Please try again with a JSON file.");
        return;
    }
    // file content was otherwise malformed
    if (!Array.isArray(content) || content.length < 1 || !content.every((curr) => curr.length === 2)) {
        alertError("File does not contain valid settings. Please try again with a proper export file.");
        return;
    }
    for (let entry of content) {
        localStorage.setItem(entry[0], entry[1]); // push everything into the browser storage
    }
    q('#storagecontainer').remove(); // refresh the display
    showLocalStorage();
}

function alertError(msg) {
    alert(msg);
}

function getScriptNotes(key) {
    switch (key) {
        case "accepted_tos":
            return "set by AO3";
        case "ao3jail":
            return "used by some scripts to stop when encountering Retry Later, OK to delete";
        case "aia_refdate":
        case "aia_ref":
            return "<a href='https://greasyfork.org/en/scripts/475525'>AO3: [Wrangling] Mark Co- and Solo-Wrangled Fandoms</a>";
        case "floatcmt":
            return "<a href='https://greasyfork.org/en/scripts/489335'>AO3: Sticky Comment Box</a>";
        case "glossary":
            return "<a href='https://greasyfork.org/en/scripts/450347'>AO3: Glossary Definition Previews</a>";
        case "agecheck_new":
        case "agecheck_old":
            return "<a href='https://greasyfork.org/en/scripts/444335'>AO3: [Wrangling] Highlight Bins with Overdue Tags</a>";
        case "cmtfmtcustom":
        case "cmtfmtstandard":
            return "<a href='https://greasyfork.org/en/scripts/484002'>AO3: Comment Formatting and Preview</a>";
        case "iconify0":
        case "iconify-count":
        case "iconify-version":
            return "set by Iconify, used by various scripts for icons on buttons";
        case "kbdpages":
        case "kbdshortcuts":
            return "<a href='https://greasyfork.org/en/scripts/451524'>AO3: [Wrangling] Keyboard Shortcuts</a>";
        case "smallertagsearch":
            return "<a href='https://greasyfork.org/en/scripts/443886'>AO3: [Wrangling] Smaller Tag Search</a>";
        case "unread_inbox_count":
        case "unread_inbox_date":
        case "unread_inbox_conf":
            return "<a href='https://greasyfork.org/en/scripts/474892'>AO3: Badge for Unread Inbox Messages</a>";
        case "script-replaceYN":
        case "script-replaceYN-on":
            return "<a href='https://greasyfork.org/en/scripts/477499'>AO3: Replace Y/N in works with your name</a>";
        case "tags_saved_date_map":
            return "<a href='https://greasyfork.org/en/scripts/438063'>AO3: [Wrangling] UW Tag Snooze Buttons</a>";
        //case "": // not yet migrated to localStorage
        //    return "<a href='https://greasyfork.org/en/scripts/432628'>AO3: [Wrangling] Snooze Buttons</a>";
        case "kudoshistory_kudosed":
        case "kudoshistory_checked":
        case "kudoshistory_seen":
        case "kudoshistory_bookmarked":
        case "kudoshistory_skipped":
            return "<a href='https://greasyfork.org/en/scripts/5835'>AO3: Kudosed and seen history</a>";
        case "ao3tracking_list":
        case "ao3tracking_lastcheck":
            return "<a href='https://greasyfork.org/en/scripts/8382'>AO3: Tracking</a>";
        case "wrangleActionButtons":
            return "<a href='https://greasyfork.org/en/scripts/501991'>AO3: [Wrangling] Action Buttons Everywhere</a>";
        case "wrangleShortcuts_act":
        case "wrangleShortcuts_tag":
            return "<a href='https://greasyfork.org/en/scripts/507705'>AO3: [Wrangling] Keyboard Shortcuts</a>";
        case "rainbowTables":
            return "<a href='https://greasyfork.org/en/scripts/445805'>AO3: [Wrangling] Rainbow Tables</a>";
        case "wrangleResources":
            return "<a href='https://greasyfork.org/en/scripts/511102'>AO3: [Wrangling] Fandom Resources Quicklinks</a>";
        default:
            return "<i>unknown</i>";
    }
}