Skip to content

Commit 511476f

Browse files
CopilotcyanzhongCopilot
authored
Add to Dictionary (#23)
* Initial plan * Add "Add to Dictionary" feature with persistent word storage - Create src/dictionary.ts for persisting dictionary words via MarkEdit file API - Add addToDictionary() export in src/lint.ts using Harper's importWords/exportWords - Load persisted dictionary words at linter startup - Add problemText field to Diagnostic interface in src/decoration.ts - Add "Add to Dictionary" button in tooltip UI (src/tooltip.ts) - Add tests for dictionary parsing (tests/dictionary.test.ts) - Update decoration tests for new problemText field - Document feature in README.md Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * Address code review: simplify problemText checks in tooltip Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * Address review: rename dict, use documents dir, integrate into Ignore with addToDict setting - Rename dictionary.ts → dict.ts, dictionary file → proofreading-dict.txt - Use documents directory instead of library for discoverability - Remove separate "Add to Dictionary" button from tooltip - Integrate dictionary into "Ignore" button, controlled by addToDict setting (default true) - Add addToDict to ProofreadingSettings with tests - Update README to document addToDict setting Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * Fix cross-editor safety: read dict from disk instead of Harper memory When adding a word, read the current file from disk (not exportWords()) to preserve words added by other editor instances. Only appends the new word if not already present, and skips the save entirely for duplicates. Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * Move addToDict to 2nd position in README settings docs Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> Co-authored-by: Ying Zhong <0x00eeee@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 8c48f14 commit 511476f

9 files changed

Lines changed: 122 additions & 13 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ You can customize behavior from `settings.json` with the `extension.markeditProo
1616
{
1717
"extension.markeditProofreading": {
1818
"autoLintDelay": 1000,
19+
"addToDict": true,
1920
"lintPreset": "relaxed",
2021
"lintRuleOverrides": {
2122
"SpelledNumbers": false,
@@ -27,6 +28,7 @@ You can customize behavior from `settings.json` with the `extension.markeditProo
2728
```
2829

2930
- `autoLintDelay`: Delay in milliseconds before automatic proofreading runs after a document change (default: `1000`). Set to `-1` to disable automatic proofreading entirely (use "Proofread Now" to lint on demand)
31+
- `addToDict`: When `true` (default), clicking "Ignore" on a flagged word also adds it to a personal dictionary so it won't be flagged in future sessions. Set to `false` to disable this behavior
3032
- `lintPreset`: `"strict"`, `"standard"` (default), or `"relaxed"`
3133
- `lintRuleOverrides`: Per-rule overrides (`true` / `false` / `null`) applied on top of the preset
3234
- `disabledLintKinds`: Additional lint kinds to filter out, available kinds:
@@ -37,3 +39,7 @@ You can customize behavior from `settings.json` with the `extension.markeditProo
3739

3840
For a full list of available rule names, see:
3941
https://writewithharper.com/docs/rules
42+
43+
## Dictionary
44+
45+
When `addToDict` is enabled (default), clicking "Ignore" on a flagged word also adds it to a personal dictionary persisted in `proofreading-dict.txt` under the MarkEdit documents directory. Dictionary words are automatically loaded when the extension starts.

src/decoration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface Diagnostic {
1010
lintKind: string;
1111
title: string;
1212
messageHtml: string;
13+
problemText: string;
1314
actions: DiagnosticAction[];
1415
}
1516

@@ -59,6 +60,7 @@ export function lintToDiagnostic(l: Lint): Diagnostic {
5960
lintKind: l.lint_kind(),
6061
title: l.lint_kind_pretty(),
6162
messageHtml: l.message_html(),
63+
problemText: l.get_problem_text(),
6264
actions: l.suggestions().map(suggestionToAction),
6365
};
6466
}

src/dict.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { MarkEdit } from 'markedit-api';
2+
3+
const dictionaryFileName = 'proofreading-dict.txt';
4+
5+
function dictionaryPath(): string {
6+
return `${MarkEdit.getDirectoryPath('documents')}/${dictionaryFileName}`;
7+
}
8+
9+
export async function loadWords(): Promise<string[]> {
10+
const content = await MarkEdit.getFileContent(dictionaryPath());
11+
if (!content) {
12+
return [];
13+
}
14+
15+
return parseWords(content);
16+
}
17+
18+
export async function saveWords(words: string[]): Promise<void> {
19+
await MarkEdit.createFile({
20+
path: dictionaryPath(),
21+
string: words.join('\n'),
22+
overwrites: true,
23+
});
24+
}
25+
26+
export function parseWords(content: string): string[] {
27+
return content.split('\n').map(w => w.trim()).filter(w => w.length > 0);
28+
}

src/lint.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { MarkEdit } from 'markedit-api';
33
import { getProofreadingSettings } from './settings';
44
import { presetDisabledRules } from './rules';
55
import { presetDisabledKinds } from './kinds';
6+
import { loadWords, saveWords } from './dict';
67

78
const linter = new LocalLinter({ binary: binaryInlined });
89
const settings = getProofreadingSettings(MarkEdit.userSettings);
@@ -11,6 +12,8 @@ const linterReady = configureLinter().catch(error => {
1112
console.warn('[MarkEdit-proofreading] Failed to configure linter.', error);
1213
});
1314

15+
export const shouldAddToDict = settings.addToDict;
16+
1417
export async function lint(text: string) {
1518
await linterReady;
1619
const lints = await linter.lint(text);
@@ -23,6 +26,18 @@ export async function lint(text: string) {
2326
return lints.filter(lint => !disabledKinds.has(lint.lint_kind()));
2427
}
2528

29+
export async function addToDictionary(word: string): Promise<void> {
30+
await linterReady;
31+
await linter.importWords([word]);
32+
33+
// Read from disk (not Harper memory) to preserve words added by other editors
34+
const existing = await loadWords();
35+
if (!existing.includes(word)) {
36+
existing.push(word);
37+
await saveWords(existing);
38+
}
39+
}
40+
2641
function resolveDisabledKinds(): ReadonlySet<string> {
2742
const fromPreset = presetDisabledKinds(settings.lintPreset);
2843
if (settings.disabledLintKinds.length === 0) {
@@ -34,23 +49,30 @@ function resolveDisabledKinds(): ReadonlySet<string> {
3449

3550
async function configureLinter() {
3651
const disabledRules = presetDisabledRules(settings.lintPreset);
52+
const hasRuleConfig =
53+
disabledRules.length > 0 ||
54+
Object.keys(settings.lintRuleOverrides).length > 0;
3755

38-
if (disabledRules.length === 0 && Object.keys(settings.lintRuleOverrides).length === 0) {
39-
return;
40-
}
56+
if (hasRuleConfig) {
57+
const config: LintConfig = await linter.getDefaultLintConfig();
4158

42-
const config: LintConfig = await linter.getDefaultLintConfig();
59+
for (const rule of disabledRules) {
60+
if (rule in config) {
61+
config[rule] = false;
62+
}
63+
}
4364

44-
for (const rule of disabledRules) {
45-
if (rule in config) {
46-
config[rule] = false;
65+
// Apply user rule overrides on top
66+
for (const [name, val] of Object.entries(settings.lintRuleOverrides)) {
67+
config[name] = val;
4768
}
48-
}
4969

50-
// Apply user rule overrides on top
51-
for (const [name, val] of Object.entries(settings.lintRuleOverrides)) {
52-
config[name] = val;
70+
await linter.setLintConfig(config);
5371
}
5472

55-
await linter.setLintConfig(config);
73+
// Load persisted dictionary words (always, even if no rules to configure)
74+
const words = await loadWords();
75+
if (words.length > 0) {
76+
await linter.importWords(words);
77+
}
5678
}

src/settings.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface ProofreadingSettings {
1313
lintPreset: LintPreset;
1414
lintRuleOverrides: LintConfig;
1515
disabledLintKinds: string[];
16+
addToDict: boolean;
1617
}
1718

1819
export function getProofreadingSettings(userSettings: JSONObject | undefined): ProofreadingSettings {
@@ -21,6 +22,7 @@ export function getProofreadingSettings(userSettings: JSONObject | undefined): P
2122
lintPreset: 'standard',
2223
lintRuleOverrides: {},
2324
disabledLintKinds: [],
25+
addToDict: true,
2426
};
2527

2628
const root = asObject(userSettings);
@@ -37,8 +39,9 @@ export function getProofreadingSettings(userSettings: JSONObject | undefined): P
3739
) as LintConfig;
3840

3941
const disabledLintKinds = parseStringArray(raw.disabledLintKinds);
42+
const addToDict = raw.addToDict === false ? false : true;
4043

41-
return { autoLintDelay, lintPreset, lintRuleOverrides, disabledLintKinds };
44+
return { autoLintDelay, lintPreset, lintRuleOverrides, disabledLintKinds, addToDict };
4245
}
4346

4447
function parseLintPreset(value: JSONValue): LintPreset {

src/tooltip.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { StateField, StateEffect } from '@codemirror/state';
22
import { showTooltip, EditorView } from '@codemirror/view';
33
import type { Tooltip, TooltipView } from '@codemirror/view';
44
import { diagnosticsField, setDiagnosticsEffect } from './decoration';
5+
import { addToDictionary, shouldAddToDict } from './lint';
56
import type { Diagnostic } from './decoration';
67

78
const setClickTooltip = StateEffect.define<Diagnostic | null>();
@@ -265,6 +266,9 @@ function createTooltip(view: EditorView, diagnostic: Diagnostic) {
265266
ignore.textContent = 'Ignore';
266267
ignore.onmousedown = (e) => e.preventDefault();
267268
ignore.onclick = () => {
269+
if (shouldAddToDict && diagnostic.problemText) {
270+
void addToDictionary(diagnostic.problemText);
271+
}
268272
const { diagnostics } = view.state.field(diagnosticsField);
269273
const filtered = diagnostics.filter(d =>
270274
!(d.from === diagnostic.from && d.to === diagnostic.to && d.lintKind === diagnostic.lintKind),

tests/decoration.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ describe('lintToDiagnostic', () => {
1717
lint_kind: () => 'Style',
1818
lint_kind_pretty: () => 'Style',
1919
message_html: () => '<p>Use another word</p>',
20+
get_problem_text: () => 'word',
2021
suggestions: () => [
2122
makeSuggestion(SuggestionKind.Remove, ''),
2223
makeSuggestion(SuggestionKind.InsertAfter, 'ed'),
@@ -32,6 +33,7 @@ describe('lintToDiagnostic', () => {
3233
lintKind: 'Style',
3334
title: 'Style',
3435
messageHtml: '<p>Use another word</p>',
36+
problemText: 'word',
3537
});
3638

3739
expect(diagnostic.actions.map(a => a.name)).toEqual(['Remove', 'Insert "ed"', 'fixed']);
@@ -43,6 +45,7 @@ describe('lintToDiagnostic', () => {
4345
lint_kind: () => 'Typo',
4446
lint_kind_pretty: () => 'Typo',
4547
message_html: () => '<p>Typo</p>',
48+
get_problem_text: () => 'typo',
4649
suggestions: () => [
4750
makeSuggestion(SuggestionKind.Remove, ''),
4851
makeSuggestion(SuggestionKind.Replace, 'word'),

tests/dict.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { parseWords } from '../src/dict';
3+
4+
describe('parseWords', () => {
5+
it('splits content into trimmed non-empty words', () => {
6+
expect(parseWords('hello\nworld\n')).toEqual(['hello', 'world']);
7+
});
8+
9+
it('trims whitespace from each line', () => {
10+
expect(parseWords(' foo \n bar ')).toEqual(['foo', 'bar']);
11+
});
12+
13+
it('filters out blank lines', () => {
14+
expect(parseWords('one\n\n\ntwo\n\nthree')).toEqual(['one', 'two', 'three']);
15+
});
16+
17+
it('returns empty array for empty content', () => {
18+
expect(parseWords('')).toEqual([]);
19+
});
20+
21+
it('handles single word without newline', () => {
22+
expect(parseWords('hello')).toEqual(['hello']);
23+
});
24+
});

tests/settings.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ describe('proofreading settings', () => {
1111
expect(settings.lintPreset).toBe('standard');
1212
expect(settings.lintRuleOverrides).toEqual({});
1313
expect(settings.disabledLintKinds).toEqual([]);
14+
expect(settings.addToDict).toBe(true);
1415
});
1516

1617
it('parses lint preset, per-rule overrides from user settings', () => {
@@ -119,4 +120,20 @@ describe('proofreading settings', () => {
119120
'extension.markeditProofreading': { autoLintDelay: 'fast' },
120121
}).autoLintDelay).toBe(1000);
121122
});
123+
124+
it('defaults addToDict to true and allows disabling', () => {
125+
expect(getProofreadingSettings(undefined).addToDict).toBe(true);
126+
127+
expect(getProofreadingSettings({
128+
'extension.markeditProofreading': { addToDict: false },
129+
}).addToDict).toBe(false);
130+
131+
expect(getProofreadingSettings({
132+
'extension.markeditProofreading': { addToDict: true },
133+
}).addToDict).toBe(true);
134+
135+
expect(getProofreadingSettings({
136+
'extension.markeditProofreading': { addToDict: 'yes' },
137+
}).addToDict).toBe(true);
138+
});
122139
});

0 commit comments

Comments
 (0)