Skip to content

Instantly share code, notes, and snippets.

@Aran-Fey
Last active March 15, 2025 06:09
Show Gist options
  • Save Aran-Fey/b125b6d49eee815e386636f125f64c9c to your computer and use it in GitHub Desktop.
Save Aran-Fey/b125b6d49eee815e386636f125f64c9c to your computer and use it in GitHub Desktop.
// ==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