diff --git a/README.md b/README.md index fc292c7..5eddf38 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,6 @@ This plugin adds a fully featured grammar and spell checker for SiYuan, powered - [x] Grammar checker like Grammarly - [x] Free and open-source - [x] [Self-hostable](https://dev.languagetool.org/http-server) grammar checking server -- [x] Offline mode (only simple spell checking) - [x] Underlines are not edited into your notes
Why does this matter? @@ -43,8 +42,6 @@ This project couldn't have been possible without (in no particular order): - The [SiYuan](https://github.com/siyuan-note/siyuan) project - [SiYuan plugin sample with vite and svelte](https://github.com/siyuan-note/plugin-sample-vite-svelte) - [LanguageTool](https://languagetool.org/) -- [ESpells](https://github.com/Monkatraz/espells) -- The authors of [offline dictionaries](https://github.com/wooorm/dictionaries?tab=readme-ov-file#list-of-dictionaries) Make sure you check them out and support them as well! diff --git a/package.json b/package.json index 3e55d9a..8627868 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "syspell", - "version": "0.2.1", + "version": "0.1.0", "type": "module", "description": "Include a whiteboard for freehand drawing anywhere in your documents.", "repository": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin", @@ -20,7 +20,6 @@ "@tsconfig/svelte": "^4.0.1", "@types/node": "^20.3.0", "cross-env": "^7.0.3", - "espells": "^0.4.1", "fast-glob": "^3.2.12", "glob": "^10.0.0", "js-yaml": "^4.1.0", diff --git a/plugin.json b/plugin.json index ea7e078..9eb5345 100644 --- a/plugin.json +++ b/plugin.json @@ -2,7 +2,7 @@ "name": "syspell", "author": "massivebox", "url": "https://git.massive.box/massivebox/syspell", - "version": "0.2.1", + "version": "0.1.0", "minAppVersion": "3.0.12", "backends": [ "windows", diff --git a/public/i18n/en_US.json b/public/i18n/en_US.json index fab8ed4..06e47ad 100644 --- a/public/i18n/en_US.json +++ b/public/i18n/en_US.json @@ -47,14 +47,6 @@ "experimentalCorrect": { "title": "[Feature preview] Apply corrections when selected", "description": "[Feature preview] This feature will modify the content of your documents when used, and can alter them significantly without the ability to roll the changes back. This feature is not recommended for production workspaces.

When a correction is chosen, apply it to the document instead of just having copied to your clipboard." - }, - "offline": { - "title": "Offline mode", - "description": "If enabled, the plugin will use a local spell checker, which is faster and more privacy friendly, but doesn't provide advanced grammar checking" - }, - "offlineDicts": { - "title": "Offline dictionaries", - "description": "Comma-separated list of dictionaries used for offline spell checking. Available options. Example: en,it" } }, "errors": { @@ -63,9 +55,9 @@ "cantRender": "This block contains elements, such as images or tables, that don't work well with the SySpell system.", "waitingForSuggestions": "Grammar suggestions for this block aren't ready yet, please close the menu and open it again after a few seconds.", "correctionNotEnabled": "The correction has been copied to your clipboard. Suggestions can be auto-applied when selected. Visit the plugin's settings to enable.", - "checkServer": "Failed to contact grammar checking server, make sure it's correctly set or enable Offline Mode in the plugin settings.", - "notImplementedNotebookSettings": "Notebook-wide grammar checking settings aren't implemented yet, they will be added in a future version. Thanks for your patience!", - "hunspellLoadError": "Failed loading offline spell checker: " + "checkServer": "Failed to contact grammar checking server, make sure it's correctly set in the plugin settings.", + "fatal": "The grammar checking plugin will quit now. Please restart SiYuan.", + "notImplementedNotebookSettings": "Notebook-wide grammar checking settings aren't implemented yet, they will be added in a future version. Thanks for your patience!" }, "docMenu": { "documentStatus": "Document status", diff --git a/src/espells.ts b/src/espells.ts deleted file mode 100644 index a3a90b6..0000000 --- a/src/espells.ts +++ /dev/null @@ -1,45 +0,0 @@ -import {Language, SpellChecker, Suggestion} from "@/spellChecker"; -import { Espells } from "espells" - -export class ESpellChecker implements SpellChecker { - - spellchecker: Espells - loadedLanguages: Language[] - - constructor(languages: {aff: string, dic: string, language: Language}[]) { - this.spellchecker = new Espells({aff: languages[0].aff, dic: languages.map(l => l.dic)}) - this.loadedLanguages = languages.map(l => l.language) - } - - async check(text: string, _: string[]): Promise { - - let suggestions: Suggestion[] = [] - - const regex = /[\p{L}']+/gu; - let match; - - while ((match = regex.exec(text)) !== null) { - const word = match[0]; - const counter = match.index; - const {correct} = this.spellchecker.lookup(word) - if(!correct) { - const hsSuggestions = this.spellchecker.suggest(word) - suggestions.push({ - typeName: "UnknownWord", - message: word, - shortMessage: "Misspelled word", - replacements: hsSuggestions, - offset: counter, - length: word.length - }) - } - } - - return suggestions - } - - async getLanguages(): Promise { - return this.loadedLanguages - } - -} \ No newline at end of file diff --git a/src/hunspellDictManager.ts b/src/hunspellDictManager.ts deleted file mode 100644 index ac1ad44..0000000 --- a/src/hunspellDictManager.ts +++ /dev/null @@ -1,51 +0,0 @@ -import {getFile, putFile} from "@/api"; - -export class HunspellDictManager { - - private static pathBase = 'data/storage/petal/syspell' - private static urlBase = 'https://raw.githubusercontent.com/wooorm/dictionaries/refs/heads/main/dictionaries' - - static async loadDictionary(language: string, downloadIfMissing: boolean): Promise<{ aff: string, dic: string }> { - - const aff = await getFile(`${this.pathBase}/${language}.aff`) - const dic = await getFile(`${this.pathBase}/${language}.dic`) - - if(aff.code == 404 || dic.code == 404) { - if(downloadIfMissing) { - await this.downloadDictionary(language) - return this.loadDictionary(language, false) - }else{ - throw new Error(`Dictionary ${language} not found`) - } - } - - return { aff, dic } - - } - - private static async downloadFile(url: string, filename: string) { - - const res = await fetch(url); - const mimeType = res.headers.get('content-type') - - if(res.status != 200) { - throw new Error(await res.text()) - } - - const blob = new Blob([await res.text()], { type: mimeType }); - const file = new File([blob], filename, { type: mimeType, lastModified: Date.now() }); - - await putFile(filename, false, file) - - } - - static async downloadDictionary(language: string) { - try { - await this.downloadFile(`${this.urlBase}/${language}/index.aff`, `${this.pathBase}/${language}.aff`); - await this.downloadFile(`${this.urlBase}/${language}/index.dic`, `${this.pathBase}/${language}.dic`); - }catch (e) { - throw new Error(`Download for dictionary '${language}' failed with ` + e) - } - } - -} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index ebf78cc..7227a9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,10 +6,6 @@ import {SettingUtils} from "@/libs/setting-utils"; import {Analytics} from "@/analytics"; import {SuggestionEngine} from "@/suggestions"; import {Menus} from "@/menus"; -import {ESpellChecker} from "@/espells"; -import {LanguageTool, LanguageToolSettings} from "@/languagetool"; -import {HunspellDictManager} from "@/hunspellDictManager"; -import {Language} from "@/spellChecker"; export default class SpellCheckPlugin extends Plugin { @@ -21,9 +17,6 @@ export default class SpellCheckPlugin extends Plugin { public analytics: Analytics public i18nx: any; // This object is just a copy of i18n, but with type "any" to not trigger type errors - public offlineSpellChecker: ESpellChecker - public onlineSpellChecker: LanguageTool - public static ENABLED_ATTR = 'custom-spellcheck-enable' public static LANGUAGE_ATTR = 'custom-spellcheck-language' @@ -31,14 +24,11 @@ export default class SpellCheckPlugin extends Plugin { this.i18nx = this.i18n new Icons(this); - this.settingsUtil = await Settings.init(this) this.analytics = new Analytics(this.settingsUtil.get('analytics')); this.suggestions = new SuggestionEngine(this) this.menus = new Menus(this) - await this.prepareSpellCheckers() - void this.analytics.sendEvent('load') const style = document.createElement('style'); @@ -93,6 +83,7 @@ export default class SpellCheckPlugin extends Plugin { }) this.eventBus.on('open-menu-doctree', async (event) => { + console.log(event) const docID = ProtyleHelpers.getNodeId(event.detail.elements[0]) // @TODO this is ugly, why does the event not carry the docID? void this.menus.addSettingsToDocMenu(docID, event.detail.menu) }) @@ -128,24 +119,4 @@ export default class SpellCheckPlugin extends Plugin { void this.analytics.sendEvent('uninstall'); } - private async prepareSpellCheckers() { - - this.onlineSpellChecker = new LanguageTool(this.settingsUtil.dump()) - const offlineLanguages = this.settingsUtil.get('offlineDicts').split(',') - - let langs: {aff: string, dic: string, language: Language}[] = [] - - try { - for(const lang of offlineLanguages) { - const { aff, dic } = await HunspellDictManager.loadDictionary(lang, true) - langs.push({aff: aff, dic: dic, language: {name: lang, code: lang, longCode: lang}}) - } - this.offlineSpellChecker = new ESpellChecker(langs) - }catch (e){ - console.error(e) - showMessage(this.i18nx.errors.hunspellLoadError + e, -1, 'error') - } - - } - } \ No newline at end of file diff --git a/src/languagetool.ts b/src/languagetool.ts index 3ec5450..bc4ee97 100644 --- a/src/languagetool.ts +++ b/src/languagetool.ts @@ -1,7 +1,6 @@ -import {Language, Suggestion} from "@/spellChecker"; -import {SpellChecker} from "@/spellChecker"; +import {PluginSettings} from "@/settings"; -type LanguageToolSuggestion = { +export type Suggestion = { message: string shortMessage: string replacements: Array<{ @@ -33,73 +32,47 @@ type LanguageToolSuggestion = { ignoreForIncompleteSentence: boolean contextForSureMatch: number } + +export type Language = { name: string; code: string; longCode: string; } interface HTTPError extends Error { status?: number; } -export type LanguageToolSettings = { - server: string - username: string - apiKey: string - picky: boolean - motherTongue: string - preferredVariants: string -} -export class LanguageTool implements SpellChecker { +export class LanguageTool { - private settings: LanguageToolSettings; - - constructor(settings: LanguageToolSettings) { - this.settings = settings - } - - public async check(text: string, languages: string[]): Promise { - - const language = languages.length > 0 ? languages[0] : 'auto'; + public static async check(text: string, language: string, settings: PluginSettings): Promise { const body = new URLSearchParams({ text: text, language: language, - level: this.settings.picky ? 'picky' : 'default', - motherTongue: this.settings.motherTongue == '' ? window.navigator.language : this.settings.motherTongue, + level: settings.picky ? 'picky' : 'default', + motherTongue: settings.motherTongue == '' ? window.navigator.language : settings.motherTongue, }); - if(this.settings.username != '') { - body.append('username', this.settings.username); + if(settings.username != '') { + body.append('username', settings.username); } - if(this.settings.apiKey) { - body.append('apiKey', this.settings.apiKey); + if(settings.apiKey) { + body.append('apiKey', settings.apiKey); } if(language == 'auto') { - body.append('preferredVariants', this.settings.preferredVariants) + body.append('preferredVariants', settings.preferredVariants) } - const res = await fetch(this.settings.server + 'v2/check', {method: 'POST', body}); + const res = await fetch(settings.server + 'v2/check', {method: 'POST', body}); if(res.status != 200) { const err = new Error('Network error') as HTTPError err.status = res.status; throw err } - const suggestions: LanguageToolSuggestion[] = (await res.json()).matches; - return suggestions.map((suggestion) => { - const ret: Suggestion = { - message: suggestion.message, - shortMessage: suggestion.shortMessage, - replacements: suggestion.replacements.map((replacement) => { - return replacement.value - }), - offset: suggestion.offset, - length: suggestion.length, - typeName: suggestion.type.typeName - } - return ret - }); + const json = await res.json(); + return json.matches; } - public async getLanguages(): Promise { - const res = await fetch(this.settings.server + 'v2/languages', {method: 'GET'}); + public static async getLanguages(settings: PluginSettings): Promise { + const res = await fetch(settings.server + 'v2/languages', {method: 'GET'}); return await res.json(); } diff --git a/src/menus.ts b/src/menus.ts index fe13833..b0d5fcd 100644 --- a/src/menus.ts +++ b/src/menus.ts @@ -1,7 +1,8 @@ import {Menu, showMessage, subMenu} from 'siyuan'; import SpellCheckPlugin from "@/index"; import {getBlockAttrs, setBlockAttrs} from "@/api"; -import {Settings} from "@/settings"; +import {LanguageTool} from "@/languagetool"; +import {PluginSettings, Settings} from "@/settings"; import {ProtyleHelpers} from "@/protyleHelpers"; import {SuggestionEngine} from "@/suggestions"; @@ -30,14 +31,14 @@ export class Menus { } }) - if(suggestion.typeName == 'UnknownWord') { + if(suggestion.type.typeName == 'UnknownWord') { // add to dictionary menu.addItem({ icon: 'add', label: this.plugin.i18nx.textMenu.addToDictionary, click: async () => { void this.plugin.analytics.sendEvent('menu-click-add-to-dictionary'); - const word = SuggestionEngine.suggestionToWrongText(suggestion, blockID) + const word = SuggestionEngine.suggestionToWrongText(suggestion) await Settings.addToDictionary(word, this.plugin.settingsUtil) showMessage(this.plugin.i18nx.textMenu.addedToDictionary + word, 5000, 'info') await this.plugin.suggestions.renderSuggestions(blockID) @@ -49,15 +50,15 @@ export class Menus { suggestion.replacements.forEach((replacement, correctionNumber) => { menu.addItem({ icon: 'spellcheck', - label: replacement, + label: replacement.value, click: async () => { void this.plugin.analytics.sendEvent('menu-click-correct', { - 'type': suggestion.typeName + 'type': suggestion.rule.category.id }); if(this.plugin.settingsUtil.get('experimentalCorrect')) { void this.plugin.suggestions.correctSuggestion(blockID, suggestionNumber, correctionNumber) }else{ - void navigator.clipboard.writeText(replacement) + void navigator.clipboard.writeText(replacement.value) showMessage(this.plugin.i18nx.errors.correctionNotEnabled, 5000, 'info') } } @@ -110,7 +111,7 @@ export class Menus { label: this.plugin.i18nx.docMenu.setDocumentLanguage, click: async (_, ev: MouseEvent) => { void this.plugin.analytics.sendEvent('docmenu-click-setlang-1'); - const languages = await this.plugin.onlineSpellChecker.getLanguages() + const languages = await LanguageTool.getLanguages(this.plugin.settingsUtil.dump()) const langMenu = new Menu('spellCheckLangMenu'); langMenu.addItem({ icon: 'autodetect', diff --git a/src/settings.ts b/src/settings.ts index 3f8cd68..39d80fc 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,7 +1,21 @@ import {SettingUtils} from "@/libs/setting-utils"; import {showMessage} from 'siyuan'; +import {LanguageTool} from "@/languagetool"; import SpellCheckPlugin from "@/index"; -import {LanguageTool, LanguageToolSettings} from "@/languagetool"; + +export type PluginSettings = { + server: string + username: string + apiKey: string + picky: boolean + motherTongue: string + preferredVariants: string + enabledByDefault: boolean + defaultLanguage: string + preferredLanguages: string + analytics: boolean +} + export class Settings { @@ -47,12 +61,13 @@ export class Settings { await su.load() // needed to fetch languages from server let languagesKV = {} try { - let languages = await new LanguageTool({server: su.get('server')}).getLanguages() + let languages = await LanguageTool.getLanguages(su.dump()) languages.forEach(language => { languagesKV[language.longCode] = language.name + ' [' + language.longCode + ']' }) - } catch(e) { + } catch { showMessage(plugin.i18nx.errors.checkServer, -1, 'error') + showMessage(plugin.i18nx.errors.fatal, -1, 'error') } su.addItem({ @@ -114,22 +129,6 @@ export class Settings { value: 'auto' }) - su.addItem({ - type: 'checkbox', - key: 'offline', - title: to.offline.title, - description: to.offline.description, - value: false - }) - - su.addItem({ - type: 'textinput', - key: 'offlineDicts', - title: to.offlineDicts.title, - description: to.offlineDicts.description, - value: 'en' - }) - su.addItem({ type: 'checkbox', key: 'analytics', @@ -138,12 +137,6 @@ export class Settings { value: true }) - su.save = async function (data?: any) { - data = data ?? this.dump(); - await this.plugin.saveData(this.file, this.dump()); - location.reload() - }.bind(su) - await su.load() return su diff --git a/src/spellChecker.ts b/src/spellChecker.ts deleted file mode 100644 index 443cd2f..0000000 --- a/src/spellChecker.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type Language = { name: string; code: string; longCode: string; } - -export type Suggestion = { - message: string - shortMessage: string - replacements: string[] - offset: number - length: number - typeName: string -} - -export interface SpellChecker { - check(text: string, languages: string[]): Promise - getLanguages(): Promise -} \ No newline at end of file diff --git a/src/spellCheckerUI.ts b/src/spellchecker.ts similarity index 96% rename from src/spellCheckerUI.ts rename to src/spellchecker.ts index d809c1c..3054e8b 100644 --- a/src/spellCheckerUI.ts +++ b/src/spellchecker.ts @@ -1,6 +1,6 @@ import {ProtyleHelpers} from "@/protyleHelpers"; -export class SpellCheckerUI { +export class SpellChecker { private readonly blockID: string; private readonly docID: string; @@ -111,13 +111,13 @@ export class SpellCheckerUI { const top = rect.bottom - editorRect.top - 2 + this.block.scrollTop; const width = rect.width; - const offset = SpellCheckerUI.distance(this.overlay, this.block) + const offset = SpellChecker.distance(this.overlay, this.block) underline.style.left = (left + offset.h) + 'px'; underline.style.top = (top + 2 + offset.v) + 'px'; underline.style.width = width + 'px'; - if(!SpellCheckerUI.checkDontUnderline(width, charsCount)) { + if(!SpellChecker.checkDontUnderline(width, charsCount)) { this.overlay.appendChild(underline); } } diff --git a/src/suggestions.ts b/src/suggestions.ts index aea86a4..5548da5 100644 --- a/src/suggestions.ts +++ b/src/suggestions.ts @@ -1,13 +1,13 @@ import {ProtyleHelpers} from "@/protyleHelpers"; -import {Settings} from "@/settings"; +import {LanguageTool, Suggestion} from "@/languagetool"; +import {PluginSettings, Settings} from "@/settings"; import {getChildBlocks, updateBlock} from "@/api"; -import {SpellCheckerUI} from "@/spellCheckerUI"; +import {SpellChecker} from "@/spellchecker"; import {showMessage} from "siyuan"; import SpellCheckPlugin from "@/index"; -import {Suggestion} from "@/spellChecker"; interface StoredBlock { - spellChecker: SpellCheckerUI; + spellChecker: SpellChecker; suggestions: Suggestion[]; } @@ -40,7 +40,7 @@ export class SuggestionEngine { const children = await getChildBlocks(blockID) if(children.length == 0) { if(!(blockID in this.blockStorage)) { - const spellChecker = new SpellCheckerUI(blockID, this.documentID) + const spellChecker = new SpellChecker(blockID, this.documentID) this.blockStorage[blockID] = { spellChecker: spellChecker, suggestions: [] @@ -90,16 +90,11 @@ export class SuggestionEngine { return this.suggestForBlock(blockID) } - if(this.plugin.settingsUtil.get('offline')) { - suggestions = await this.plugin.offlineSpellChecker.check(text, [this.documentLanguage]) - }else{ - try { - suggestions = await this.plugin.onlineSpellChecker.check(text, [this.documentLanguage]) - }catch (_) { - showMessage(this.plugin.i18nx.errors.checkServer, 5000, 'error') - } + try { + suggestions = await LanguageTool.check(text, this.documentLanguage, this.plugin.settingsUtil.dump()) + }catch (_) { + showMessage(this.plugin.i18nx.errors.checkServer, 5000, 'error') } - this.blockStorage[blockID].suggestions = suggestions } @@ -114,15 +109,14 @@ export class SuggestionEngine { } this.blockStorage[blockID].spellChecker.clearUnderlines() this.blockStorage[blockID].suggestions.forEach(suggestion => { - if(!Settings.isInCustomDictionary(SuggestionEngine.suggestionToWrongText(suggestion, blockID), this.plugin.settingsUtil)) { + if(!Settings.isInCustomDictionary(SuggestionEngine.suggestionToWrongText(suggestion), this.plugin.settingsUtil)) { this.blockStorage[blockID].spellChecker.highlightCharacterRange(suggestion.offset, suggestion.offset + suggestion.length) } }) } - static suggestionToWrongText(suggestion: Suggestion, blockID: string): string { - const blockTxt = ProtyleHelpers.fastGetBlockText(blockID) - return blockTxt.slice(suggestion.offset, suggestion.offset + suggestion.length) + static suggestionToWrongText(suggestion: Suggestion): string { + return suggestion.context.text.slice(suggestion.context.offset, suggestion.context.offset + suggestion.context.length) } private getAbsoluteOffsetInBlock(range: Range, blockID: string): number { @@ -168,7 +162,7 @@ export class SuggestionEngine { const suggestion = this.blockStorage[blockID].suggestions[suggestionNumber] const rich = ProtyleHelpers.fastGetBlockHTML(blockID) const fixedOffset = this.adjustIndexForTags(rich, suggestion.offset) - const newStr = rich.slice(0, fixedOffset) + suggestion.replacements[correctionNumber] + rich.slice(fixedOffset + suggestion.length) + const newStr = rich.slice(0, fixedOffset) + suggestion.replacements[correctionNumber].value + rich.slice(fixedOffset + suggestion.length) console.log("new str " + newStr); await updateBlock('markdown', window.Lute.New().BlockDOM2Md(newStr), blockID)