spell-cli 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/bin/spell.js +64 -0
- package/package.json +27 -0
- package/src/args.js +101 -0
- package/src/clipboard.js +44 -0
- package/src/config.js +47 -0
- package/src/dictionary.js +53 -0
- package/src/speller.js +132 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ali
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# spell
|
|
2
|
+
|
|
3
|
+
`spell` is a tiny local command-line helper that prompt you the correct spelling of the word.
|
|
4
|
+
|
|
5
|
+
I built it for local use for me instead of going the my browser consitinilty: type a word, get the corrected version, and optionally copy it to your clipboard.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- Corrects one word at a time
|
|
10
|
+
- Keeps working when the word is already correct
|
|
11
|
+
- Can show a short definition with `-d`
|
|
12
|
+
- Supports clipboard copy on Linux and macOS
|
|
13
|
+
- Lets you save clipboard behavior as a default (`--copy-default on|off`)
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
### npm (recommended)
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install -g spell-cli
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then run:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
spell appertunity
|
|
27
|
+
# opportunity
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### From source (GitHub)
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
git clone https://github.com/alisufayan/spell-cli.git
|
|
34
|
+
cd spell
|
|
35
|
+
npm install
|
|
36
|
+
npm link
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`npm link` makes `spell` available globally on your machine.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# basic correction
|
|
45
|
+
spell appertunity
|
|
46
|
+
# opportunity
|
|
47
|
+
|
|
48
|
+
# already-correct words still work
|
|
49
|
+
spell extraordinary
|
|
50
|
+
# extraordinary
|
|
51
|
+
|
|
52
|
+
# definition mode
|
|
53
|
+
spell -d extordinary
|
|
54
|
+
# extraordinary
|
|
55
|
+
# adjective
|
|
56
|
+
# 1.
|
|
57
|
+
# very unusual or remarkable
|
|
58
|
+
|
|
59
|
+
# one-time clipboard behavior
|
|
60
|
+
spell --copy opportunity
|
|
61
|
+
spell --no-copy opportunity
|
|
62
|
+
|
|
63
|
+
# persistent default clipboard behavior
|
|
64
|
+
spell --copy-default on
|
|
65
|
+
spell --copy-default off
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Notes
|
|
69
|
+
|
|
70
|
+
- `-d` uses `dictionaryapi.dev` only when definition mode is requested.
|
|
71
|
+
- Clipboard commands:
|
|
72
|
+
- macOS: `pbcopy`
|
|
73
|
+
- Linux: `wl-copy`, fallback to `xclip`, fallback to `xsel`
|
|
74
|
+
|
|
75
|
+
## Roadmap
|
|
76
|
+
|
|
77
|
+
- Homebrew install support
|
package/bin/spell.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { parseArgs, printHelp } = require("../src/args");
|
|
5
|
+
const { readConfig, writeConfig } = require("../src/config");
|
|
6
|
+
const { loadSpeller, correctWord } = require("../src/speller");
|
|
7
|
+
const { copyToClipboard } = require("../src/clipboard");
|
|
8
|
+
const { fetchDefinition } = require("../src/dictionary");
|
|
9
|
+
const packageJson = require("../package.json");
|
|
10
|
+
|
|
11
|
+
async function main() {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
14
|
+
|
|
15
|
+
if (parsed.action === "help") {
|
|
16
|
+
printHelp();
|
|
17
|
+
process.exit(parsed.exitCode);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (parsed.action === "version") {
|
|
21
|
+
console.log(packageJson.version);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (parsed.action === "set-copy-default") {
|
|
26
|
+
const current = readConfig();
|
|
27
|
+
current.copy = parsed.value === "on";
|
|
28
|
+
writeConfig(current);
|
|
29
|
+
console.log(`Default clipboard copy is ${parsed.value}.`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const loaded = await loadSpeller();
|
|
34
|
+
const corrected = correctWord(loaded.speller, loaded.words, parsed.word);
|
|
35
|
+
console.log(corrected);
|
|
36
|
+
|
|
37
|
+
const config = readConfig();
|
|
38
|
+
const shouldCopy = parsed.copyOverride !== undefined ? parsed.copyOverride : Boolean(config.copy);
|
|
39
|
+
if (shouldCopy) {
|
|
40
|
+
const copied = copyToClipboard(corrected);
|
|
41
|
+
if (!copied.ok) {
|
|
42
|
+
console.error(`Warning: ${copied.reason}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (parsed.define) {
|
|
47
|
+
const definition = await fetchDefinition(corrected);
|
|
48
|
+
if (!definition) {
|
|
49
|
+
console.error("Definition not found.");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log(definition.partOfSpeech);
|
|
54
|
+
definition.definitions.forEach((def, i) => {
|
|
55
|
+
console.log(`${i + 1}. ${def}`);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error(error.message || String(error));
|
|
60
|
+
process.exitCode = 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spell-cli",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Simple local spelling CLI with optional definitions and clipboard copy",
|
|
5
|
+
"bin": {
|
|
6
|
+
"spell": "bin/spell.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin",
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"type": "commonjs",
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node bin/spell.js"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"spell",
|
|
18
|
+
"spelling",
|
|
19
|
+
"cli",
|
|
20
|
+
"dictionary"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"dictionary-en": "^4.0.0",
|
|
25
|
+
"nspell": "^2.1.5"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/args.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const HELP_TEXT = `spell - tiny local spelling helper
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
spell <word>
|
|
7
|
+
spell -d <word>
|
|
8
|
+
spell -c <word>
|
|
9
|
+
spell --no-copy <word>
|
|
10
|
+
spell --copy-default on
|
|
11
|
+
spell --copy-default off
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
-d, --define Show part of speech and first definition
|
|
15
|
+
-c, --copy Copy corrected word to clipboard for this run
|
|
16
|
+
--no-copy Disable clipboard copy for this run
|
|
17
|
+
--copy-default Set persistent clipboard default (on/off)
|
|
18
|
+
-h, --help Show help
|
|
19
|
+
-v, --version Show version`;
|
|
20
|
+
|
|
21
|
+
function printHelp() {
|
|
22
|
+
console.log(HELP_TEXT);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseArgs(args) {
|
|
26
|
+
if (args.length === 0) {
|
|
27
|
+
return { action: "help", exitCode: 1 };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) {
|
|
31
|
+
return { action: "help", exitCode: 0 };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (args.length === 1 && (args[0] === "-v" || args[0] === "--version")) {
|
|
35
|
+
return { action: "version" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (args[0] === "--copy-default") {
|
|
39
|
+
if (args.length !== 2 || (args[1] !== "on" && args[1] !== "off")) {
|
|
40
|
+
throw new Error("Usage: spell --copy-default on|off");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { action: "set-copy-default", value: args[1] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let define = false;
|
|
47
|
+
let copyOverride;
|
|
48
|
+
let word;
|
|
49
|
+
|
|
50
|
+
for (const arg of args) {
|
|
51
|
+
if (arg === "-d" || arg === "--define") {
|
|
52
|
+
define = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (arg === "-c" || arg === "--copy") {
|
|
57
|
+
copyOverride = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (arg === "--no-copy") {
|
|
62
|
+
copyOverride = false;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (arg === "-h" || arg === "--help") {
|
|
67
|
+
return { action: "help", exitCode: 0 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (arg === "-v" || arg === "--version") {
|
|
71
|
+
return { action: "version" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (arg.startsWith("-")) {
|
|
75
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!word) {
|
|
79
|
+
word = arg;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
throw new Error("Please provide only one word at a time.");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!word) {
|
|
87
|
+
throw new Error("Usage: spell <word>");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
action: "lookup",
|
|
92
|
+
word,
|
|
93
|
+
define,
|
|
94
|
+
copyOverride
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = {
|
|
99
|
+
printHelp,
|
|
100
|
+
parseArgs
|
|
101
|
+
};
|
package/src/clipboard.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require("child_process");
|
|
4
|
+
|
|
5
|
+
function copyToClipboard(text) {
|
|
6
|
+
const candidates = [];
|
|
7
|
+
|
|
8
|
+
if (process.platform === "darwin") {
|
|
9
|
+
candidates.push({ command: "pbcopy", args: [] });
|
|
10
|
+
} else if (process.platform === "linux") {
|
|
11
|
+
candidates.push(
|
|
12
|
+
{ command: "wl-copy", args: [] },
|
|
13
|
+
{ command: "xclip", args: ["-selection", "clipboard"] },
|
|
14
|
+
{ command: "xsel", args: ["--clipboard", "--input"] }
|
|
15
|
+
);
|
|
16
|
+
} else {
|
|
17
|
+
return {
|
|
18
|
+
ok: false,
|
|
19
|
+
reason: "Clipboard copy is only supported on Linux and macOS."
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const candidate of candidates) {
|
|
24
|
+
const result = spawnSync(candidate.command, candidate.args, {
|
|
25
|
+
input: text,
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
28
|
+
timeout: 2000
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (!result.error && result.status === 0) {
|
|
32
|
+
return { ok: true, command: candidate.command };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
reason: "No clipboard command worked (tried wl-copy/xclip/xsel or pbcopy)."
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = {
|
|
43
|
+
copyToClipboard
|
|
44
|
+
};
|
package/src/config.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const os = require("os");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
|
|
7
|
+
const DEFAULT_CONFIG = { copy: false };
|
|
8
|
+
|
|
9
|
+
function getConfigDir() {
|
|
10
|
+
if (process.platform === "darwin") {
|
|
11
|
+
return path.join(os.homedir(), "Library", "Application Support", "spell");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const baseDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
15
|
+
return path.join(baseDir, "spell");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getConfigPath() {
|
|
19
|
+
return path.join(getConfigDir(), "config.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readConfig() {
|
|
23
|
+
try {
|
|
24
|
+
const raw = fs.readFileSync(getConfigPath(), "utf8");
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
return { ...DEFAULT_CONFIG, ...parsed };
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error.code !== "ENOENT") {
|
|
29
|
+
console.error("Warning: config is invalid, using defaults.");
|
|
30
|
+
}
|
|
31
|
+
return { ...DEFAULT_CONFIG };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeConfig(config) {
|
|
36
|
+
fs.mkdirSync(getConfigDir(), { recursive: true });
|
|
37
|
+
fs.writeFileSync(
|
|
38
|
+
getConfigPath(),
|
|
39
|
+
`${JSON.stringify({ copy: Boolean(config.copy) }, null, 2)}\n`,
|
|
40
|
+
"utf8"
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = {
|
|
45
|
+
readConfig,
|
|
46
|
+
writeConfig
|
|
47
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
async function fetchDefinition(word) {
|
|
4
|
+
const url = `https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(word.toLowerCase())}`;
|
|
5
|
+
const controller = new AbortController();
|
|
6
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
10
|
+
if (!response.ok) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const entries = await response.json();
|
|
15
|
+
if (!Array.isArray(entries)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
if (!entry || !Array.isArray(entry.meanings)) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
for (const meaning of entry.meanings) {
|
|
25
|
+
if (!meaning || !Array.isArray(meaning.definitions) || meaning.definitions.length === 0) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const defs = meaning.definitions;
|
|
30
|
+
const selected = defs.length > 1 ? [defs[0], defs[1]] : [defs[0]];
|
|
31
|
+
const valid = selected.filter(d => d && typeof d.definition === "string");
|
|
32
|
+
if (valid.length === 0) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
partOfSpeech: meaning.partOfSpeech || "unknown",
|
|
38
|
+
definitions: valid.map(d => d.definition.trim())
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
} finally {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = {
|
|
52
|
+
fetchDefinition
|
|
53
|
+
};
|
package/src/speller.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const nspell = require("nspell");
|
|
4
|
+
|
|
5
|
+
async function loadSpeller() {
|
|
6
|
+
const module = await import("dictionary-en");
|
|
7
|
+
const dictionary = module.default;
|
|
8
|
+
return {
|
|
9
|
+
speller: nspell(dictionary),
|
|
10
|
+
words: parseWordList(dictionary.dic)
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseWordList(dicBuffer) {
|
|
15
|
+
const text = Buffer.from(dicBuffer).toString("utf8");
|
|
16
|
+
const lines = text.split(/\r?\n/);
|
|
17
|
+
const words = new Set();
|
|
18
|
+
|
|
19
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
20
|
+
const line = lines[index].trim();
|
|
21
|
+
if (!line || line.startsWith("#")) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const slashIndex = line.indexOf("/");
|
|
26
|
+
const word = slashIndex >= 0 ? line.slice(0, slashIndex) : line;
|
|
27
|
+
if (word) {
|
|
28
|
+
words.add(word);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return Array.from(words);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function levenshteinDistance(a, b, maxDistance) {
|
|
36
|
+
if (Math.abs(a.length - b.length) > maxDistance) {
|
|
37
|
+
return maxDistance + 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
41
|
+
|
|
42
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
43
|
+
const current = [i];
|
|
44
|
+
let smallest = current[0];
|
|
45
|
+
|
|
46
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
47
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
48
|
+
const value = Math.min(
|
|
49
|
+
previous[j] + 1,
|
|
50
|
+
current[j - 1] + 1,
|
|
51
|
+
previous[j - 1] + cost
|
|
52
|
+
);
|
|
53
|
+
current[j] = value;
|
|
54
|
+
if (value < smallest) {
|
|
55
|
+
smallest = value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (smallest > maxDistance) {
|
|
60
|
+
return maxDistance + 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
previous = current;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return previous[b.length];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function findClosestWord(input, words) {
|
|
70
|
+
const lowerInput = input.toLowerCase();
|
|
71
|
+
const minLength = Math.max(1, lowerInput.length - 2);
|
|
72
|
+
const maxLength = lowerInput.length + 2;
|
|
73
|
+
const maxDistance = 2;
|
|
74
|
+
|
|
75
|
+
let bestWord = null;
|
|
76
|
+
let bestDistance = maxDistance + 1;
|
|
77
|
+
|
|
78
|
+
for (const candidate of words) {
|
|
79
|
+
const lowerCandidate = candidate.toLowerCase();
|
|
80
|
+
if (lowerCandidate.length < minLength || lowerCandidate.length > maxLength) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const distance = levenshteinDistance(lowerInput, lowerCandidate, maxDistance);
|
|
85
|
+
if (distance < bestDistance) {
|
|
86
|
+
bestDistance = distance;
|
|
87
|
+
bestWord = candidate;
|
|
88
|
+
if (distance === 1) {
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return bestWord;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function preserveCase(source, target) {
|
|
98
|
+
if (source.toUpperCase() === source) {
|
|
99
|
+
return target.toUpperCase();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const firstChar = source.charAt(0);
|
|
103
|
+
const rest = source.slice(1);
|
|
104
|
+
if (firstChar === firstChar.toUpperCase() && rest === rest.toLowerCase()) {
|
|
105
|
+
return `${target.charAt(0).toUpperCase()}${target.slice(1)}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return target;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function correctWord(speller, words, word) {
|
|
112
|
+
const input = word.trim();
|
|
113
|
+
if (speller.correct(input)) {
|
|
114
|
+
return input;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const suggestions = speller.suggest(input);
|
|
118
|
+
if (suggestions.length === 0) {
|
|
119
|
+
const fallback = findClosestWord(input, words);
|
|
120
|
+
if (!fallback) {
|
|
121
|
+
return input;
|
|
122
|
+
}
|
|
123
|
+
return preserveCase(input, fallback);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return preserveCase(input, suggestions[0]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
loadSpeller,
|
|
131
|
+
correctWord
|
|
132
|
+
};
|