SankakuEasySubs

Automate the management of tab subscriptions on chan.sankakucomplex.com

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        SankakuEasySubs
// @namespace   https://greasyfork.org/users/731869
// @version     0.2
// @license     MIT
// @description Automate the management of tab subscriptions on chan.sankakucomplex.com
// @match       https://chan.sankakucomplex.com/post/show/*
// @match       https://legacy.sankakucomplex.com/post/show/*
// @run-at      document-end
// @grant       GM.setValue
// @grant       GM.getValue
// ==/UserScript==
/* jshint esversion: 8 */
const BASE_TAG_URL = "https://chan.sankakucomplex.com/tag_subscription";
const GET_SUB_URL = `${BASE_TAG_URL}/index`;
const CREATE_GROUP_URL = `${BASE_TAG_URL}/create`;
const SAVE_GROUP_URL = `${BASE_TAG_URL}/update`;
const USER_EDIT_URL = "https://chan.sankakucomplex.com/user/edit";
const TAG_ROW_REGEX = /tag-subscription-row-(?<id>\d+)/;
const MANAGED_PREFIX = "MANAGED_DONT_TOUCH_";
const TAG_LIMIT = 20;
const GROUP_LIMIT = 32;
const GM_STORE_KEY = "subscription";
/**
 * Unlike XMPHttpRequest, the fetch API will not reject even if the response has an HTTP error
 * status code. This helper function ensures that:
 * - it throws an error on HTTP error status codes
 *
 * @async
 * @param {string} url the URL of the request.
 * @param {string} on_err the error message when the response has an HTTP error status code.
 * @param {?(RequestInit | undefined)} [options] HTTP request options, same as the second
 * parameter of {@link fetch}.
 * @returns {Promise<Response>}
 */
async function fetch_or(url, on_err, options) {
    const res = await fetch(url, options);
    if (res.status >= 400) {
        throw new Error(`${res.status} ${res.statusText}: ${on_err}`);
    }
    return res;
}
// interface TagSubs {
//   existing: number;
//   managed: TagGroup[];
// }
// const schema = {
//   type: "object",
//   properties: {
//     existing: { type: "number" },
//     managed: {
//       type: "array",
//       items: {
//         type: "object",
//         properties: {
//           id: { type: "number" },
//           tags: {
//             type: "array",
//             items: { type: "string" }
//           }
//         }
//       }
//     }
//   }
// };
/**
 * An aggregation of the core functionalities of this script.
 *
 * @class TagSubscriptions
 * @typedef {TagSubscriptions}
 */
class TagSubscriptions {
    constructor(existing, managed) {
        this.existing = existing;
        this.managed = managed;
        const map = new Map();
        managed.forEach((group, i) => {
            for (const tag of group.tags) {
                map.set(tag, i);
            }
        });
        this.map = map;
    }
    /**
     * Initializes the script from {@link GET_SUB_URL} in the rare cases when the storage is wiped or
     * this script is removed by the user once.
     *
     * @private
     * @static
     * @async
     * @returns {Promise<TagSubscriptions>}
     */
    static async init() {
        const res = await fetch_or(GET_SUB_URL, "Failed at fetching subscription page");
        const sub_page = new DOMParser().parseFromString(await res.text(), "text/html");
        const rows = Array.from(sub_page.querySelectorAll("tr[id^='tag-subscription-row']"));
        const groups = rows
            .map(row => {
            var _a, _b, _c;
            const name = (_a = row.querySelector("input[id$='name']")) === null || _a === void 0 ? void 0 : _a.getAttribute("value");
            if (name === null || name === undefined) {
                throw new Error("Malformed tag subscription group name");
            }
            const id_match = TAG_ROW_REGEX.exec(row.id);
            if (id_match === null) {
                throw new Error("Malformed tag subscription row id");
            }
            const id = parseInt(id_match[1]);
            const tags = (_c = (_b = row.querySelector("input[id$='tag_query']")) === null || _b === void 0 ? void 0 : _b.getAttribute("value")) === null || _c === void 0 ? void 0 : _c.split(" ");
            if (tags === undefined) {
                throw new Error("Malformed tag subscription query");
            }
            return { name, id, tags };
        });
        const managed = groups.filter(group => group.name.startsWith(MANAGED_PREFIX)).map(({ id, tags }) => {
            return { id, tags };
        });
        managed.sort((a, b) => a.id - b.id);
        const subs = new TagSubscriptions(rows.length - managed.length, managed);
        // on initialization all un-managed tags are copied to managed groups
        for (const group of groups.filter(g => !g.name.startsWith(MANAGED_PREFIX))) {
            for (const tag of group.tags) {
                await subs.add_tag_without_commit(tag);
            }
        }
        await subs.commit();
        return subs;
    }
    /**
     * The usual way to initialize this script. The data has to be synchronized to the store because
     * of the following assumptions of user behavior:
     * 1. Users may load the script on multiple tabs simultaneously.
     * 2. Users are unlikely to interact with (i.e. click on generated links) the script on multiple
     *    tabs simultaneously.
     * @static
     * @async
     * @returns {Promise<TagSubscriptions>}
     */
    static async init_from_store() {
        const stored = await GM.getValue(GM_STORE_KEY);
        if (typeof stored === "string") {
            // sankaku replaces the default JSON stringify functionality with a non-standard polyfill, in
            // this case an array of objects is stringified to a string instead
            const subs = JSON.parse(stored);
            return new TagSubscriptions(subs.existing, JSON.parse(subs.managed));
        }
        else {
            const subs = await TagSubscriptions.init();
            await subs.save_to_store();
            return subs;
        }
    }
    async save_to_store() {
        await GM.setValue(GM_STORE_KEY, JSON.stringify({
            existing: this.existing,
            managed: this.managed
        }));
    }
    async load_from_store() {
        const subs = await TagSubscriptions.init_from_store();
        this.existing = subs.existing;
        this.managed = subs.managed;
        this.map = subs.map;
    }
    async add_group() {
        // sankaku server rejects this POST request if these headers are not present
        const headers = {
            // "X-Prototype-Version": "1.6.0.3",
            "X-Requested-With": "XMLHttpRequest"
        };
        const res = await fetch_or(CREATE_GROUP_URL, "Failed at creating new group", { method: "POST", headers });
        const id_match = TAG_ROW_REGEX.exec(await res.text());
        if (id_match === null) {
            throw new Error("Malformed group creation response");
        }
        return parseInt(id_match[1]);
    }
    /**
     * Test whether a tag is contained by any of the managed tag groups.
     *
     * @param {string} tag
     * @returns {boolean}
     */
    contains_tag(tag) {
        return this.map.has(tag);
    }
    async add_tag_without_commit(tag) {
        if (this.contains_tag(tag)) {
            return;
        }
        let idx = this.managed.findIndex(group => group.tags.length < TAG_LIMIT);
        if (idx === -1) {
            if (this.existing + this.managed.length > GROUP_LIMIT) {
                throw new Error("Exceeded maximum number of tags");
            }
            const id = await this.add_group();
            this.managed.push({ id, tags: [] });
            idx = this.managed.length - 1;
        }
        this.managed[idx].tags.push(tag);
        this.map.set(tag, idx);
    }
    /**
     * Add a tag to the tag subscription. This operation must be synchronized or the changes made by
     * the user on other simultaneous tabs will be reverted.
     *
     * @async
     * @param {string} tag
     * @returns {*}
     */
    async add_tag(tag) {
        await this.load_from_store();
        await this.add_tag_without_commit(tag);
        await this.commit();
        await this.save_to_store();
    }
    /**
     * Remove a tag from the tag subscription. This operation must be synchronized or the changes made
     * by the user on other simultaneous tabs will be reverted.
     *
     * @async
     * @param {string} tag
     * @returns {*}
     */
    async remove_tag(tag) {
        await this.load_from_store();
        const idx = this.map.get(tag);
        if (idx === undefined) {
            return;
        }
        const query = this.managed[idx].tags;
        query.splice(query.indexOf(tag), 1);
        this.map.delete(tag);
        await this.commit();
        await this.save_to_store();
    }
    /**
     * Commit the current state of managed tag groups to the sankaku website.
     *
     * @async
     * @returns {*}
     */
    async commit() {
        const form_data = new URLSearchParams({
            "commit": "Save"
        });
        this.managed.forEach((group, i) => {
            form_data.append(`tag_subscription[${group.id}][name]`, `${MANAGED_PREFIX}${i.toString().padStart(2, "0")}`);
            form_data.append(`tag_subscription[${group.id}][tag_query]`, group.tags.join(" "));
            form_data.append(`tag_subscription[${group.id}][is_visible_on_profile]`, "false");
        });
        await fetch_or(SAVE_GROUP_URL, "Failed at updating tag groups", {
            method: "POST",
            body: form_data,
            redirect: "manual",
        });
    }
}
/**
 * Add links to easily add or remove tags from tag subscription on post page. Accepts
 * {@link TagSubscriptions} as a parameter because otherwise this function has to be async, but it
 * doesn't actually perform any async operation beside (potentially) initializing
 * {@link TagSubscriptions}.
 *
 * @param {TagSubscriptions} subs
 */
function on_post_page_load(subs) {
    var _a;
    function detect_login_state() {
        var _a;
        const first_header = (_a = document.querySelector("#navbar > li:first-child > a")) === null || _a === void 0 ? void 0 : _a.textContent;
        return first_header === "My Account";
    }
    function anchor(sign, tag) {
        const a = document.createElement("a");
        a.href = "#";
        a.text = sign;
        a.addEventListener('click', async (e) => {
            e.preventDefault();
            e.stopPropagation();
            a.setAttribute("style", "pointer-events: none;");
            if (a.text === "+") {
                await subs.add_tag(tag);
                a.text = "-";
            }
            else {
                await subs.remove_tag(tag);
                a.text = "+";
            }
            a.setAttribute("style", "");
        });
        return a;
    }
    if (!detect_login_state()) {
        throw new Error("Not logged in");
    }
    const sidebar = document.querySelector("#tag-sidebar");
    if (sidebar === null) {
        throw new Error("Tag sidebar not found");
    }
    for (const div of sidebar.querySelectorAll("li[class^='tag-type'] > div[id^='tag_container']")) {
        const tag = (_a = div.querySelector("a")) === null || _a === void 0 ? void 0 : _a.text.replace(" ", "_");
        if (tag === undefined) {
            throw new Error("Tag name not found");
        }
        const sign = subs.contains_tag(tag) ? "-" : "+";
        div.prepend(document.createTextNode(" "));
        div.prepend(anchor(sign, tag));
    }
}
/**
 * Test whether user is logged in to sankaku. The better idea is to check the status code and
 * Location header of each http response to see whether they are redirected to the login page,
 * however that's currently impossible because of:
 * - Redirected http requests are opaque in fetch API and invisible in XMLHttpRequest API _by
 *   design_.
 * - Those requests would be blocked by most user's browser because sankaku redirects HTTPS requests
 *   to HTTP login page, triggering un-catchable "insecure content on secure page" error hence the
 *   final destination is unobtainable in an user-script.
 *
 * ## known problems
 * The current implementation of login test is vulnerable to TOCTOU problems, e.g. if users log out
 * then browser back to a post page they will be able to click generated links while logged out thus
 * cause inconsistency.
 *
 * @async
 * @returns {Promise<boolean>} return `true` if user is logged in.
 */
async function login_test() {
    const res = await fetch_or(USER_EDIT_URL, "login test failed", { redirect: "manual" });
    return !res.redirected;
}
(async () => {
    "use strict";
    if (!await login_test()) {
        throw new Error("Not logged in");
    }
    const subs = await TagSubscriptions.init_from_store();
    on_post_page_load(subs);
})().catch(console.error);