NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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(); })();