// ==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);