您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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.
// ==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>"; } }