Dedonych / CSSHistory

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