// ==UserScript==
// @name hipda-ID笔记
// @namespace http://tampermonkey.net/
// @version 0.6.0
// @description 来自地板带着爱,记录上网冲浪的美好瞬间
// @author 屋大维
// @license MIT
// @match https://www.hi-pda.com/forum/*
// @match https://www.4d4y.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
// @icon https://icons.iconarchive.com/icons/iconshock/real-vista-project-managment/64/task-notes-icon.png
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// @grant GM_getResourceText
// @grant GM_addStyle
// ==/UserScript==
(function() {
'use strict';
// CONST
const BROWSER_KEY = 'alt+I';
const MANAGEMENT_KEY = "alt+U";
// 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} .card{box-shadow:0 4px 8px 0 rgba(0,0,0,.2);transition:.3s;width:100%;overflow-y: scroll;}.card:hover{box-shadow:0 8px 16px 0 rgba(0,0,0,.2)}.container{padding:2px 16px}");
GM_addStyle(".flex-container{display:flex;flex-wrap: wrap;}.flex-container>div{background-color:#f1f1f1;width:500px;max-height:500px;margin:15px; padding:5px;text-align:left;}");
// Your code here...
// helpers
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 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;
}
// classes
class HpThread {
constructor() {
}
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));
}
addNoteBrowserUI(_notebook) {
$('#menu>ul').append($(`<li class="menu_2"><a href="javascript:void(0)" hidefocus="true" id="noteButton_browser">搜索笔记</a></li>`));
var that = this;
// create dialog
let dialog = htmlToElement(`
<div id="noteDialog_browser" style="display: none;">
<div id="noteDialog_browser_search_bar" style="width: 80%; margin: 20px auto 20px auto;">
<select style="display: inline-block;" name="searchMethod" id="noteDialog_browser_search_method">
<option value="content">笔记内容</option>
<option value="userName">用户名</option>
</select>
<input type="text" autofocus="true" style="display: inline-block; width: 300px;" id="noteDialog_browser_search_input">
</div>
<div id="noteDialog_browser_note_list" style="width: 95%; margin: 10px auto 10px auto;" class="flex-container">
</div>
</div>
`);
$("body").append(dialog);
function updateNoteList() {
$('#noteDialog_browser_note_list').empty(); // remove all notes from the list
var notes;
var searchMethod = $('#noteDialog_browser_search_method').val();
var searchInput = $('#noteDialog_browser_search_input').val();
if (searchMethod === "userName") {
notes = _notebook.getNotesByUsername(searchInput);
} else if (searchMethod === "content") {
notes = _notebook.getNotesByKeyword(searchInput);
} else {
return;
}
for (let i=0; i<notes.length; i++) {
let element = noteToHtmlElement(notes[i]);
$('#noteDialog_browser_note_list').append(element);
}
}
function noteToHtmlElement(note) {
var searchMethod = $('#noteDialog_browser_search_method').val();
var searchInput = $('#noteDialog_browser_search_input').val();
var userName = note.userName;
var uid = note.uid;
var content = note.note;
if (searchMethod === 'userName') {
userName = userName.replaceAll(searchInput, '<mark class="highlight">$&</mark>');
}
if (searchMethod === 'content') {
content = content.replaceAll(searchInput, '<mark class="highlight">$&</mark>');
}
// highlight all URLs
var expression = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)?/gi;
var regex = new RegExp(expression);
content = content.replace(regex, '<a style="color:blue;" href="$&" target="_blank">$&</a>')
var html = `
<div class="card">
<div style="font-size: 2em; float: left; margin: 10px;">${userName}</div>
<div style="float: right;">
<button class="noteEditButton">编辑</button>
<button class="noteDeleteButton" style="margin-right: 2px;">删除</button>
</div>
<div class="container" style="word-break: break-all; white-space: pre-wrap;">
${content}
</div>
</div>
`;
var element = $(html);
// delete
element.find("button.noteDeleteButton").click(function() {
let r = confirm(`确定要删除 ${note.userName} 的ID笔记吗?`);
if (!r) {
return;
}
_notebook.delete(uid);
updateNoteList();
});
// edit
element.find("button.noteEditButton").click(function() {
// note dialog (this will be different from the one opened in posts)
let dialog = htmlToElement(`
<div id="noteDialog_${uid}" style="display: none;">
<textarea rows="10" wrap="hard" placeholder="暂时没有笔记">
</div>
`);
$("body").append(dialog);
// bind event listener
console.log("open note for", userName);
// freshly fetched from DB
$(`#noteDialog_${uid}`).find('textarea').first().val(_notebook.get(uid));
$(`#noteDialog_${uid}`).dialog({
title: `ID笔记:${userName}`,
dialogClass: "no-close",
closeText: "hide",
closeOnEscape: true,
height: Math.max(parseInt($(window).height() * 0.4), 350),
width: Math.max(parseInt($(window).width() * 0.4), 600),
buttons: [
{
text: "确认",
click: function() {
// save the new note before close
let newNote = $(`#noteDialog_${uid}`).find('textarea').first().val();
if (newNote.length === 0) {
_notebook.delete(uid);
} else {
_notebook.put(uid, userName, newNote);
}
$(this).dialog( "close" );
// update the Note List
updateNoteList();
}
},
{
text: "取消",
click: function() {
// close without saving
$(this).dialog( "close" );
}
}
]
});
});
return element;
}
function openBrowser() {
$('#menu>ul>li').first().removeClass("current");
$('#menu>ul>li').first().addClass("menu_2");
$('#noteButton_browser').parent().removeClass("menu_2");
$('#noteButton_browser').parent().addClass("current");
console.log("open notebook browser dialog");
$(`#noteDialog_browser`).dialog({
title: "ID笔记:浏览器",
modal: true,
height: parseInt($(window).height() * 0.8),
width: parseInt($(window).width() * 0.8),
closeOnEscape: true,
open: function(event, ui) {
$('.ui-widget-overlay').css("background-color", "black");
$('.ui-widget-overlay').css("opacity", "0.6");
},
close: function(event, ui) {
$('#menu>ul>li').first().removeClass("menu_2");
$('#menu>ul>li').first().addClass("current");
$('#noteButton_browser').parent().removeClass("current");
$('#noteButton_browser').parent().addClass("menu_2");
}
});
}
$(document).ready( function () {
$('#noteDialog_browser_search_input').on("input", () => {
updateNoteList();
});
$('#noteDialog_browser_search_method').change(() => {
updateNoteList();
});
$(document).on ("click", `#noteButton_browser`, function () {
openBrowser();
});
// HOTKEY
addHotKey(BROWSER_KEY, openBrowser);
});
}
addNoteManagementUI(_notebook) {
var that = this;
var button = htmlToElement(`
<button id="noteButton_management">
<span><img src="https://icons.iconarchive.com/icons/iconshock/real-vista-project-managment/32/task-notes-icon.png"></img></span>
</button>
`);
// create dialog
let dialog = htmlToElement(`
<div id="noteDialog_management" style="display: none;">
<h3>hipda-ID笔记 v${GM_info.script.version}</h3>
<p style="margin: 10px auto 10px auto;">来自地板带着爱</p>
<p id="noteStat" style="margin: 10px auto 10px auto;"></p>
<div>
<button id="noteButton_import">导入</button>
<button id="noteButton_export">导出</button>
<button id="noteButton_reset">重置</button>
<button id="noteButton_migrate">4d4y</button>
<input type="hidden" autofocus="true" />
</div>
</div>
`);
$("body").append(dialog);
function updateNoteStat() {
let note_stat = _notebook.getNotebookStat();
$(`#noteStat`).text(`共${note_stat.note_number}条ID笔记,大小为${(note_stat.size_kb).toFixed(2)}KB`);
}
function openManagement() {
console.log("open notebook management dialog");
// update statistics
updateNoteStat();
$(`#noteDialog_management`).dialog({
title: "ID笔记:管理面板",
height: 200,
width: 300,
closeOnEscape: true,
});
}
$(document).ready( function () {
$(document).on ("click", "#noteButton_migrate", function() {
let r = confirm("确定要从hi-pda迁移到4d4y吗?");
if (!r) {
return;
}
_notebook.migrate();
alert("迁移成功!");
});
$(document).on ("click", "#noteButton_import", function() {
let r = confirm("确定要导入ID笔记吗?现有笔记将会被覆盖!");
if (!r) {
return;
}
// prompt cannot handle large file, extend it in the future
let data = prompt("请将 id笔记.json 中的文本复制粘贴入文本框:");
if (data !== null) {
// try to load
try {
let j = JSON.parse(data);
_notebook.importNotebook(j);
} catch(err) {
alert("格式错误!" + err);
return;
}
alert("导入成功!");
updateNoteStat();
}
});
$(document).on ("click", "#noteButton_export", function() {
let r = confirm("确定要导出ID笔记吗?");
if (!r) {
return;
}
let a = document.createElement("a");
a.href = "data:text," + encodeURIComponent(_notebook.exportNotebook());
a.download = "id笔记.json";
a.click();
});
$(document).on ("click", "#noteButton_reset", function() {
let r = confirm("确定要清空ID笔记吗?");
if (!r) {
return;
}
_notebook.resetNotebook();
alert("ID笔记已经清空!");
updateNoteStat();
});
$(document).on ("click", `#noteButton_management`, function () {
openManagement();
});
// HOTKEY
addHotKey(MANAGEMENT_KEY, openManagement);
});
// 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() {
return $(this._post_div).find("div.postinfo > a").first().text();
}
getPostAuthorUid() {
return parseInt($(this._post_div).find("div.postinfo > a").first().attr("href").split("uid=")[1]);
}
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()}`;
return `https://www.4d4y.com/forum/redirect.php?goto=findpost&ptid=${this.threadTid}&pid=${this.getPostPid()}`;
}
getPostContent() {
// 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);
}
addNoteUI(_notebook) {
let uid = this.getPostAuthorUid();
let index = $(this._post_div).index();
let userName = this.getPostAuthorName();
var that = this;
// create an UI element which contains data and hooks
// button
let button = htmlToElement(`
<button id="noteButton_${index}" style="color:grey; margin-left:20px;">
ID笔记
</button>
`);
// note dialog
let dialog = htmlToElement(`
<div id="noteDialog_${index}" style="display: none;">
<textarea rows="10" wrap="hard" placeholder="暂时没有笔记">
</div>
`);
$("body").append(dialog);
// add event to button
$(document).ready( function () {
$(document).on ("click", `#noteButton_${index}`, function () {
console.log("open note for", userName);
// freshly fetched from DB
$(`#noteDialog_${index}`).find('textarea').first().val(_notebook.get(uid));
$(`#noteDialog_${index}`).dialog({
title: `ID笔记:${userName}`,
dialogClass: "no-close",
closeText: "hide",
closeOnEscape: true,
height: Math.max(parseInt($(window).height() * 0.4), 350),
width: Math.max(parseInt($(window).width() * 0.4), 600),
buttons: [
{
text: "插入当前楼层",
click: function() {
let txt = $(`#noteDialog_${index}`).find('textarea').first();
var caretPos = txt[0].selectionStart;
var textAreaTxt = txt.val();
var txtToAdd = `\n====\n引用: ${that.getGotoUrl()}\n【${that.getTimestamp(true)}】\n${that.getPostAuthorName()} 在《${that.threadTitle}》中说:\n ${that.getPostBrief(200)}\n====\n`;
txt.val(textAreaTxt.substring(0, caretPos) + txtToAdd + textAreaTxt.substring(caretPos) );
}
},
{
text: "确认",
click: function() {
// save the new note before close
let newNote = $(`#noteDialog_${index}`).find('textarea').first().val();
if (newNote.length === 0) {
_notebook.delete(uid);
} else {
_notebook.put(uid, userName, newNote);
}
$(this).dialog( "close" );
}
},
{
text: "取消",
click: function() {
// close without saving
$(this).dialog( "close" );
}
}
]
});
});
});
// add UI
let d = $(this._post_div).find("td[rowspan='2'].postauthor").first();
d.append(button);
}
}
class Notebook {
// notebook data structure:
// this._notebook[uid] = {uid, userName, note};
constructor() {
// initialization
this._name = "hipda-notebook";
this._notebook = {};
return (async () => {
this.loadFromLocalStorage();
return this;
})();
}
async loadFromLocalStorage() {
console.log("load ID Notebook from Local Storage");
let data = await GM.getValue(this._name, null);
if (data !== null) {
this._notebook = JSON.parse(data);
}
}
async saveToLocalStorage() {
console.log("save ID Notebook to Local Storage");
await GM.setValue(this._name, JSON.stringify(this._notebook));
}
put(uid, userName, note) {
// we need userName here, so user can analyze notes even after export
this._notebook[uid] = {uid, userName, note};
this.saveToLocalStorage();
}
get(uid) {
if (uid in this._notebook) {
return this._notebook[uid].note;
}
return "";
}
delete(uid) {
if (uid in this._notebook) {
delete this._notebook[uid];
this.saveToLocalStorage();
}
}
getNotesByUsername(userName) {
if (userName.length === 0) {
return [];
}
function compareFn(a, b) {
if (a.userName < b.userName) {
return -1;
}
if (a.userName > b.userName) {
return 1;
}
return 0;
}
return Object.values(this._notebook).filter(x => x.userName.toLocaleLowerCase().indexOf(userName.toLocaleLowerCase()) !== -1).sort(compareFn);
}
getNotesByKeyword(keyword) {
if (keyword.length === 0) {
return [];
}
function compareFn(a, b) {
if (a.note < b.userName) {
return -1;
}
if (a.userName > b.userName) {
return 1;
}
return 0;
}
return Object.values(this._notebook).filter(x => x.note.toLocaleLowerCase().indexOf(keyword.toLocaleLowerCase()) !== -1).sort(compareFn);
}
exportNotebook() {
// can add meta data here
let output = {
notebook: this._notebook,
version: GM_info.script.version,
timestamp: + new Date()
};
return JSON.stringify(output);
}
importNotebook(input) {
let attrs = ['notebook', 'version', 'timestamp'];
for (let i=0; i<attrs.length; i++) {
if (!input.hasOwnProperty(attrs[i])) {
throw(`bad format: ${attrs[i]} does not exist`);
}
}
this._notebook = {...input.notebook};
this.saveToLocalStorage();
}
resetNotebook() {
this._notebook = {};
this.saveToLocalStorage();
}
getNotebookStat() {
return {
'note_number': Object.keys(this._notebook).length,
'size_kb': (new TextEncoder().encode(this.exportNotebook())).length / 1024
};
}
migrate() {
// update all hi-pda urls to 4d4y urls
Object.keys(this._notebook).forEach(uid => {
let oldVal = this._notebook[uid].note;
let newVal = oldVal.replace('www.hi-pda.com/forum/' ,'www.4d4y.com/forum/');
this._notebook[uid].note = newVal;
});
}
}
async function main() {
// get a thread object
var THIS_THREAD = new HpThread();
var notebook = await new Notebook();
// notebook browser
THIS_THREAD.addNoteBrowserUI(notebook);
// management panel
THIS_THREAD.addNoteManagementUI(notebook);
// render UI below
// ID notes
var hp_posts = THIS_THREAD.getHpPosts();
for (let i=0; i<hp_posts.length; i++) {
let hp_post = hp_posts[i];
try {
hp_post.addNoteUI(notebook);
} catch(e) {
// deleted post, simply pass it
console.log("unable to parse the post, pass");
}
}
}
main();
})();