Add free-type spell checker for students
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
91fe38c00d
commit
a2fb3b056a
5 files changed
+106
-2
No files matched your search
Generated
+45
-1
@@ -9,9 +9,11 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"dictionary-en": "^4.0.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"mammoth": "^1.12.0",
|
"mammoth": "^1.12.0",
|
||||||
"multer": "^2.2.0"
|
"multer": "^2.2.0",
|
||||||
|
"nspell": "^2.1.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
@@ -273,6 +275,16 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dictionary-en": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dictionary-en/-/dictionary-en-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3NHnE1uq33ZE/CIwaZ6gqxa4BnglHnxeAcTM0GJ7cmtRGcvX9InMK/IqLtYcUMFCUpUNgybH+DzkqdgAo3F1zg==",
|
||||||
|
"license": "(MIT AND BSD)",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dingbat-to-unicode": {
|
"node_modules/dingbat-to-unicode": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
|
||||||
@@ -583,6 +595,29 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-buffer": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-promise": {
|
"node_modules/is-promise": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
@@ -783,6 +818,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nspell": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/nspell/-/nspell-2.1.5.tgz",
|
||||||
|
"integrity": "sha512-PSStyugKMiD9mHmqI/CR5xXrSIGejUXPlo88FBRq5Og1kO5QwQ5Ilu8D8O5I/SHpoS+mibpw6uKA8rd3vXd2Sg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-buffer": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
|||||||
+3
-1
@@ -11,9 +11,11 @@
|
|||||||
"author": "NateEmmert",
|
"author": "NateEmmert",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"dictionary-en": "^4.0.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"mammoth": "^1.12.0",
|
"mammoth": "^1.12.0",
|
||||||
"multer": "^2.2.0"
|
"multer": "^2.2.0",
|
||||||
|
"nspell": "^2.1.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
|
|||||||
@@ -2519,6 +2519,37 @@ $('#btn-test').addEventListener('click', () => {
|
|||||||
startTest();
|
startTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#btn-freetype').addEventListener('click', () => {
|
||||||
|
backTarget = () => openKid(currentStudent.id, 'spelling');
|
||||||
|
$('#freetype-feedback').innerHTML = '';
|
||||||
|
$('#freetype-input').value = '';
|
||||||
|
show('freetype');
|
||||||
|
$('#freetype-input').focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#freetype-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = $('#freetype-input');
|
||||||
|
const typed = input.value.trim();
|
||||||
|
if (!typed) return;
|
||||||
|
const fb = $('#freetype-feedback');
|
||||||
|
try {
|
||||||
|
const r = await api(`/api/spellcheck?word=${encodeURIComponent(typed)}`);
|
||||||
|
if (r.correct) {
|
||||||
|
fb.innerHTML = `<div class="banner good">✅ "${esc(r.word)}" is spelled correctly!</div>`;
|
||||||
|
} else {
|
||||||
|
const tips = r.suggestions.length
|
||||||
|
? ` Did you mean: ${r.suggestions.map((x) => `<strong>${esc(x)}</strong>`).join(', ')}?`
|
||||||
|
: '';
|
||||||
|
fb.innerHTML = `<div class="banner bad">❌ "${esc(r.word)}" is not spelled correctly.${tips}</div>`;
|
||||||
|
alert(`"${r.word}" is spelled wrong. Try again!`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
fb.innerHTML = `<div class="banner bad">${esc(err.message)}</div>`;
|
||||||
|
}
|
||||||
|
input.select();
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- docs (OnlyOffice) ----------
|
// ---------- docs (OnlyOffice) ----------
|
||||||
|
|
||||||
let docsEditorInstance = null;
|
let docsEditorInstance = null;
|
||||||
|
|||||||
@@ -99,6 +99,7 @@
|
|||||||
<div class="big-actions">
|
<div class="big-actions">
|
||||||
<button id="btn-practice" class="big-btn practice">✏️ Practice</button>
|
<button id="btn-practice" class="big-btn practice">✏️ Practice</button>
|
||||||
<button id="btn-test" class="big-btn test">⭐ Friday Test</button>
|
<button id="btn-test" class="big-btn test">⭐ Friday Test</button>
|
||||||
|
<button id="btn-freetype" class="big-btn print-list">🔍 Check any word</button>
|
||||||
<button id="btn-print-list" class="big-btn print-list">🖨 Print my words</button>
|
<button id="btn-print-list" class="big-btn print-list">🖨 Print my words</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="kid-test-history"></div>
|
<div id="kid-test-history"></div>
|
||||||
@@ -273,6 +274,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Free-type spell check -->
|
||||||
|
<section id="view-freetype" class="view" hidden>
|
||||||
|
<button class="back-btn" data-back="freetype">← Back</button>
|
||||||
|
<div class="word-card">
|
||||||
|
<p class="prompt">Type any word and I'll tell you if it's spelled right:</p>
|
||||||
|
<div id="freetype-feedback" class="feedback"></div>
|
||||||
|
<form id="freetype-form" autocomplete="off">
|
||||||
|
<input id="freetype-input" class="spell-input" type="text" autocomplete="off"
|
||||||
|
autocapitalize="off" autocorrect="off" spellcheck="false" aria-label="Type a word" enterkeyhint="go">
|
||||||
|
<p class="hint">Press Enter to check</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Practice (spelling) -->
|
<!-- Practice (spelling) -->
|
||||||
<section id="view-practice" class="view" hidden>
|
<section id="view-practice" class="view" hidden>
|
||||||
<button class="back-btn" data-back="practice">← Back</button>
|
<button class="back-btn" data-back="practice">← Back</button>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { readFile, writeFile, unlink } from 'node:fs/promises';
|
|||||||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||||
import mammoth from 'mammoth';
|
import mammoth from 'mammoth';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
|
import nspell from 'nspell';
|
||||||
|
import dictionaryEn from 'dictionary-en';
|
||||||
import { db, sha256 } from './db.js';
|
import { db, sha256 } from './db.js';
|
||||||
|
|
||||||
const ROOT = dirname(fileURLToPath(import.meta.url));
|
const ROOT = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -435,6 +437,16 @@ app.get('/api/state/:studentId', (req, res) => {
|
|||||||
res.json({ student: { ...student, isBirthdayWeek: isBirthdayWeek(student.birthday) }, assignment, listProgress });
|
res.json({ student: { ...student, isBirthdayWeek: isBirthdayWeek(student.birthday) }, assignment, listProgress });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Free-type spell check: any word, checked against a bundled English dictionary
|
||||||
|
const speller = nspell(dictionaryEn);
|
||||||
|
app.get('/api/spellcheck', (req, res) => {
|
||||||
|
const word = String(req.query.word || '').trim();
|
||||||
|
if (!/^[A-Za-z'’-]{1,60}$/.test(word)) return res.status(400).json({ error: 'Type a single word using letters only.' });
|
||||||
|
const clean = word.replace(/’/g, "'");
|
||||||
|
const correct = speller.correct(clean) || speller.correct(clean.toLowerCase());
|
||||||
|
res.json({ word, correct, suggestions: correct ? [] : speller.suggest(clean).slice(0, 4) });
|
||||||
|
});
|
||||||
|
|
||||||
// Build a practice session: the target list's unmastered words only.
|
// Build a practice session: the target list's unmastered words only.
|
||||||
// listId overrides the student's pinned weekly assignment (used when launched from a course item).
|
// listId overrides the student's pinned weekly assignment (used when launched from a course item).
|
||||||
app.get('/api/session/:studentId', (req, res) => {
|
app.get('/api/session/:studentId', (req, res) => {
|
||||||
|
|||||||
Reference in new issue
Block a user