nowheremanx / hipda-时光机

// ==UserScript==
// @name         hipda-时光机
// @namespace    http://tampermonkey.net/
// @version      0.3
// @description  如果时光可以倒流
// @author       屋大维
// @license      MIT
// @match        https://www.hi-pda.com/forum/*
// @resource     IMPORTED_CSS https://code.jquery.com/ui/1.13.0/themes/base/jquery-ui.css
// @require      https://code.jquery.com/jquery-3.4.1.min.js
// @require      https://code.jquery.com/ui/1.13.0/jquery-ui.js
// @require      https://cdn.jsdelivr.net/gh/pieroxy/lz-string/libs/lz-string.js
// @icon         https://icons.iconarchive.com/icons/hamzasaleem/stock/64/Time-Machine-icon.png
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM.deleteValue
// @grant        GM.listValues
// @grant        GM_getResourceText
// @grant        GM_addStyle
// @grant        GM.notification
// ==/UserScript==

(function () {
  'use strict';

  // CONST
  const EXPIRE_DAYS_LIMIT = 30;
  const MAX_SIZE_LIMIT = 20 * 1024;
  const EXPIRE_DAYS_DEFAULT = 14;
  const MAX_SIZE_DEFAULT = 10 * 1024; // KB
  const MANAGEMENT_KEY = "alt+Y";
  const DEVELOP = false;

  // CSS
  const my_css = GM_getResourceText("IMPORTED_CSS");
  GM_addStyle(my_css);
  GM_addStyle(".no-close .ui-dialog-titlebar-close{display:none} textarea{height:100%;width:100%;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}");

  // JS
  var LZString_JS = `var LZString=function(){var f=String.fromCharCode;var keyStrBase64="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";var keyStrUriSafe="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$";var baseReverseDic={};function getBaseValue(alphabet,character){if(!baseReverseDic[alphabet]){baseReverseDic[alphabet]={};for(var i=0;i<alphabet.length;i++){baseReverseDic[alphabet][alphabet.charAt(i)]=i}}return baseReverseDic[alphabet][character]}var LZString={compressToBase64:function(input){if(input==null)return"";var res=LZString._compress(input,6,function(a){return keyStrBase64.charAt(a)});switch(res.length%4){default:case 0:return res;case 1:return res+"===";case 2:return res+"==";case 3:return res+"="}},decompressFromBase64:function(input){if(input==null)return"";if(input=="")return null;return LZString._decompress(input.length,32,function(index){return getBaseValue(keyStrBase64,input.charAt(index))})},compressToUTF16:function(input){if(input==null)return"";return LZString._compress(input,15,function(a){return f(a+32)})+" "},decompressFromUTF16:function(compressed){if(compressed==null)return"";if(compressed=="")return null;return LZString._decompress(compressed.length,16384,function(index){return compressed.charCodeAt(index)-32})},compressToUint8Array:function(uncompressed){var compressed=LZString.compress(uncompressed);var buf=new Uint8Array(compressed.length*2);for(var i=0,TotalLen=compressed.length;i<TotalLen;i++){var current_value=compressed.charCodeAt(i);buf[i*2]=current_value>>>8;buf[i*2+1]=current_value%256}return buf},decompressFromUint8Array:function(compressed){if(compressed===null||compressed===undefined){return LZString.decompress(compressed)}else{var buf=new Array(compressed.length/2);for(var i=0,TotalLen=buf.length;i<TotalLen;i++){buf[i]=compressed[i*2]*256+compressed[i*2+1]}var result=[];buf.forEach(function(c){result.push(f(c))});return LZString.decompress(result.join(""))}},compressToEncodedURIComponent:function(input){if(input==null)return"";return LZString._compress(input,6,function(a){return keyStrUriSafe.charAt(a)})},decompressFromEncodedURIComponent:function(input){if(input==null)return"";if(input=="")return null;input=input.replace(/ /g,"+");return LZString._decompress(input.length,32,function(index){return getBaseValue(keyStrUriSafe,input.charAt(index))})},compress:function(uncompressed){return LZString._compress(uncompressed,16,function(a){return f(a)})},_compress:function(uncompressed,bitsPerChar,getCharFromInt){if(uncompressed==null)return"";var i,value,context_dictionary={},context_dictionaryToCreate={},context_c="",context_wc="",context_w="",context_enlargeIn=2,context_dictSize=3,context_numBits=2,context_data=[],context_data_val=0,context_data_position=0,ii;for(ii=0;ii<uncompressed.length;ii+=1){context_c=uncompressed.charAt(ii);if(!Object.prototype.hasOwnProperty.call(context_dictionary,context_c)){context_dictionary[context_c]=context_dictSize++;context_dictionaryToCreate[context_c]=true}context_wc=context_w+context_c;if(Object.prototype.hasOwnProperty.call(context_dictionary,context_wc)){context_w=context_wc}else{if(Object.prototype.hasOwnProperty.call(context_dictionaryToCreate,context_w)){if(context_w.charCodeAt(0)<256){for(i=0;i<context_numBits;i++){context_data_val=context_data_val<<1;if(context_data_position==bitsPerChar-1){context_data_position=0;context_data.push(getCharFromInt(context_data_val));context_data_val=0}else{context_data_position++}}value=context_w.charCodeAt(0);for(i=0;i<8;i++){context_data_val=context_data_val<<1|value&1;if(context_data_position==bitsPerChar-1){context_data_position=0;context_data.push(getCharFromInt(context_data_val));context_data_val=0}else{context_data_position++}value=value>>1}}else{value=1;for(i=0;i<context_numBits;i++){context_data_val=context_data_val<<1|value;if(context_data_position==bitsPerChar-1){context_data_position=0;context_data.push(getCharFromInt(context_data_val));context_data_val=0}else{context_data_position++}value=0}value=context_w.charCodeAt(0);for(i=0;i<16;i++){context_data_val=context_data_val<<1|value&1;if(context_data_position==bitsPerChar-1){context_data_position=0;context_data.push(getCharFromInt(context_data_val));context_data_val=0}else{context_data_position++}value=value>>1}}context_enlargeIn--;if(context_enlargeIn==0){context_enlargeIn=Math.pow(2,context_numBits);context_numBits++}delete context_dictionaryToCreate[context_w]}else{value=context_dictionary[context_w];for(i=0;i<context_numBits;i++){context_data_val=context_data_val<<1|value&1;if(context_data_position==bitsPerChar-1){context_data_position=0;context_data.push(getCharFromInt(context_data_val));context_data_val=0}else{context_data_position++}value=value>>1}}context_enlargeIn--;if(context_enlargeIn==0){context_enlargeIn=Math.pow(2,context_numBits);context_numBits++}context_dictionary[context_wc]=context_dictSize++;context_w=String(context_c)}}if(context_w!==""){if(Object.prototype.hasOwnProperty.call(context_dictionaryToCreate,context_w)){if(context_w.charCodeAt(0)<256){for(i=0;i<context_numBits;i++){context_data_val=context_data_val<<1;if(context_data_position==bitsPerChar-1){context_data_position=0;context_data.push(getCharFromInt(context_data_val));context_data_val=0}else{context_data_position++}}value=context_w.charCodeAt(0);for(i=0;i<8;i++){context_data_val=context_data_val<<1|value&1;if(context_data_position==bitsPerChar-1){context_data_position=0;context_data.push(getCharFromInt(context_data_val));context_data_val=0}else{context_data_position++}value=value>>1}}else{value=1;for(i=0;i<context_numBits;i++){context_data_val=context_data_val<<1|value;if(context_data_position==bitsPerChar-1){context_data_position=0;context_data.push(getCharFromInt(context_data_val));context_data_val=0}else{context_data_position++}value=0}value=context_w.charCodeAt(0);for(i=0;i<16;i++){context_data_val=context_data_val<<1|value&1;if(context_data_position==bitsPerChar-1){context_data_position=0;context_data.push(getCharFromInt(context_data_val));context_data_val=0}else{context_data_position++}value=value>>1}}context_enlargeIn--;if(context_enlargeIn==0){context_enlargeIn=Math.pow(2,context_numBits);context_numBits++}delete context_dictionaryToCreate[context_w]}else{value=context_dictionary[context_w];for(i=0;i<context_numBits;i++){context_data_val=context_data_val<<1|value&1;if(context_data_position==bitsPerChar-1){context_data_position=0;context_data.push(getCharFromInt(context_data_val));context_data_val=0}else{context_data_position++}value=value>>1}}context_enlargeIn--;if(context_enlargeIn==0){context_enlargeIn=Math.pow(2,context_numBits);context_numBits++}}value=2;for(i=0;i<context_numBits;i++){context_data_val=context_data_val<<1|value&1;if(context_data_position==bitsPerChar-1){context_data_position=0;context_data.push(getCharFromInt(context_data_val));context_data_val=0}else{context_data_position++}value=value>>1}while(true){context_data_val=context_data_val<<1;if(context_data_position==bitsPerChar-1){context_data.push(getCharFromInt(context_data_val));break}else context_data_position++}return context_data.join("")},decompress:function(compressed){if(compressed==null)return"";if(compressed=="")return null;return LZString._decompress(compressed.length,32768,function(index){return compressed.charCodeAt(index)})},_decompress:function(length,resetValue,getNextValue){var dictionary=[],next,enlargeIn=4,dictSize=4,numBits=3,entry="",result=[],i,w,bits,resb,maxpower,power,c,data={val:getNextValue(0),position:resetValue,index:1};for(i=0;i<3;i+=1){dictionary[i]=i}bits=0;maxpower=Math.pow(2,2);power=1;while(power!=maxpower){resb=data.val&data.position;data.position>>=1;if(data.position==0){data.position=resetValue;data.val=getNextValue(data.index++)}bits|=(resb>0?1:0)*power;power<<=1}switch(next=bits){case 0:bits=0;maxpower=Math.pow(2,8);power=1;while(power!=maxpower){resb=data.val&data.position;data.position>>=1;if(data.position==0){data.position=resetValue;data.val=getNextValue(data.index++)}bits|=(resb>0?1:0)*power;power<<=1}c=f(bits);break;case 1:bits=0;maxpower=Math.pow(2,16);power=1;while(power!=maxpower){resb=data.val&data.position;data.position>>=1;if(data.position==0){data.position=resetValue;data.val=getNextValue(data.index++)}bits|=(resb>0?1:0)*power;power<<=1}c=f(bits);break;case 2:return""}dictionary[3]=c;w=c;result.push(c);while(true){if(data.index>length){return""}bits=0;maxpower=Math.pow(2,numBits);power=1;while(power!=maxpower){resb=data.val&data.position;data.position>>=1;if(data.position==0){data.position=resetValue;data.val=getNextValue(data.index++)}bits|=(resb>0?1:0)*power;power<<=1}switch(c=bits){case 0:bits=0;maxpower=Math.pow(2,8);power=1;while(power!=maxpower){resb=data.val&data.position;data.position>>=1;if(data.position==0){data.position=resetValue;data.val=getNextValue(data.index++)}bits|=(resb>0?1:0)*power;power<<=1}dictionary[dictSize++]=f(bits);c=dictSize-1;enlargeIn--;break;case 1:bits=0;maxpower=Math.pow(2,16);power=1;while(power!=maxpower){resb=data.val&data.position;data.position>>=1;if(data.position==0){data.position=resetValue;data.val=getNextValue(data.index++)}bits|=(resb>0?1:0)*power;power<<=1}dictionary[dictSize++]=f(bits);c=dictSize-1;enlargeIn--;break;case 2:return result.join("")}if(enlargeIn==0){enlargeIn=Math.pow(2,numBits);numBits++}if(dictionary[c]){entry=dictionary[c]}else{if(c===dictSize){entry=w+w.charAt(0)}else{return null}}result.push(entry);dictionary[dictSize++]=w+entry.charAt(0);enlargeIn--;w=entry;if(enlargeIn==0){enlargeIn=Math.pow(2,numBits);numBits++}}}};return LZString}();if(typeof define==="function"&&define.amd){define(function(){return LZString})}else if(typeof module!=="undefined"&&module!=null){module.exports=LZString}`;

  // helpers
  // dedicated web worker for data compression / decompression
  function workerLZString(s, method) {
    var compressFnStr = "onmessage = function(e){let output=LZString.compressToEncodedURIComponent(e.data);postMessage(output);}";
    var decompressFnStr = "onmessage = function(e){let output=LZString.decompressFromEncodedURIComponent(e.data);postMessage(output);}"
    var FnStr = compressFnStr;
    if (method === "compress") {
      FnStr = compressFnStr;
    }
    else if (method === "decompress") {
      FnStr = decompressFnStr;
    }
    else {
      throw ("invalid method!");
    }
    return new Promise((resolve, reject) => {
      try {
        var script = LZString_JS + FnStr;
        var blob = new Blob([script], {
          type: 'text/javascript'
        });
        var blob_url = URL.createObjectURL(blob);
        var worker = new Worker(blob_url);
        worker.onmessage = function (e) {
          worker.terminate();
          print("web worker: done");
          resolve(e.data);
        }
        worker.onerror = function (e) {
          print('web worker error:', e);
        }
        // submit task
        worker.postMessage(s);
        print(`web worker: ${method}ing...`);
      }
      catch (err) {
        reject(error);
      }
    });
  }

  function getKeys(e) { // keycode 转换
    var codetable = {
      '96': 'Numpad 0',
      '97': 'Numpad 1',
      '98': 'Numpad 2',
      '99': 'Numpad 3',
      '100': 'Numpad 4',
      '101': 'Numpad 5',
      '102': 'Numpad 6',
      '103': 'Numpad 7',
      '104': 'Numpad 8',
      '105': 'Numpad 9',
      '106': 'Numpad *',
      '107': 'Numpad +',
      '108': 'Numpad Enter',
      '109': 'Numpad -',
      '110': 'Numpad .',
      '111': 'Numpad /',
      '112': 'F1',
      '113': 'F2',
      '114': 'F3',
      '115': 'F4',
      '116': 'F5',
      '117': 'F6',
      '118': 'F7',
      '119': 'F8',
      '120': 'F9',
      '121': 'F10',
      '122': 'F11',
      '123': 'F12',
      '8': 'BackSpace',
      '9': 'Tab',
      '12': 'Clear',
      '13': 'Enter',
      '16': 'Shift',
      '17': 'Ctrl',
      '18': 'Alt',
      '20': 'Cape Lock',
      '27': 'Esc',
      '32': 'Spacebar',
      '33': 'Page Up',
      '34': 'Page Down',
      '35': 'End',
      '36': 'Home',
      '37': '←',
      '38': '↑',
      '39': '→',
      '40': '↓',
      '45': 'Insert',
      '46': 'Delete',
      '144': 'Num Lock',
      '186': ';:',
      '187': '=+',
      '188': ',<',
      '189': '-_',
      '190': '.>',
      '191': '/?',
      '192': '`~',
      '219': '[{',
      '220': '\|',
      '221': ']}',
      '222': '"'
    };
    var Keys = '';
    e.shiftKey && (e.keyCode != 16) && (Keys += 'shift+');
    e.ctrlKey && (e.keyCode != 17) && (Keys += 'ctrl+');
    e.altKey && (e.keyCode != 18) && (Keys += 'alt+');
    return Keys + (codetable[e.keyCode] || String.fromCharCode(e.keyCode) || '');
  };

  function addHotKey(codes, func) { // 监视并执行快捷键对应的函数
    document.addEventListener('keydown', function (e) {
      if ((e.target.tagName != 'INPUT') && (e.target.tagName != 'TEXTAREA') && getKeys(e) == codes) {
        func();
        e.preventDefault();
        e.stopPropagation();
      }
    }, false);
  };

  function print() {
    if (DEVELOP) {
      console.log(...arguments);
    }
  }

  async function compress(s) {
    if (typeof (Worker) !== "undefined") {
      //great, your browser supports web workers
      return await workerLZString(s, "compress");
    }
    else {
      //not supported
      return LZString.compressToEncodedURIComponent(s);
    }
  }

  async function decompress(s) {
    if (typeof (Worker) !== "undefined") {
      //great, your browser supports web workers
      return await workerLZString(s, "decompress");
    }
    else {
      //not supported
      return LZString.decompressFromEncodedURIComponent(s);
    }
  }

  async function tabSafeRunner(lock_key, callback, timeout) {
    // implement file lock with GM
    // lock_content format: {id, timestamp}
    const session_id = uuidv4();
    const retry_delay = 100; // retry every 100ms
    const run_self_again_with_delay = async () => {
      await sleep(retry_delay);
      return await tabSafeRunner(lock_key, callback, timeout);
    }

    var lock_val = await GM.getValue(lock_key, null);
    var lock_content;
    if (lock_val !== null) {
      // maybe lock was aquired by another tab
      try {
        lock_content = JSON.parse(lock_val);
        // check if lock is expired
        if (lock_content.timestamp + timeout > (+new Date())) {
          // not expired, try later
          return await run_self_again_with_delay();
        }
      }
      catch (e) {
        print("invalid lock content, ignore it", e);
      }
    }
    // when code runs here, there are two situations
    // 1. lock expired
    // 2. lock invalid
    // so it is my turn...
    print("┌====lock acquired====┐");
    lock_content = {
      id: session_id,
      timestamp: (+new Date())
    };
    await GM.setValue(lock_key, JSON.stringify(lock_content));
    // just in case another tab aquired lock in the same time... double check
    await sleep(10);

    lock_val = await GM.getValue(lock_key, null);
    if (lock_val === null) {
      print("└====lock damaged====┘");
      return await run_self_again_with_delay();
    }
    lock_content = JSON.parse(lock_val);
    if (lock_content.id !== session_id) {
      print("└====lock damaged====┘");
      return await run_self_again_with_delay();
    }
    // ok, now lock is secured...
    try {
      return await callback();
    }
    finally {
      // clean the lock. We cannot set null as the value, so delete it
      await GM.deleteValue(lock_key);
      print("└====lock released====┘");
    }
  }

  function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  function getStringSize(s) {
    return (new TextEncoder().encode(s)).length;
  }

  function htmlToElement(html) {
    var template = document.createElement('template');
    html = html.trim(); // Never return a text node of whitespace as the result
    template.innerHTML = html;
    return template.content.firstChild;
  }

  function getEpoch(date_str, time_str) {
    let [y, m, d] = date_str.split("-").map(x => parseInt(x));
    let [H, M] = time_str.split(":").map(x => parseInt(x));
    return new Date(y, m - 1, d, H, M, 0).getTime() / 1000;
  }

  function uuidv4() {
    // https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid
    return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));
  }

  // classes
  class HpThread {
    constructor() {
      this.initializeIndicator();
    }

    initializeIndicator() {
      $("#footlink").append($('<p class="smalltext" id="tmIndicator"></p>'));
    }

    indicatorSetMessage(msg) {
      $('#tmIndicator').text("【时光机】" + msg);
    }

    getThreadTid() {
      return location.href.match(/tid=(\d+)/) ? parseInt(location.href.match(/tid=(\d+)/)[1]) : -999;
    }

    getUserUid() {
      return parseInt($("cite > a").attr("href").split("uid=")[1]);
    }

    getThreadTitle() {
      let l = $('#nav').text().split(" » ");
      return l[l.length - 1];
    }

    getHpPosts() {
      let threadTid = this.getThreadTid();
      let threadTitle = this.getThreadTitle();
      let divs = $('#postlist > div').get();
      return divs.map(d => new HpPost(threadTid, threadTitle, d));
    }

    addTimeMachineManagementUI(_db) {
      // call this after document loaded
      var that = this;
      var slidersResponsive = false; // set it to false when values are manipulated by script
      var button = htmlToElement(`
              <button id="tmButton_management">
                <span><img src="https://icons.iconarchive.com/icons/hamzasaleem/stock/32/Time-Machine-icon.png"></img></span>
              </button>
            `);

      // create dialog
      let dialog = htmlToElement(`
              <div id="tmDialog_management" style="display: none;">
                <h3>hipda-时光机 v${GM_info.script.version}</h3>
                <br />
                <div style="marign: 20px auto 20px auto;">
                    <input type="hidden" autofocus="true" />
                    <div  style="display:inline; width: 50%; float:left; text-align:left;">数据保质期: <span id="tmUserConfigExpireDays">-</span> 天</div>
                    <div style="display:inline; width: 50%; float:left;">
                    <div style="margin: 5px;" id="expire_days_slider"></div>
                    </div>
                    <div  style="display:inline; width: 50%; float:left;  text-align:left;">数据库容量: <span id="tmUserConfigMaxSize">-</span> MB</div>
                    <div style="display:inline; width: 50%; float:left;">
                    <div style="margin: 5px;" id="max_size_slider"></div>
                    </div>
                </div>
                <div style="margin-top: 50px;" id="tmStat">
                </div>
                <div id="tmProgressbar"></div>
                <br />
                <div style="margin: 0px auto 10px auto;">
                  <button id="tmButton_reset">重置</button>
                  <button id="tmButton_cleanup">清理</button>
                </div>
              </div>
            `);
      $("body").append(dialog);

      $('#tmProgressbar').progressbar({
        value: false
      });

      updateTimeMachineUserConfigUI(true);

      async function updateTimeMachineUserConfig() {
        // UI -> DB
        let expire_days = $("#expire_days_slider").slider("value");
        let max_size = parseInt($("#max_size_slider").slider("value") * 1024);
        await _db.saveUserConfigToLocalStorage(max_size, expire_days);
      }

      async function updateTimeMachineUserConfigUI(init = false) {
        // DB -> UI
        slidersResponsive = false;
        let user_config = await _db.getUserConfig();
        if (init) {
          // create sliders
          $("#expire_days_slider").slider({
            min: 1,
            max: EXPIRE_DAYS_LIMIT,
            value: user_config.EXPIRE_DAYS
          });
          $("#max_size_slider").slider({
            min: 3,
            max: parseInt(MAX_SIZE_LIMIT / 1024),
            value: parseInt(user_config.MAX_SIZE / 1024)
          });
        }
        let expire_days = $("#expire_days_slider").slider("value");
        let max_size = parseInt($("#max_size_slider").slider("value") * 1024);
        $(`#tmUserConfigExpireDays`).text(`${user_config.EXPIRE_DAYS}`);
        $(`#tmUserConfigMaxSize`).text(`${(user_config.MAX_SIZE/1024.0).toFixed(2)}`);
        slidersResponsive = true;
      }

      async function updateTimeMachineStatUI() {
        $(`#tmStat`).text("统计中...");
        $('#tmProgressbar').progressbar({
          value: false
        });
        let stat = await _db.getTimeMachineStat();
        let user_config = await _db.getUserConfig();

        // delay for animation :D
        setTimeout(() => {
          var unsyncedText = stat.cached_post_number === 0 ? "" : `(尚有${stat.cached_post_number}个快照未同步)`;
          $(`#tmStat`).text(`
                    共${stat.post_number}个楼层,${stat.snapshot_number}个快照${unsyncedText};
                    大小为 ${(stat.size_kb).toFixed(2)}KB (压缩后 ${stat.compressed_size_kb.toFixed(2)}KB)

                    `);

          $('#tmProgressbar').progressbar({
            value: stat.compressed_size_kb,
            max: user_config.MAX_SIZE
          });
        }, 300);
      }

      async function openDialog() {
        $(`#tmDialog_management`).dialog({
          title: "时光机:管理面板",
          height: 300,
          width: 500,
          closeOnEscape: true,
        });
        await updateTimeMachineStatUI();
      }

      $("#expire_days_slider").on("slidechange", async function (event, ui) {
        if (!slidersResponsive) {
          return;
        }
        $('#tmProgressbar').progressbar({
          value: false
        });
        await updateTimeMachineUserConfig();
        await updateTimeMachineUserConfigUI();
        await updateTimeMachineStatUI();
      });
      $("#max_size_slider").on("slidechange", async function (event, ui) {
        if (!slidersResponsive) {
          return;
        }
        $('#tmProgressbar').progressbar({
          value: false
        });
        await updateTimeMachineUserConfig();
        await updateTimeMachineUserConfigUI();
        await updateTimeMachineStatUI();
      });

      $(button).click(async function () {
        openDialog();
      });

      $("#tmButton_reset").click(async function () {
        let r = confirm("确定要重置时光机吗?");
        if (!r) {
          return;
        }
        await _db.resetTimeMachine();
        await updateTimeMachineStatUI();
        updateTimeMachineUserConfigUI(true); // db => UI
      });

      $("#tmButton_cleanup").click(async function () {
        let r = confirm("确定要手动清理过期快照吗?");
        if (!r) {
          return;
        }
        await _db.cleanUp();
        await updateTimeMachineStatUI();
      });
      // HOTKEY
      addHotKey(MANAGEMENT_KEY, openDialog);
      // add UI
      let d = $("td.modaction").last();
      d.append(button);
    }
  }

  class HpPost {
    constructor(threadTid, threadTitle, postDiv) {
      this.threadTid = threadTid;
      this.threadTitle = threadTitle;
      this._post_div = postDiv;
    }

    getPostAuthorName() {
      if ($(this._post_div).find("div.postinfo > a").length > 0) {
        return $(this._post_div).find("div.postinfo > a").first().text();
      }
      else {
        // deleted user
        let ele = $(this._post_div).find("td.postauthor").first().clone();
        ele.find('em').remove();
        return ele.text().trim();
      }
    }

    getPostAuthorUid() {
      if ($(this._post_div).find("div.postinfo > a").length > 0) {
        return parseInt($(this._post_div).find("div.postinfo > a").first().attr("href").split("uid=")[1]);
      }
      else {
        // deleted user
        return -999;

      }
    }

    getPostPid() {
      return parseInt($(this._post_div).attr("id").split("_")[1]);
    }

    getGotoUrl() {
      return `https://www.hi-pda.com/forum/redirect.php?goto=findpost&ptid=${this.threadTid}&pid=${this.getPostPid()}`;
    }

    getPostContent() {
      if ($(this._post_div).find("div.locked").length > 0) {
        // locked post
        return "提示: 作者被禁止或删除 内容自动屏蔽";
      }
      // get text without quotes
      let t = $(this._post_div).find("td.t_msgfont").first().clone();
      t.find('.quote').replaceWith("<p>【引用内容】</p>");
      t.find('.t_attach').replaceWith("<p>【附件】</p>");
      t.find('img').remove();
      let text = t.text().replace(/\n+/g, "\n").trim();
      return text;
    }

    getPostBrief(n) {
      let content = this.getPostContent();
      if (content.length <= n) {
        return content;
      }
      return content.slice(0, n) + "\n\n【以上为截取片段】";
    }

    getOriginalTimestamp(use_string = false) {
      let dt = $(this._post_div).find("div.authorinfo > em").text().trim().split(" ").slice(1, 3);
      if (use_string) {
        return dt.join(" ");
      }
      return getEpoch(dt[0], dt[1]);
    }

    getLastTimestamp(use_string = false) {
      let ele = $(this._post_div).find("i.pstatus").get();
      if (ele.length !== 0) {
        let dt = $(this._post_div).find("i.pstatus").text().trim().split(" ").slice(3, 5);
        if (use_string) {
          return dt.join(" ");
        }
        return getEpoch(dt[0], dt[1]);
      }
      return null;
    }

    getTimestamp(use_string = false) {
      // get last edit time
      let lastTimestamp = this.getLastTimestamp(use_string);
      return lastTimestamp ? lastTimestamp : this.getOriginalTimestamp(use_string);
    }

    getPostState() {
      let uid = this.getPostAuthorUid()
      let userName = this.getPostAuthorName();
      let tid = this.threadTid;
      let title = this.threadTitle;
      let pid = this.getPostPid();
      let content = $(this._post_div).html();
      let timestamp = this.getTimestamp();
      return {
        uid,
        userName,
        tid,
        title,
        pid,
        content,
        timestamp
      }
    }

    updateContent(payload) {
      var content = payload.content;
      var remark = $(`<div style="background-color: red; width: 100%; text-align:center;">数据来自时光机,内容仅作参考</div>`);
      // thread content
      if (($(this._post_div).find("div.locked").length > 0) || ($(content).find("div.locked").length > 0)) {
        // locked post
        $(this._post_div).find('td.postcontent').first().replaceWith($(content).find('td.postcontent').first());
        // remark
        $(this._post_div).find('div.defaultpost').prepend(remark);
      }
      else {
        // normal post
        $(this._post_div).find('td.t_msgfont').replaceWith($(content).find('td.t_msgfont'));
        // remark
        $(this._post_div).find('td.t_msgfont').prepend(remark);
      }
      // signature
      $(this._post_div).find('td .postcontent, .postbottom').replaceWith($(content).find('td .postcontent, .postbottom'));
      // title (thread main post)
      $(this._post_div).find('#threadtitle > h1').replaceWith($(content).find('#threadtitle > h1'));

    }

    addTimeMachineUI(_db) {
      // call this after document loaded
      var that = this;
      let pid = this.getPostPid();
      let index = $(this._post_div).index();

      if (_db.get(pid).length <= 1) {
        return;
      }

      // change background
      $(this._post_div).css("background-color", "rgba(255, 255, 0, 0.1)");

      let button = htmlToElement(`
              <button id="tmButton_${index}" style="color:grey; margin-left:0px;">
                时光机
              </button>
            `);
      let d = $(this._post_div).find("td[rowspan='2'].postauthor").first();
      d.append(button);

      // note dialog
      let dialog = htmlToElement(`
              <div id="tmDialog_${index}" style="display: none;">
                <div style="margin: 10px auto 20px auto;">请选择一个历史版本</div>
                  <div class="controlgroup-vertical">
                  </div>
              </div>
            `);
      $("body").append(dialog);

      _db.get(pid).forEach((x, xIndex) => {
        $(dialog).find("div.controlgroup-vertical").append(`
                <label for="radio-${pid}-${xIndex}">
                  ${new Date(x.timestamp * 1000 ).toLocaleString()}
                </label>
                <input type="radio" name="radio-${pid}" value="${xIndex}" id="radio-${pid}-${xIndex}">
                `);
        $(`input[name="radio-${pid}"]`).val([xIndex]);
      });

      $(dialog).find(".controlgroup-vertical").controlgroup({
        "direction": "vertical"
      });
      // add onChange event
      $(`input[name="radio-${pid}"]`).change(function () {
        let xIndex = $(`input[name="radio-${pid}"]:checked`).val();
        let payload = _db.get(pid)[parseInt(xIndex)];
        that.updateContent(payload);
      });

      $(button).click(function () {
        $(`#tmDialog_${index}`).dialog({
          title: `时光机`,
          dialogClass: "no-close",
          closeText: "hide",
          closeOnEscape: true,
          height: 350,
          width: 600,
          buttons: [{
            text: "确认",
            click: function () {
              $(this).dialog("close");
            }
          }]
        });
      });

    }

  }

  class DB {
    // {pid:[{}, {}]}
    // add compression in the future
    constructor() {
      // initialization
      this._lock_name = "hipda-timemachine-lock"; // can write timestamp and DB only when lock is available; lock value in LocalStorage is a Boolean
      this._db_timestamp_name = "hipda-timemachine-timestamp";
      this._db_timestamp = null; // disk DB
      this._tab_db_timestamp = null; // memory DB
      this._name = "hipda-timemachine";
      this._cache_key_prefix = "hipda-timemachine-cache-pid-";
      this._user_config_key = "user-config" // this k-v is used to store user's configuration
      this._db = {};
      return (async () => {
        await this.loadFromLocalStorage();
        await this.getUserConfig();
        return this;
      })();
    }

    // bench mark
    async benchmark() {
      var pids = this.getPids();
      var n = 50000;
      console.log(`benchmark loading speed with ${n} posts`);
      console.log("writing to system...");
      var bulk_data = {
        data: []
      };
      var requests = []
      for (let i = 0; i < n; i++) {
        let index = i % (pids.length);
        let data = this._db[pids[index]];
        bulk_data.data.push(data);
        requests.push(GM.setValue(`benchmark_${index}`, JSON.stringify({
          data: data
        })));
      }
      bulk_data = await compress(JSON.stringify(bulk_data));
      await GM.setValue("benchmark_bulk", bulk_data);
      await Promise.all(requests);

      console.log("reading to system");
      var t0;
      t0 = +new Date()
      bulk_data = await GM.getValue("benchmark_bulk", null);
      bulk_data = await decompress(bulk_data);
      bulk_data = JSON.parse(bulk_data);
      console.log("reading bulk: ", (+new Date() - t0) / 1000, "s");

      t0 = +new Date();
      requests = [];
      for (let i = 0; i < n; i++) {
        let index = i % (pids.length);
        requests.push(GM.getValue(`benchmark_${index}`, null));
      }
      await Promise.all(requests);
      for (let i = 0; i < n; i++) {
        let data = await requests[i];
        JSON.parse(data);
      }
      console.log("reading separately: ", (+new Date() - t0) / 1000, "s");

      // clean up
      requests = [];
      var keys = await GM.listValues();
      keys = keys.filter(x => x.startsWith("benchmark_"));
      for (let i = 0; i < keys.length; i++) {
        requests.push(GM.deleteValue(keys[i]));
      }
      await Promise.all(requests);
      console.log("done");
    }

    // about DB versions (timestamp)
    updateTabTimestamp() {
      this._tab_db_timestamp = +new Date();
    }

    async updateLocalStorageTimestamp() {
      await GM.setValue(this._db_timestamp_name, this._tab_db_timestamp === null ? (+new Date()) : this._tab_db_timestamp);
    }

    async getLocalStorageTimestamp() {
      let ts = await GM.getValue(this._db_timestamp_name, null);
      if (ts === null) {
        // set it
        await this.updateLocalStorageTimestamp();
        return await this.getLocalStorageTimestamp();
      }
      else {
        return ts;
      }
    }

    // about user configurations
    async getUserConfig() {
      if (!(this._user_config_key in this._db)) {
        this.saveUserConfig(MAX_SIZE_DEFAULT, EXPIRE_DAYS_DEFAULT);
        await this.saveToLocalStorage_SAFE();
      }
      return this._db[this._user_config_key];
    }

    saveUserConfig(max_size, expire_days) {
      // only modify tab
      this._db[this._user_config_key] = {
        MAX_SIZE: max_size,
        EXPIRE_DAYS: expire_days
      };
      this.updateTabTimestamp();
    }

    async saveUserConfigToLocalStorage(max_size, expire_days) {
      this.saveUserConfig(max_size, expire_days);
      await this.saveToLocalStorage_SAFE();
    }

    // about DB IO
    async loadFromLocalStorage() {
      print("load History from Local Storage");
      let t0 = +new Date();

      this._db_timestamp = await this.getLocalStorageTimestamp();
      this._tab_db_timestamp = this._db_timestamp;
      let data = await GM.getValue(this._name, null);
      if (data !== null) {
        let decomp_data = await decompress(data)
        this._db = JSON.parse(decomp_data);
      }
      else {
        print("no History data found...");
        this._db = {};
      }

      print(`loading History took ${((+ new Date()-t0)/1000).toFixed(2)}s`);
    }

    async saveToLocalStorage_SAFE() {
      // this function handles multi-tab sync
      // this tab can only save the data when lock is available
      let timeout = this.getEstimatedCompressionTime();
      return await tabSafeRunner(this._lock_name, this.saveToLocalStorage.bind(this), timeout);
    }

    async mergeLocalStorage() {
      // if the DB was modified after loading, we should merge _db and DB
      // if tid content conflicts, we keep the current version
      let tab_db = {
        ...this._db
      };
      let tab_pids = this.getPids();
      await this.loadFromLocalStorage(); // now this._db is from LocalStorage
      for (let i = 0; i < tab_pids.length; i++) {
        this._db[tab_pids[i]] = tab_db[tab_pids[i]];
      }
      // keep current config as well
      this._db[this._user_config_key] = tab_db[this._user_config_key];
      // flag the change
      await this.updateTabTimestamp();
      await this.saveToLocalStorage();
    }

    async saveToLocalStorage() {
      // tab DB > loaded DB:
      //   1. DB was updated after we load it: merge
      //   2. DB wat not updated after we load it: overwrite
      // tab DB == loaded DB:
      //   1. DB was updated after we load it: reload
      //   2. DB wat not updated after we load it: skip
      // tab DB < loaded DB:
      //   impossible
      let t0 = +new Date();

      let local_storage_timestamp = await this.getLocalStorageTimestamp();
      if (this._tab_db_timestamp > this._db_timestamp) {
        // tab -> DB
        if (local_storage_timestamp > this._db_timestamp) {
          print("merge History from Local Storage");
          await this.mergeLocalStorage();
        }
        else {
          print("write current version of History to Local Storage");
          let d = JSON.stringify(this._db);
          let cs = await compress(d);
          await GM.setValue(this._name, cs);
          // now we assume we reloaded the DB
          this._db_timestamp = this._tab_db_timestamp;
        }
        await this.updateLocalStorageTimestamp();
      }
      else {
        // DB -> tab
        if (local_storage_timestamp > this._db_timestamp) {
          await this.loadFromLocalStorage();
        }
        else {
          print("already up-to-date");
        }
      }

      let time_spent = (+new Date() - t0) / 1000; // seconds

      print(`saving History took ${(time_spent).toFixed(2)}s`);
      // local_storage_timestamp = await this.getLocalStorageTimestamp();
      // print("载入的版本", new Date(this._db_timestamp).toISOString());
      // print("内存的版本", new Date(this._tab_db_timestamp).toISOString());
      // print("系统的版本", new Date(local_storage_timestamp).toISOString());
      return time_spent;
    }

    isPayloadNew(pid, payload) {
      // compare DB with payload
      if (!(pid in this._db)) {
        return true;
      }
      let maxTimestamp = this._db[pid].map(x => x.timestamp).sort()[this._db[pid].length - 1];
      return maxTimestamp < payload.timestamp;
    }

    async cacheToLocalStorage(pid, payload) {
      // save post data to local storage
      var to_be_saved;
      var cache_key = `${this._cache_key_prefix}${pid}`;
      let currentDataStr = await GM.getValue(cache_key, null);
      if (currentDataStr === null) {
        to_be_saved = {
          data: [payload]
        };
      }
      else {
        let currentData = JSON.parse(currentDataStr);
        // check timestamp
        let maxTimestamp = currentData.data.map(x => x.timestamp).sort()[currentData.data.length - 1];
        if (maxTimestamp >= payload.timestamp) {
          // no need to add
          return;
        }
        to_be_saved = {
          ...currentData
        };
        to_be_saved.data.push(payload);

      }
      await GM.setValue(cache_key, JSON.stringify(to_be_saved));
    }

    async getCachedPids() {
      var keys = await GM.listValues();
      var pids = keys.filter(x => x.startsWith(this._cache_key_prefix)).map(x => x.split(this._cache_key_prefix)[1]);
      return pids;
    }

    async loadCachedPids() {
      var pids = await this.getCachedPids();
      if (pids.length === 0) {
        return [];
      }
      var requests = [];
      for (let i = 0; i < pids.length; i++) {
        let cache_key = `${this._cache_key_prefix}${pids[i]}`;
        requests.push(GM.getValue(cache_key));
      }
      // wait all finished
      var payload_strs = await Promise.all(requests);
      var payloads = payload_strs.map(x => JSON.parse(x).data).reduce((a, b) => [...a, ...b]);
      for (let i = 0; i < payloads.length; i++) {
        let payload = payloads[i];
        this.putLocal(payload.pid, payload);
      }
      return pids;
    }

    async tryToConsumeCachedPids() {
      print("try to consume cached posts...");
      var pids = await this.loadCachedPids();
      await this.saveToLocalStorage();
      // try to remove cache. It won't hurt if the cache is not entirely cleaned.
      var requests = [];
      for (let i = 0; i < pids.length; i++) {
        let pid = pids[i];
        let cache_key = `${this._cache_key_prefix}${pid}`;
        requests.push(GM.deleteValue(cache_key));
      }
      await Promise.all(requests);
      return pids;
    }

    async tryToConsumeCachedPids_SAFE() {
      // this function handles multi-tab sync
      // this tab can only save the data when lock is available
      let timeout = this.getEstimatedCompressionTime();
      return await tabSafeRunner(this._lock_name, this.tryToConsumeCachedPids.bind(this), timeout);
    }

    putLocal(pid, payload) {
      if (!(pid in this._db)) {
        this._db[pid] = [];
        this._db[pid].push({
          ...payload
        });
        this.updateTabTimestamp();
        return;
      }
      // check timestamp
      let maxTimestamp = this._db[pid].map(x => x.timestamp).sort()[this._db[pid].length - 1];
      if (maxTimestamp < payload.timestamp) {
        this._db[pid].push(payload);
        this.updateTabTimestamp();
      }
      else {
        return;
      }
    }

    get(pid) {
      if (pid in this._db) {
        return this._db[pid];
      }
      return [];
    }

    getPids() {
      let pids = Object.keys(this._db).filter(x => x !== this._user_config_key);
      return pids;
    }

    // about high level operations
    async resetTimeMachine() {
      this._db = {};
      this.saveUserConfig(MAX_SIZE_DEFAULT, EXPIRE_DAYS_DEFAULT);
      await this.saveToLocalStorage_SAFE();
      // double check if merge was conducted
      if (this.getPids().length !== 0) {
        await this.resetTimeMachine();
      }
    }

    async checkHealth() {
      let user_config = await this.getUserConfig();
      let stat = await this.getTimeMachineStat();
      if (stat.compressed_size_kb > user_config.MAX_SIZE) {
        GM.notification(`储存空间到达${(user_config.MAX_SIZE / 1024.0).toFixed(2)}MB,后台将强制清理过期快照`, "【hipda-时光机】");
        await this.cleanUp_SAFE();
      }
    }

    async cleanUp_SAFE() {
      // this function handles multi-tab sync
      // this tab can only save the data when lock is available
      let timeout = this.getEstimatedCompressionTime();
      return await tabSafeRunner(this._lock_name, this.cleanUp.bind(this), timeout);
    }

    async cleanUp() {
      let user_config = await this.getUserConfig();
      // remove all of pids: only 1 record and post timestamp is EXPIRE_DAYS days ago
      let minTimestamp = new Date() / 1000 - user_config.EXPIRE_DAYS * 24 * 60 * 60;
      let pids = this.getPids();
      for (let i = 0; i < pids.length; i++) {
        if (this.get(pids[i]).length > 1) {
          continue;
        }
        // check if expired
        if (this.get(pids[i])[0].timestamp < minTimestamp) {
          delete this._db[pids[i]];
        }
      }
      this.updateTabTimestamp();
      await this.saveToLocalStorage();
      // check if it is successful
      let stat = await this.getTimeMachineStat();
      if (stat.compressed_size_kb > user_config.MAX_SIZE) {
        GM.notification(`清理后快照容量依然超标,请手动重置或者调整数据库参数!否则将一直收到提醒。`, "【hipda-时光机】");
      }
      else {
        GM.notification(`快照清理完成,目前数据库容量占用率为 ${(stat.compressed_size_kb / user_config.MAX_SIZE * 100.0).toFixed(2)}%`, "【hipda-时光机】");
      }
    }

    async getTimeMachineStat() {
      let db_str = JSON.stringify(this._db);
      let compressed_db_str = await GM.getValue(this._name, "");
      let pids = this.getPids();
      let cached_pids = await this.getCachedPids();
      return {
        'post_number': pids.length,
        'snapshot_number': pids.map(k => this._db[k].length).reduce((partial_sum, a) => partial_sum + a, 0),
        'cached_post_number': cached_pids.length,
        'size_kb': getStringSize(db_str) / 1024,
        'compressed_size_kb': getStringSize(compressed_db_str) / 1024
      };
    }

    async getEstimatedCompressionTime() {
      let compressed_db_str = await GM.getValue(this._name, "");
      let time = getStringSize(compressed_db_str) / 1024 * 4 // ms
      print(`Estimated Maximum Compression Time: ${(time/1000).toFixed(2)}s`);
      return time;
    }

  }

  async function main() {
    $(document).ready(async function () {

      // db
      var db = await new DB();

      // get a thread object
      var THIS_THREAD = new HpThread();

      THIS_THREAD.indicatorSetMessage("收录楼层数据中...");

      // render UI below
      var hp_posts = THIS_THREAD.getHpPosts();

      for (let i = 0; i < hp_posts.length; i++) {
        let hp_post = hp_posts[i];
        try {
          let payload = hp_post.getPostState();
          db.putLocal(payload.pid, payload);

        }
        catch (e) {
          // deleted thread, simply pass it
          print("unable to parse the post, pass");
          if (DEVELOP) {
            throw (e);
          }
        }
        // now it is ready to add timemachine, as the DB[post] is already up-to-date
        hp_post.addTimeMachineUI(db);
      }
      THIS_THREAD.addTimeMachineManagementUI(db);

      // saving is much slower than loading... leave this after UI rendering
      let time_spent = await db.saveToLocalStorage_SAFE();
      THIS_THREAD.indicatorSetMessage(`楼层数据收录完毕!耗时 ${time_spent.toFixed(2)}s`);

      await db.checkHealth();

    });
  }

  async function main_cached_version() {
    $(document).ready(async function () {

      // db
      var db = await new DB();
      // await db.benchmark();
      // return;

      // get a thread object
      var THIS_THREAD = new HpThread();

      THIS_THREAD.indicatorSetMessage("收录楼层数据中...");

      // render UI below
      var hp_posts = THIS_THREAD.getHpPosts();

      var requests = [];
      for (let i = 0; i < hp_posts.length; i++) {
        let hp_post = hp_posts[i];
        try {
          let payload = hp_post.getPostState();
          if (db.isPayloadNew(payload.pid, payload)) {
            db.putLocal(payload.pid, payload); // save to tab for current page
            requests.push(db.cacheToLocalStorage(payload.pid, payload)); // save to cache for DB
          }
        }
        catch (e) {
          // deleted thread, simply pass it
          print("unable to parse the post, pass");
          if (DEVELOP) {
            throw (e);
          }
        }
        // now it is ready to add timemachine, as the DB[post] is already up-to-date
        hp_post.addTimeMachineUI(db);
      }
      THIS_THREAD.addTimeMachineManagementUI(db);

      // saving is much slower than loading... leave this after UI rendering
      await Promise.all(requests);
      THIS_THREAD.indicatorSetMessage(`${requests.length}个快照缓存完毕!`);

      // if there is no cached pids at the moment, simply skip sync. Otherwise it will block other tabs for a while (lock aquire/release)
      let cached_pids = await db.getCachedPids();
      if (cached_pids.length === 0) {
        THIS_THREAD.indicatorSetMessage(`同步缓存完毕,共同步0个快照`);
        print("all set.")
        return;
      }

      var bar = {
        0: "-",
        1: "\\",
        2: "|",
        3: "/",
        4: "-",
        5: "/"
      }; // sometimes I feel I am creative :D
      var counter = 0;
      var wheel = setInterval(() => {
        THIS_THREAD.indicatorSetMessage(`${bar[counter%6]} 正在尝试同步缓存!`);
        counter += 1;
      }, 100)
      var pids = await db.tryToConsumeCachedPids_SAFE();
      clearInterval(wheel);

      THIS_THREAD.indicatorSetMessage(`同步缓存完毕,共同步${pids.length}个快照`);
      print("finished sync...")
      print("check DB health...")
      await db.checkHealth();
      print("all set.")
    });
  }

  // main();
  main_cached_version();

})();