NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name CSSHistory
// @namespace http://tampermonkey.net/
// @version 1.0.3
// @description History of css in shikimori using IndexedDB
// @author Dedonych
// @match http://*
// @match https://*
// @include /https?:\/\/(www\.)?shiki(mori)?\.([a-z]+?)\/.*/
// @icon https://www.google.com/s2/favicons?sz=64&domain=shikimori.one
// @grant none
// @updateURL https://openuserjs.org/meta/Dedonych/CSSHistory.meta.js
// @downloadURL https://openuserjs.org/install/Dedonych/CSSHistory.user.js
// @license MIT
// ==/UserScript==
// ------------------------------ START SETTINGS ------------------------------
// How long css should be kept before auto-deleting. If value is 0 - removes time limit. Default - 48 hours.
const MAX_STORAGE_TIME = 1000 * 3600 * 48;
// Minimal interval before creating new history. If interval not passed and you save css multiply times - adds to last history to optimize. Default - 15 seconds.
const MIN_SAVE_INTERVAL = 15000; // In miliseconds only. Только в милисекундах
// Don't save css if content is identical to the last one
const SAVE_ONLY_CHANGED = true;
// Enable logs in database
const LOG_ENABLED = true;
/* Style colors */
const BUTTON_BACKGROUND = "#a37306";
const MENU_BACKGROUND = "#222";
const MENU_BORDER_COLOR = "#fff";
const EXIT_BUTTON_BACKGROUND = "crimson";
const CLEAR_ALL_BACKGROUND = "darkorchid";
const LIST_BUTTON_ACTIVE = "#429222";
// ------------------------------- END SETTINGS -------------------------------
const style = `
/* button */
#css-history-btn { margin-right: 16px; background: ${BUTTON_BACKGROUND} }
/* menu */
#css-history-menu {
display: flex; gap: 8px;
position: fixed; top: 50%; left: 50%; translate: -50% -50%;
background: ${MENU_BACKGROUND};
border: 2px solid ${MENU_BORDER_COLOR};
z-index: 99999; width: 90vw; height: 80vh;
}
/* Enable shade if css-histor-menu exist */
:has(#css-history-menu) .b-shade { display: block; z-index: 55; }
/* close button */
#css-history-menu>button { position:absolute; top:8px; right:8px; padding:8px; background: ${EXIT_BUTTON_BACKGROUND}; z-index:1 }
/* CodeMirror */
#css-history-menu .CodeMirror { flex:1; height:100%; }
/* list of keys */
#css-history-menu>nav { overflow-y: scroll; padding: 4px 8px; text-wrap:nowrap; }
/* list buttons */
#css-history-menu nav div {margin-top:4px; border-top: 1px dashed; }
#css-history-clear { width:100%; background: ${CLEAR_ALL_BACKGROUND}; }
#css-history-menu div.active>.b-button.m-2 { background:${LIST_BUTTON_ACTIVE} }
`;
/**
* @typedef {keyof HTMLElementTagNameMap} T
* @param {T} tag
* @param {HTMLElementTagNameMap[T]} props
* @returns {HTMLElementTagNameMap[T]}
*/
const c = (tag, props) =>
Object.assign(document.createElement(tag), {
...props,
...(props && props.id ? { id: `css-history-${props.id}` } : {}),
});
document.head.append(c("style", { innerHTML: style }));
/* -------------------- Text for button -------------------- */
/** @type {Intl.RelativeTimeFormat} */
var rtf;
const timeAgo = (t, now = Date.now()) => {
let s = Math.round((t - now) / 1000);
if (Math.abs(s) < 60) return rtf.format(s, "second");
let m = Math.round(s / 60);
if (Math.abs(m) < 60) return rtf.format(m, "minute");
let h = Math.round(m / 60);
if (Math.abs(h) < 24) return rtf.format(h, "hour");
let d = Math.round(h / 24);
return rtf.format(d, "day");
};
/** @prop {number} length */
const lenToSize = (length) => {
const format = (s, f) => typeof s == "string" ? s : s.toFixed(2) + f;
let kb = length / 1024;
if (kb < 1) return format("<0", "KB");
if (kb < 1024) return format(kb, "KB");
let mb = kb / 1024;
if (mb < 1024) return format(mb, "MB");
return format(">1", "GB");
};
/* -------------------- End Text for button -------------------- */
/**
*
* @typedef {object} IValue
* @property {string} value
* @property {number} key
*/
/** @returns {Promise<DB>} */
function createDB() {
return new Promise((res, rej) => {
const req = indexedDB.open("css-history");
req.onerror = () => rej(req.error);
req.onsuccess = () => res(new DB(req.result));
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains("history")) {
db.createObjectStore("history");
console.log("Storage was created!");
}
};
});
}
class DB {
/** @type {IDBDatabase} */
#db;
/** @type {[number,string]} */
#last_key;
constructor(db) {
this.#db = db;
// Check and delete storage after timer
this.#removeExpired();
}
#log(...any) {
LOG_ENABLED && console.log(`%cCustom storage DB: `, "color:yellow", ...any);
}
async #removeExpired() {
if (MAX_STORAGE_TIME == 0) return;
const storage = await this.getAllKeys();
if (storage.length == 0) return;
this.#last_key = storage[0];
const date_now = Date.now();
for (const key of storage) {
if (date_now > +key[0] + MAX_STORAGE_TIME) {
await this.delete(key, "Removing expired storage");
}
}
}
/**
* @param {IDBTransactionMode} mode
*/
#tx(mode = "readonly") {
return this.#db.transaction("history", mode).objectStore("history");
}
/**
* @param {IDBValidKey | IDBKeyRange} key
* @returns {Promise<IValue>}
*/
get(key) {
return new Promise((resolve, reject) => {
if(typeof key == 'object') key = key.join("_");
const req = this.#tx().get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
/**
* @returns {Promise<[number,string][]>}
*/
getAllKeys() {
return new Promise((resolve, reject) => {
const req = this.#tx().getAllKeys();
req.onsuccess = () =>
resolve(
req.result.map((e) => e.split("_")).sort((a, b) => b[0] - a[0])
);
req.onerror = () => reject(req.error);
});
}
/**
* @returns {Promise<IValue>}
*/
getLast() {
return this.#last_key ? this.get(this.#last_key.join("_")) : null;
}
/**
* @param {any} value
*/
set(value) {
return new Promise(async (resolve, reject) => {
const date_now = Date.now();
const size_now = lenToSize(value.length); // in KB
if (SAVE_ONLY_CHANGED) {
const last_value = await this.getLast();
if (last_value && last_value.value == value) {
return this.#log(`Set new value aborted. (saveOnlyChanged) `);
}
}
const checkKey =
this.#last_key && date_now - this.#last_key[0] < MIN_SAVE_INTERVAL
? this.#last_key[0]
: date_now;
if (checkKey !== date_now) {
this.#log("Set new value to last key. (minSaveInterval)");
}
const req = this.#tx("readwrite").put(
{ value },
checkKey + "_" + size_now
);
req.onsuccess = () => {
this.#last_key = [checkKey, size_now];
checkKey == date_now &&
LOG_ENABLED &&
setTimeout(() => {
this.#log("Interval passed.");
}, MIN_SAVE_INTERVAL);
resolve(req.result);
};
req.onerror = () => reject(req.error);
});
}
/**
* @param {IDBValidKey | IDBKeyRange} key
*/
delete(key, text) {
return new Promise((resolve, reject) => {
if(typeof key == 'object') key = key.join("_");
const req = this.#tx("readwrite").delete(key);
req.onsuccess = () => {
this.#log(text, new Date(key).toLocaleString("ru"));
if (this.#last_key?.[0] == key) {
this.#last_key = [0, 0];
}
resolve();
};
req.onerror = () => reject(req.error);
});
}
clear() {
return new Promise((resolve, reject) => {
const req = this.#tx("readwrite").clear();
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
}
const langs = {
history_button: ["history", "история"],
clearAll_button: ["Clear all", "Удалить все"],
empty_keys: [
"Nothing here. Save style in shikimori to add here.",
"Ничего нету. Сохраните стиль на шикимори, чтобы добавить сюда.",
],
confirm_remove: [
"Do you want delete current history?",
"Вы хотите удалить данную историю?",
],
confirm_clearAll: [
"Do you want delete all history?",
"Вы хотите удалить все истории?",
],
};
/** @type {CodeMirror.Editor & {constructor: CodeMirror}} */
var cm;
var li = 0;
async function init() {
if (
!location.pathname.includes("/edit/styles") ||
location.pathname.includes("api") ||
document.getElementById("css-history-btn")
)
return;
if (!document.querySelector(".CodeMirror")) {
return setTimeout(() => init(), 1000);
}
li = document.body.dataset.locale == "ru" ? 1 : 0;
rtf = new Intl.RelativeTimeFormat(document.body.dataset.locale, {
numeric: "auto",
});
// add CodeMirror to var
cm = document.querySelector(".CodeMirror")?.CodeMirror;
// init DB
const DB = await createDB();
// make button like save button
const historyButton = c("input", {
type: "submit",
id: "btn",
onclick: (e) => {
e.preventDefault();
createBlock(DB, updateButtonValue);
},
});
// function to change history button name. Adding length of db.
function updateButtonValue() {
DB.getAllKeys().then(
(e) =>
(historyButton.value = `CSS ${langs.history_button[li]} (${e.length})`)
);
}
const buttonsBlock = document.querySelector(
".p-profiles-edit .editor-container .buttons"
);
buttonsBlock.prepend(historyButton);
buttonsBlock
.querySelector(`[id^='submit_style']`)
.addEventListener("click", () => {
const value = cm.getValue();
DB.set(value).then(() => updateButtonValue());
});
updateButtonValue();
}
/**
* @param {DB} db
* @param {()=>void} update
*/
function createBlock(db, update) {
const blockMenu = c("menu", { id: "menu" });
document.body.append(blockMenu);
const removeBlock = () => blockMenu.remove();
// add to background shade click to remove menuBlock
document
.querySelector(".b-shade")
.addEventListener("click", removeBlock, { once: true });
addEventListener("keyup", (e) => e.key == "Escape" && removeBlock(), {
once: true,
});
const closeButton = c("button", {
textContent: "X",
onclick: removeBlock,
});
const cmta = c("textarea");
const leftSide = c("nav");
blockMenu.append(closeButton, leftSide, cmta);
const new_cm = cm.constructor.fromTextArea(cmta, {
mode: "text/x-scss", // scss for better displaying nesting
theme: "solarized light", // default theme
tabSize: 2,
lineNumbers: true, // numbers for stroke
readOnly: true,
lineWrapping: true,
});
db.getAllKeys().then((keys) => {
if (keys.length == 0)
return (leftSide.innerHTML = `<h1>${langs.empty_keys[li]}</h1>`);
const frag = document.createDocumentFragment();
for (const key of keys) {
const div = c("div", { title: new Date(+key[0]).toLocaleString("ru") });
const button = c("button", {
className: "b-button m-2",
textContent: timeAgo(+key[0]) + ", " + key[1],
onclick: () => {
leftSide.querySelector(".active")?.classList.remove("active");
div.classList.add("active");
db.get(key).then((data) => {
new_cm.setValue(data.value);
cmta.dataset.key = key;
});
},
});
const remove = c("button", {
textContent: "X",
className: "b-button",
onclick: () => {
if (confirm(langs.confirm_remove[li])) {
db.delete(key, "Remove from storage manualy").then(() => update());
if (cmta.dataset.key == key) new_cm.setValue("");
div.remove();
}
},
});
div.append(button, remove);
frag.append(div);
}
const clearAllButton = c("button", {
textContent: langs.clearAll_button[li],
id: "clear",
className: "b-button",
onclick: () => {
if (confirm(langs.confirm_clearAll[li]))
db.clear().then(() => {
leftSide.querySelectorAll("div").forEach((e) => e.remove());
update();
});
},
});
leftSide.append(clearAllButton, frag);
});
}
addEventListener("turbolinks:load", init);
addEventListener("DOMContentLoaded", init);
init();