// ==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>";
}
}