/*
MIT License
Copyright (c) 2020 Viresh Ratnakar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The latest code and documentation for Exet can be found at:
https://github.com/viresh-ratnakar/exet
Current version: v0.91, January 9, 2024
*/
function ExetModals() {
this.modal = null;
document.addEventListener('click', this.handleClick.bind(this));
document.addEventListener('keydown', this.handleClick.bind(this));
};
ExetModals.prototype.handleClick = function(e) {
if (!this.modal) {
return
}
if (!this.modal.contains(e.target) || e.key == "Escape") {
this.hide()
}
}
// If caller calls this in response to a click event e, then caller should also
// call e.stopPropagation().
ExetModals.prototype.showModal = function(elt) {
this.hide()
if (!elt) {
return
}
this.modal = elt;
this.modal.style.display = 'block'
}
ExetModals.prototype.hide = function() {
if (!this.modal) {
return
}
if (exet.postscript && this.modal.id == 'xet-other-sections') {
exet.postscript.style.display = 'none';
}
this.modal.style.display = 'none'
this.modal = null;
}
function ExetRevManager() {
this.REV_LOADED_FROM_FILE = 1
this.REV_CREATED_BLANK = 2
this.REV_CREATED_AUTOBLOCK = 3
this.REV_JUMPED_TO_REV = 10
this.REV_GRID_CHANGE = 20
this.REV_LIGHT_REVERSAL = 24
this.REV_AUTOFILL_GRIDFILL_CHANGE = 28
this.REV_GRIDFILL_CHANGE = 30
this.REV_ENUM_CHANGE = 40
this.REV_CLUE_CHANGE = 50
this.REV_METADATA_CHANGE = 60
this.REV_PREFLEX_CHANGE = 70
this.REV_OPTIONS_CHANGE = 80
this.revMsgs = {}
this.revMsgs[this.REV_LOADED_FROM_FILE] = "Loaded from a file"
this.revMsgs[this.REV_CREATED_BLANK] = "Created a blank grid"
this.revMsgs[this.REV_CREATED_AUTOBLOCK] = "Created a blank grid " +
"with automagic blocks"
this.revMsgs[this.REV_JUMPED_TO_REV] = "Jumped to a previous revision"
this.revMsgs[this.REV_GRID_CHANGE] = "Grid change"
this.revMsgs[this.REV_LIGHT_REVERSAL] = "Light reversal"
this.revMsgs[this.REV_AUTOFILL_GRIDFILL_CHANGE] = "Autofilled grid-fill " +
"change"
this.revMsgs[this.REV_GRIDFILL_CHANGE] = "Grid-fill change"
this.revMsgs[this.REV_ENUM_CHANGE] = "Enum change"
this.revMsgs[this.REV_CLUE_CHANGE] = "Clue or anno change"
this.revMsgs[this.REV_METADATA_CHANGE] = "Metadata change"
this.revMsgs[this.REV_PREFLEX_CHANGE] = "Change in the list of or options " +
"for preferred words"
this.revMsgs[this.REV_OPTIONS_CHANGE] = "Crossword options change"
/* State for throttled revision-saving */
this.throttleRevTimer = null;
this.saveLagMS = 5000
this.throttlingLastRev = 0;
/**
* Special localStorage key for storing preferences and state,
* qualified by non-default lexicon properties.
*/
this.SPECIAL_KEY_PREFIX = '42-exet-42';
this.SPECIAL_KEY = this.SPECIAL_KEY_PREFIX;
if ('en' != exetLexicon.language || 'Latin' != exetLexicon.script ||
1 != exetLexicon.maxCharCodes) {
this.SPECIAL_KEY += `-${exetLexicon.language}-${exetLexicon.script}` +
`-${exetLexicon.maxCharCodes}`;
}
this.spaceUsedAtStart = 0
for (let idx = 0; idx < window.localStorage.length; idx++) {
let id = window.localStorage.key(idx)
this.spaceUsedAtStart += window.localStorage.getItem(id).length
}
this.spaceUsed = this.spaceUsedAtStart
let k500 = '1234567812345678'
while (k500.length < 500000) {
k500 = k500 + k500
}
let tempKey = '42-exet-cap-42-'
this.spaceLeftAtStart = 0
for (let i = 0; i < 20; i++) {
// Only count up to 10 MB
try {
window.localStorage.setItem(tempKey + i, k500)
this.spaceLeftAtStart += k500.length
} catch (err) {
break
}
}
for (let i = 0; i < 20; i++) {
window.localStorage.removeItem(tempKey + i)
}
// Id for previews
this.previewId = `exet-preview-${Math.random().toString(36).substring(2, 8)}`
};
ExetRevManager.prototype.inMB = function(num) {
return (num / 1000000).toFixed(2)
}
ExetRevManager.prototype.choosePuzRev = function(manageStorage,
puz, elt, callback) {
let choices = [];
if (puz) {
let stored = window.localStorage.getItem(puz.id);
let spaceUsed = stored.length;
choices = [{id: puz.id, title: puz.title, space: spaceUsed}];
} else {
this.spaceUsed = 0;
for (let idx = 0; idx < window.localStorage.length; idx++) {
let id = window.localStorage.key(idx);
let stored = window.localStorage.getItem(id);
let spaceUsed = stored.length;
this.spaceUsed += spaceUsed;
if (id.startsWith(this.SPECIAL_KEY_PREFIX)) {
continue;
}
try {
stored = JSON.parse(stored);
} catch (err) {
continue;
}
if (!stored || !stored["id"] || !stored["revs"] || !stored["maxRevNum"]) {
continue;
}
let title = '';
if (stored.revs.length > 0) {
title = stored.revs[stored.revs.length - 1].title;
}
choices.push({id: stored.id, title: title, space: spaceUsed});
}
}
const storageUsedMB = this.inMB(this.spaceUsed);
const storageFreeMB = this.inMB(this.spaceUsedAtStart +
this.spaceLeftAtStart - this.spaceUsed);
exet.storageUsed.innerText = storageUsedMB;
exet.storageFree.innerText = storageFreeMB;
let html = `
Select puzzle ID/Title
Select revision
`
for (let i = 0; i < choices.length; i++) {
html = html + `
${choices[i].id}
${choices[i].title}
${this.inMB(choices[i].space)} MB
`
}
html = html + `
Space used: ${storageUsedMB} MBSpace available: ${storageFreeMB} MB
`
}
this.revChoices.innerHTML = html
this.revSelectors = []
this.revChoice = -1
for (let i = 0; i < this.storedRevs.revs.length; i++) {
let selector = document.getElementById(`xet-rev-choice-${i}`)
this.revSelectors.push(selector)
selector.addEventListener('click', e => {
if (!this.storedRevs) {
return
}
this.puzPriorDeleter.disabled = true
this.puzRevDeleter.disabled = true
this.puzRevSelector.disabled = true
this.preview.innerHTML = ''
if (exolvePuzzles[this.previewId]) {
exolvePuzzles[this.previewId].destroy();
}
if (i == this.revChoice) {
this.revChoice = -1
selector.className = ''
this.revChoices.className = 'xet-choices'
} else {
for (let j = 0; j < this.revSelectors.length; j++) {
if (j != i) {
this.revSelectors[j].className = ''
}
}
this.revChoice = i
selector.className = 'xet-chosen'
this.revChoices.className = 'xet-choices xet-picked'
let exolve = this.storedRevs.revs[i].exolve.replace(
/exolve-id:[^\n]*/, `exolve-id: ${this.previewId}`)
exet.renderPreview(exolve, "xet-preview")
this.puzPriorDeleter.disabled = (i <= 0)
this.puzRevDeleter.disabled = false
this.puzRevSelector.disabled = false
}
})
}
};
ExetRevManager.prototype.saveLocal = function(k, v) {
try {
window.localStorage.setItem(k, v)
} catch (err) {
alert('No available local storage left. Please use the ' +
'"Manage local storage" menu option to free up some space.');
console.log('Could not save value of length ' + v.length + ' for key: ' + k)
}
}
ExetRevManager.prototype.saveRev = function(revType, details="") {
if (!exet || !exet.puz || !exet.puz.id) {
console.log('Cannot save revision when there is no puzzle!')
return
}
let stored = window.localStorage.getItem(exet.puz.id)
if (!stored) {
stored = {
id: exet.puz.id,
maxRevNum: 0,
revs: []
}
} else {
stored = JSON.parse(stored)
}
let exolve = exet.getExolve()
if (stored.revs.length > 0) {
let lastRev = stored.revs[stored.revs.length - 1]
if (lastRev.exolve == exolve &&
lastRev.prefix == exet.prefix && lastRev.suffix == exet.suffix &&
lastRev.scratchPad == exet.puz.scratchPad.value &&
lastRev.preflex &&
JSON.stringify(lastRev.preflex) == JSON.stringify(exet.preflex) &&
lastRev.unpreflex &&
JSON.stringify(lastRev.unpreflex) == JSON.stringify(exet.unpreflex) &&
lastRev.noProperNouns == exet.noProperNouns &&
lastRev.tryReversals == exet.tryReversals &&
lastRev.minpop == exet.minpop &&
lastRev.asymOK == exet.asymOK) {
return
}
}
stored.maxRevNum++;
let exetRev = new ExetRev(exet.puz.id, (exet.puz.title ? exet.puz.title : ''),
stored.maxRevNum, revType, Date.now(), details)
exetRev.maxRevNum = stored.maxRevNum
exetRev.prefix = exet.prefix
exetRev.suffix = exet.suffix
exetRev.exolve = exolve
exetRev.scratchPad = exet.puz.scratchPad.value
exetRev.navState = [exet.puz.currDir, exet.puz.currRow, exet.puz.currCol]
exetRev.preflex = exet.preflex
exetRev.unpreflex = exet.unpreflex
exetRev.noProperNouns = exet.noProperNouns
exetRev.asymOK = exet.asymOK
exetRev.tryReversals = exet.tryReversals
exetRev.minpop = exet.minpop
stored.revs.push(exetRev)
this.saveLocal(exet.puz.id, JSON.stringify(stored))
};
ExetRevManager.prototype.throttledSaveRev = function(revType, details="") {
let urgent = revType <= 10;
if (this.throttleRevTimer) {
clearTimeout(this.throttleRevTimer);
if (this.throttlingRevType > 0 && revType < this.throttlingRevType) {
urgent = true
}
}
this.throttleRevTimer = null;
this.throttlingRevType = 0;
if (urgent) {
this.saveRev(revType, details)
return
}
this.throttlingRevType = revType;
this.throttleRevTimer = setTimeout(() => {
this.saveRev(revType, details)
this.throttleRevTimer = null;
this.throttlingRevType = 0;
}, this.saveLagMS);
}
ExetRevManager.prototype.saveAllRevisions = function() {
const storage = {}
for (let idx = 0; idx < window.localStorage.length; idx++) {
let id = window.localStorage.key(idx)
if (id.startsWith(this.SPECIAL_KEY_PREFIX)) {
continue
}
let storedRevsBlob = window.localStorage.getItem(id)
let storedRevs = null
try {
storedRevs = JSON.parse(storedRevsBlob)
} catch (err) {
continue
}
if (!storedRevs || !storedRevs['revs']) {
continue
}
storage[id] = storedRevs
}
const json = JSON.stringify(storage, null, 2)
const a = document.createElement('a');
a.style.display = 'none';
document.body.appendChild(a);
a.href = window.URL.createObjectURL(
new Blob([json], {type: 'text/json'})
);
let filename = `exet-revisions-${(new Date()).toISOString()}.json`
a.setAttribute('download', filename)
a.click();
window.URL.revokeObjectURL(a.href);
document.body.removeChild(a);
exetModals.hide()
}
ExetRevManager.prototype.mergeRevisionsFile = function() {
exetModals.hide()
let fr = new FileReader();
fr.onload = function(){
let allSavedRevs = {}
try {
allSavedRevs = JSON.parse(fr.result)
} catch (err) {
alert('Could not parse the saved revisions file')
return
}
existingRevs = {}
for (let idx = 0; idx < window.localStorage.length; idx++) {
let id = window.localStorage.key(idx)
if (id.startsWith(this.SPECIAL_KEY_PREFIX)) {
continue
}
let storedRevsBlob = window.localStorage.getItem(id)
let storedRevs = null
try {
storedRevs = JSON.parse(storedRevsBlob)
} catch (err) {
continue
}
if (!storedRevs || !storedRevs['revs']) {
continue
}
for (rev of storedRevs['revs']) {
const revHash = exetLexicon.javaHash(JSON.stringify(rev))
existingRevs[revHash] = true
}
}
let numRevs = 0
let numRevsMerged = 0
let numDupRevs = 0
let numNonLatest = 0
const mergeOnlyLatest = document.getElementById(
'xet-merge-only-latest-revs').checked ? true : false;
for (let id in allSavedRevs) {
savedRevs = allSavedRevs[id]['revs']
if (!savedRevs || savedRevs.length == 0) {
continue
}
const start = mergeOnlyLatest ? savedRevs.length - 1 : 0
if (mergeOnlyLatest) {
numNonLatest += savedRevs.length - 1
}
revsToSplice = []
for (let i = start; i < savedRevs.length; i++) {
numRevs++
const rev = savedRevs[i]
const revHash = exetLexicon.javaHash(JSON.stringify(rev))
if (existingRevs[revHash]) {
numDupRevs++
continue
}
revsToSplice.push(rev)
}
if (revsToSplice.length == 0) {
continue
}
let stored = window.localStorage.getItem(id)
if (stored) {
try {
stored = JSON.parse(stored)
} catch (err) {
console.log('Skipped id in merging as JSON.parse() failed, id: ' + id)
continue;
}
}
if (!stored) {
stored = { id: id, maxRevNum: 0, revs: [] }
}
for (rev of revsToSplice) {
stored['revs'].push(rev)
}
stored['revs'].sort((r1, r2) => r1.timestamp - r2.timestamp);
for (rev of stored['revs']) {
if (rev.revNum > stored.maxRevNum) {
stored.maxRevNum = rev.revNum
}
}
exetRevManager.saveLocal(id, JSON.stringify(stored))
numRevsMerged += revsToSplice.length
}
const ignored = (numNonLatest > 0) ?
`Ignored ${numNonLatest} non-latest revisions.` : '';
alert(`From ${numRevs} revisions considered across ` +
`${Object.keys(allSavedRevs).length} crosswords, merged ` +
`${numRevsMerged} revisions. There were ${numDupRevs} revisions ` +
`that already existed. ${ignored}`);
}
let f = document.getElementById('xet-merge-revs-file').files[0]
fr.readAsText(f)
}
function ExetRev(id, title, revNum, revType, timestamp, details="") {
this.id = id;
this.title = title
this.revNum = revNum;
this.revType = revType;
this.timestamp = timestamp;
this.details = details;
// prefix, suffix, exolve should be set directly.
};
function Exet() {
this.version = 'v0.91, January 9, 2024';
this.puz = null;
this.prefix = '';
this.suffix = '';
this.exolveOtherSec = '';
this.preflex = [];
this.preflexInUse = {};
this.unpreflex = {};
this.noProperNouns = false;
this.asymOK = false;
this.tryReversals = false;
this.DEFAULT_MINPOP = exetConfig.defaultPopularity;
this.setMinPop(this.DEFAULT_MINPOP)
this.DRAFT = '[DRAFT]';
this.CLUE_NOT_SET = 'Set clue and clear draft marker...';
this.MENU_SEPARATOR = '';
this.TOP_CLEARANCE = 36;
this.formatTags = {
clear: {inClue: [true, false]},
def: {open: '~{', close: '}~', inClue: [true]},
i: {open: '', close: '', inClue: [true, false]},
b: {open: '', close: '', inClue: [false]},
u: {open: '', close: '', inClue: [false]},
s: {open: '', close: '', inClue: [false]},
caps: {inClue: [false]},
alt: {inClue: [false]},
}
// Start in the Exet tab
this.currTab = "exet"
this.savedIndsSelect = ""
// State for throttled handlers
this.throttledGridTimer = null;
this.throttledPreflexTimer = null;
this.throttledUnpreflexTimer = null;
this.throttledClueTimer = null;
this.throttledMetadataTimer = null;
this.throttledCharadeTimer = null;
this.viabilityUpdateTimer = null;
this.inputLagMS = 400
this.longInputLagMS = 2000
this.sweepMS = 500
// Params for light choices shown.
this.sweepMaxChoices = 5000
this.sweepMaxChoicesSmall = 4
this.shownLightChoices = 200
this.tipsList = [
`If you want to allow enum mismatches, then add the line
exolve-option: ignore-enum-mismatch
using
Edit > Add/Edit special sections: > Other Exolve sections.`,
`You can specify up to a 100 desired words to fill, using the
"Set preferred fills" button near
the bottom. These words will be prioritized in autofill as well
as in the suggested fills list.`,
`In a cryptic clue, you can specify a part of the clue to be the definition
part using Ctrl-D after selecting it. This part gets underlined when
the solution is revealed.`,
`The "Analysis" button shows useful information about the grid, the
grid-fill, and the clues. You can use it to check for issues such
as: grids that are not fully connected, consecutive unchecked cells,
too many long clues, too many uncommon entries, etc.`,
`When setting a crossword with ninas, enter the nina letters first
and mark them using Edit > Mark grid cell: > Toggle nina ($).
While you fill the rest of the grid, clearing lights will not
erase any nina cells.`,
`You can add a preamble to the crossword (e.g., some special instructions)
using
Edit > Add/Edit special sections: > Preamble.`,
`Wordplay tabs such as "Charades" and "Anagrams" show candidate wordplays
for the currently selected entry. However, you can edit the fodder text
directly in the tab to experiment with alternatives.`,
`You can use autofill to create pangrams, and even constrained
pangrams, where all the letters in the alphabet get used over some
specified cells, such as circled cells and unchecked cells.`,
`You can create a 3-D crossword using
Open > New 3-D grid:. You can also reverse some lights with
Edit > Reverse current light. You can let autofill suggest
reversals using the "Try reversals: [ ]" option on the main Exet tab.
Reversed lights are often seen in 3-D crosswords.`,
];
this.tipIdx = -1;
this.TIP_ENUM_MISMATCH = 0;
this.TIP_ANALYSIS = 3;
this.lastTipShownTime = 0;
};
Exet.prototype.setMinPop = function(m) {
if (m < 0) m = 0
this.minpop = m
this.indexMinPop = Math.floor(exetLexicon.startLen * (100 - m) / 100)
}
Exet.prototype.startNav = function(dir='A', row=0, col=0) {
if (!this.puz) return;
if (row < 0 || row >= this.puz.gridHeight ||
col < 0 || col >= this.puz.gridWidth) {
row = 0
col = 0
}
if (dir != 'A' && dir != 'D' && dir != 'Z') {
dir = 'A'
let gridCell = this.puz.grid[row][col]
if (gridCell.isLight && !gridCell.acrossClueLabel) {
if (gridCell.downClueLabel) {
dir = 'D'
} else if (gridCell.z3dClueLabel) {
dir = 'Z'
}
}
}
this.puz.currRow = row
this.puz.currCol = col
this.puz.currDir = dir
if (this.puz.grid[row][col].isLight) {
this.puz.activateCell(row, col)
} else {
this.navDarkness(row, col)
}
}
Exet.prototype.hideExolveElement = function(suffix) {
const elt = document.getElementById(this.puz.prefix + '-' + suffix);
if (elt) {
elt.style.display = 'none';
}
}
Exet.prototype.setPuzzle = function(puz) {
if (puz.hasDgmlessCells) {
alert('Diagramless cells are not supported');
return;
}
if (puz.hasNodirClues) {
alert('Nodir clues not yet supported');
return;
}
if (puz.hasRebusCells) {
alert('Rebus cells are not supported');
return;
}
if (puz.offNumClueIndices.length > 0) {
alert('Non-numeric clues not yet supported');
return;
}
if ((puz.language && puz.language != exetLexicon.language) ||
(!puz.language && 'en' != exetLexicon.language) ||
(puz.languageScript && puz.languageScript != exetLexicon.script) ||
(!puz.languageScript && 'Latin' != exetLexicon.script)) {
alert('The lexicon is in ' +
exetLexicon.language + ' (' + exetLexicon.script + ') but the ' +
'puzzle has ' + puz.language + ' (' + puz.languageScript + ')');
return;
}
if (puz.langMaxCharCodes != exetLexicon.maxCharCodes) {
alert('Lexicon has MaxCharCodes = ' + exetLexicon.maxCharCodes +
' but the puzzle has ' + puz.langMaxCharCodes);
return;
}
if (puz.columnarLayout) {
puz.columnarLayout = false;
puz.gridcluesContainer.className = 'xlv-grid-and-clues-flex';
puz.cluesContainer.className = 'xlv-clues xlv-clues-flex';
}
let gridFillChanges = false;
for (let i = 0; i < puz.gridHeight; i++) {
for (let j = 0; j < puz.gridWidth; j++) {
const gridCell = puz.grid[i][j];
if (!gridCell.isLight) continue;
if (gridCell.skipNum) {
alert('Skipped-number cells not yet supported');
return;
}
if (gridCell.solution == '0') {
gridCell.solution = '?';
gridFillChanges = true;
}
if (gridCell.solution != '?' &&
!exetLexicon.letterSet[gridCell.solution]) {
alert('Entry ' + gridCell.solution + ' in grid[' + i + '][' + j +
'] is not present in the lexicon');
return;
}
}
}
this.puz = puz;
puz.useWebifi = false;
puz.hltOverwrittenMillis = 0;
puz.revealAll(false);
if (!this.prefix && !this.suffix) {
this.prefix = '' +
'\n' +
'\n' +
'\n' +
'\n' +
'\n' +
'\n' +
'