/* MIT License Copyright (c) 2025 Viresh Ratnakar See the full license notice in exolve-m.js. */ /** * Library for setting up client UI for Exost crossword hosting at: * * https://xlufz.ratnakar.org/exost.html * * The main two functions are requestPwd() and uploadExolve(). These are * used from Exolve Player as well as Exet. * * This library is also used from the Exost site. There, apert from the above, * there is additionally a management interface for viewing all your crosswords * and possibly deleting specific crosswords. Further, it supports reading * puz/ipuz/exolve files (which also works in Exolve Player, but only needs * to use uploadExolve() from there). For reading from files, apart from * passing a config entry for uploadFileEltId, you should have script tags in * your HTML * file that load exolve-from-{puz,ipuz}.js. */ class ExolveExost { /** * Config fields: { * // Required: * exostURL: 'https://xlufz.ratnakar.org/exost.html', * apiServer: 'https://xlufz.ratnakar.org/exost.php', * emailEltId: 'xst-email', * pwdEltId: 'xst-pwd', * pwdStatusEltId: 'xst-pwd-status', * uploadStatusEltId: 'xst-upload-status', * * // Optional: * listEltId: 'xst-list', * listContainerEltId: 'xst-list-container', * listStatusEltId: 'xst-list-status', * uploadFileEltId: 'xst-upload-file', * tempEltId: 'xst-temp-xlv', * uploadCallback: 1-arg callback function, * varName: 'exost' // name of global ExolveExost var (assumed to be 'exost' if missing) * } */ constructor(config) { this.exostURL = config.exostURL; this.apiServer = config.apiServer; this.emailElt = document.getElementById(config.emailEltId); this.pwdElt = document.getElementById(config.pwdEltId); this.pwdStatusElt = document.getElementById(config.pwdStatusEltId); this.uploadStatusElt = document.getElementById(config.uploadStatusEltId); if (!this.exostURL || !this.apiServer || !this.emailElt || !this.pwdElt || !this.pwdStatusElt || !this.uploadStatusElt) { this.showError("Invalid ExolveExost config"); return; } this.varName = config.varName ?? 'exost'; this.listElt = document.getElementById(config.listEltId) ?? null; this.listContainerElt = document.getElementById(config.listContainerEltId) ?? null; this.listStatusElt = document.getElementById(config.listStatusEltId) ?? null; this.uploadFileElt = document.getElementById(config.uploadFileEltId) ?? null; this.uploadCallback = config.uploadCallback ?? null; this.tempEltId = config.tempEltId ?? ''; this.tempElt = this.tempEltId ? (document.getElementById(this.tempEltId) ?? null) : null; this.uploadFileName = ''; this.uploadData = ''; } showError(msg) { console.log('showError: ' + msg); alert(msg); } requestPwd() { const email = this.emailElt.value.trim(); if (!email) { this.showError("Email missing"); return; } const formData = new FormData(); formData.append('op', 'auth'); formData.append('email', email); fetch(this.apiServer, { method: 'POST', body: formData }) .then(r => r.json()) .then(data => { if (data.error) { console.log(data); this.showError("Error from 'auth': " + data.error); } else { console.log(data); const sentDate = data.sent ? new Date(data.sent * 1000) : new Date(); this.pwdStatusElt.innerHTML = (data.throttled ? 'Please wait, emailed recently at ' : 'Emailed at ') + (sentDate).toLocaleString(); } }) .catch(e => this.showError("Error in fetch/auth: " + e.message)); } fetchList() { if (!this.listElt || !this.listContainerElt || !this.listStatusElt) { return; /** unsuported */ } const email = this.emailElt.value.trim(); const pwd = this.pwdElt.value.trim(); if (!email || !pwd) { this.showError("Email or password missing"); return; } const formData = new FormData(); formData.append('op', 'list'); formData.append('email', email); formData.append('pwd', pwd); fetch(this.apiServer, { method: 'POST', body: formData }) .then(r => r.json()) .then(data => { if (data.error) { this.showError("Error from 'list': " + data.error); } else { this.renderList(data); this.listStatusElt.innerHTML = 'Last refreshed: ' + (new Date()).toLocaleString(); } }) .catch(e => this.showError("Error in fetch/list: " + e.message)); } /** * Helper used by fetchList(). */ renderList(items) { this.listElt.innerHTML = ''; this.listContainerElt.style.display = 'block'; if (items.length === 0) { this.listElt.innerHTML = '

No crosswords found.

'; return; } // Create table structure for better data display const table = document.createElement('table'); table.className = 'xst-url-list'; table.innerHTML = ` ID Title Copy URL/Embed, Delete KB Created, Updated `; const randId = 'xstid-' + Math.random().toString(36).substring(3, 9); let ctr = 0; items.forEach(item => { const tr = document.createElement('tr'); // Format size const sizeKB = (item.size / 1024).toFixed(1); // Format Date (simplified) const createdStr = new Date(item.created).toLocaleString(); const updatedStr = new Date(item.updated).toLocaleString(); const crup = createdStr + (createdStr == updatedStr ? '' : '
' + updatedStr); ctr += 1; const idBase = randId + '-' + ctr; tr.innerHTML = ` ${item.id} ${item.title} ${sizeKB} ${crup} `; table.appendChild(tr); }); this.listElt.appendChild(table); } deleteCrossword(id) { const email = this.emailElt.value.trim(); const pwd = this.pwdElt.value.trim(); if (!email || !pwd) { this.showError("Email or password missing"); return; } if (!confirm(`Are you sure you want to delete puzzle "${id}"?`)) { return; } const formData = new FormData(); formData.append('op', 'delete'); formData.append('email', email); formData.append('pwd', pwd); formData.append('id', id); fetch(this.apiServer, { method: 'POST', body: formData }) .then(r => r.json()) .then(data => { if (data.error) { this.showError("Error from 'delete': " + data.error); } else { this.fetchList(); } }) .catch(e => this.showError("Error in fetch/delete: " + e.message)); } /** * Convenience function to convert a puzzle URL to an iframe embed code. */ iframeEmbed(url) { return ` `; } /** * Convenience function to copy a URL or iframe embed code to the clipboard. */ copyURL(url, embed, eltId=null) { const text = embed ? this.iframeEmbed(url) : url; navigator.clipboard.writeText(text); if (eltId) { const elt = document.getElementById(eltId); if (elt) { const saved = elt.innerHTML; elt.innerHTML = '📋'; elt.disabled = true; setTimeout(() => { elt.innerHTML = saved; elt.disabled = false; }, 1000); } } } upload() { if (!this.uploadStatusElt) { return; /** unsuported */ } if (!this.uploadData) { this.showError("No valid crossword data has been set"); return; } const email = this.emailElt.value.trim(); const pwd = this.pwdElt.value.trim(); if (!email || !pwd) { this.showError("Email or password missing"); return; } const formData = new FormData(); formData.append('op', 'upload'); formData.append('email', email); formData.append('pwd', pwd); formData.append('data', this.uploadData); fetch(this.apiServer, { method: 'POST', body: formData }) .then(r => r.json()) // Expect JSON now .then(data => { if(data.error) { this.showError("Error from 'upload': " + data.error); } else { if (this.uploadCallback) { this.uploadCallback(data); } this.fetchList(); this.uploadStatusElt.innerHTML = 'Uploaded at: ' + (new Date()).toLocaleString(); } }) .catch(e => this.showError("Error in fetch/upload: " + e.message)); } /** * Pass a rendered puzzle if one already exists. */ uploadExolve(specs, puz=null) { if (!this.setExolve(specs, puz)) { this.showError("Invalid Exolve data"); return; } this.upload(); } idGoodForExost(id) { const regex = /^[a-zA-Z0-9_-]+$/; return regex.test(id); } makeIdGoodForExost(specs, id) { const safeSet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"; const regex = /[a-zA-Z0-9_-]/; const dashRegex = /[ ()!@#$%^&*+~`=]/; const goodId = id.split('').map(ch => { if (regex.test(ch)) return ch; if (dashRegex.test(ch)) return '-'; const index = ch.charCodeAt(0) % 64; return safeSet[index]; }).join(''); return specs.replace( /^\s*exolve-id:.*$/m, ' exolve-id: ' + goodId); } /** * Pass puz as non-NULL if you already have it rendered. */ setExolve(specs, puz=null) { let start = specs.indexOf('exolve-begin') let end = specs.indexOf('exolve-end') if (start < 0 || end < 0 || start >= end) { return false; } while (start > 0 && specs.charAt(start - 1) == ' ') { start--; } const dataSansEnd = specs.substring(start, end); let idFromPuz = ''; if (puz) { idFromPuz = puz.id; } else { if (!this.tempElt) { if (!this.tempEltId) { this.tempEltId = 'xst-temp-xlv-elt'; } this.tempElt = document.createElement("div"); this.tempElt.id = this.tempEltId; this.tempElt.style.display = 'none'; } let tempPuz = null; try { tempPuz = new Exolve(dataSansEnd + 'exolve-end', this.tempEltId, null, false, 0, 0, false); } catch (err) { console.log(err); tempPuz = null; } if (!tempPuz) { console.log('Crossword specs invalid: could not create Exolve object'); return false; } idFromPuz = tempPuz.id; tempPuz.destroy(); this.tempElt.innerHTML = ''; } this.uploadData = dataSansEnd; this.uploadData += ` exolve-host: Exost\n`; if (idFromPuz && (dataSansEnd.indexOf('exolve-id:') < 0)) { /** Insert idFromPuz (must have been auto-generated) */ this.uploadData += ` exolve-id: ${idFromPuz}\n`; } this.uploadData += 'exolve-end'; /** Convert to alphanumeric id if needed */ const idRegex = /^\s*exolve-id:\s*(.+)\s*$/m; const match = this.uploadData.match(idRegex); if (!match || match.length <= 1) { console.log('No exolve-id found in crossword specs.'); return false; } const id = match[1]; if (!this.idGoodForExost(id)) { this.uploadData = this.makeIdGoodForExost(this.uploadData, id); } return true; } setIpuz(specs) { let start = specs.indexOf('{') let end = specs.lastIndexOf('}') if (start < 0 || end < 0 || start >= end) { return false; } const ipuzJSON = specs.substring(start, end) + '}'; try { const ipuz = JSON.parse(ipuzJSON); const exolve = exolveFromIpuz(ipuz, this.uploadFileName); if (!exolve) { return false; } return this.setExolve(exolve); } catch (err) { console.log(err); } return false; } setPuz(buffer) { const exolve = exolveFromPuz(buffer, this.uploadFileName); if (!exolve) { return false; } return this.setExolve(exolve, true); } setFromBuffer(buffer) { let utf8decoder = new TextDecoder(); const specs = utf8decoder.decode(buffer); if (this.setExolve(specs)) { return true; } if (this.setIpuz(specs)) { return true; } if (this.setPuz(buffer)) { return true; } return false; } openFile(ev) { if (!this.uploadFileElt || !this.uploadStatusElt) { return; /** unsuported */ } const f = this.uploadFileElt.files[0]; this.uploadData = ''; this.uploadStatusElt.innerHTML = 'Reading...'; if (!f) { this.uploadStatusElt.innerHTML = ''; return; } const fr = new FileReader(); fr.onload = (e => { if (this.setFromBuffer(fr.result)) { this.uploadStatusElt.innerHTML = 'Ready to upload'; } else { this.uploadStatusElt.innerHTML = 'Could not parse'; this.uploadFileElt.value = ''; } }); this.uploadFileName = f.name; fr.readAsArrayBuffer(f); } }