// ==UserScript==
// @name ao3 sticky filters
// @namespace https://sincerelyandyourstruly.neocities.org
// @author 白雪花
// @description rewriting thE saved filters script from https://greasyfork.org/en/scripts/3578-ao3-saved-filters, as well as adding in features made possible by flamebyrd's tag id bookmarklet (https://random.fangirling.net/scripts/ao3_tag_id)
// @match http*://archiveofourown.org/tags/*/works*
// @match http*://archiveofourown.org/works?work_search*
// @match http*://archiveofourown.org/works?commit=*&tag_id=*
// @downloadURL https://raw.githubusercontent.com/XiaoBaiXueHua/soql/main/publishable/filterscript.js
// @updateURL https://raw.githubusercontent.com/XiaoBaiXueHua/soql/main/publishable/filterscript.js
// @version 2.4
// @history 2.4 - added file uploading for imports
// @history 2.3 - optimized how the filter optimizer works + fixed the monthly storage cleanup thing + also just began preparing to optimize shit in general
// @history 2.2.2 - script can now self-correct when it stores a filter id's name wrong (like in a botched import)
// @history 2.2.1 - fixed a bug abt the tag ui not showing up on global tag types
// @history 2.2 - added ability to optimize filters to ui. idiot-proofed the ui a bit more (it's me i'm idiots)
// @history 2.1 - added ability to import/export saved filters
// @grant none
// @run-at document-end
// ==/UserScript==
if (!window.soql) {
window.soql = {
autofilters: {
enabled: true
}
}
} else {
window.soql[`autofilters`] = {
enabled: true
};
}
window.soql[`toCss`] = function (str) { return str.replaceAll(/\W+/g, "-"); }
// fuck it, making this a class so we can always get this shit fresh
window.soql.autofilters[`relevant`] = class { // let's see if we can attach a class to it
static get fandoms() {
return JSON.parse(localStorage[listKey]); // we'll see if we can replace this w/the window obj version later
}
static get all() {
// const lesigh = new Object(localStorage);
return Object.entries(localStorage).filter((entry) => { return (entry[0].search(/^(filter|enable|ids)/) >= 0) }); // only returns the local storage entries relevant to this script, like the filters, enables, and ids
}
static get keys() {
return Object.keys(localStorage).filter((entry) => entry.search(/^(filter|enable|ids)/) >= 0); // just the keys
}
static get values() {
return Object.values(localStorage).filter((entry) => entry.search(/^(filter|enable|ids)/) >= 0); // just the values
}
static get finable() {
return rel.all.filter((entry) => (entry[0].search(/^ids/) < 0)); // returned as entries
}
static get finableKeys() {
return rel.keys.filter((entry) => entry.search(/^ids/) < 0);
}
static get finableValues() {
return rel.values.filter((entry) => entry.search(/^ids/) < 0);
}
static get filters() {
return rel.finable.filter((entry) => entry[0].search(/filter/) >= 0);
}
static get ids() {
return rel.all.filter((entry) => (entry[0].search(/^ids/) >= 0)); // returned as entries
}
static get idKeys() {
return rel.keys.filter((entry) => entry.search(/^ids/) >= 0);
}
static get idValues() {
return rel.values.filter((entry) => entry.search(/^ids/) >= 0);
}
static push(key, val, addition) {
console.log(`key: ${key}, val: `, val, ` addition: `, addition);
try {
const tmp = val;
(tmp.length < 1) ? tmp[0] = addition : tmp.push(addition);
localStorage.setItem(key, JSON.stringify(tmp)); // save the new ver to local storage
console.log(`localStorage after pushing: `, localStorage[key]);
} catch (e) {
console.error(`you're only supposed to use the static rel.push to get around the way the getters work on the local storage :/`, e);
}
}
}
const rel = window.soql.autofilters.relevant; // this is just shorthanding for the purposes of in this script
/* various important global vars */
window.soql.remAmbig = /\s\((\w+(\s|&)*?|\d+\s?)+\)/g; //removes disambiguators
const header = document.querySelector("h2:has(a.tag)");
const currentTag = header.querySelector("a.tag"); //the current tag being searched
const tagName = currentTag.innerText.replace(window.soql.remAmbig, "").trim();
const errorFlash = document.querySelector("div.flash.error");
const noResults = function () {
return header.innerHTML.match(/\n0\s/) ? true : false;
}(); //will allow for the fandom box to be made
//here's the local storage array
/* keeping the fandoms w/saved filters in an array: */
var listKey = "saved fandoms";
if (!localStorage[listKey]) { localStorage.setItem(listKey, JSON.stringify(new Array())); } // set it to a new array if it doesn't exist
window.soql.autofilters.fandoms = function () { return JSON.parse(localStorage[listKey]); } // save this to the window thing
// monthly cleanup stuff
let today = dateFloor(), lastCleanup = dateFloor(); // default is today
try {
} catch (e) {
localStorage.setItem(`lastCleanup`, lastCleanup);
}
console.log(`today's date: ${today}\nlast cleanup date: ${lastCleanup}`);
function dateFloor(date = new Date()) { // function for getting the floor value of the date as a string
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
}
// function for cleaning up storage: done automatically each month on the 1st and probably whenever you hit "optimize filters"
function storageCleanup() {
console.log(`cleaning...`);
var localFilters = 0, localEnables = 0; // local storage entry for the saved filters n whether that fandom's been enabled/disabled
const filteredFandoms = new Array();
const orphans = rel.all; // bind this
console.log(`rel.all: `, orphans);
function allowable(str) {
var i = 0, allowed = false;
const r = rel.fandoms;
// then we loop through all the currently included fandoms to see if this enable is Safe
while (!allowed && i < r.length) {
// ...also protect the enable global lol
let reg = new RegExp(`(${r[i]}|${toCss(r[i])}|global)`); // regex which checks if the thing is allowed in either its standard or css form
if (str.search(reg) > 0) {
allowed = true;
break;
}
i++;
}
return allowed;
}
for (const [key, value] of rel.all) {
const srch = key.match(/^(filter|enable)/);
const globalvance = !(key.search(/-(global|advanced-search)$/) < 0);
if (srch && !globalvance) { // also make sure you're not doing this to the global/advanced searches. just leave those alone
// console.log(`key: ${key}; value:\n${value}`)
if (srch[0] == "filter" && key !== `filter-advanced-search`) { // simply Do Not Do This on the advanced search
const f = key.replace(/^filter-/, "");
if (value !== "") { // if that filter Has Values, then we keep its fandom name. and also we have to keep the global
localFilters++;
filteredFandoms.push(f);
} else { // otherwise remove those entries
console.log(`uhhh empty value for a filter (${key})`)
localStorage.removeItem(key);
localStorage.removeItem(`enable-${toCss(f)}`);
}
} else {
localEnables++;
}
}
}
localStorage.setItem(listKey, JSON.stringify(filteredFandoms)); // lalala save this
if (localFilters !== localEnables) {
// then we have to run it through again to clean up orphaned enables lol
// the only reason there Would be orphans is bc the script
console.log(`ah. ${localFilters} filters & ${localEnables} enables. orphaned enables must die now.`)
for (const key of rel.finableKeys) {
console.log(`key: ${key}`);
// console.info(`key: ${key}`, rel.finable);
if (key.search(/^enable-/) >= 0) {
var safeEnable = allowable(key);
if (!safeEnable) {
// if the enable item is not found in the list of saved fandoms, it is purged
localStorage.removeItem(key);
}
}
}
}
// now clean up the ids
for (const [key, value] of rel.ids) {
// console.debug(`key: ${key}\nvalue: ${value}`);
if (value == "") { // i genuinely have no idea how a blank id array could have happened, but since it's smth i'm encountering during debugging, i guess we're working with it now
localStorage.removeItem(key);
} else {
const e = JSON.parse(value);
// console.debug(`${key} allowed? `, allowable(key));
if (!allowable(key)) {
// console.log(e, e.length);
// if it's not allowed (not in the saved fandoms --> had no saved filters last time cleaning was run)
if (e.length == 1) {
console.debug(`sole e entry: `, e[0]);
if (e[0].length < 1) {
// this line will have to exist forever now to atone for my sins of "bugged out the saving id keys" for so long
console.debug(`for some reason this entry is empty.`);
localStorage.removeItem(key);
} else if (key.search(toCss(e[0][0])) >= 0) {
console.debug(`the Sole entry is just the fandom's id number`);
localStorage.removeItem(key); // tbh just get rid of it at that point
}
}
} else {
autosave(key, JSON.stringify(e.sort((a, b) => {
return parseInt(a[1]) - parseInt(b[1]);
}))); // sorts the existing ones by ascending id #
}
}
}
console.log(`localStorage before cleanup: `, orphans, `\nlocalStorage after cleanup: `, rel.all);
localStorage.setItem(`lastCleanup`, today); // sets it like this so that it's nice and Clean
// console.log(`last cleaned: `, localStorage[`lastCleanup`]);
}
if (((new Date(today)).getDate() == 1) && (today !== lastCleanup)) { // basically try to only do it once on the day of
// if ((new Date(today)).getDate() == 1) { // basically try to only do it once on the day of
console.log(`ahhh yes... monthly cleanup time`);
storageCleanup();
}
/* removes local storage on blank tags */
const search_submit = window.location.search;
const currentPath = window.location.pathname.toString();
const shouldClear = currentPath.match(/tags/);
if (shouldClear && !search_submit) {
localStorage.setItem("filter-advanced-search", "");
}
/* current fandom checker */
const works = document.querySelector("#main.works-index");
const form = document.querySelector("form#work-filters");
// function for sniping the fandom name name of a tag from a page
window.soql.autofilters[`getFandom`] = function (el = document, t = `[tagName]`) {
var fandom_cutoff = 70;
var raws = el.querySelectorAll("#include_fandom_tags label"); //gets the fandom count from the dropdown on the side
if (!raws[0]) { return null; };
var raw = raws[0].innerText;
var fandom = raw.replace(window.soql.remAmbig, "").trim();
//later, maybe have it look at the other top fandoms n see if they're related, either by like an author name, or if there's an "all media types" attached to redeclare the cutoff
var fandomCount = raw.match(/\(\d+\)/).toString();
fandomCount = fandomCount.substring(1, fandomCount.length - 1); //chops off parentheses
fandomCount = parseInt(fandomCount);
var tagCount = el.querySelector(`h2:has(a.tag)`).innerText;
tagCount = tagCount.match(/(\d+,?\d*)+(?=\sW)/)[0].replaceAll(/,/g, ""); // get the number, remove the commas
tagCount = parseInt(tagCount); //now turn it into an integer
console.log(`there are ${tagCount.toLocaleString()} works in the ${t} tag.`);
if (tagCount < 10) {
// if there are fewer than 10 works in a tag, then we should Probably have a bit more careful thought going on
console.log(`there are ${raws.length} fandoms listed for this minor tag o.o`);
const ms = raw.match(window.soql.remAmbig);
var lowPercent = (parseInt(ms[ms.length - 1].replaceAll(/(\(|,|\))/g, "")) / tagCount) * 100; // uhhh takes the number in parentheses off the fandoms drop-down list, parses it as an integer, n divides it by number of fics in the tag
if (lowPercent >= fandom_cutoff) {
console.log(`${lowPercent}% of fics in ${t} tag belong in the ${fandom} tag.`)
} else {
console.log(`not enough fics to make a determination.`);
return;
}
}
if (!fandom || !fandomCount || !tagCount) { return; } // you know maybe in the rewrite, maybe instead of having it return nothing/null for these things, have it return "global" instead. might do something good.
var meetsCutoff = (fandomCount / tagCount * 100 >= fandom_cutoff);
if (meetsCutoff && rel.fandoms.indexOf(fandom) < 0) { //if it qualifies as being part of a fandom & is not yet in the array, add it and then save it to local storage
const tmp = rel.fandoms; // have to do it this way, since the static thing automatically always fetches it from the localStorage ehe
tmp.push(fandom);
autosave(listKey, JSON.stringify(tmp));
}
return meetsCutoff ? fandom : null;
}
const fandomName = window.soql.autofilters.getFandom(document, tagName);
console.info(`fandomName: ${fandomName}`);
window.soql.autofilters[`fandomName`] = fandomName; // bind this
window.soql.autofilters[`cssName`] = window.soql.autofilters.fandomName ? window.soql.toCss(fandomName) : null;
console.log(rel.fandoms);
/* function to make css-friendly versions of a name */
function toCss(str) {
return str.replaceAll(/\W+/g, "-");
}
const cssFanName = fandomName ? toCss(fandomName) : null;
// attach the idKeyVals class
window.soql.autofilters[`idKeyVals`] = class {
static get global() {
// returns a json
let jason = [ // default freebies
["Not Rated", 9], ["General Audiences", 10], ["Teen And Up", 11], ["Mature", 12], ["Explicit", 13], // ratings
["Author Chose Not to Use Archive Warnings", 14], ["No Archive Warnings Apply", 16], ["Graphic Depictions of Violence", 17], ["Major Character Death", 18], ["Rape/Non-Con", 19], ["Underage Sex", 20], // warnings
["General", 21], ["F/M", 22], ["M/M", 23], ["Other", 24], ["F/F", 116], ["Multi", 2246], // categories
["Chatting & Messaging", 106225] // general nuisances
];
try {
jason = JSON.parse(localStorage.getItem(`ids-global`));
} catch (e) {
console.warn(`error in getting global ids: `, e);
localStorage.setItem(`ids-global`, JSON.stringify(jason));
}
return jason;
}
static specific(fanName) {
let jason = [[]];
if (fanName) { // basically if it's not null
try {
jason = JSON.parse(localStorage.getItem(`ids-${window.soql.toCss(fanName)}`));
} catch (e) {
localStorage.setItem(`ids-${window.soql.toCss(fanName)}`, JSON.stringify(jason)); // make a new thing for legitimate new fandoms not in our storage
}
} else if (fanName == null) {
// just proceed to assume it's global at that point
return window.soql.autofilters.idKeyVals.global;
}
return jason; // i really don't know why i didn't just do this
}
static get fandom() { // defaults to the current fandom tag you're in
return window.soql.autofilters.idKeyVals.specific(window.soql.autofilters.fandomName);
}
static includes(params = { name: null, number: null }) {
console.log(`checking for an inclusion...`);
let idNumber = null, idName = null;
if (typeof (params) == "number") {
idNumber = params; // backwards compatibility
} else if (typeof (params) == "string") {
// this is if we're searching for it by name
if (parseInt(params) == NaN) {
idName = params;
} else {
idNumber = params;
}
} else if (typeof (params) == "object") {
// i guess this is like an exact match sort of thing
try {
idNumber = params.number;
} catch (e) {
// dne on the id number
try { // second-class search filter parameter name
idName = params.name;
} catch (e) {
// dne on the id name
}
}
}
const byId = (!(idNumber == null) && !(idNumber == NaN)); // boolean for determining if we're checking against an id or not
const opts = window.soql.autofilters.idKeyVals.global.concat(window.soql.autofilters.idKeyVals.fandom).filter( // look in both the global and the current fandom
(entry) => {
if (!entry) { return false; } // in case there's holes i guess
// console.log(`entry being filtered: `, entry);
return (byId ? (entry[1] == parseInt(idNumber)) : (entry[0] == idName));
}
);
// console.log(`opts: `, opts);
if (opts.length > 0) { console.info(`found id #${idNumber} as "${opts[0][0]}".`) }
return (opts.length > 0) ? opts[0] : false;
}
static replace([filterName, idNumber]) {
const whichever = window.soql.autofilters.fandomName ? window.soql.autofilters.idKeyVals.fandom : window.soql.autofilters.idKeyVals.global;
const rem = whichever.filter((entry) => (entry[1] !== parseInt(idNumber))); // produces an array with everything still intact Except for the id number in question
rem.push([filterName, idNumber]);
autosave(`ids-${window.soql.autofilters.fandomName ? cssFanName : "global"}`, JSON.stringify(rem)); // and then also save it
}
static push(n, i, fn) { //by default, do this w/the current tag's name, id, and fandom. the import process will need to loop through this later, hence the params
var add = [n, i];
const incl = window.soql.autofilters.idKeyVals.includes(i);
if (incl) {
if ((incl[0] !== n)) {
console.debug(`updating tag name to "${n}"`)
window.soql.autofilters.idKeyVals.replace(add); // replace it with its proper name if it doesn't match AND if we've specified it should be renamed
}
} else {
console.log(`hmm. we don't have ${JSON.stringify(add)} in here.`);
const tmp = fn ? window.soql.autofilters.idKeyVals.specific(fn) : window.soql.autofilters.idKeyVals.global; // pick which json we're pushing to
console.log(`idKeyVals.push tmp:`, tmp);
rel.push(`ids-${fn ? window.soql.toCss(fn) : "global"}`, tmp, add);
}
}
}
function emptyStorage(key) { //function to give you that particular localStorage (n set it to nothing if dne)
if (!localStorage[key]) {
localStorage.setItem(key, "");
}
return localStorage[key];
}
function searchType(str) {
return (str == "global" || str == "advanced-search") ? str : "fandom"; // function for flattening fandom names down to just "fandom". will probably be more useful when doing a proper rewrite tbh
}
/* local storage keys */
function enable(key) {
if (key == "advanced-search") { return null; }
let enabled = true;
try {
enabled = JSON.parse(localStorage[`enable-${key}`]);
} catch (e) {
console.warn(`[${key}] has no set filters yet`);
//if it's not "null" (aka no fandom), then default is true
if (key) {
localStorage.setItem(`enable-${key}`, true);
}
}
return enabled;
}
function filterTypes(name) {
var is = name == "fandom" ? true : false;
if (is && !fandomName) { return null; } //exit from trying to make a fandom box in a global tag
var key = `filter-${is ? fandomName : name}`;
if (!localStorage[key]) { localStorage.setItem(key, "") }; //if there doesn't already exist a filter for this fandom, set it now
var filter = localStorage[key];
var en = enable(is ? cssFanName : name);
var obj = [name, key, filter, en];
return obj;
}
var global = filterTypes("global");
var fan = filterTypes("fandom");
var tempp = filterTypes("advanced-search");
/* declaring functions */
function autosave(key, value) {
localStorage.setItem(key, value);
};
window.soql.autofilters[`autosave`] = autosave; // i wonder if we can do it this way
function checkbox(name, bool, prefix) {
prefix = prefix ? prefix : "enable"; //if not specified, then the prefix will be "enable";
const cbox = document.createElement("input");
cbox.setAttribute("type", "checkbox");
cbox.id = `${prefix}-${name}`;
cbox.checked = bool;
const l = document.createElement("label");
l.setAttribute("for", `${prefix}-${name}`);
l.innerHTML = prefix;
const span = document.createElement("span");
span.append(cbox, l);
span.addEventListener("click", function () {
bool = cbox.checked; //bool should be stored in a var, like g_enable or smth, so now we're updating it to the latest checked status
autosave(`${prefix}-${name}`, bool);
});
return span;
};
//function to transform an array. not sure why i had the return at the end since i obviously never set any vars to it
function box(obj) {
if (!obj) { return null; }; //exit if no fandom
var is = (obj[0] == "fandom"); //thing for checking if this box is a fandom type or not
var name = obj[0];
const box = document.createElement("textarea");
box.id = `${name}Filters`;
box.value = obj[2] ? obj[2] : "";
box.addEventListener("keyup", async () => {
obj[2] = box.value;
await autosave(obj[1], obj[2]);
});
const label = document.createElement("label");
label.className = "filter-box-label";
var htm = name;
htm += is ? ` (${fandomName})` : "";
label.innerHTML = `${htm}:`;
label.setAttribute("for", `${name}Filters`);
const chk = checkbox(is ? cssFanName : name, obj[3]);
const els = [label, box, chk];
obj.push(els);
return obj;
};
box(global);
const globEl = global[4];
/* now for the tag id fetcher */
/* the function to add the tag ids n stuff */
//gotta make these first for tagUI
const navList = document.querySelector("#main ul.user.navigation");
const filtButt = document.createElement("li");
filtButt.id = "get_id_butt";
filtButt.innerHTML = `Tag ID`;
/* id fetcher function, by flamebyrd */
window.soql.autofilters[`getID`] = function (el = document) {
let i = null;
if (el.querySelector("#favorite_tag_tag_id")) {
console.log("favorite tag id method")
i = el.querySelector("#favorite_tag_tag_id").value;
} else if (el.querySelector("a.rss")) {
console.log("rss feed method");
var href = el.querySelector("a.rss");
href = href.getAttribute("href");
href = href.match(/\d+/);
i = href;
} else if (el.querySelector("#include_freeform_tags input:first-of-type")) {
console.log("first freeform tag method");
i = el.querySelector("#include_freeform_tags input:first-of-type").value;
} else if (el.querySelector("#subscription_subscribable_id")) {
console.log("subscribable id method");
i = el.querySelector("#subscription_subscribable_id").value;
};
if (typeof (i) !== "number") {
try {
i = parseInt(i); // try turning it into a number
} catch (e) {
console.warn(`wah smth weird happened to our id#`);
}
}
return i;
}
const id = window.soql.autofilters.getID();
var filter_ids = `filter_ids:${id}`;
/* now to deal w/the currently-existing form */
const searchdt = document.querySelector("dt.search:not(.autocomplete)");
const searchdd = document.querySelector("dd.search:not(.autocomplete");
const advSearch = document.querySelector("#work_search_query");
//if there's one there will obvs be the other, but just so that they don't feel left out, using "or"
if (searchdt !== null || searchdd !== null) {
if (!search_submit) {
window.soql.autofilters.idKeyVals.push(tagName, id, fandomName); // first, just save the tag id in local storage. save me the time
}
advSearch.hidden = true;
const fakeSearch = document.createElement("input");
fakeSearch.id = "fakeSearch";
fakeSearch.setAttribute("autocomplete", "off");
fakeSearch.value = tempp[2] ? tempp[2] : "";
fakeSearch.addEventListener("keyup", async () => {
tempp[2] = fakeSearch.value; //have to do this bc unlike the other boxes, it didn't go through a function for its autosaving thing
await autosave(tempp[1], tempp[2]);
});
searchdd.appendChild(fakeSearch);
const details = document.createElement("details");
details.id = "stickyFilters";
const summary = document.createElement("summary");
summary.innerHTML = "Saved Filters";
const saveDiv = document.createElement("div");
/* make the global box */
for (const el of globEl) {
saveDiv.appendChild(el);
};
const fanEl = box(fan) ? fan[4] : null;
if (fanEl) {
for (const el of fanEl) {
saveDiv.appendChild(el);
};
}
/* when a search returns nothing */
else if (noResults) {
var html = `Your search returned no results. Would you like to review your filters?`;
debuggy(html);
};
details.append(summary, saveDiv);
searchdt.insertAdjacentElement("beforebegin", details);
} else if (errorFlash) {
var html = "Double-check your filters for mistakes.";
debuggy(html);
} else {
console.error("lol idk you dun goof'd i guess")
}
/* the debugger textboxes */
function debuggy(t = "", par = header) {
if (form) { form.hidden = true; } //hide the search form on the 0 results page
const debugDiv = document.createElement("div");
debugDiv.id = "error_debug";
const p = document.createElement("p");
p.innerHTML = t;
var href = `${currentTag.href}/works`;
const reSearch = document.createElement("ul");
reSearch.className = "actions";
reSearch.id = "debugged-search";
if (noResults && !errorFlash) {
const showFilters = document.createElement("a");
showFilters.innerHTML = "Show All Filters";
showFilters.href = "#";
showFilters.addEventListener("click", function () {
showAllFilters(debugDiv);
showFilters.remove(); //remove self after showing all the filters
})
reSearch.appendChild(showFilters);
} else if (errorFlash) {
showAllFilters(debugDiv); //will automatically do the debug div on the error flash page
}
const research = document.createElement("a");
research.href = href;
research.innerHTML = "Search Again";
reSearch.appendChild(research);
par.insertAdjacentElement("afterend", debugDiv);
debugDiv.insertAdjacentElement("afterend", reSearch);
header.insertAdjacentElement("afterend", p);
}
/* function for showing all the filters */
function showAllFilters(parent) {
for (const [key, value] of rel.finable) {
if (key.toString().startsWith("filter-") && value) {
const cssId = toCss(key);
const div = document.createElement("div");
div.id = `${cssId}-div`;
const label = document.createElement("label");
label.innerHTML = key.replace(/(filter|-)/g, " ").trim();
label.setAttribute("for", cssId);
const textarea = document.createElement("textarea");
textarea.id = cssId;
textarea.value = value;
textarea.addEventListener("keyup", async () => {
await autosave(key, textarea.value);
});
div.append(label, textarea);
parent.prepend(div);
}
}
}
const fandomEl = fandomName ? fan[4] : null;
//the id filter selector should be made into a class tbh, but since idk how to execute that correctly rn, it'll just be global vars
const select = document.createElement("select");
select.className = "filterSelector"; //should make it a class since it'll probably be used again when working on banishment
function currentSel() {
return select.value;
}
function selectorType() {
return (currentSel() == "global") ? "global" : "fandom";
};
/* display the filter_ids and actions */
function tagUI() {
const frm = document.querySelector(`#filter_opt`)
if (!frm) {
const filterOpt = document.createElement("fieldset");
filterOpt.id = "filter_opt";
/* heading & current tag info */
const h4 = document.createElement("h4");
h4.id = "filter-heading";
h4.innerHTML = "Autofilter Options";
const p = document.createElement("p");
p.innerHTML = `Current tag: ${tagName}`;
let txtarea = document.querySelector(`textarea#${selectorType()}Filters`);
try {
if (txtarea.value.match(id)) {
p.innerHTML += ` (already included in the ${selectorType()} filters.)`
}
} catch (e) {
console.info(`not in a fandom tag, probably`, e);
}
/* display ID # & choose where to append the tag */
const fil = document.createElement("div");
const id_exp = document.createElement("ul"); //make the div w/the id output and the buttons for importing/exporting opts
id_exp.className = "actions";
const output = document.createElement("input");
output.id = "id_output";
output.value = id;
const label = document.createElement("label");
label.innerHTML = "filter_ids:";
label.setAttribute("for", "id_output");
label.appendChild(output);
/* import/export buttons */
const impDiv = document.createElement("div"); //div for the import process
const impButt = document.createElement("li");
impButt.innerHTML = `Import Filters`;
impButt.addEventListener("click", () => {
impsy(impDiv);
})
const expButt = document.createElement("li");
expButt.innerHTML = `Export Filters`;
expButt.addEventListener("click", () => {
expy(rel.all);
});
const optimizeButt = document.createElement("li");
optimizeButt.innerHTML = `Optimize Filters`;
optimizeButt.addEventListener("click", optimizeFilters);
const nowEditP = document.createElement("p"); // makes a paragraph to clarify what the selection does (changes which filter this tag is being edited to);
nowEditP.innerHTML = `Currently editing filter: `;
nowEditP.append(select);
/* selection dropdown */
const globalOpt = ``;
if (!fandomName) { //if in a global tag, give the option to pick a fandom for this particular tag
select.innerHTML = globalOpt;
for (var fandom of rel.fandoms) {
const option = document.createElement("option");
option.innerHTML = fandom;
option.setAttribute("value", fandom);
select.appendChild(option);
}
} else {
const option = document.createElement("option");
option.innerHTML = fandomName;
option.setAttribute("value", fandomName);
select.appendChild(option);
select.innerHTML += globalOpt;
}
/* exclude, include, or remove a tag */
const buttonAct = document.createElement("div");
buttonAct.id = "tag_actions";
const appp = document.createElement("div");
appp.id = "append-p";
const ex = {
pre: `-${filter_ids}`,
ing: "exclud",
}
const inc = {
pre: filter_ids,
ing: "includ",
}
const rem = {
pre: "",
ing: "Remov"
}
//function for adding the filter to the search values + saved local storage
function addFilt(obj) {
var filtArr = [selectorType(), `filter-${currentSel()}`, localStorage[`filter-${currentSel()}`], select.selectedIndex];
var textarea = document.getElementById(`${filtArr[0]}Filters`);
var curr = select.options[filtArr[3]].text;
var filt = ` ${filtArr[2]} `; //need the spaces in order to correctly match the values later lol. it'll be trimmed in the end
var type = obj.ing;
var old_ids = new RegExp(` -?${filter_ids} `);
var newFilt = ` ${obj.pre} `;
const p = document.createElement("p");
p.className = "appended-tag";
//if this tag is already being filtered in some way...
if (filt.match(old_ids)) {
//...first check if it's being filtered in the Same Way (in or out)
if (filt.match(newFilt)) {
p.innerHTML = `You are already ${obj.ing}ing ${tagName} from ${curr}!`
} else {
filt = filt.replace(old_ids, newFilt); //i forgot. to put in the "filt =". i feel like an idiot
/* removal */
if (type == "Remov") {
p.innerHTML = `${obj.ing}ed ${tagName} from ${curr}.`
} else {
p.innerHTML = `Changed ${tagName} to ${obj.ing}e in ${curr}.`;
}
}
} else if (type == "Remov") { //if you're supposed to be removing smth that isn't there, tell them
p.innerHTML = `${tagName} isn't in your ${curr} filters!`;
} else {
filt += newFilt;
p.innerHTML = `Now ${obj.ing}ing ${tagName} in ${curr}.`;
}
filt = filt.replace(/\s{2,}/g, " ").trim(); //remove extra whitespaces
if (textarea) {
textarea.value = filt;
}
filtArr[2] = filt;
appp.prepend(p);
autosave(filtArr[1], filtArr[2]); //set the key to the filter value
}
function tagButtons(obj) {
const div = document.createElement("div");
const button = document.createElement("button");
button.innerHTML = `${obj.ing}e Tag`;
div.appendChild(button);
buttonAct.appendChild(div);
button.addEventListener("click", function () {
addFilt(obj);
})
};
var ugh = [ex, inc, rem]; //i am lazy and so shall array the various action buttons so that i can loop them instead of doing fucking. tagButtons(thing) every fucking time
for (const a of ugh) {
tagButtons(a);
}
id_exp.append(impButt, expButt, optimizeButt, impDiv);
// fil.append(id_exp, select);
fil.append(label, id_exp, nowEditP);
filterOpt.append(h4, p, fil, buttonAct, appp);
navList.parentElement.insertAdjacentElement("afterend", filterOpt);
} else {
frm.hidden = !(frm.hidden); // toggle that shit
}
}
//only add the tag id fetcher button if there's a form
if (form) {
navList.insertAdjacentElement("afterbegin", filtButt);
filtButt.addEventListener("mouseup", tagUI);
}
/* add filters + temp search to search w/in results box */
function submission() {
var globeSub = document.querySelector("#enable-global").checked ? global[2] : "";
var fanSub = "";
if (fandomName) {
if (document.querySelector(`#enable-${cssFanName}`).checked) {
fanSub = fan[2] ? fan[2] : ""; //if you don't do this, then it'll submit "undefined" when there's nothing
}
};
var tempSub = tempp[2] ? tempp[2] : "";
advSearch.value = `${globeSub} ${fanSub} ${tempSub}`;
advSearch.value = advSearch.value.replace(/\s{2,}/g, " ").trim();
}
if (form) { form.addEventListener("submit", submission) };
/* autosubmit + previous filters drop */
if (search_submit == "") {
let globIsCheck = false; //by default
try {
globIsCheck = document.querySelector("#enable-global").checked;
} catch (e) {
console.log("this is probably the local debug page for the error search ", e);
}
/* ----------------------------- */
//there needs to be both the thing enabled and a value in the thing
if (globIsCheck && global[2]) {
console.log("global checked && filtered");
submission();
form.submit();
} else if (fan && document.querySelector(`#enable-${cssFanName}`).checked && fan[2]) {
console.log("fandom checked && filtered");
submission();
form.submit();
};
} else if (!noResults) {
const details = document.createElement("details");
details.className = "prev-search";
const summary = document.createElement("summary");
summary.innerHTML = "FILTERS:";
details.appendChild(summary);
function filterloop(key) {
var filterStore = emptyStorage(`filter-${key}`);
if (filterStore) {
function pp(str, attr = {}) {
const el = attr.el ? attr.el : "span";
const e = document.createElement(el);
e.innerHTML = str;
if (attr) {
for (const [k, v] of Object.entries(attr)) {
e.setAttribute(k, v);
}
}
return e;
}
const p = document.createElement("p");
p.className = `prev-${key.replaceAll(/\W+/g, "-")}`;
const l = document.createElement("strong");
l.innerHTML = `${key.replaceAll(/-/g, " ").trim()} Filters:`;
p.append(l, document.createElement("br")); // append these first ofc
const o = Object.entries(objectify(parseFilter(filterStore.split(/\s+/))));
console.log(o);
var i = 0;
for (const [king, value] of o) {
const v = value.trim();
if (king !== "complex") {
const nums = v.match(/\b\d+\b/g); // this is more relevant for the replacing thing tbh
const valSplit = v.split(/\s+\|\|\s+/);
const spanner = pp(`${king}:`);
if (nums || valSplit) {
if (valSplit.length > 1) {
spanner.innerHTML += "(";
for (var j = 0; j < valSplit.length; j++) {
spanner.appendChild(pp(valSplit[j]));
if (j < valSplit.length - 1) {
spanner.innerHTML += ", ";
}
}
spanner.innerHTML += ")";
} else {
spanner.appendChild(pp(v));
}
if (key.search(/filter_ids/) >= 0) {
// replace all the filters w/the id names n stuff
}
} else {
spanner.appendChild(pp(v));
}
p.appendChild(spanner);
} else {
p.appendChild(pp(v));
}
if (i < o.length - 1) {
p.innerHTML += ", ";
}
i++;
}
details.appendChild(p);
};
};
if (tempp[2]) {
//this one's different bc the adv search doesn't Actually have a checkbox for its enabling
filterloop("advanced-search");
}
if (global[3]) {
filterloop("global");
}
if (fan && fan[3]) {
filterloop(fandomName);
}
header.insertAdjacentElement("afterend", details);
}
//from https://attacomsian.com/blog/javascript-download-file
const download = (path, filename) => {
const anchor = document.createElement("a");
anchor.href = path;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
}
/* export saved filters as a json */
function expy(obj) {
console.info("now executing expy on", obj);
// var arr = obj;
var jason = {};
for (const [key, value] of obj) {
jason[key] = value; // just trust that we're feeding the f'n a clean version of what we want saved already
}
jason = JSON.stringify(jason);
//downloading as json from https://attacomsian.com/blog/javascript-download-file
const blob = new Blob([jason], { type: 'application/json' }); //create blob object
const DL_jason = URL.createObjectURL(blob);
download(DL_jason, `autofilters_${dateFloor()}.json`); //download the file
URL.revokeObjectURL(DL_jason); //release object url
};
/* import saved filters from a string */
function impsy(div) { //for now just have it read from a specified div
div.id = "importDiv";
const instructions = document.createElement("p");
//remember to remove the hard-coding of the import csv checkbox and also remove the hacky version of its import process by Finishing It
instructions.innerHTML = `Please upload your backup as a .json or .txt file, or paste your exported options into the textbox below. This will override your current settings. | `;
const tb = document.createElement("textarea");
const fileUpload = document.createElement(`input`);
fileUpload.type = "file";
fileUpload.setAttribute(`accept`, `.json, .txt, .csv`);
// yeah i just copied the reader from https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsText
fileUpload.addEventListener(`change`, previewFile);
function previewFile() {
const file = fileUpload.files[0];
const reader = new FileReader();
reader.addEventListener(
"load",
() => {
// this will then display a text file
tb.value = reader.result;
},
false,
);
if (file) {
reader.readAsText(file);
}
}
const parseButt = document.createElement("button");
parseButt.innerHTML = "Save Imported Settings";
parseButt.addEventListener("click", () => {
var impCsv = document.querySelector("#import_csv").checked;
const impSet = tb.value;
if (impCsv) {
const obj = function () {
var j = [];
for (const tag of impSet.split("\n")) {
j.push(tag.split(/,/g));
}
return j;
}();
var key = `ids-${toCss(currentSel())}`;
autosave(key, JSON.stringify(obj));
} else {
let parsable = false;
try {
JSON.parse(impSet);
parsable = true;
} catch (e) {
alert("sorry, this can't be parsed.");
}
if (parsable) {
const obj = Object.entries(JSON.parse(impSet));
console.log(obj);
for (const [key, value] of obj) {
try {
localStorage.setItem(key, JSON.stringify(JSON.parse(value)));
} catch (e) {
console.error(e);
console.log(`key: ${key}\n`, value);
localStorage.setItem(key, value); // for the non-json versions like the filters
}
}
alert("filters successfully imported.");
window.location.reload();
}
}
})
div.append(instructions, fileUpload, tb, parseButt);
}
function optimizeFilters() {
storageCleanup(); // clean it up first
console.log(`rel.finable: `, rel.finable);
for (const [key, value] of rel.filters) {
var finalStr = value;
// console.log(`full ${key}:\n`, value);
const sp = value.trim().split(/\s+/);
// don't bother if there's fewer than two different things getting filtered
if (sp.length > 1) {
// console.log(`sp: `, sp);
finalStr = ""; // reset this
const cleaned = objectify(parseFilter(sp));
console.log(cleaned);
for (const [k, v] of Object.entries(cleaned)) {
let val = v.trim();
if (k !== "complex") {
finalStr += ` ${k}:`;
if (val.split(/\s+/).length > 1) { // put them in parentheses if you need to
val = `(${val})`;
}
}
finalStr += val;
}
}
localStorage.setItem(key, finalStr.trim()); // heh and we also have the groundwork to do this as like an object thing now too... sick as hell >:3
}
}
function parseFilter(arr) { // takes and returns an array
// console.log(`parseFilter arr: `, arr);
const whee = new Array(); // new one each loop,, ehe
var paren = 0, str = ""; // track how many layers deep into parentheses we are & total string
for (var i = 0; i < arr.length; i++) {
const s = arr[i];
let lookahead = "";
try {
lookahead = arr[i + 1];
} catch (e) {
// we're at the last of them
}
const opens = s.match(/\(/g), closes = s.match(/\)/g);
if (opens) {
paren += opens.length;
}
if (closes) {
paren -= closes.length;
}
str += `${s} `; // add the space back in
if (paren == 0 && s !== "TO") {
// if we're at a 0 layer (& not working with a range), then push it to the groupings and reset
if (lookahead) { // if there's smth to look forward to
if (lookahead !== "TO") {
// and the next one is NOT a "TO", then it's fine to reset
whee.push(str.trim());
str = "";
}
} else {
// if there's nothing to look forward to, then we have to push the last one anyway
whee.push(str.trim());
str = "";
}
}
}
return whee;
}
function objectify(arr) { // turns a filter in the thing into an object
const tmp = { complex: "" };
for (const ex of arr) {
let ind = "complex", v = ex;
const range = ex.match(/(\[|\{|\}|\]|<|>)/g); // all the stuff associated w/ranges
const query = ex.match(/^-?(\w|_)+(?=:)/);
// if it's a normal type of filter like filter_ids: or bookmark_count: or crossover:, WITHOUT being a range, then gotta do some processing
if (query && !range) {
ind = query[0];
// if (!cleaned[ind]) { cleaned[ind] = ""; }
tmp[ind] ? tmp[ind] += ` ||` : tmp[ind] = ""; // we don't need the double bars for complex requests, so if the thing already exists, then add them in for simple types
v = ex.replaceAll(`${ind}:`, "").replaceAll(/(^\(|\)$)/g, ""); // first just remove the query n its colon, then get rid of its outermost shell of parentheses
}
tmp[ind] += ` ${v}`;
}
return tmp;
}
/* CSS STYLING AT THE END BC IT'S A PICKY BITCH */
var css = `
/* error 0 results debug */
#error_debug {
display: flex;
flex-wrap: wrap;
}
#error_debug label {
font-weight: bold;
text-transform: capitalize;
}
#error_debug textarea {
resize: none;
scrollbar-width: thin!important;
font-family: monospace;
}
#error_debug > div {
width: 30%;
margin: 10px 1%;
}
#error_debug textarea {
font-size: 9pt;
}
#debugged-search {
float: none;
margin-bottom: 20px;
text-align: left;
}
#debugged-search a {
margin-left: 10px;
}
#debugged-search a:first-of-type {margin: 0;}
@media only screen and (max-width: 48em) {
#error_debug > div {
width: 98%;
}
}
`; //gonna need this for the 0 results page anyway, might as well set it to smth
if (form) {
//const optMWidth = window.getComputedStyle(form).width;
const borderBottom = window.getComputedStyle(document.querySelector("form#work-filters dt")).borderBottom;
css += `
#main *:not(a, #id_output, button, .current) {box-sizing: border-box;}
#get_id_butt:hover {cursor: pointer;}
#id_output {width: max-content;min-width: 0; position: static;}
#importDiv {display: block;}
#stickyFilters {
margin-top: 5px;
}
#stickyFilters summary {
padding: 3px 0;
padding-left: 3px;
border-bottom: 1px solid white;
float: left;
min-width: 100%;
margin-bottom: 5px;
}
#stickyFilters summary:active, #stickyFilters summary:focus {
border-bottom: 1px dotted;
}
#stickyFilters > div {
margin-top: 5px;
}
#stickyFilters textarea, #importDiv textarea {
resize: none;
scrollbar-width: thin!important;
font-family: monospace;
}
#stickyFilters textarea {
min-height: 8em;
}
#stickyFilters label {
font-weight: bold;
text-transform: capitalize;
}
#stickyFilters label small {font-weight: normal;}
#stickyFilters input[type="checkbox"] {
min-width: 1em;
min-height: 1em;
margin-right: 0.67em;
position: static;
}
#stickyFilters span {
padding-bottom: 3px;
margin-bottom: 3px;
display: block;
width: 100%;
border-bottom: ${borderBottom};
}
#filter_opt {
display: block;
float: right;
min-width: 30em;
max-width: 100%; /* this can't be vw or else it overflows on mobile */
width: 482px;
margin-top: 5px;
margin-right: 5px;
text-align: left;
}
#filter_opt[hidden] {
display: none;
}
#filter_opt h4 {
text-align: center;
margin-top: 0;
padding-bottom: 0.25em;
border-bottom: 1px solid;
}
#filter_opt .filterSelector {
min-width: clamp(3em, 100%, 10.5em);
max-width: calc(100% - 0.175em);
}
#filter_opt .actions {
display: block;
width: 100%;
margin: 0.25em auto;
}
#filter_opt input {
border-radius: 0.3em;
}
#filter_opt label {
background: none;
border: none;
padding: 0;
font-weight: bold;
}
#tag_actions {
width: 100%;
margin: 5px 0 0;
padding-bottom: 5px;
display: grid;
grid-template-columns: repeat(3, 3fr);
}
#tag_actions button {
display: block;
text-transform: capitalize;
margin: 0 auto;
}
#tag_actions {
width: 100%;
margin: 5px 0 0;
padding-bottom: 5px;
display: grid;
grid-template-columns: repeat(3, 3fr);
}
#tag_actions button {
display: block;
text-transform: capitalize;
margin: 0 auto;
}
#append-p {max-height: 4em; overflow-y: auto; scrollbar-width: thin!important; font-size: 0.9em;}
.appended-tag {
border-bottom: ${borderBottom};
}
.filter-box-label {display: block;}
.prev-search {margin-top: 5px;}
.prev-search p {padding-left:45px;}
.prev-search p strong {text-transform: capitalize;}
.prev-search summary {font-size: 1.15em;}
.prev-search span {font-family: monospace; font-size: 8pt; color: black;}
[class^="prev"] span:has(span) {
background: none;
color: revert;
}
.prev-advanced-search span {
background-color:#d3fdac;
}
.prev-global span {
background-color: #bfebfd;
}
.prev-${cssFanName} span {
background-color: #d8cefb;
}
.banish {
margin: 0;
max-width: 15ch;
position: absolute;
top: 1.15lh;
right: 0;
min-width: 0;
}
@media only screen and (max-width: 48em) {
.prev-search {margin: 10px 0;}
.prev-search p {padding-left: 15px;}
#filter_opt {
min-width: 0;
}
#filter_opt .actions br {display: block;}
}
`;
}
const style = document.createElement("style");
style.innerText = css;
document.querySelector("head").appendChild(style);