Last active
July 10, 2022 12:10
-
-
Save mozurin/2558af2ae1627cce9074227553947840 to your computer and use it in GitHub Desktop.
Add solved markers to Hashitaka's puzzle links.
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
// ChangeLog: | |
// 0.2.1 Add rough English translation. | |
// 0.2.0 Add new feature that overwrites page title of game player tab by | |
// referred link name. | |
// | |
// ChangeLog.ja: | |
// 0.2.1 雑な英語翻訳を追加 | |
// 0.2.0 ゲーム画面タブのタイトルをリンク元のリンク名で書き換える機能を追加 | |
// | |
// ==UserScript== | |
// @name HashitakaSolved | |
// @namespace https://puzzle-laboratory.hatenadiary.jp/ | |
// @version 0.2.1 | |
// @description Add solved markers to Hashitaka's puzzle links. | |
// @author mozurin | |
// @match https://puzzle-laboratory.hatenadiary.jp/* | |
// @match *://pzv.jp/* | |
// @match *://puzz.link/* | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=hatenadiary.jp | |
// @downloadURL https://gist.github.com/mozurin/2558af2ae1627cce9074227553947840/raw/HashitakaSolved.user.js | |
// @grant GM.setValue | |
// @grant GM.getValue | |
// ==/UserScript== | |
(function() | |
{ | |
'use strict'; | |
const textUnchecked = '☐'; | |
const textChecked = '✅'; | |
const textNeutral = '☆' | |
const textFavorite = '⭐'; | |
const textTranslations = { | |
timerPrompt: { | |
en: 'Complete time (in format like 123:59:59):', | |
ja: 'クリアにかかった時間 (123:59:59 の形式で入力):', | |
}, | |
timerInvalid: { | |
en: 'Invalid time format.', | |
ja: '時間の形式が異なります', | |
}, | |
managerMenu: { | |
en: 'Puzzle status manager', | |
ja: '回答記録ツール', | |
}, | |
managerExportLink: { | |
en: 'Back up stored data', | |
ja: 'データをバックアップ', | |
}, | |
managerImportLink: { | |
en: 'Import backed up file', | |
ja: 'インポート', | |
}, | |
managerTruncateLink: { | |
en: 'Drop all data', | |
ja: '消去', | |
}, | |
managerExportConfirm: { | |
en: ( | |
'Are you sure to start backing up all puzzle status data? ' + | |
'(It may take some time before start downloading)' | |
), | |
ja: ( | |
'回答データをバックアップしますか? ' + | |
'(ダウンロードが始まるまでに少し時間がかかる場合があります)' | |
), | |
}, | |
managerImportFailure: { | |
en: ( | |
'Failed to import backed up file. ' + | |
'Check whether you selected correct file.' | |
), | |
ja: ( | |
'データのインポートに失敗しました。' + | |
'正しいファイルを選択しているか確認してください。' | |
), | |
}, | |
managerImportNotice: { | |
en: ( | |
'Successfully imported backed up file data. ' + | |
'All data stored before this import are kept as-is, so ' + | |
'try "Drop all data" first and re-import if you want to ' + | |
'replace stored data by backed up one completely.' | |
), | |
ja: ( | |
'バックアップ済のデータをインポートしました。' + | |
'ブラウザのデータはそのままに、バックアップされている問題の' + | |
'データだけが上書きされているので、完全にデータを置き' + | |
'換えたい場合は、先にデータを消去してからもう一度' + | |
'インポートを行ってください。' | |
), | |
}, | |
managerTruncateConfirm: { | |
en: 'Do you want to delete all data stored in this browser?', | |
ja: 'このブラウザから回答データを消去しますか?', | |
}, | |
managerTruncateConfirmTwice: { | |
en: 'Are you really sure to delete data?', | |
ja: '本当にデータを消去しても大丈夫ですか?', | |
}, | |
managerTruncateComplete: { | |
en: 'Data deletion complete.', | |
ja: '消去しました。', | |
}, | |
}; | |
const textManagerExportFilename = 'hashitaka-backup.dat'; | |
const textFallbackLanguage = 'en'; | |
const textEmptyColor = '#aaa'; | |
const textLinkHighlightColor = '#fca'; | |
const keyPrefix = 'hsl'; | |
const lastProblemCacheKey = `${keyPrefix}.lastProblemTitles`; | |
const lastProblemInfoTimeoutSecs = 30; | |
const lastProblemCacheWriteWaitSecs = 5; | |
const lastProblemCacheWaitInterval = 500; | |
const lastProblemCacheWaitTimeout = 5000; | |
const pzprInitializeWaitInterval = 100; | |
const pzprInitializeWaitTimeout = 10000; | |
const pzprTitleOverwritePollInterval = 500; | |
const gamePlayerDomains = ['pzv.jp', 'puzz.link']; | |
// text translator | |
function i18n(key) | |
{ | |
if (!textTranslations[key]) { | |
return `${key}: translation falied`; | |
} | |
return ( | |
textTranslations[key][navigator.language] || | |
textTranslations[key][textFallbackLanguage] | |
); | |
} | |
// promise timer | |
function promiseWait(msec) | |
{ | |
return new Promise( | |
(resolve, reject) => { | |
window.setTimeout(() => resolve(msec), msec); | |
} | |
); | |
} | |
async function promiseCondition(condition, interval, timeout) | |
{ | |
const startAt = (new Date()).getTime(); | |
while (true) { | |
const tickBegin = (new Date()).getTime(); | |
const result = await Promise.resolve(condition()); | |
if (result) { | |
return result; | |
} | |
const tickDiff = (new Date()).getTime(); | |
await promiseWait( | |
Math.max(interval - (tickDiff - tickBegin), 1) | |
); | |
if ( | |
timeout && | |
(new Date()).getTime() - startAt > timeout | |
) { | |
throw 'timeout'; | |
} | |
} | |
} | |
// for puz-pre / puzz.link game player pages | |
if (gamePlayerDomains.includes(location.hostname.toLowerCase())) | |
{ | |
const pageUrl = location.href; | |
Promise.all( | |
[ | |
// wait for pzpr ui initialization | |
promiseCondition( | |
() => ( | |
window.eval('window.ui') && | |
window.eval('window.ui.puzzle') && | |
window.eval('window.ui.puzzle.pid') | |
), | |
pzprInitializeWaitInterval, | |
pzprInitializeWaitTimeout | |
), | |
// wait for GM.setValue in previous page that may delay | |
promiseCondition( | |
async () => { | |
const cacheStored = await GM.getValue( | |
lastProblemCacheKey | |
); | |
const cache = ( | |
cacheStored? JSON.parse(cacheStored) : {} | |
); | |
if ( | |
pageUrl in cache && ( | |
( | |
(new Date()).getTime() - | |
cache[pageUrl].time | |
) < 1000 * lastProblemInfoTimeoutSecs | |
) | |
) { | |
return cache[pageUrl]; | |
} | |
}, | |
lastProblemCacheWaitInterval, | |
lastProblemCacheWaitTimeout | |
) | |
] | |
).then( | |
async ([_, info]) => { | |
// set page title | |
document.title = info.title; | |
document.getElementById('title2').innerText = info.title; | |
// keep set title; assigning value to document.title can | |
// modify value slightly (e.g. trailing space will be removed) | |
const titleShouldBeKept = document.title; | |
// clear expired cache here | |
// | |
// XXX: may overwrite parallel writing in other tab, but only | |
// once in a blue moon I think | |
const cacheStored = await GM.getValue(lastProblemCacheKey); | |
const cache = cacheStored? JSON.parse(cacheStored) : {}; | |
let garbageFound = false; | |
const currentTime = (new Date()).getTime(); | |
for (let key in cache) { | |
if ( | |
currentTime - cache[key].time > | |
1000 * lastProblemInfoTimeoutSecs | |
) { | |
delete cache[key]; | |
garbageFound = true; | |
} | |
} | |
if (garbageFound) { | |
GM.setValue( | |
lastProblemCacheKey, | |
JSON.stringify(cache) | |
); | |
} | |
// watch title and overwrite again when changed | |
// | |
// Note: puz-pre can change title on following condition | |
// * new puzzle loaded | |
// * language changed | |
// * tool area hidden/shown | |
while (true) { | |
await promiseCondition( | |
() => document.title != titleShouldBeKept, | |
pzprTitleOverwritePollInterval | |
); | |
const currentPuzzleURL = window.eval( | |
'window.ui.puzzle.getURL();' | |
); | |
if (currentPuzzleURL != pageUrl) { | |
return; | |
} | |
document.title = info.title; | |
document.getElementById('title2').innerText = info.title; | |
// avoid busy loop on unexpected condition | |
await promiseWait(pzprTitleOverwritePollInterval); | |
} | |
} | |
).catch( | |
e => console.log('HashitakaSolved: title changer: aborted.', e) | |
); | |
return; | |
} | |
// hash / problem key calculation | |
const getHash = ( | |
() => { | |
const encoder = new TextEncoder(); | |
const alg = 'SHA-1'; | |
return async function (str) | |
{ | |
const hash = await crypto.subtle.digest( | |
alg, | |
encoder.encode(str) | |
); | |
return await 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); | |
} | |
); | |
}; | |
} | |
)(); | |
async function extractKeyFromLink(url) | |
{ | |
const urlParsed = new URL(url); | |
if ( | |
!gamePlayerDomains.includes(urlParsed.hostname.toLowerCase()) || | |
!urlParsed.pathname.startsWith('/p') | |
) { | |
return; | |
} | |
const problem = urlParsed.search.substring(1); | |
const type = problem.split('/')[0]; | |
return `${type}-${await getHash(problem)}`; | |
} | |
// widget constructor | |
function SolvedWidget(link, key) | |
{ | |
// get saved status | |
const keyPrefixed = `${keyPrefix}-${key}`; | |
const state = Object.assign( | |
{ | |
cleared: false, | |
favorite: false, | |
time: '00:00', | |
}, | |
JSON.parse(localStorage.getItem(keyPrefixed)) | |
); | |
// 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>) | |
</p> | |
`.trim(); | |
// add event handlers | |
function saveState() | |
{ | |
localStorage.setItem(keyPrefixed, JSON.stringify(state)); | |
}; | |
te.content.querySelector('a.hslCheckbox').addEventListener( | |
'click', | |
function (e) | |
{ | |
const checked = this.classList.contains('checked'); | |
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(i18n('timerPrompt'), state.time); | |
if (null !== newTime) { | |
newTime = newTime.trim() || '00:00'; | |
if (!newTime.match(/^(\d+:)?\d{2}:\d{2}$/)) { | |
alert(i18n('timerInvalid')); | |
} 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 | |
); | |
// add widget | |
link.parentNode.insertBefore(te.content.firstChild, link.nextSibling); | |
} | |
// insert stylesheet | |
const css = document.createElement('style'); | |
css.innerText = ` | |
p.hslBox { | |
display: inline-block; | |
border: inset 1px #999; | |
background: #ddd; | |
padding: 0 0.2em; | |
margin: 0 0.5em; | |
} | |
p.hslBox > a { | |
text-decoration: none; | |
color: black; | |
} | |
p.hslBox > a:hover { | |
text-decoration: underline; | |
color: #333; | |
} | |
p.hslBox > a.hslCheckbox.unchecked:before { | |
content: "${textUnchecked}"; | |
} | |
p.hslBox > a.hslCheckbox.checked:before { | |
content: "${textChecked}"; | |
} | |
p.hslBox > a.hslFavorite.neutral:before { | |
content: "${textNeutral}"; | |
} | |
p.hslBox > a.hslFavorite.favorite:before { | |
content: "${textFavorite}"; | |
} | |
p.hslBox > a.hslTimer.empty { | |
color: ${textEmptyColor}; | |
} | |
`; | |
document.body.appendChild(css); | |
// insert widgets for each puzzle links | |
document.querySelectorAll( | |
gamePlayerDomains.map(h => `a[href*="/${h}/p"]`).join(', ') | |
).forEach( | |
link => { | |
extractKeyFromLink(link.href).then( | |
key => new SolvedWidget(link, key) | |
); | |
// highlight last clicked problem and remember its title | |
link.addEventListener( | |
'focus', | |
function (e) | |
{ | |
this.style.background = textLinkHighlightColor; | |
this.style.fontWeight = 'bold'; | |
GM.getValue(lastProblemCacheKey).then( | |
cacheStored => { | |
let cache = {}; | |
if (cacheStored) { | |
cache = JSON.parse(cacheStored); | |
} | |
cache[link.href] = { | |
title: this.innerText.trim(), | |
time: (new Date()).getTime(), | |
}; | |
GM.setValue( | |
lastProblemCacheKey, | |
JSON.stringify(cache) | |
); | |
} | |
); | |
}, | |
false | |
); | |
link.addEventListener( | |
'blur', | |
function (e) | |
{ | |
this.style.background = ''; | |
this.style.fontWeight = ''; | |
}, | |
false | |
); | |
} | |
); | |
// insert management commands into menu list | |
const te = document.createElement('template'); | |
te.innerHTML = ` | |
<p class="hslManagerMenu"> | |
${i18n('managerMenu')}: | |
<a class="hslManagerExport" href="#"> | |
${i18n('managerExportLink')} | |
</a> | <a class="hslManagerImport" href="#"> | |
${i18n('managerImportLink')} | |
</a> | <a class="hslManagerTruncate" href="#"> | |
${i18n('managerTruncateLink')} | |
</a> | |
</p> | |
`.trim(); | |
te.content.querySelector('a.hslManagerExport').addEventListener( | |
'click', | |
function (e) | |
{ | |
if (confirm(i18n('managerExportConfirm'))) | |
{ | |
// dump | |
const wholeData = {}; | |
for (let n = 0; n < localStorage.length; n++) { | |
const key = localStorage.key(n); | |
if (key.startsWith(`${keyPrefix}-`)) { | |
wholeData[key] = localStorage.getItem(key); | |
} | |
} | |
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); | |
Object.keys(data).forEach( | |
key => { | |
if (key.startsWith('hsl-')) { | |
localStorage.setItem(key, data[key]); | |
} | |
} | |
); | |
alert(i18n('managerImportNotice')); | |
location.reload(); | |
} catch (e) { | |
console.log('[Import failure]', e); | |
alert(i18n('managerImportFailure')); | |
} | |
}; | |
reader.readAsBinaryString(this.files[0]); | |
}; | |
input.click(); | |
e.preventDefault(); | |
return false; | |
}, | |
false | |
); | |
te.content.querySelector('a.hslManagerTruncate').addEventListener( | |
'click', | |
function (e) | |
{ | |
if ( | |
confirm(i18n('managerTruncateConfirm')) && | |
confirm(i18n('managerTruncateConfirmTwice')) | |
) | |
{ | |
const keys = []; | |
for (let n = 0; n < localStorage.length; n++) { | |
const key = localStorage.key(n); | |
if (key.startsWith(`${keyPrefix}-`)) { | |
keys.push(key); | |
} | |
} | |
keys.forEach( | |
key => localStorage.removeItem(key) | |
); | |
alert(i18n('managerTruncateComplete')); | |
location.reload(); | |
} | |
e.preventDefault(); | |
return false; | |
}, | |
false | |
); | |
document.querySelector('#footer-inner').appendChild( | |
te.content.firstChild | |
); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment