Last active
April 19, 2022 19:19
-
-
Save mozurin/26b63f4b4cee6344a1c76ea10a9fbc79 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name PenpaSolvedWidget | |
// @namespace https://dislife.com/ | |
// @version 0.0.4 | |
// @description Add solved markers to Pazupure or Puzz.link problem links. | |
// @author mozurin | |
// @match *://*/* | |
// @noframes | |
// @require https://cdnjs.cloudflare.com/ajax/libs/js-sha1/0.6.0/sha1.min.js | |
// @grant GM.getValue | |
// @grant GM.setValue | |
// @grant GM.listValues | |
// @grant GM.deleteValue | |
// @downloadURL https://gist.github.com/mozurin/26b63f4b4cee6344a1c76ea10a9fbc79/raw/PenpaSolvedWidget.user.js | |
// ==/UserScript== | |
(function() | |
{ | |
'use strict'; | |
// stop if no puzzle links found | |
const penpaLinks = document.querySelectorAll( | |
'a[href*="/pzv.jp/p"], a[href*="/puzz.link/p"]' | |
); | |
if (penpaLinks.length < 1) { | |
return; | |
} | |
// string constants | |
const textUnchecked = '☐'; | |
const textChecked = '✅'; | |
const textNeutral = '☆' | |
const textFavorite = '⭐'; | |
const textTimerPrompt = 'クリアにかかった時間 (11:22:33 の形式で入力):'; | |
const textTimerInvalid = '時間の形式が異なります'; | |
const textInfoSolvedAt = 'クリア日時'; | |
const textInfoSolvedUrl = '問題掲載ページ'; | |
const textInfoCloseLink = '閉じる'; | |
const textManagerMenu = '回答記録ツール'; | |
const textManagerExportLink = 'データをバックアップ'; | |
const textManagerImportLink = 'インポート'; | |
const textManagerTruncateLink = '消去'; | |
const textManagerMigrateLink = '移行'; | |
const textManagerExportConfirm = ( | |
'回答データをバックアップしますか? ' + | |
'(ダウンロードが始まるまでに少し時間がかかる場合があります)' | |
); | |
const textManagerExportFilename = 'penpa-solved-backup.dat'; | |
const textManagerImportFailure = ( | |
'データのインポートに失敗しました。' + | |
'正しいファイルを選択しているか確認してください。' | |
); | |
const textManagerImportNotice = ( | |
'バックアップ済のデータをインポートしました。' + | |
'ブラウザのデータはそのままに、バックアップされている問題のデータ' + | |
'だけが上書きされているので、完全にデータを置き換えたい場合は、' + | |
'先にデータを消去してからもう一度インポートを行ってください。' | |
); | |
const textManagerTruncateConfirm = ( | |
'このブラウザから回答データを消去しますか?' | |
); | |
const textManagerTruncateConfirmTwice = ( | |
'本当にデータを消去しても大丈夫ですか?' | |
); | |
const textManagerTruncateComplete = '消去しました。'; | |
const textManagerMigrateConfirm = ( | |
'旧バージョンでこのサイトの localStorage に保存されたデータを' + | |
'新バージョン用のストレージに移行しますか? ' + | |
'(同じパズルのデータは上書きされます。' + | |
'また旧バージョンはサイトごとに異なるストレージを使っているため、' + | |
'移行はサイトごとに繰り返す必要があります)' | |
); | |
const textManagerMigrateComplete = '移行が完了しました。'; | |
const keyPrefix = 'hsl-'; | |
// get sha1 hash for given string as base64 string | |
function getHash(str) | |
{ | |
const hash = sha1.arrayBuffer(str); | |
return new Promise( | |
(resolve, reject) => { | |
const blob = new Blob([hash]); | |
const reader = new FileReader(); | |
reader.onload = e => { | |
resolve(e.target.result.split(',')[1]); | |
}; | |
reader.readAsDataURL(blob); | |
} | |
); | |
} | |
// widget main | |
function SolvedWidget(link) | |
{ | |
const problem = link.href.split('?')[1]; | |
const type = problem.split('/')[0]; | |
getHash(problem).then( | |
hash => { | |
this.key = `${keyPrefix}${type}-${hash}`; | |
return GM.getValue(this.key, null); | |
} | |
).then( | |
stateJSON => { | |
const state = Object.assign( | |
{ | |
cleared: false, | |
favorite: false, | |
time: '00:00', | |
solvedAt: null, | |
solvedUrl: null, | |
}, | |
JSON.parse(stateJSON) | |
); | |
// create widget elements | |
const te = document.createElement('template'); | |
te.innerHTML = ` | |
<p class="hslBox"> | |
<a href="#" class="hslCheckbox ${ | |
state.cleared? 'checked' : 'unchecked' | |
}"></a> | |
<a href="#" class="hslFavorite ${ | |
state.favorite? 'favorite' : 'neutral' | |
}"></a> | |
(<a href="#" class="hslTimer ${ | |
state.time == '00:00'? 'empty' : 'valid' | |
}">${state.time}</a>) | |
<a href="#" class="hslInfo">ℹ️</a> | |
</p> | |
`.trim(); | |
// add event handlers | |
const saveState = () => GM.setValue( | |
this.key, | |
JSON.stringify(state) | |
); | |
te.content.querySelector('a.hslCheckbox').addEventListener( | |
'click', | |
function (e) | |
{ | |
const checked = this.classList.contains('checked'); | |
if (!checked) { | |
state.solvedUrl = location.href; | |
state.solvedAt = new Date().getTime(); | |
} | |
this.classList.replace( | |
checked? 'checked' : 'unchecked', | |
checked? 'unchecked' : 'checked' | |
); | |
state.cleared = !checked; | |
saveState(); | |
e.preventDefault(); | |
return false; | |
}, | |
false | |
); | |
te.content.querySelector('a.hslFavorite').addEventListener( | |
'click', | |
function (e) | |
{ | |
const favorite = this.classList.contains('favorite'); | |
this.classList.replace( | |
favorite? 'favorite' : 'neutral', | |
favorite? 'neutral' : 'favorite' | |
); | |
state.favorite = !favorite; | |
saveState(); | |
e.preventDefault(); | |
return false; | |
}, | |
false | |
); | |
te.content.querySelector('a.hslTimer').addEventListener( | |
'click', | |
function (e) | |
{ | |
let newTime = prompt(textTimerPrompt, state.time); | |
if (null !== newTime) { | |
newTime = newTime.trim(); | |
if (!newTime.match(/^(\d+:)?\d{2}:\d{2}$/)) { | |
alert(textTimerInvalid); | |
} else { | |
if (newTime.length > 5) { | |
const hour = parseInt( | |
newTime.split(':', 1) | |
); | |
const remains = newTime.substring( | |
newTime.length - 5 | |
); | |
if (hour > 0) { | |
newTime = ( | |
hour.toString() + ':' + remains | |
); | |
} else { | |
newTime = remains; | |
} | |
} | |
this.innerText = newTime; | |
this.classList.replace( | |
newTime === '00:00'? 'valid' : 'empty', | |
newTime === '00:00'? 'empty' : 'valid' | |
); | |
state.time = newTime; | |
saveState(); | |
} | |
} | |
e.preventDefault(); | |
return false; | |
}, | |
false | |
); | |
te.content.querySelector('a.hslInfo').addEventListener( | |
'click', | |
function (e) | |
{ | |
const bg = document.createElement('div'); | |
bg.classList.add('hslModalBackground'); | |
document.body.appendChild(bg); | |
const te = document.createElement('template'); | |
te.innerHTML = ` | |
<div class="hslInfoBox"> | |
<table> | |
<tr> | |
<th>${textInfoSolvedAt}</th> | |
<td>${ | |
state.solvedAt? | |
new Date( | |
state.solvedAt | |
).toLocaleString() : '' | |
}</td> | |
</tr> | |
<tr> | |
<th>${textInfoSolvedUrl}</th> | |
<td>${ | |
state.solvedUrl? | |
'<a target="_blank" ' + | |
'href="' + state.solvedUrl + | |
'">' + state.solvedUrl + '</a>' | |
: '' | |
}</td> | |
</tr> | |
</table> | |
<a href="#" class="hslInfoClose"> | |
${textInfoCloseLink} | |
</a> | |
</div> | |
`.trim(); | |
te.content.querySelector( | |
'a.hslInfoClose' | |
).addEventListener( | |
'click', | |
function (e) | |
{ | |
document.body.removeChild(this.parentNode); | |
document.body.removeChild(bg); | |
e.preventDefault(); | |
return false; | |
}, | |
false | |
); | |
document.body.appendChild(te.content.firstChild); | |
e.preventDefault(); | |
return false; | |
}, | |
false | |
); | |
// add widget | |
link.parentNode.insertBefore( | |
te.content.firstChild, | |
link.nextSibling | |
); | |
} | |
); | |
} | |
// insert stylesheet | |
const css = document.createElement('style'); | |
css.innerText = ` | |
p.hslBox { | |
display: inline-block !important; | |
border: inset 1px #999 !important; | |
background: #ddd !important; | |
padding: 0 0.2em !important; | |
margin: 0 0.5em !important; | |
} | |
p.hslBox > a { | |
text-decoration: none !important; | |
color: black !important; | |
display: inline !important; | |
font-weight: normal !important; | |
} | |
p.hslBox > a:hover { | |
text-decoration: underline !important; | |
color: #333 !important; | |
font-weight: normal !important; | |
} | |
p.hslBox > a.hslCheckbox.unchecked:before { | |
content: "${textUnchecked}" !important; | |
} | |
p.hslBox > a.hslCheckbox.checked:before { | |
content: "${textChecked}" !important; | |
} | |
p.hslBox > a.hslFavorite.neutral:before { | |
content: "${textNeutral}" !important; | |
} | |
p.hslBox > a.hslFavorite.favorite:before { | |
content: "${textFavorite}" !important; | |
} | |
p.hslBox > a.hslTimer.empty { | |
color: #aaa !important; | |
} | |
div.hslModalBackground { | |
position: fixed !important; | |
left: 0 !important; | |
top: 0 !important; | |
width: 100% !important; | |
height: 100% !important; | |
opacity: 0.5 !important; | |
background: black !important; | |
z-index: 10 !important; | |
} | |
div.hslInfoBox { | |
color: black !important; | |
position: fixed !important; | |
width: 25% !important; | |
left: 37.5% !important; | |
top: 40% !important; | |
background: #ddd !important; | |
border: outset 1px #999 !important; | |
z-index: 100 !important; | |
padding: 0.5em !important; | |
} | |
div.hslInfoBox > table { | |
border: solid 1px black !important; | |
width: 100% !important; | |
border-collapse: collapse !important; | |
} | |
div.hslInfoBox > table tr { | |
border-bottom: solid 1px black !important; | |
} | |
div.hslInfoBox > table th { | |
width: 20% !important; | |
} | |
div.hslInfoBox > a.hslInfoClose { | |
display: inline-block; | |
width: 100%; | |
text-align: right; | |
} | |
div.hslManagerMenu { | |
position: fixed !important; | |
right: 0 !important; | |
bottom: 0 !important; | |
border: inset 1px #999 !important; | |
background: #ddd !important; | |
color: black !important; | |
padding: 0.5em !important; | |
font-size: 10px !important; | |
} | |
div.hslManagerMenu > a { | |
color: black !important; | |
text-decoration: none !important; | |
} | |
div.hslManagerMenu > a:hover { | |
color: #333 !important; | |
text-decoration: underline !important; | |
} | |
`; | |
document.body.appendChild(css); | |
// insert widgets for each puzzle links | |
penpaLinks.forEach( | |
link => { | |
new SolvedWidget(link); | |
// hint highlight for last clicked problem | |
link.addEventListener( | |
'focus', | |
function (e) | |
{ | |
this.style.background = '#fca'; | |
this.style.fontWeight = 'bold'; | |
}, | |
false | |
); | |
link.addEventListener( | |
'blur', | |
function (e) | |
{ | |
this.style.background = ''; | |
this.style.fontWeight = ''; | |
}, | |
false | |
); | |
} | |
); | |
// insert management commands as floating menu | |
const te = document.createElement('template'); | |
te.innerHTML = ` | |
<div class="hslManagerMenu"> | |
${textManagerMenu}: | |
<a class="hslManagerExport" href="#">${textManagerExportLink}</a> | |
| | |
<a class="hslManagerImport" href="#">${textManagerImportLink}</a> | |
| | |
<a class="hslManagerTruncate" href="#"> | |
${textManagerTruncateLink} | |
</a> | |
| | |
<a class="hslManagerMigrate" href="#"> | |
${textManagerMigrateLink} | |
</a> | |
</div> | |
`.trim(); | |
te.content.querySelector('a.hslManagerExport').addEventListener( | |
'click', | |
function (e) | |
{ | |
if (confirm(textManagerExportConfirm)) | |
{ | |
GM.listValues().then( | |
keys => Promise.all( | |
keys.filter(key => key.startsWith(keyPrefix)).map( | |
key => Promise.all([key, GM.getValue(key)]) | |
) | |
) | |
).then( | |
kvs => { | |
const wholeData = Object.fromEntries(kvs); | |
const blob = new Blob( | |
[JSON.stringify(wholeData)], | |
{type: 'application/json'} | |
); | |
// show save-as dialog | |
const dl = document.createElement('a'); | |
dl.setAttribute( | |
'download', | |
textManagerExportFilename | |
); | |
dl.href = URL.createObjectURL(blob); | |
dl.click(); | |
} | |
); | |
} | |
e.preventDefault(); | |
return false; | |
}, | |
false | |
); | |
te.content.querySelector('a.hslManagerImport').addEventListener( | |
'click', | |
function (e) | |
{ | |
const input = document.createElement('input'); | |
input.type = 'file'; | |
input.onchange = function (e) | |
{ | |
const reader = new FileReader(); | |
reader.onload = e => { | |
try { | |
const data = JSON.parse(e.target.result); | |
Promise.all( | |
Object.keys(data).filter( | |
key => key.startsWith(keyPrefix) | |
).map( | |
key => GM.setValue(key, data[key]) | |
) | |
).then( | |
() => { | |
alert(textManagerImportNotice); | |
location.reload(); | |
} | |
) | |
} catch (e) { | |
console.log('[Import failure]', e); | |
alert(textManagerImportFailure); | |
} | |
}; | |
reader.readAsBinaryString(this.files[0]); | |
}; | |
input.click(); | |
e.preventDefault(); | |
return false; | |
}, | |
false | |
); | |
te.content.querySelector('a.hslManagerTruncate').addEventListener( | |
'click', | |
function (e) | |
{ | |
if ( | |
confirm(textManagerTruncateConfirm) && | |
confirm(textManagerTruncateConfirmTwice) | |
) | |
{ | |
const keys = []; | |
GM.listValues().then( | |
keys => Promise.all( | |
keys.filter(key => key.startsWith(keyPrefix)).map( | |
key => GM.deleteValue(key) | |
) | |
) | |
).then( | |
() => { | |
alert(textManagerTruncateComplete); | |
location.reload(); | |
} | |
); | |
} | |
e.preventDefault(); | |
return false; | |
}, | |
false | |
); | |
te.content.querySelector('a.hslManagerMigrate').addEventListener( | |
'click', | |
function (e) | |
{ | |
if (confirm(textManagerMigrateConfirm)) | |
{ | |
Promise.all( | |
[...Array(localStorage.length).keys()].map( | |
n => localStorage.key(n) | |
).filter( | |
key => key.startsWith(keyPrefix) | |
).map( | |
key => GM.setValue(key, localStorage.getItem(key)) | |
) | |
).then( | |
() => { | |
alert(textManagerMigrateComplete); | |
location.reload(); | |
} | |
); | |
} | |
e.preventDefault(); | |
return false; | |
}, | |
false | |
); | |
document.body.appendChild( | |
te.content.firstChild | |
); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment