Skip to content

Instantly share code, notes, and snippets.

@mozurin
Last active April 19, 2022 19:19
Show Gist options
  • Save mozurin/26b63f4b4cee6344a1c76ea10a9fbc79 to your computer and use it in GitHub Desktop.
Save mozurin/26b63f4b4cee6344a1c76ea10a9fbc79 to your computer and use it in GitHub Desktop.
// ==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