Skip to content

Instantly share code, notes, and snippets.

@mozurin
Last active July 10, 2022 12:10
Show Gist options
  • Save mozurin/2558af2ae1627cce9074227553947840 to your computer and use it in GitHub Desktop.
Save mozurin/2558af2ae1627cce9074227553947840 to your computer and use it in GitHub Desktop.
Add solved markers to Hashitaka's puzzle links.
// 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