Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DataEntry] Give longer gloss spelling suggestions #2842

Merged
merged 14 commits into from
Jan 9, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@ export default function GlossWithSuggestions(
): ReactElement {
const spellChecker = useContext(SpellCheckerContext);

const maxSuggestions = 5;

useEffect(() => {
if (props.onUpdate) {
props.onUpdate();
Expand All @@ -39,11 +37,8 @@ export default function GlossWithSuggestions(
<Autocomplete
id={props.textFieldId}
disabled={props.isDisabled}
filterOptions={(options: string[]) =>
options.length <= maxSuggestions
? options
: options.slice(0, maxSuggestions)
}
// there's a bug with disappearing options if filterOptions isn't specified
filterOptions={(options) => options}
// freeSolo allows use of a typed entry not available as a drop-down option
freeSolo
includeInputInList
Expand Down
36 changes: 33 additions & 3 deletions src/utilities/spellChecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ interface SplitWord {
final?: string;
}

const maxSuggestions = 5;

export default class SpellChecker {
private bcp47: Bcp47Code | undefined;
private dictLoader: DictionaryLoader | undefined;
private dictLoaded: { [key: string]: string[] } = {};
private spell: nspell | undefined;

constructor(lang?: string) {
Expand All @@ -29,9 +32,11 @@ export default class SpellChecker {

this.bcp47 = bcp47;
this.dictLoader = new DictionaryLoader(bcp47);
this.dictLoaded = {};
await this.dictLoader.loadDictionary().then((dic) => {
if (dic !== undefined) {
this.spell = nspell("SET UTF-8", dic);
this.addToDictLoaded(dic);
if (process.env.NODE_ENV === "development") {
console.log(`Loaded spell-checker: ${bcp47}`);
}
Expand All @@ -47,6 +52,7 @@ export default class SpellChecker {

const part = await this.dictLoader.loadDictPart(word);
if (part) {
this.addToDictLoaded(part);
this.spell.personal(part);
}
}
Expand All @@ -55,6 +61,15 @@ export default class SpellChecker {
return this.spell?.correct(word);
}

addToDictLoaded(entries: string): void {
entries.split("\n").map((w) => {
if (!(w[0] in this.dictLoaded)) {
this.dictLoaded[w[0]] = [];
}
this.dictLoaded[w[0]].push(w);
});
}

static cleanAndSplit(word: string): SplitWord {
// Trim whitespace from the start and non-letter/-mark/-number characters from the end.
// Use of \p{L}\p{M}\p{N} here matches that in split_dictionary.py.
Expand Down Expand Up @@ -88,15 +103,30 @@ export default class SpellChecker {
// Don't await--just load for future use.
this.load(final);

// Get spelling suggestions.
let suggestions = this.spell.suggest(final);
if (!suggestions.length) {
// Extend the current word to get suggestions 1 or 2 characters longer.
suggestions = this.spell.suggest(`${final}..`);

// Add lookahead suggestions.
if (this.dictLoaded[final[0]] && suggestions.length < maxSuggestions) {
const lookahead = this.dictLoaded[final[0]].filter(
(entry) =>
entry.length >= final.length &&
entry.substring(0, final.length) === final &&
!suggestions.includes(entry)
);
suggestions.push(...lookahead.sort());
}

// Limit to maxSuggestions.
if (suggestions.length > maxSuggestions) {
suggestions = suggestions.slice(0, maxSuggestions);
}

// Prepend the start of the typed phrase, if any.
if (suggestions.length && allButFinal) {
suggestions = suggestions.map((w) => allButFinal + w);
}

return suggestions;
}
}
2 changes: 1 addition & 1 deletion src/utilities/tests/dictionaryLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const bcp47 = Bcp47Code.Es;

describe("DictionaryLoader", () => {
describe("constructor", () => {
it(" ets lang and gets keys", () => {
it("gets lang and gets keys", () => {
const loader = new DictionaryLoader(bcp47);
expect(loader.lang === bcp47);
expect(mockGetKeys).toHaveBeenCalledTimes(1);
Expand Down
46 changes: 38 additions & 8 deletions src/utilities/tests/spellChecker.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import SpellChecker from "utilities/spellChecker";

jest.mock("resources/dictionaries", () => ({
getDict: () => Promise.resolve(`1\n${mockValidWord}`),
getDict: () => Promise.resolve(`2\n${mockValidWordA}\n${mockValidWordBExt}`),
getKeys: () => [],
}));

const mockValidWord = "mockWord";
const invalidWord = "asdfghjkl";
const mockWord = "mockWord";
const mockValidWordA = `${mockWord}A`;
const mockWordB = `${mockWord}B`;
const mockWordC = `${mockWord}C`;
const mockValidWordBExt = `${mockWordB}Extended`;

describe("SpellChecker", () => {
describe("correct", () => {
it("detects a correctly spelled word", (done) => {
const spellChecker = new SpellChecker("en");
// Give the dictionary half-a-sec to load.
setTimeout(() => {
expect(spellChecker.correct(mockValidWord)).toEqual(true);
expect(spellChecker.correct(mockValidWordA)).toEqual(true);
done();
}, 500);
});
Expand All @@ -22,7 +27,7 @@ describe("SpellChecker", () => {
const spellChecker = new SpellChecker("en");
// Give the dictionary half-a-sec to load.
setTimeout(() => {
expect(spellChecker.correct("abjkdsjf")).toEqual(false);
expect(spellChecker.correct(invalidWord)).toEqual(false);
done();
}, 500);
});
Expand Down Expand Up @@ -73,13 +78,38 @@ describe("SpellChecker", () => {
});

describe("getSpellingSuggestions", () => {
it("returns suggestions", (done) => {
it("returns nothing for gibberish", (done) => {
const spellChecker = new SpellChecker("en");
// Give the dictionary half-a-sec to load.
setTimeout(() => {
expect(
spellChecker.getSpellingSuggestions(`${mockValidWord}`)
).toHaveLength(1);
const suggestions = spellChecker.getSpellingSuggestions(invalidWord);
expect(suggestions).toHaveLength(0);
done();
}, 500);
});

it("returns spelling correction", (done) => {
const spellChecker = new SpellChecker("en");
// Give the dictionary half-a-sec to load.
setTimeout(() => {
const suggestions = spellChecker.getSpellingSuggestions(mockWordC);
// Returns suggestion with 1 letter different.
expect(suggestions).toContain(mockValidWordA);
// Don't return lookahead for word 1 letter different.
expect(suggestions).not.toContain(mockValidWordBExt);
done();
}, 500);
});

it("returns spelling correction and lookahead", (done) => {
const spellChecker = new SpellChecker("en");
// Give the dictionary half-a-sec to load.
setTimeout(() => {
const suggestions = spellChecker.getSpellingSuggestions(mockWordB);
// Returns suggestion with 1 letter different.
expect(suggestions).toContain(mockValidWordA);
// Returns suggestions with many letters added.
expect(suggestions).toContain(mockValidWordBExt);
done();
}, 500);
});
Expand Down