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();
})();