Last active
March 15, 2025 06:09
-
-
Save Aran-Fey/b125b6d49eee815e386636f125f64c9c to your computer and use it in GitHub Desktop.
This file contains hidden or 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 LMD enhancements | |
// @description Improves the LMD website in various ways | |
// @namespace http://tampermonkey.net/ | |
// @version 2025-03-15 | |
// @author Aran-Fey | |
// @match https://logic-masters.de/Raetselportal/Suche/* | |
// @match https://logic-masters.de/Raetselportal/Raetsel/zeigen.php* | |
// @match https://sudokupad.app/* | |
// @match https://beta.sudokupad.app/* | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=logic-masters.de | |
// @grant GM_setClipboard | |
// @grant GM_getValues | |
// @grant GM_setValues | |
// @grant GM_deleteValues | |
// @grant GM_addValueChangeListener | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
if (window.location.href.includes('logic-masters')){ | |
if (window.location.href.includes('Raetsel/zeigen')){ | |
insertLinksIntoComments(); | |
addSolutionCodeDescriptionToLinks(); | |
autoSubmitSolutionCode(); | |
sendBroadcastMessageIfPuzzleIsSolved(); | |
} else { | |
updatePuzzleIconWhenSolved(); | |
} | |
} else { | |
addHotkeys(); | |
autoGenerateSolutionCode(); | |
} | |
// ================================= | |
function insertLinksIntoComments(){ | |
// The HTML is a mess. Comments are just <divs> dumped below the puzzle description, | |
// and they contain a bunch of <p>s with <br>s and text nodes inside. | |
for (let comment of document.querySelectorAll('.centercolumn div p')){ | |
for (let childNode of comment.childNodes){ | |
if (childNode.nodeType !== 3){ | |
continue; | |
} | |
// We found a text node. Check if it contains any links. | |
let text = childNode.textContent; | |
let matches = findAllRegexMatches(/https?:\/\/\S+/g, text); | |
if (matches.length === 0){ | |
continue; | |
} | |
// Replace the node with text and links | |
let endOfPreviousMatch = 0; | |
for (let match of matches){ | |
// Add the text before the link | |
let newNode = document.createTextNode(text.substring(endOfPreviousMatch, match.index)); | |
comment.insertBefore(newNode, childNode); | |
// Add the link | |
let link = document.createElement('a'); | |
link.href = match[0]; | |
link.textContent = match[0]; | |
comment.insertBefore(link, childNode); | |
endOfPreviousMatch = match.index + match[0].length; | |
} | |
// Add the text after the final link | |
let newNode = document.createTextNode(text.substring(endOfPreviousMatch, text.length)); | |
comment.insertBefore(newNode, childNode); | |
// Remove the original text | |
childNode.remove(); | |
} | |
} | |
} | |
function addSolutionCodeDescriptionToLinks(){ | |
let solutionCodeDescription = document.querySelector('.rp_loesungscode_descr').textContent; | |
for (let link of document.querySelectorAll('.centercolumn a')){ | |
let url; | |
try { | |
url = new URL(link.href); | |
} catch (error){ | |
continue; | |
} | |
url.searchParams.set('puzzle-id', getPuzzleId()); | |
url.searchParams.set('solution-code', solutionCodeDescription); | |
link.href = url; | |
} | |
} | |
function autoSubmitSolutionCode(){ | |
let storageKey = `solution code ${getPuzzleId()}`; | |
let values = GM_getValues([storageKey]); | |
let solutionCode = values[storageKey]; | |
if (solutionCode !== undefined){ | |
submitSolutionCode(solutionCode); | |
} else { | |
GM_addValueChangeListener(storageKey, function(key, oldValue, newValue, remote) { | |
if (newValue !== undefined){ | |
submitSolutionCode(newValue); | |
} | |
}); | |
} | |
function submitSolutionCode(solutionCode){ | |
GM_deleteValues([storageKey]); | |
let form = document.querySelector('form.rp_speed_solution_code'); | |
if (form === null){ | |
// Solution code was already submitted | |
return; | |
} | |
form.querySelector('input[type="text"]').value = solutionCode; | |
form.querySelector('input[type="submit"]').click(); | |
} | |
} | |
function addHotkeys(){ | |
window.addEventListener('keydown', onKeyPress, {capture: true}); | |
function onKeyPress(event){ | |
if (event.ctrlKey && event.key === 'c'){ | |
copySelectedRowOrColumn(); | |
return; | |
} | |
} | |
} | |
function copySelectedRowOrColumn(){ | |
// Make sure that either a row or a column is selected | |
let selectedRows = new Set(); | |
let selectedColumns = new Set(); | |
let rowSize = getRowSize(); | |
let svg = document.getElementById('svgrenderer'); | |
let cellSize = svg.querySelector('#cell-grids').getBBox().width / rowSize - 1; // -1 as fudge factor | |
for (let selectedCell of svg.querySelectorAll('#cell-highlights rect')){ | |
let row = Math.trunc(selectedCell.y.baseVal.value / cellSize); | |
selectedRows.add(row); | |
let col = Math.trunc(selectedCell.x.baseVal.value / cellSize); | |
selectedColumns.add(col); | |
} | |
// Get the contents of the row/column, or abort | |
let digits; | |
if (selectedRows.size === 1 && selectedColumns.size === rowSize){ | |
digits = getRow(selectedRows.values().next().value); | |
} else if (selectedColumns.size === 1 && selectedRows.size === rowSize){ | |
digits = getCol(selectedColumns.values().next().value); | |
} else { | |
return; | |
} | |
GM_setClipboard(digits); | |
} | |
function autoGenerateSolutionCode(){ | |
let description = new URL(document.location).searchParams.get('solution-code'); | |
if (description === null){ | |
return; | |
} | |
// To detect when the sudoku is complete, we'll add a MutationObserver to the SVG | |
// element that contains cell values. The sudoku is complete when every cell has | |
// a value. | |
let svg; | |
let numCellsToFillOut; | |
let mutationObserver = new MutationObserver(generateSolutionCodeIfSudokuIsComplete); | |
let notification = null; | |
window.addEventListener('load', () => { | |
setTimeout(() => { | |
svg = document.getElementById('svgrenderer'); | |
numCellsToFillOut = getRowSize() ** 2 - svg.getElementById('cell-givens').children.length; | |
mutationObserver.observe( | |
svg.getElementById('cell-values'), | |
{childList: true, subtree: true} | |
); | |
generateSolutionCodeIfSudokuIsComplete(); | |
}, 700); | |
}); | |
function generateSolutionCodeIfSudokuIsComplete(){ | |
if (svg.querySelectorAll('.cell-value').length < numCellsToFillOut){ | |
return; | |
} | |
// Just because every cell has a value doesn't mean the puzzle is solved | |
// (correctly). Remove the previous notification, if any. | |
if (notification !== null){ | |
notification.remove(); | |
} | |
notification = document.createElement('div'); | |
notification.style.position = 'absolute'; | |
notification.style.zIndex = '9999'; | |
notification.style.right = '1rem'; | |
notification.style.bottom = '1rem'; | |
notification.style.maxWidth = 'min(30em, 90vw)'; | |
notification.style.color = '#ddd'; | |
notification.style.background = '#222'; | |
notification.style.border = '0.5rem solid #555'; | |
notification.style.borderRadius = '0.5rem'; | |
notification.style.padding = '0.5rem'; | |
let solutionCode = getSolutionCode(description); | |
if (solutionCode === null){ | |
notification.innerHTML = ` | |
<div>Solution code instructions:</div> | |
<div>${htmlEscape(description)}</div> | |
`; | |
} else { | |
let puzzleId = new URL(document.location.href).searchParams.get('puzzle-id'); | |
if (puzzleId){ | |
GM_setValues({[`solution code ${puzzleId}`]: solutionCode}); | |
} | |
notification.innerHTML = ` | |
<div>Solution code:</div> | |
<div style="user-select: all">${solutionCode}</div> | |
`; | |
} | |
document.body.appendChild(notification); | |
} | |
} | |
function sendBroadcastMessageIfPuzzleIsSolved(){ | |
let channel = new BroadcastChannel('puzzle solved'); | |
window.addEventListener('load', () => { | |
if (!document.querySelector('.rp_speed_solution_code')){ | |
channel.postMessage(getPuzzleId()); | |
} | |
}); | |
} | |
function updatePuzzleIconWhenSolved(){ | |
let channel = new BroadcastChannel('puzzle solved'); | |
channel.onmessage = (event) => { | |
let puzzleId = event.data; | |
let puzzleLink = document.querySelector(`.rp_raetselliste a[href$="zeigen.php?id=${puzzleId}"]`); | |
if (puzzleLink === null){ | |
return; | |
} | |
let img = puzzleLink.parentElement.previousSibling.firstElementChild; | |
img.src = '/Raetselportal/bilder/geloest.png'; | |
img.alt = 'solved'; | |
img.title = 'solved'; | |
}; | |
} | |
// ===================== | |
// Note: Row and column numbers are 0-indexed. | |
let getRowSize = function(){ | |
return parseInt(document.querySelector('#board .grid .cells .row').lastElementChild.getAttribute('col')) + 1; | |
} | |
function getCellValues(){ | |
let result = []; | |
let svg = document.getElementById('svgrenderer'); | |
let cellSize = svg.querySelector('#cell-grids').getBBox().width / getRowSize() - 1; // -1 as fudge factor | |
for (let cell of svg.querySelectorAll('.cell-given, .cell-value')){ | |
let col = Math.trunc(cell.x.baseVal[0].value / cellSize); | |
let row = Math.trunc(cell.y.baseVal[0].value / cellSize); | |
result.push([row, col, cell.textContent]); | |
} | |
return result; | |
} | |
let getRow = function(rowNr){ | |
let values = new Array(getRowSize()); | |
for (let [row, col, value] of getCellValues()){ | |
if (row === rowNr){ | |
values[col] = value; | |
} | |
} | |
return values.join(''); | |
}; | |
let getCol = function(colNr){ | |
let values = new Array(getRowSize()); | |
for (let [row, col, value] of getCellValues()){ | |
if (col === colNr){ | |
values[row] = value; | |
} | |
} | |
return values.join(''); | |
}; | |
let getBox = function(boxNr){ | |
let boxWidth, boxHeight, boxesPerRow; | |
switch (getRowSize()){ | |
case 4: | |
boxWidth = boxHeight = 2; | |
boxesPerRow = 2; | |
break; | |
case 6: | |
boxWidth = 3; | |
boxHeight = 2; | |
boxesPerRow = 2; | |
break; | |
case 9: | |
boxWidth = boxHeight = 3; | |
boxesPerRow = 3; | |
break; | |
default: | |
return ''; | |
} | |
let minRow = Math.floor(boxNr / boxesPerRow) * boxHeight; | |
let maxRow = minRow + boxHeight; | |
let minCol = (boxNr % boxesPerRow) * boxWidth; | |
let maxCol = minCol + boxWidth; | |
let values = []; | |
for (let [row, col, value] of getCellValues()){ | |
if (row >= minRow && row < maxRow && col >= minCol && col < maxCol){ | |
values.push([row, col, value]); | |
} | |
} | |
// Sort by column, then by row | |
values.sort((a, b) => a[1] - b[1]); | |
values.sort((a, b) => a[0] - b[0]); | |
return values.map(item => item[2]).join(''); | |
}; | |
let getPositiveDiagonal = function(){ | |
let rowSize = getRowSize(); | |
let values = new Array(rowSize); | |
for (let [row, col, value] of getCellValues()){ | |
if (col === rowSize - row - 1){ | |
values[col] = value; | |
} | |
} | |
return values.join(''); | |
} | |
let getNegativeDiagonal = function(){ | |
let values = new Array(getRowSize()); | |
for (let [row, col, value] of getCellValues()){ | |
if (row === col){ | |
values[row] = value; | |
} | |
} | |
return values.join(''); | |
} | |
function getPuzzleId(){ | |
return new URL(window.location.href).searchParams.get('id'); | |
} | |
function htmlEscape(text){ | |
return new Option(text).innerHTML; | |
} | |
function findAllRegexMatches(regex, text){ | |
let matches = []; | |
while (true){ | |
let match = regex.exec(text); | |
if (match === null){ | |
break; | |
} | |
matches.push(match); | |
} | |
return matches; | |
} | |
function getSolutionCode(description){ | |
let keywordToFuncWith0Args = { | |
'positive diagonal': getPositiveDiagonal, | |
'negative diagonal': getNegativeDiagonal, | |
}; | |
let keywordToFuncWith1Arg = { | |
'row': getRow, | |
'rows': getRow, | |
'column': getCol, | |
'columns': getCol, | |
'box': getBox, | |
'boxes': getBox, | |
}; | |
let numberBeforeKeyword = { | |
'first': 1, | |
'second': 2, | |
'third': 3, | |
'fourth': 4, | |
'fifth': 5, | |
'sixth': 6, | |
'seventh': 7, | |
'eighth': 8, | |
'ninth': 9, | |
'1st': 1, | |
'2nd': 2, | |
'3rd': 3, | |
'4th': 4, | |
'5th': 5, | |
'6th': 6, | |
'7th': 7, | |
'8th': 8, | |
'9th': 9, | |
'last': getRowSize(), | |
'top': 1, | |
'bottom': getRowSize(), | |
}; | |
let numberAfterKeyword = { | |
'1': 1, | |
'2': 2, | |
'3': 3, | |
'4': 4, | |
'5': 5, | |
'6': 6, | |
'7': 7, | |
'8': 8, | |
'9': 9, | |
'one': 1, | |
'two': 2, | |
'three': 3, | |
'four': 4, | |
'five': 5, | |
'six': 6, | |
'seven': 7, | |
'eight': 8, | |
'nine': 9, | |
} | |
// Step 1: Remove noise like "18 digits total" from the instructions | |
description = description.toLowerCase(); | |
description = description.replace(/(?<!row )(?<!column )(\d+|six|nine|twelve|eighteen) digits/g, ''); | |
// Step 2: Extract numbers and key words like "row", "column", | |
// "first", "last" from the instructions | |
let parts = []; | |
for (let obj of [keywordToFuncWith0Args,keywordToFuncWith1Arg,numberBeforeKeyword,numberAfterKeyword]){ | |
for (let key of Object.keys(obj)){ | |
parts.push(key); | |
} | |
} | |
let regex = new RegExp(`\\b(${parts.join('|')})\\b`, 'gi'); | |
let keywords = findAllRegexMatches(regex, description).map(match => match[0]); | |
console.log(keywords); | |
// Step 3: Loop through the keywords and construct the solution code | |
let solutionCode = ''; | |
let getDigits = getRow; | |
while (keywords.length > 0){ | |
let keyword = keywords.shift(); | |
if (keyword in keywordToFuncWith0Args){ | |
solutionCode += keywordToFuncWith0Args[keyword](); | |
} else if (keyword in keywordToFuncWith1Arg){ | |
getDigits = keywordToFuncWith1Arg[keyword]; | |
} else if (keyword in numberAfterKeyword){ | |
let index = numberAfterKeyword[keyword]; | |
solutionCode += getDigits(index - 1); | |
} else { | |
let index = numberBeforeKeyword[keyword]; | |
let nextWord = keywords.find(keyword => keyword in keywordToFuncWith1Arg); | |
// It's possible that we can't find another keyword, since "top" and | |
// "bottom" often appear in sentences like "row 5, top to bottom". | |
if (nextWord !== undefined){ | |
solutionCode += keywordToFuncWith1Arg[nextWord](index - 1); | |
} | |
} | |
} | |
if (solutionCode.length > 0){ | |
return solutionCode; | |
} | |
// If we didn't understand the instructions, check if there's maybe a solution | |
// code written in the "congrats, you solved the puzzle" dialog | |
let element = document.querySelector('.dialog .supportlinks'); | |
if (element !== null){ | |
let text = element.previousSibling.previousSibling.textContent; | |
let match = text.match(/solution code.*:\s+(.*)|solution code.*\bis\b.*"(.*?)"/i); | |
if (match !== null){ | |
return match[1] || match[2]; | |
} | |
} | |
return null; | |
} | |
// ==== TESTING ==== | |
function testGetSolutionCode(){ | |
getRow = (row) => `r${row+1}`; | |
getCol = (col) => `c${col+1}`; | |
getBox = (box) => `b${box+1}`; | |
getPositiveDiagonal = () => 'pd'; | |
getNegativeDiagonal = () => 'nd'; | |
getRowSize = () => 99; | |
const TESTS = [ | |
['r1', 'row 1'], | |
['b2', 'box 2'], | |
['r3c7', 'row 3, then column 7'], | |
['r3c7', 'row 3 and column 7 (16 digits)'], | |
['r1c99', 'first row, last column'], | |
['c9', 'column 9 (top to bottom)'], | |
['c1c3', 'columns 1 and 3'], | |
['pd', 'the positive diagonal, bottom left to top right'], | |
]; | |
for (let [expectedResult, instructions] of TESTS){ | |
let result = getSolutionCode(instructions); | |
if (result !== expectedResult){ | |
console.log(result, '!=', expectedResult, instructions); | |
} | |
} | |
console.log('testGetSolutionCode() done.'); | |
} | |
//testGetSolutionCode(); | |
})(); | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment