├── styles.css ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── dependabot.yml ├── FUNDING.yml ├── workflows │ ├── biome.yml │ ├── rumdl-lint.yml │ ├── stale-bot.yml │ ├── pr-title.yml │ └── obsidian-plugin-release.yml └── pull_request_template.md ├── .gitignore ├── src ├── obsidian-undocumented-api.d.ts ├── providers │ ├── adapter.d.ts │ ├── model-info.ts │ └── openai.ts ├── utils.ts ├── main.ts ├── accept-reject-suggestions.ts ├── settings.ts └── proofread.ts ├── .knip.jsonc ├── .editorconfig ├── manifest.json ├── .githooks └── pre-commit ├── package.json ├── versions.json ├── LICENSE ├── Justfile ├── tsconfig.json ├── .rumdl.toml ├── .esbuild.mjs ├── biome.jsonc ├── eslint.config.mjs ├── .release.mjs └── README.md /styles.css: -------------------------------------------------------------------------------- 1 | /* placeholder */ 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # npm 2 | node_modules 3 | 4 | # external source maps 5 | *.js.map 6 | 7 | # obsidian 8 | data.json 9 | main.js 10 | -------------------------------------------------------------------------------- /src/obsidian-undocumented-api.d.ts: -------------------------------------------------------------------------------- 1 | import "obsidian"; 2 | 3 | declare module "obsidian" { 4 | interface Editor { 5 | cm: EditorView; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | commit-message: 8 | prefix: "chore(dependabot): " 9 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/github/administering-a-repository/managing-repository-settings/displaying-a-sponsor-button-in-your-repository 2 | 3 | custom: https://www.paypal.me/ChrisGrieser 4 | ko_fi: pseudometa 5 | -------------------------------------------------------------------------------- /.knip.jsonc: -------------------------------------------------------------------------------- 1 | // DOCS https://knip.dev/overview/configuration 2 | { 3 | "entry": ["src/main.ts"], 4 | "project": ["**/*.ts"], 5 | 6 | // used only in pre-commit hook / task, 7 | "ignoreDependencies": ["markdownlint-cli", "eslint-plugin-obsidianmd"], 8 | 9 | "$schema": "https://unpkg.com/knip@5/schema-jsonc.json" 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/biome.yml: -------------------------------------------------------------------------------- 1 | name: Biome check 2 | 3 | on: 4 | # not on `push`, since that's covered by pre-commit checks 5 | pull_request: 6 | paths: 7 | - "**.ts" 8 | - "**.css" 9 | - "**.jsonc?" 10 | - "**.m?js" 11 | 12 | jobs: 13 | biome-check: 14 | name: Biome PR check 15 | runs-on: macos-latest 16 | steps: 17 | - uses: actions/checkout@v6 18 | - uses: biomejs/setup-biome@v2 19 | - run: biome ci 20 | -------------------------------------------------------------------------------- /.github/workflows/rumdl-lint.yml: -------------------------------------------------------------------------------- 1 | name: Markdown linting via rumdl 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - "**/*.md" 8 | - ".github/workflows/rumdl-lint.yml" 9 | - ".rumdl.toml" 10 | pull_request: 11 | paths: 12 | - "**/*.md" 13 | 14 | jobs: 15 | rumdl: 16 | name: rumdl 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v6 20 | - uses: rvben/rumdl@v0 21 | with: 22 | report-type: annotations 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | max_line_length = 100 5 | end_of_line = lf 6 | charset = utf-8 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 3 10 | tab_width = 3 11 | trim_trailing_whitespace = true 12 | 13 | [*.{yml,yaml,scm,cff}] 14 | indent_style = space 15 | indent_size = 2 16 | tab_width = 2 17 | 18 | [*.py] 19 | indent_style = space 20 | indent_size = 4 21 | tab_width = 4 22 | 23 | [*.md] 24 | indent_style = space 25 | indent_size = 4 26 | trim_trailing_whitespace = false 27 | -------------------------------------------------------------------------------- /src/providers/adapter.d.ts: -------------------------------------------------------------------------------- 1 | import type { MODEL_SPECS } from "src/providers/model-info.ts"; 2 | import type { ProofreaderSettings } from "src/settings.ts"; 3 | 4 | type ProviderResponse = { 5 | newText: string; // output text from LLM 6 | isOverlength: boolean; // whether output hit token limit, i.e., output is truncated 7 | }; 8 | 9 | export type ProviderAdapter = ( 10 | settings: ProofreaderSettings, 11 | oldText: string, 12 | ) => Promise; 13 | 14 | export type ModelName = keyof typeof MODEL_SPECS; 15 | export type ProviderName = (typeof MODEL_SPECS)[ModelName]["provider"]; 16 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Platform } from "obsidian"; 2 | 3 | export function logError(msg: string, obj: unknown): void { 4 | if (Platform.isMobileApp) { 5 | // No issue way of checking the logs on mobile, thus recommending to 6 | // retrieve error via running on desktop instead. 7 | new Notice(`Error: ${msg}\n\nFor details, run the respective function on the desktop.`); 8 | } else { 9 | const hotkey = Platform.isMacOS ? "cmd+opt+i" : "ctrl+shift+i"; 10 | new Notice( 11 | `[Proofreader plugin] Error: ${msg}\n\n Check the console for more details (${hotkey}).`, 12 | ); 13 | console.error("[Proofreader plugin] error", obj); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "proofreader", 3 | "name": "Proofreader", 4 | "description": "AI-based proofreading and stylistic improvements for your writing. Changes are inserted as suggestions directly in the editor, similar to suggested changes in word processing apps.", 5 | "version": "1.6.4", 6 | "minAppVersion": "1.5.8", 7 | "isDesktopOnly": false, 8 | "author": "pseudometa (aka Chris Grieser)", 9 | "authorUrl": "https://chris-grieser.de/", 10 | "helpUrl": "https://github.com/chrisgrieser/obsidian-proofreader#readme", 11 | "fundingUrl": { 12 | "Ko-Fi": "https://ko-fi.com/pseudometa", 13 | "PayPal": "https://www.paypal.me/ChrisGrieser" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Problem statement 2 | 3 | 4 | ## Proposed solution 5 | 6 | 7 | ## AI usage disclosure 8 | 11 | 12 | ## Checklist 13 | - [ ] I ran `git hook run pre-commit -- "check-all"` and all checks passed. 14 | - [ ] Variable names follow `camelCase` convention. 15 | - [ ] All AI-generated code has been reviewed by a human. 16 | - [ ] Documentation (`README.md` and affected settings) has been updated 17 | for any new or modified functionality. 18 | -------------------------------------------------------------------------------- /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zsh 2 | set -o errexit # block commit if there are any issues reported 3 | cd "$(git rev-parse --show-toplevel)" 4 | #─────────────────────────────────────────────────────────────────────────────── 5 | 6 | echo "Pre-Commit Hook" 7 | 8 | echo "(1/3) Biome" 9 | if [[ "$1" == "check-all" ]]; then 10 | npx biome check --write --error-on-warnings --log-kind="compact" 11 | else 12 | # `--staged` so unused things in unstaged files does not block commit 13 | npx biome check --error-on-warnings --staged --no-errors-on-unmatched --log-kind="compact" 14 | fi 15 | 16 | echo "(2/3) TypeScript" 17 | npx tsc --noEmit --skipLibCheck --strict 18 | echo "✅" 19 | 20 | echo "(3/3) Knip" 21 | npx knip 22 | test -t || echo "✅" # when running in terminal, knip already has output 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Proofreader", 3 | "description": "AI-based proofreading and stylistic improvements for your writing. Changes are inserted as suggestions directly in the editor, similar to suggested changes in word processing apps.", 4 | "author": "Christopher Grieser", 5 | "version": "1.6.4", 6 | "keywords": [], 7 | "license": "MIT", 8 | "main": "main.js", 9 | "devDependencies": { 10 | "@biomejs/biome": "latest", 11 | "@types/diff": "^7.0.2", 12 | "@types/node": "^22.5.5", 13 | "builtin-modules": "^3.2.0", 14 | "esbuild": "^0.25.1", 15 | "eslint-plugin-obsidianmd": "^0.1.8", 16 | "knip": "latest", 17 | "markdownlint-cli": "latest", 18 | "obsidian": "latest", 19 | "tslib": "2.7.0", 20 | "typescript": "^5.6.2" 21 | }, 22 | "dependencies": { 23 | "diff": "^7.0.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea 3 | title: "Feature Request: " 4 | labels: [enhancement] 5 | body: 6 | - type: checkboxes 7 | id: checklist 8 | attributes: 9 | label: Checklist 10 | options: 11 | - label: "I have read the plugin's documentation." 12 | required: true 13 | - label: The feature would be useful to more users than just me. 14 | required: true 15 | - type: textarea 16 | id: feature-requested 17 | attributes: 18 | label: Feature Requested 19 | description: A clear and concise description of the feature. 20 | validations: 21 | required: true 22 | - type: textarea 23 | id: screenshot 24 | attributes: 25 | label: Relevant Screenshot 26 | description: If applicable, add screenshots or a screen recording to help explain the request. 27 | -------------------------------------------------------------------------------- /.github/workflows/stale-bot.yml: -------------------------------------------------------------------------------- 1 | name: Stale bot 2 | on: 3 | schedule: 4 | - cron: "18 04 * * 3" 5 | 6 | permissions: 7 | issues: write 8 | pull-requests: write 9 | 10 | jobs: 11 | stale: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Close stale issues 15 | uses: actions/stale@v10 16 | with: 17 | repo-token: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | # DOCS https://github.com/actions/stale#all-options 20 | days-before-stale: 180 21 | days-before-close: 7 22 | stale-issue-label: "Stale" 23 | stale-issue-message: | 24 | This issue has been automatically marked as stale. 25 | **If this issue is still affecting you, please leave any comment**, for example "bump", and it will be kept open. 26 | close-issue-message: | 27 | This issue has been closed due to inactivity, and will not be monitored. 28 | -------------------------------------------------------------------------------- /src/providers/model-info.ts: -------------------------------------------------------------------------------- 1 | import type { ProviderAdapter, ProviderName } from "src/providers/adapter"; 2 | import { openAiRequest } from "src/providers/openai"; 3 | 4 | export const PROVIDER_REQUEST_MAP: Record = { 5 | openai: openAiRequest, 6 | }; 7 | 8 | export const MODEL_SPECS = { 9 | "gpt-5-nano": { 10 | provider: "openai", 11 | displayText: "GPT 5 nano", 12 | maxOutputTokens: 128_000, 13 | info: { 14 | costPerMillionTokens: { input: 0.05, output: 0.4 }, 15 | reasoning: 2, 16 | speed: 5, 17 | url: "https://platform.openai.com/docs/models/gpt-5-nano", 18 | }, 19 | }, 20 | "gpt-5-mini": { 21 | provider: "openai", 22 | displayText: "GPT 5 mini", 23 | maxOutputTokens: 128_000, 24 | info: { 25 | costPerMillionTokens: { input: 0.25, output: 2.0 }, 26 | reasoning: 3, 27 | speed: 4, 28 | url: "https://platform.openai.com/docs/models/gpt-5-nano", 29 | }, 30 | }, 31 | } as const; // `as const` needed for type inference 32 | -------------------------------------------------------------------------------- /.github/workflows/pr-title.yml: -------------------------------------------------------------------------------- 1 | name: PR title 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | - reopened 10 | - ready_for_review 11 | 12 | permissions: 13 | pull-requests: read 14 | 15 | jobs: 16 | semantic-pull-request: 17 | name: Check PR title 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: amannn/action-semantic-pull-request@v6 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | with: 24 | requireScope: false 25 | subjectPattern: ^(?![A-Z]).+$ # disallow title starting with capital 26 | types: | # add `improv` to the list of allowed types 27 | improv 28 | fix 29 | feat 30 | refactor 31 | build 32 | ci 33 | style 34 | test 35 | chore 36 | perf 37 | docs 38 | break 39 | revert 40 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1": "1.5.8", 3 | "0.5.0": "1.5.8", 4 | "0.5.1": "1.5.8", 5 | "0.5.2": "1.5.8", 6 | "0.9.0": "1.5.8", 7 | "0.10.0": "1.5.8", 8 | "0.10.1": "1.5.8", 9 | "0.10.2": "1.5.8", 10 | "0.11.0": "1.5.8", 11 | "0.11.1": "1.5.8", 12 | "0.12.0": "1.5.8", 13 | "0.12.1": "1.5.8", 14 | "0.13.0": "1.5.8", 15 | "0.14.0": "1.5.8", 16 | "0.15.0": "1.5.8", 17 | "0.15.1": "1.5.8", 18 | "0.15.2": "1.5.8", 19 | "0.15.3": "1.5.8", 20 | "0.15.4": "1.5.8", 21 | "0.15.5": "1.5.8", 22 | "0.15.6": "1.5.8", 23 | "0.15.7": "1.5.8", 24 | "1.0.0": "1.5.8", 25 | "1.0.1": "1.5.8", 26 | "1.0.2": "1.5.8", 27 | "1.1.0": "1.5.8", 28 | "1.1.1": "1.5.8", 29 | "1.1.2": "1.5.8", 30 | "1.1.3": "1.5.8", 31 | "1.2.0": "1.5.8", 32 | "1.2.1": "1.5.8", 33 | "1.2.2": "1.5.8", 34 | "1.2.3": "1.5.8", 35 | "1.2.4": "1.5.8", 36 | "1.2.5": "1.5.8", 37 | "1.3.0": "1.5.8", 38 | "1.3.1": "1.5.8", 39 | "1.3.2": "1.5.8", 40 | "1.4.0": "1.5.8", 41 | "1.5.0": "1.5.8", 42 | "1.5.1": "1.5.8", 43 | "1.6.0": "1.5.8", 44 | "1.6.1": "1.5.8", 45 | "1.6.2": "1.5.8", 46 | "1.6.3": "1.5.8", 47 | "1.6.4": "1.5.8" 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Christopher Grieser 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. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: [bug] 5 | body: 6 | - type: textarea 7 | id: bug-description 8 | attributes: 9 | label: Bug Description 10 | description: A clear and concise description of the bug. 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: screenshot 15 | attributes: 16 | label: Relevant Screenshot 17 | description: If applicable, add screenshots or a screen recording to help explain your problem. 18 | - type: textarea 19 | id: reproduction-steps 20 | attributes: 21 | label: To Reproduce 22 | description: Steps to reproduce the problem 23 | placeholder: | 24 | For example: 25 | 1. Go to '...' 26 | 2. Click on '...' 27 | 3. Scroll down to '...' 28 | - type: input 29 | id: obsi-version 30 | attributes: 31 | label: Obsidian Version 32 | description: You can find the version in the *About* tab of the settings. 33 | placeholder: 1.1.2 34 | validations: 35 | required: true 36 | - type: checkboxes 37 | id: checklist 38 | attributes: 39 | label: Checklist 40 | options: 41 | - label: I updated to the latest version of the plugin. 42 | required: true 43 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | set quiet := true 2 | 3 | test_vault := "$HOME/writing-vault" 4 | 5 | #─────────────────────────────────────────────────────────────────────────────── 6 | 7 | [macos] 8 | build-and-reload: 9 | #!/usr/bin/env zsh 10 | node .esbuild.mjs 11 | 12 | if [ ! -d "{{ test_vault }}/.obsidian" ]; then 13 | echo 14 | echo "Vault not found: {{ test_vault }}/.obsidian" 15 | return 1 16 | fi 17 | 18 | plugin_id=$(grep '"id"' "./manifest.json" | cut -d'"' -f4) 19 | mkdir -p "{{ test_vault }}/.obsidian/plugins/$plugin_id/" 20 | cp -f "main.js" "styles.css" "manifest.json" "{{ test_vault }}/.obsidian/plugins/$plugin_id/" 21 | vault_name=$(basename "{{ test_vault }}") 22 | open "obsidian://open?vault=$vault_name" 23 | 24 | # reload (REQUIRES: registering the URI manually in a helper plugin) 25 | open "obsidian://reload-plugin?id=$plugin_id&vault=$vault_name" 26 | 27 | check-all: 28 | git hook run pre-commit -- "check-all" 29 | 30 | check-tsc-qf: 31 | npx tsc --noEmit --skipLibCheck --strict && echo "Typescript OK" 32 | 33 | release: 34 | node .release.mjs 35 | 36 | analyze: 37 | node .esbuild.mjs analyze 38 | 39 | init: 40 | #!/usr/bin/env zsh 41 | git config core.hooksPath .githooks 42 | npm install 43 | node .esbuild.mjs 44 | 45 | update-deps: 46 | npm update && node .esbuild.mjs 47 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | // DOCS https://www.typescriptlang.org/tsconfig 2 | //────────────────────────────────────────────────────────────────────────────── 3 | 4 | { 5 | "compilerOptions": { 6 | "baseUrl": ".", 7 | "moduleResolution": "node", 8 | "module": "ESNext", 9 | "target": "ES2022", 10 | "lib": ["DOM", "ES5", "ES6", "ES7", "es2023"], 11 | 12 | // SOURCE strictest tsconfig 2.0.0 https://github.com/tsconfig/bases/blob/main/bases/strictest.json 13 | "strict": true, 14 | "allowUnusedLabels": false, 15 | "allowUnreachableCode": false, 16 | "exactOptionalPropertyTypes": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "noImplicitOverride": true, 19 | "noImplicitReturns": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | 23 | // disabled 24 | "noUncheckedIndexedAccess": false, // *too* strict since it complains about every [0] 25 | "noPropertyAccessFromIndexSignature": false, // not useful https://stackoverflow.com/a/70748402/22114136 26 | 27 | // helpers 28 | "allowSyntheticDefaultImports": true, 29 | "allowImportingTsExtensions": true, 30 | "isolatedModules": true, 31 | "esModuleInterop": false, // setting to true causes issues with Obsidian's imported `moment` 32 | "importHelpers": true, 33 | "skipLibCheck": true, 34 | "forceConsistentCasingInFileNames": true 35 | }, 36 | "include": ["src/**/*.ts"], 37 | 38 | "$schema": "https://json.schemastore.org/tsconfig" 39 | } 40 | -------------------------------------------------------------------------------- /.rumdl.toml: -------------------------------------------------------------------------------- 1 | # DOCS https://github.com/rvben/rumdl/blob/main/docs/global-settings.md 2 | 3 | [global] 4 | line-length = 80 5 | disable = [ 6 | "MD032", # blanks-around-lists: space waster 7 | ] 8 | 9 | # ------------------------------------------------------------------------------ 10 | 11 | [MD004] # ul-style 12 | style = "dash" # GitHub default & quicker to type 13 | 14 | [MD007] # ul-indent 15 | indent = 4 # consistent with .editorconfig 16 | 17 | [MD013] # line-length 18 | code-blocks = false 19 | reflow = true # enable auto-formatting 20 | 21 | [MD022] # blanks-around-headings 22 | lines-below = 0 # rule of proximity 23 | 24 | [MD029] # ol-prefix 25 | style = "ordered" 26 | 27 | [MD033] # inline-html 28 | allowed-elements = ["a", "img"] # badges 29 | 30 | [MD049] # emphasis-style 31 | style = "asterisk" # better than underscore, since it's not considered a word-char 32 | 33 | [MD050] # strong-style 34 | style = "asterisk" # better than underscore, since it's not considered a word-char 35 | 36 | [MD060] # auto-format tables 37 | enabled = true # opt-in, since disabled by default 38 | 39 | [MD063] # heading-capitalization 40 | enabled = true # opt-in, since disabled by default 41 | style = "sentence_case" 42 | ignore-words = ["Obsidian"] 43 | 44 | # ------------------------------------------------------------------------------ 45 | 46 | [per-file-ignores] 47 | # does not need to start with h1 48 | ".github/pull_request_template.md" = ["MD041"] 49 | -------------------------------------------------------------------------------- /.esbuild.mjs: -------------------------------------------------------------------------------- 1 | import { appendFileSync } from "node:fs"; 2 | import builtins from "builtin-modules"; 3 | import esbuild from "esbuild"; 4 | 5 | const banner = `/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 6 | If you want to view the source, please visit the GitHub repository of this plugin. */`; 7 | 8 | const production = process.argv[2] === "production"; 9 | const analyze = process.argv[2] === "analyze"; 10 | 11 | //────────────────────────────────────────────────────────────────────────────── 12 | 13 | const result = await esbuild 14 | .build({ 15 | entryPoints: ["src/main.ts"], 16 | banner: { js: banner + "\n" }, 17 | outfile: "main.js", 18 | bundle: true, 19 | // biome-ignore format: no need to inspect this regularly 20 | external: ["obsidian", "electron", "@codemirror/autocomplete", "@codemirror/collab", "@codemirror/commands", "@codemirror/language", "@codemirror/lint", "@codemirror/search", "@codemirror/state", "@codemirror/view", "@lezer/common", "@lezer/highlight", "@lezer/lr", ...builtins], 21 | format: "cjs", 22 | target: "es2022", 23 | sourcemap: production || analyze ? false : "inline", 24 | minify: production || analyze, 25 | drop: ["debugger"], 26 | treeShaking: true, 27 | logLevel: analyze ? "silent" : "info", 28 | metafile: analyze, 29 | }) 30 | .catch(() => process.exit(1)); 31 | 32 | //────────────────────────────────────────────────────────────────────────────── 33 | 34 | // DOCS https://esbuild.github.io/api/index#metafile 35 | if (result.metafile) { 36 | const sizes = await esbuild.analyzeMetafile(result.metafile, { 37 | verbose: false, 38 | }); 39 | console.info(sizes); 40 | } 41 | 42 | // FIX prevent Obsidian from removing the source map when using dev build 43 | // https://forum.obsidian.md/t/source-map-trimming-in-dev-builds/87612 44 | if (!production) appendFileSync(import.meta.dirname + "/main.js", "\n/* nosourcemap */"); 45 | -------------------------------------------------------------------------------- /.github/workflows/obsidian-plugin-release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: ["*"] 6 | 7 | env: 8 | PLUGIN_NAME: ${{ github.event.repository.name }} 9 | 10 | #─────────────────────────────────────────────────────────────────────────────── 11 | 12 | jobs: 13 | build: 14 | runs-on: macos-latest 15 | permissions: { contents: write } 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v6 19 | 20 | - name: Setup node 21 | uses: actions/setup-node@v6 22 | with: { node-version: "22.x" } 23 | 24 | - name: Build plugin 25 | run: | 26 | npm install 27 | node .esbuild.mjs "production" 28 | mkdir ${{ env.PLUGIN_NAME }} 29 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 30 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 31 | 32 | - name: Create release notes 33 | id: release_notes 34 | uses: mikepenz/release-changelog-builder-action@v6 35 | env: 36 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 37 | with: 38 | mode: "COMMIT" 39 | configurationJson: | 40 | { 41 | "label_extractor": [{ 42 | "pattern": "^(\\w+)(\\([\\w\\-\\.]+\\))?(!)?: .+", 43 | "on_property": "title", 44 | "target": "$1" 45 | }], 46 | "categories": [ 47 | { "title": "## 🚀 New features", "labels": ["feat", "improv"] }, 48 | { "title": "## 🛠️ Fixes", "labels": ["fix"] }, 49 | { "title": "## 👾 Other", "labels": [] } 50 | ], 51 | "ignore_labels": ["release", "bump"] 52 | } 53 | 54 | - name: Release 55 | uses: softprops/action-gh-release@v2 56 | with: 57 | token: ${{ secrets.GITHUB_TOKEN }} 58 | body: ${{ steps.release_notes.outputs.changelog }} 59 | files: | 60 | ${{ env.PLUGIN_NAME }}.zip 61 | main.js 62 | manifest.json 63 | styles.css 64 | -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | // DOCS 2 | // https://biomejs.dev/reference/configuration/ 3 | // https://biomejs.dev/linter/javascript/rules/ 4 | //────────────────────────────────────────────────────────────────────────────── 5 | { 6 | "linter": { 7 | "domains": { "project": "all", "test": "all" }, 8 | 9 | // last rule update: 2025-07-09 10 | "rules": { 11 | "performance": { 12 | "noBarrelFile": "on" 13 | }, 14 | "style": { 15 | "useNamingConvention": "on", 16 | "useShorthandAssign": "on", 17 | "useForOf": "on", 18 | "useFilenamingConvention": "on", 19 | "useDefaultSwitchClause": "on", 20 | "useConsistentArrayType": "on", 21 | "useAtIndex": "on", 22 | "noYodaExpression": "on", 23 | "noUselessElse": "on", 24 | "noUnusedTemplateLiteral": "on", 25 | "noSubstr": "on", 26 | "noProcessEnv": "on", 27 | "noNestedTernary": "on", 28 | "noInferrableTypes": "on", // typos: ignore-line 29 | "useTemplate": "off" // too strict, simple concatenations are often fine 30 | }, 31 | "suspicious": { 32 | "noConsole": { 33 | "level": "warn", 34 | "options": { "allow": ["assert", "error", "warn", "info", "debug", "trace"] } 35 | }, 36 | "useErrorMessage": "on", 37 | "useAwait": "on", 38 | "noVar": "on", 39 | "noEmptyBlockStatements": "on" 40 | }, 41 | "complexity": { 42 | "useWhile": "on", 43 | "noUselessStringConcat": "on" 44 | }, 45 | "correctness": { 46 | "noUndeclaredDependencies": "off", // incompatible with non-relative typescript imports 47 | "noUndeclaredVariables": "on" 48 | } 49 | } 50 | }, 51 | "javascript": { 52 | "globals": ["activeDocument", "activeWindow"] // Electron 53 | }, 54 | "formatter": { 55 | "useEditorconfig": true, 56 | "lineWidth": 100, // needs to be set despite editorconfig https://github.com/biomejs/biome/issues/6475#issuecomment-2994126794 57 | "formatWithErrors": true 58 | }, 59 | "files": { 60 | "ignoreUnknown": true 61 | }, 62 | "vcs": { 63 | "enabled": true, 64 | "clientKind": "git", 65 | "useIgnoreFile": true 66 | }, 67 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json" 68 | } 69 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // DOCS https://github.com/obsidianmd/eslint-plugin#usage 2 | //────────────────────────────────────────────────────────────────────────────── 3 | 4 | import tsparser from "@typescript-eslint/parser"; 5 | import { defineConfig } from "eslint/config"; 6 | import obsidianmd from "eslint-plugin-obsidianmd"; 7 | 8 | export default defineConfig([ 9 | { 10 | files: ["src/**/*.ts"], 11 | languageOptions: { 12 | parser: tsparser, 13 | parserOptions: { project: "./tsconfig.json" }, 14 | }, 15 | // @ts-expect-error 16 | plugins: { obsidianmd: obsidianmd }, 17 | rules: { 18 | // PENDING https://github.com/obsidianmd/eslint-plugin/pull/70 19 | "obsidianmd/commands/no-command-in-command-id": "error", 20 | "obsidianmd/commands/no-command-in-command-name": "error", 21 | "obsidianmd/commands/no-default-hotkeys": "error", 22 | "obsidianmd/commands/no-plugin-id-in-command-id": "error", 23 | "obsidianmd/commands/no-plugin-name-in-command-name": "error", 24 | "obsidianmd/settings-tab/no-manual-html-headings": "error", 25 | "obsidianmd/settings-tab/no-problematic-settings-headings": "error", 26 | "obsidianmd/vault/iterate": "error", 27 | "obsidianmd/detach-leaves": "error", 28 | "obsidianmd/hardcoded-config-path": "error", 29 | "obsidianmd/no-forbidden-elements": "error", 30 | "obsidianmd/no-plugin-as-component": "error", 31 | "obsidianmd/no-sample-code": "error", 32 | "obsidianmd/no-tfile-tfolder-cast": "error", 33 | "obsidianmd/no-view-references-in-plugin": "error", 34 | "obsidianmd/no-static-styles-assignment": "error", 35 | "obsidianmd/object-assign": "error", 36 | "obsidianmd/platform": "error", 37 | "obsidianmd/prefer-file-manager-trash-file": "warn", 38 | "obsidianmd/prefer-abstract-input-suggest": "error", 39 | "obsidianmd/regex-lookbehind": "error", 40 | "obsidianmd/sample-names": "error", 41 | "obsidianmd/validate-manifest": "error", 42 | "obsidianmd/validate-license": ["error"], 43 | }, 44 | }, 45 | { 46 | // @ts-expect-error 47 | plugins: { obsidianmd: obsidianmd }, 48 | rules: { 49 | // PENDING https://github.com/obsidianmd/eslint-plugin/issues/71 50 | "obsidianmd/ui/sentence-case": ["warn", { brands: ["OpenAI"] }], 51 | }, 52 | }, 53 | ]); 54 | -------------------------------------------------------------------------------- /src/providers/openai.ts: -------------------------------------------------------------------------------- 1 | import { Notice, type RequestUrlResponse, requestUrl } from "obsidian"; 2 | import type { ProviderAdapter } from "src/providers/adapter"; 3 | import { MODEL_SPECS } from "src/providers/model-info"; 4 | import { logError } from "src/utils"; 5 | 6 | export const openAiRequest: ProviderAdapter = async (settings, oldText) => { 7 | if (!settings.openAiApiKey) { 8 | new Notice("Please set your OpenAI API key in the plugin settings."); 9 | return; 10 | } 11 | 12 | const endpoint = settings.openAiEndpoint || "https://api.openai.com/v1/responses"; 13 | 14 | let response: RequestUrlResponse; 15 | try { 16 | // DOCS https://platform.openai.com/docs/api-reference/responses/create 17 | response = await requestUrl({ 18 | url: endpoint, 19 | method: "POST", 20 | contentType: "application/json", 21 | // biome-ignore lint/style/useNamingConvention: not by me 22 | headers: { Authorization: "Bearer " + settings.openAiApiKey }, 23 | body: JSON.stringify({ 24 | model: settings.model, 25 | reasoning: { effort: settings.reasoningEffort }, 26 | input: [ 27 | { role: "developer", content: settings.staticPrompt }, 28 | { role: "user", content: oldText }, 29 | ], 30 | }), 31 | }); 32 | console.debug("[Proofreader plugin] OpenAI response", response); 33 | } catch (error) { 34 | if ((error as { status: number }).status === 401) { 35 | const msg = "OpenAI API key is not valid. Please verify the key in the plugin settings."; 36 | new Notice(msg, 6_000); 37 | return; 38 | } 39 | logError("OpenAI request failed.", error); 40 | return; 41 | } 42 | 43 | // DOCS https://platform.openai.com/docs/api-reference/responses/get 44 | // biome-ignore format: clearer this way 45 | const newText = response.json?.output 46 | ?.find((item: { role: string; content: { text: string }[] }) => item.role === "assistant") 47 | ?.content[0].text; 48 | if (!newText) { 49 | logError("Invalid structure of OpenAI response.", response); 50 | return; 51 | } 52 | 53 | // determine overlength 54 | // https://platform.openai.com/docs/guides/conversation-state?api-mode=responses#managing-context-for-text-generation 55 | const outputTokensUsed = response.json?.usage?.completion_tokens || 0; 56 | const isOverlength = outputTokensUsed >= MODEL_SPECS[settings.model].maxOutputTokens; 57 | 58 | return { newText: newText, isOverlength: isOverlength }; 59 | }; 60 | -------------------------------------------------------------------------------- /.release.mjs: -------------------------------------------------------------------------------- 1 | import { spawn } from "node:child_process"; 2 | import { readFileSync, writeFileSync } from "node:fs"; 3 | import readlinePromises from "node:readline/promises"; 4 | 5 | /** @param {string} filepath */ 6 | function readJson(filepath) { 7 | return JSON.parse(readFileSync(filepath, "utf8")); 8 | } 9 | 10 | /** @param {string} filepath @param {object} jsonObj */ 11 | function writeJson(filepath, jsonObj) { 12 | writeFileSync(filepath, JSON.stringify(jsonObj, null, "\t") + "\n"); 13 | } 14 | 15 | //────────────────────────────────────────────────────────────────────────────── 16 | // PROMPT FOR TARGET VERSION 17 | 18 | const manifest = readJson("manifest.json"); 19 | const currentVersion = manifest.version; 20 | const rl = readlinePromises.createInterface({ 21 | input: process.stdin, 22 | output: process.stdout, 23 | }); 24 | 25 | console.info(`current version: ${currentVersion}`); 26 | const nextVersion = await rl.question(" next version: "); 27 | console.info("───────────────────────────"); 28 | if (!nextVersion?.match(/\d+\.\d+\.\d+/) || nextVersion === currentVersion) { 29 | console.error("\x1b[1;31mInvalid target version given, aborting.\x1b[0m"); 30 | process.exit(1); 31 | } 32 | 33 | rl.close(); 34 | 35 | //────────────────────────────────────────────────────────────────────────────── 36 | // UPDATE VERSION IN VARIOUS JSONS 37 | 38 | manifest.version = nextVersion; 39 | writeJson("manifest.json", manifest); 40 | 41 | const versionsJson = readJson("versions.json"); 42 | versionsJson[nextVersion] = manifest.minAppVersion; 43 | writeJson("versions.json", versionsJson); 44 | 45 | const packageJson = readJson("package.json"); 46 | packageJson.version = nextVersion; 47 | writeJson("package.json", packageJson); 48 | 49 | const packageLock = readJson("package-lock.json"); 50 | packageLock.version = nextVersion; 51 | packageLock.packages[""].version = nextVersion; 52 | writeJson("package-lock.json", packageLock); 53 | 54 | //────────────────────────────────────────────────────────────────────────────── 55 | // UPDATE GIT REPO 56 | 57 | const gitCommands = [ 58 | "git add manifest.json versions.json package.json package-lock.json", 59 | `git commit --no-verify --message="release: ${nextVersion}"`, // skip hook, since only bumping 60 | "git pull --no-progress", 61 | "git push --no-progress", 62 | `git tag ${nextVersion}`, // tag triggers the release action 63 | "git push --no-progress origin --tags", 64 | ]; 65 | 66 | // INFO as opposed to `exec`, `spawn` does not buffer the output 67 | const gitProcess = spawn(gitCommands.join(" && "), [], { shell: true }); 68 | gitProcess.stdout.on("data", (data) => console.info(data.toString().trim())); 69 | gitProcess.stderr.on("data", (data) => console.info(data.toString().trim())); 70 | gitProcess.on("error", (_err) => process.exit(1)); 71 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | import { acceptOrRejectInText, acceptOrRejectNextSuggestion } from "src/accept-reject-suggestions"; 3 | import { proofreadDocument, proofreadText } from "src/proofread"; 4 | import { DEFAULT_SETTINGS, type ProofreaderSettings, ProofreaderSettingsMenu } from "src/settings"; 5 | 6 | export default class Proofreader extends Plugin { 7 | settings: ProofreaderSettings = DEFAULT_SETTINGS; 8 | 9 | override async onload(): Promise { 10 | // settings 11 | await this.loadSettings(); 12 | this.addSettingTab(new ProofreaderSettingsMenu(this)); 13 | 14 | // commands 15 | this.addCommand({ 16 | id: "proofread-selection-paragraph", 17 | name: "Proofread selection/paragraph", 18 | editorCallback: (editor): Promise => proofreadText(this, editor), 19 | icon: "bot-message-square", 20 | }); 21 | this.addCommand({ 22 | id: "proofread-full-document", 23 | name: "Proofread full document", 24 | editorCallback: (editor): Promise => proofreadDocument(this, editor), 25 | icon: "bot-message-square", 26 | }); 27 | this.addCommand({ 28 | id: "accept-suggestions-in-text", 29 | name: "Accept suggestions in selection/paragraph", 30 | editorCallback: (editor): void => acceptOrRejectInText(editor, "accept"), 31 | icon: "check-check", 32 | }); 33 | this.addCommand({ 34 | id: "reject-suggestions-in-text", 35 | name: "Reject suggestions in selection/paragraph", 36 | editorCallback: (editor): void => acceptOrRejectInText(editor, "reject"), 37 | icon: "x", 38 | }); 39 | this.addCommand({ 40 | id: "accept-next-suggestion", 41 | name: "Accept next suggestion (or go to suggestion if outside viewport)", 42 | editorCallback: (editor): void => acceptOrRejectNextSuggestion(editor, "accept"), 43 | icon: "check-check", 44 | }); 45 | this.addCommand({ 46 | id: "reject-next-suggestion", 47 | name: "Reject next suggestion (or go to suggestion if outside viewport)", 48 | editorCallback: (editor): void => acceptOrRejectNextSuggestion(editor, "reject"), 49 | icon: "x", 50 | }); 51 | 52 | console.info(this.manifest.name + " Plugin loaded."); 53 | } 54 | 55 | override onunload(): void { 56 | console.info(this.manifest.name + " Plugin unloaded."); 57 | } 58 | 59 | async saveSettings(): Promise { 60 | // Ensure default values are not written, so the user will not load 61 | // outdated defaults when the default settings are changed. 62 | const settings = structuredClone(this.settings); 63 | for (const key in settings) { 64 | const name = key as keyof ProofreaderSettings; 65 | // @ts-expect-error intentional removal 66 | if (settings[name] === DEFAULT_SETTINGS[name]) settings[name] = undefined; 67 | } 68 | 69 | await this.saveData(settings); 70 | } 71 | 72 | async loadSettings(): Promise { 73 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/accept-reject-suggestions.ts: -------------------------------------------------------------------------------- 1 | import { type Editor, type EditorPosition, Notice } from "obsidian"; 2 | 3 | //────────────────────────────────────────────────────────────────────────────── 4 | 5 | export function rejectChanges(str: string): string { 6 | return str.replace(/~~([^=~]+)~~/g, "$1").replace(/==[^=~]+==/g, ""); 7 | } 8 | 9 | function acceptChanges(str: string): string { 10 | return str.replace(/==([^=~]+)==/g, "$1").replace(/~~[^=~]+~~/g, ""); 11 | } 12 | 13 | //────────────────────────────────────────────────────────────────────────────── 14 | 15 | function removeMarkup(text: string, mode: "accept" | "reject"): string { 16 | const noMarkup = mode === "accept" ? acceptChanges(text) : rejectChanges(text); 17 | const cleanedUp = noMarkup 18 | .replace(/ {2}(?!\n)/g, " ") // double spaces (not EoL due to 2-space-rule) 19 | .replace(/ ([,.:;—–!?])/g, "$1"); // spaces preceding punctuation 20 | return cleanedUp; 21 | } 22 | 23 | // Manually calculating the visibility of an offset is necessary, since 24 | // CodeMirror's viewport includes extra margin around the visible area. 25 | function positionVisibleOnScreen(editor: Editor, pos: EditorPosition): boolean { 26 | const offset = editor.posToOffset(pos); 27 | 28 | const coord = editor.cm.coordsAtPos(offset); 29 | if (!coord) return false; // no coord = outside viewport 30 | 31 | // FIX typo-casting as `unknown` and then actual type, since Obsidian's 32 | // typing is incomplete, see https://forum.obsidian.md/t/api-bug-editor-getscrollinfo-is-typed-incorrectly/98886 33 | const view = editor.getScrollInfo() as unknown as { clientHeight: number }; 34 | 35 | const visible = coord.top < view.clientHeight && coord.top > 0; 36 | return visible; 37 | } 38 | 39 | //────────────────────────────────────────────────────────────────────────────── 40 | 41 | export function acceptOrRejectInText(editor: Editor, mode: "accept" | "reject"): void { 42 | const selection = editor.getSelection(); 43 | const cursor = editor.getCursor(); 44 | const scope = selection ? "selection" : "paragraph"; 45 | const text = selection || editor.getLine(cursor.line); 46 | 47 | if (!text.match(/==|~~/)) { 48 | new Notice(`There are no highlights or strikethroughs in the ${scope}.`, 3000); 49 | return; 50 | } 51 | 52 | const updatedText = removeMarkup(text, mode); 53 | if (selection) editor.replaceSelection(updatedText); 54 | else editor.setLine(cursor.line, updatedText); 55 | 56 | // keep cursor location 57 | const charsLess = text.length - updatedText.length; 58 | cursor.ch = Math.max(cursor.ch - charsLess, 0); 59 | editor.setCursor(cursor); 60 | } 61 | 62 | export function acceptOrRejectNextSuggestion(editor: Editor, mode: "accept" | "reject"): void { 63 | const cursor = editor.getCursor(); 64 | const cursorOffset = editor.posToOffset(cursor) + 1; 65 | const text = editor.getValue(); 66 | 67 | // CASE 1: if cursor not visible, scroll to it instead 68 | if (!positionVisibleOnScreen(editor, cursor)) { 69 | new Notice("Cursor is not visible. Scrolled to the cursor instead."); 70 | editor.scrollIntoView({ from: cursor, to: cursor }, true); 71 | return; 72 | } 73 | 74 | // FIND NEXT SUGGESTION 75 | // since highlights and strikethroughs do not span lines, it is safe to 76 | // start searching at the beginning of the cursor line 77 | const startOfCursorlineOffset = editor.posToOffset({ 78 | line: cursor.line, 79 | ch: 0, 80 | }); 81 | let searchStart = startOfCursorlineOffset; 82 | 83 | let matchText = ""; 84 | let matchStart = 0; 85 | let matchEnd = 0; 86 | while (true) { 87 | // next match includes previous and next characters to catch leftover spaces 88 | const nextMatch = text.slice(searchStart).match(/ ?(==[^~=]*?==|~~[^~=]*~~).?/); 89 | if (!nextMatch) { 90 | new Notice("There are no highlights or strikethroughs until the end of the note.", 3000); 91 | return; 92 | } 93 | matchText = nextMatch[0]; 94 | matchStart = searchStart + (nextMatch.index as number); 95 | matchEnd = matchStart + matchText.length; 96 | const cursorOnMatch = cursorOffset >= matchStart && cursorOffset <= matchEnd; 97 | const cursorBeforeMatch = cursorOffset <= matchStart; 98 | if (cursorOnMatch || cursorBeforeMatch) break; 99 | 100 | // -1 to account for the next character being included in the pattern, 101 | // (strings with directly adjacent markup such as `==foobar==~~baz~~` 102 | // would otherwise be sliced to `~baz~~` on the next iteration) 103 | searchStart = matchEnd - 1; 104 | } 105 | const matchStartPos = editor.offsetToPos(matchStart); 106 | const matchEndPos = editor.offsetToPos(matchEnd); 107 | 108 | // CASE 2: if suggestion not visible, scroll to it instead 109 | if (!positionVisibleOnScreen(editor, matchEndPos)) { 110 | new Notice("Next suggestion not visible. Scrolled to next suggestion instead."); 111 | editor.scrollIntoView({ from: matchStartPos, to: matchEndPos }, true); 112 | editor.setCursor(matchStartPos); 113 | return; 114 | } 115 | 116 | // CASE 3: Cursor & suggestion visible -> update text 117 | const updatedText = removeMarkup(matchText, mode); 118 | editor.replaceRange(updatedText, matchStartPos, matchEndPos); 119 | editor.setCursor(matchStartPos); 120 | } 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Proofreader 2 | ![Obsidian downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&color=%23483699&label=downloads&query=%24%5B%22proofreader%22%5D.downloads&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json&style=plastic) 3 | ![GitHub download count](https://img.shields.io/github/downloads/chrisgrieser/obsidian-proofreader/total?label=GitHub%20Downloads&style=plastic) 4 | ![Last release](https://img.shields.io/github/v/release/chrisgrieser/obsidian-proofreader?label=Latest%20Release&style=plastic) 5 | 6 | AI-based proofreading and stylistic improvements for your writing. Changes are 7 | inserted as suggestions directly in the editor, similar to the suggested changes 8 | feature in word processing apps. 9 | 10 | Showcase 11 | 12 | ## Table of contents 13 | 14 | 15 | 16 | - [Features](#features) 17 | - [Installation & setup](#installation--setup) 18 | - [Plugin installation](#plugin-installation) 19 | - [Get an OpenAI API key](#get-an-openai-api-key) 20 | - [Usage](#usage) 21 | - [Visual appearance of the changes](#visual-appearance-of-the-changes) 22 | - [Testimonials](#testimonials) 23 | - [Plugin development](#plugin-development) 24 | - [General](#general) 25 | - [Adding support for new LLMs](#adding-support-for-new-llms) 26 | - [About the developer](#about-the-developer) 27 | 28 | 29 | 30 | ## Features 31 | - Suggested changes are inserted directly into the text: Additions as 32 | `==highlights==` and removals as `~~strikethroughs~~`. 33 | - Accept or reject changes with just one hotkey. 34 | - Easy to use: No complicated plugin settings and AI parameters to configure. 35 | 36 | | | Professional proofreading service | Proofreader plugin | 37 | | ------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------- | 38 | | Cost for English text of 10,000 words | ~ $400, depending on the service | ~ \$0.01 – $0.06[^1] | 39 | | Completion duration | up to 3 work days | about 5 minutes | 40 | | Input format | usually Microsoft Word (`.docx`) | Markdown file in Obsidian | 41 | | Method of incorporating changes | mostly mouse clicks | keyboard shortcuts | 42 | | Additional benefits | Editor makes general comments on writing style. | Plugin can also be used to quickly proofread single sentences or paragraphs. | 43 | 44 | [^1]: Estimated pricing for the [GPT 4.1 nano 45 | model](https://platform.openai.com/docs/models/) in April 2025. The plugin 46 | developer is not responsible if the actual costs differ. You can track your 47 | usage costs [on this page](https://platform.openai.com/usage). 48 | 49 | > [!NOTE] 50 | > This plugin requires an **OpenAI API key** and incurs costs at OpenAI based on 51 | > usage. Network requests are made when running the proofreading command. (PRs 52 | > [adding support for other LLMs](#adding-support-for-new-llms) are welcome.) 53 | 54 | ## Installation & setup 55 | 56 | ### Plugin installation 57 | [Install in Obsidian](https://obsidian.md/plugins?id=proofreader) 58 | 59 | ### Get an OpenAI API key 60 | 1. [Create an OpenAI account](https://auth.openai.com/create-account). 61 | 2. Go to [this site](https://platform.openai.com/api-keys), and click `Create 62 | new secret key`. 63 | 3. Copy the API key. 64 | 4. In Obsidian, go to `Settings → Proofreader` and paste your API key there. 65 | 66 | > [!TIP] 67 | > The usage costs should not be very high, nonetheless you can track them 68 | > [on this page](https://platform.openai.com/usage). 69 | 70 | ## Usage 71 | 1. Use the command `Proofread selection/paragraph` to check the selected 72 | text. If there is no selection, the command will check the current paragraph. 73 | - Alternatively, you can also check the whole document with `Proofread full 74 | document`. However, note that the quality of AI suggestions tends to 75 | decrease when proofreading too much text at once. 76 | 2. The changes are automatically inserted. 77 | 3. Accept/reject changes with `Accept suggestions in selection/paragraph` 78 | and `Reject suggestions in selection/paragraph`. 79 | Same as the proofreading command, the `accept` and `reject` commands affect 80 | the current paragraph if there is no selection. Alternatively, you can also 81 | only accept/reject the next suggestion after your cursor via `Accept next 82 | suggestion` and `Reject next suggestion`. 83 | 84 | ## Visual appearance of the changes 85 | You can add the following CSS snippet to make highlights and strikethroughs 86 | appear like suggested changes, similar to the screenshot further above. 87 | ([Manual: How to add CSS snippets.](https://help.obsidian.md/snippets)) 88 | 89 | ```css 90 | .cm-strikethrough { 91 | text-decoration-color: var(--color-red); 92 | } 93 | 94 | .cm-s-obsidian span.cm-highlight { 95 | background-color: rgba(var(--color-green-rgb), 35%); 96 | } 97 | ``` 98 | 99 | ## Testimonials 100 | 101 | > I was paying $29 a month for type.ai until today, your plugin made me cancel 102 | > the subscription, because the only feature I wanted from there was this inline 103 | > granular diffing which no other app offered, until Proofreader. 104 | > [@samwega](https://github.com/chrisgrieser/obsidian-proofreader/discussions/1#discussioncomment-12972780) 105 | 106 | ## Plugin development 107 | 108 | ### General 109 | 110 | ```bash 111 | just init # run once after cloning 112 | 113 | just format # run all formatters 114 | just build # builds the plugin 115 | just check # runs the pre-commit hook (without committing) 116 | ``` 117 | 118 | > [!NOTE] 119 | > This repo uses a pre-commit hook, which prevents commits that do not build or 120 | > do not pass the checks. 121 | 122 | ### Adding support for new LLMs 123 | 1. Create a new adapter for the LLM in 124 | [./src/providers/](./src/providers/). This should take ~50 lines of code. 125 | 2. In [./src/providers/model-info.ts](./src/providers/model-info.ts), add the 126 | adapter function to `PROVIDER_ADAPTER_MAP`, and add models for the new 127 | provider to `MODEL_SPECS`. 128 | 3. In [./src/settings.ts], add a setting for the API key to 129 | `ProofreaderSettingsMenu` and add a field to `DEFAULT_SETTINGS`. 130 | 131 | ## About the developer 132 | In my day job, I am a sociologist studying the social mechanisms underlying the 133 | digital economy. For my PhD project, I investigate the governance of the app 134 | economy and how software ecosystems manage the tension between innovation and 135 | compatibility. If you are interested in this subject, feel free to get in touch. 136 | 137 | - [Website](https://chris-grieser.de/) 138 | - [ResearchGate](https://www.researchgate.net/profile/Christopher-Grieser) 139 | - [Mastodon](https://pkm.social/@pseudometa) 140 | - [LinkedIn](https://www.linkedin.com/in/christopher-grieser-ba693b17a/) 141 | 142 | Buy Me a Coffee at ko-fi.com 145 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { PluginSettingTab, Setting } from "obsidian"; 2 | import type Proofreader from "src/main"; 3 | import type { ModelName } from "src/providers/adapter"; 4 | import { MODEL_SPECS } from "src/providers/model-info"; 5 | 6 | // https://platform.openai.com/docs/api-reference/responses/object#responses/object-reasoning 7 | const reasoningEffortOptions = ["minimal", "low", "medium", "high"] as const; 8 | type ReasoningEffort = (typeof reasoningEffortOptions)[number]; 9 | 10 | export const DEFAULT_SETTINGS = { 11 | openAiApiKey: "", 12 | model: "gpt-5-nano" as ModelName, 13 | reasoningEffort: "minimal" as ReasoningEffort, 14 | openAiEndpoint: "", 15 | 16 | preserveItalicAndBold: false, 17 | preserveTextInsideQuotes: false, 18 | preserveBlockquotes: false, 19 | preserveNonSmartPuncation: false, 20 | diffWithSpace: false, 21 | 22 | staticPrompt: 23 | "Act as a professional editor. Please make suggestions how to improve clarity, readability, grammar, and language of the following text. Preserve the original meaning and any technical jargon. Suggest structural changes only if they significantly improve flow or understanding. Avoid unnecessary expansion or major reformatting (e.g., no unwarranted lists). Try to make as little changes as possible, refrain from doing any changes when the writing is already sufficiently clear and concise. Output only the revised text and nothing else. The text may contain Markdown formatting, which should be preserved when appropriate. The text is:", 24 | }; 25 | 26 | export type ProofreaderSettings = typeof DEFAULT_SETTINGS; 27 | 28 | //────────────────────────────────────────────────────────────────────────────── 29 | 30 | // DOCS https://docs.obsidian.md/Plugins/User+interface/Settings 31 | export class ProofreaderSettingsMenu extends PluginSettingTab { 32 | plugin: Proofreader; 33 | 34 | constructor(plugin: Proofreader) { 35 | super(plugin.app, plugin); 36 | this.plugin = plugin; 37 | } 38 | 39 | display(): void { 40 | const { containerEl } = this; 41 | const settings = this.plugin.settings; 42 | 43 | containerEl.empty(); 44 | 45 | //──────────────────────────────────────────────────────────────────────── 46 | // OpenAI settings 47 | new Setting(containerEl).setName("OpenAI").setHeading(); 48 | 49 | // API KEYS 50 | new Setting(containerEl) 51 | .setName("API key") 52 | .setDesc("Get your API key from https://platform.openai.com/api-keys") 53 | .addText((input) => { 54 | input.inputEl.type = "password"; // obfuscates the field 55 | input.inputEl.setCssProps({ width: "100%" }); 56 | input 57 | // eslint-disable-next-line obsidianmd/ui/sentence-case -- PENDING https://github.com/obsidianmd/eslint-plugin/issues/71 58 | .setPlaceholder("sk-123456789…") 59 | .setValue(settings.openAiApiKey) 60 | .onChange(async (value) => { 61 | settings.openAiApiKey = value.trim(); 62 | await this.plugin.saveSettings(); 63 | }); 64 | }); 65 | 66 | new Setting(containerEl) 67 | .setName("Model") 68 | .setDesc( 69 | "The nano model is slightly quicker and cheaper. " + 70 | "The mini model is more slightly more accurate, but also more expensive. ", 71 | ) 72 | .addDropdown((dropdown) => { 73 | for (const key in MODEL_SPECS) { 74 | const model = MODEL_SPECS[key as ModelName]; 75 | dropdown.addOption(key, model.displayText); 76 | } 77 | dropdown.setValue(settings.model).onChange(async (value) => { 78 | settings.model = value as ModelName; 79 | await this.plugin.saveSettings(); 80 | }); 81 | }); 82 | 83 | new Setting(containerEl) 84 | .setName("Reasoning effort") 85 | .setDesc("Higher uses more tokens and is slower, but produces better results.") 86 | .addDropdown((dropdown) => { 87 | for (const option of reasoningEffortOptions) { 88 | dropdown.addOption(option, option); 89 | } 90 | dropdown.setValue(settings.reasoningEffort).onChange(async (value) => { 91 | settings.reasoningEffort = value as ReasoningEffort; 92 | await this.plugin.saveSettings(); 93 | }); 94 | }); 95 | 96 | new Setting(containerEl) 97 | .setName("Advanced: URL endpoint") 98 | .setDesc( 99 | "Endpoint for OpenAi-compatible models, using the API key from above. " + 100 | "Leave empty to use the regular OpenAI API. " + 101 | "Most users do not need to change this setting, only change this if you know what you are doing. ", 102 | ) 103 | .addText((input) => { 104 | input.inputEl.setCssProps({ width: "100%" }); 105 | input 106 | // eslint-disable-next-line obsidianmd/ui/sentence-case -- PENDING https://github.com/obsidianmd/eslint-plugin/issues/71 107 | .setPlaceholder("https://...") 108 | .setValue(settings.openAiEndpoint) 109 | .onChange(async (value) => { 110 | settings.openAiApiKey = value.trim(); 111 | await this.plugin.saveSettings(); 112 | }); 113 | }); 114 | 115 | //──────────────────────────────────────────────────────────────────────── 116 | // DIFF OPTIONS 117 | new Setting(containerEl).setName("Diff").setHeading(); 118 | 119 | new Setting(containerEl) 120 | .setName("Space-sensitive diff") 121 | .setDesc( 122 | "Processes spaces more accurately, but results in smaller, more numerous changes.", 123 | ) 124 | .addToggle((toggle) => 125 | toggle.setValue(settings.diffWithSpace).onChange(async (value) => { 126 | settings.diffWithSpace = value; 127 | await this.plugin.saveSettings(); 128 | }), 129 | ); 130 | 131 | new Setting(containerEl) 132 | .setName("Preserve text inside quotes") 133 | .setDesc( 134 | 'No changes will be made to text inside quotation marks (""). ' + 135 | "(This is not flawless, as the AI sometimes suggests changes across quotes.)", 136 | ) 137 | .addToggle((toggle) => 138 | toggle.setValue(settings.preserveTextInsideQuotes).onChange(async (value) => { 139 | settings.preserveTextInsideQuotes = value; 140 | await this.plugin.saveSettings(); 141 | }), 142 | ); 143 | new Setting(containerEl) 144 | .setName("Preserve bold and italic formatting") 145 | .setDesc( 146 | "Preserve **bold**, and *italic* formatting." + 147 | "(This is not flawless, as the AI occasionally rewrite text alongside formatting changes.)", 148 | ) 149 | .addToggle((toggle) => 150 | toggle.setValue(settings.preserveItalicAndBold).onChange(async (value) => { 151 | settings.preserveItalicAndBold = value; 152 | await this.plugin.saveSettings(); 153 | }), 154 | ); 155 | new Setting(containerEl) 156 | .setName("Preserve text in blockquotes and callouts") 157 | .setDesc( 158 | "No changes will be made to lines beginning with `>`. " + 159 | "(This is not flawless, as the AI sometimes proposes changes across paragraphs.)", 160 | ) 161 | .addToggle((toggle) => 162 | toggle.setValue(settings.preserveBlockquotes).onChange(async (value) => { 163 | settings.preserveBlockquotes = value; 164 | await this.plugin.saveSettings(); 165 | }), 166 | ); 167 | new Setting(containerEl) 168 | .setName("Preserve non-smart punctuation") 169 | .setDesc( 170 | "Prevent changing non-smart punctuation to their smart counterparts, " + 171 | ' for instance changing " to “ or 1-2 to 1–2. ' + 172 | "This can be relevant for tools like pandoc, which automatically convert " + 173 | "non-smart punctuation based on how they are configured. ", 174 | ) 175 | .addToggle((toggle) => 176 | toggle.setValue(settings.preserveNonSmartPuncation).onChange(async (value) => { 177 | settings.preserveNonSmartPuncation = value; 178 | await this.plugin.saveSettings(); 179 | }), 180 | ); 181 | 182 | //──────────────────────────────────────────────────────────────────────── 183 | // ADVANCED 184 | new Setting(containerEl).setName("Advanced").setHeading(); 185 | 186 | new Setting(containerEl) 187 | .setName("System prompt") 188 | .setDesc( 189 | "The LLM must respond ONLY with the updated text for this plugin to work. Leave the text field empty to reset to the default prompt. " + 190 | "Most users do not need to change this setting, only change this if you know what you are doing. ", 191 | ) 192 | .addTextArea((textarea) => { 193 | textarea.inputEl.setCssProps({ width: "25vw", height: "15em" }); 194 | textarea 195 | .setValue(settings.staticPrompt) 196 | .setPlaceholder("Make suggestions based on…") 197 | .onChange(async (value) => { 198 | settings.staticPrompt = value.trim() || DEFAULT_SETTINGS.staticPrompt; 199 | await this.plugin.saveSettings(); 200 | }); 201 | }); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/proofread.ts: -------------------------------------------------------------------------------- 1 | import { type Change, diffWords, diffWordsWithSpace } from "diff"; 2 | import { type Editor, getFrontMatterInfo, Notice } from "obsidian"; 3 | import { rejectChanges } from "src/accept-reject-suggestions"; 4 | import type Proofreader from "src/main"; 5 | import type { ModelName, ProviderAdapter } from "src/providers/adapter"; 6 | import { MODEL_SPECS, PROVIDER_REQUEST_MAP } from "src/providers/model-info"; 7 | import type { ProofreaderSettings } from "src/settings"; 8 | 9 | function getDiffMarkdown( 10 | settings: ProofreaderSettings, 11 | oldText: string, 12 | newText: string, 13 | isOverlength?: boolean, 14 | ): { textWithSuggestions: string; changeCount: number } { 15 | console.debug("[Proofreader plugin] old text:", oldText); 16 | console.debug("[Proofreader plugin] new text:", newText); 17 | 18 | // ENSURE SAME AMOUNT OF SURROUNDING WHITESPACE 19 | // (A selection can have surrounding whitespace, but the AI response usually 20 | // removes those. This results in the text effectively being trimmed.) 21 | const leadingWhitespace = oldText.match(/^(\s*)/)?.[0] || ""; 22 | const trailingWhitespace = oldText.match(/(\s*)$/)?.[0] || ""; 23 | newText = newText.replace(/^(\s*)/, leadingWhitespace).replace(/(\s*)$/, trailingWhitespace); 24 | 25 | // GET DIFF https://github.com/kpdecker/jsdiff#readme 26 | const diffWithSpace = diffWordsWithSpace(oldText, newText); 27 | const diffRegular = diffWords(oldText, newText); 28 | const diff = settings.diffWithSpace ? diffWithSpace : diffRegular; 29 | console.debug("[Proofreader plugin] regular diff:", diffRegular); 30 | console.debug("[Proofreader plugin] diff with space:", diffWithSpace); 31 | const use = settings.diffWithSpace ? "diff with space" : "regular diff"; 32 | console.debug("[Proofreader plugin] using:", use); 33 | 34 | if (isOverlength) { 35 | // do not remove text after cutoff-length 36 | (diff.at(-1) as Change).removed = false; 37 | const cutOffCallout = 38 | "\n\n" + 39 | "> [!INFO] End of proofreading\n" + 40 | "> The input text was too long. Text after this point is unchanged." + 41 | "\n\n"; 42 | diff.splice(-2, 0, { 43 | added: false, 44 | removed: false, 45 | value: cutOffCallout, 46 | }); 47 | } 48 | 49 | // CONVERT DIFF TO TEXT 50 | // with ==highlights== and ~~strikethrough~~ as suggestions 51 | let textWithChanges = diff 52 | .map((part) => { 53 | if (!part.added && !part.removed) return part.value; 54 | const withMarkup = part.added ? `==${part.value}==` : `~~${part.value}~~`; 55 | 56 | // FIX for Obsidian live preview: leading spaces result in missing markup 57 | const fixedForObsidian = withMarkup.replace(/^(==|~~)( )/, "$2$1"); 58 | return fixedForObsidian; 59 | }) 60 | .join(""); 61 | console.debug("[Proofreader plugin] text with changes:", textWithChanges); 62 | 63 | // FIX for Obsidian live preview: isolated trailing markup rendered wrong 64 | textWithChanges = textWithChanges.replace(/(==|~~)([^=~]+) \1 /g, "$1$2$1 "); 65 | 66 | // CLEANUP 67 | textWithChanges = textWithChanges 68 | .replace(/~~\[\^\w+\]~~/g, "$1") // preserve footnotes 69 | .replace(/~~(.+?)(.{1,2})~~==(\1)==/g, "$1~~$2~~") // only removal of 1-2 char, e.g. plural-s 70 | .replace(/~~(.+?)~~==(?:\1)(.{1,2})==/g, "$1==$2==") // only addition of 1-2 char 71 | .replace(/ {2}(?!$)/gm, " "); // rare double spaces created by diff (not EoL due to 2-space-rule) 72 | 73 | // PRESERVE SPECIAL CONTENT 74 | if (settings.preserveItalicAndBold) { 75 | // bold/italic can be via * or _ 76 | textWithChanges = textWithChanges.replace(/~~(\*\*?|__?)~~/g, "$1"); 77 | } 78 | if (settings.preserveNonSmartPuncation) { 79 | textWithChanges = textWithChanges 80 | .replace(/~~"~~==[“”]==/g, '"') // preserve non-smart quotes 81 | .replace(/~~'~~==[‘’]==/g, "'") 82 | .replace(/(\d)~~-~~==–==(\d)/g, "$1-$2"); // preserve non-smart dashes in number ranges 83 | } 84 | if (settings.preserveBlockquotes) { 85 | textWithChanges = textWithChanges 86 | .replace(/^~~>~~/gm, ">") // if AI removes blockquote marker 87 | .replace(/^~~(>[^~=]*)~~$/gm, "$1") // if AI removes blockquote itself 88 | .replace(/^>.*/gm, (blockquote) => rejectChanges(blockquote)); 89 | } 90 | if (settings.preserveTextInsideQuotes) { 91 | textWithChanges = textWithChanges.replace(/"[^"]+"/g, (quote) => rejectChanges(quote)); 92 | } 93 | console.debug("[Proofreader plugin] text after cleanup:", textWithChanges); 94 | 95 | const changeCount = (textWithChanges.match(/==|~~/g)?.length || 0) / 2; 96 | return { textWithSuggestions: textWithChanges, changeCount: changeCount }; 97 | } 98 | 99 | async function validateAndGetChangesAndNotify( 100 | plugin: Proofreader, 101 | oldText: string, 102 | scope: string, 103 | ): Promise { 104 | const { app, settings } = plugin; 105 | 106 | // GUARD outdated model 107 | const model = MODEL_SPECS[settings.model as ModelName]; 108 | if (!model) { 109 | const errmsg = `! The model "${settings.model}" is outdated. Please select a more recent one in the settings.`; 110 | new Notice(errmsg, 10_000); 111 | return; 112 | } 113 | // GUARD valid start-text 114 | if (oldText.trim() === "") { 115 | new Notice(`${scope} is empty.`); 116 | return; 117 | } 118 | if (oldText.match(/==|~~/)) { 119 | const warnMsg = 120 | `${scope} already has highlights or strikethroughs.\n\n` + 121 | "Please accept/reject the changes before making another proofreading request."; 122 | new Notice(warnMsg, 6000); 123 | return; 124 | } 125 | 126 | // parameters 127 | const fileBefore = app.workspace.getActiveFile()?.path; 128 | const longInput = oldText.length > 1500; 129 | const veryLongInput = oldText.length > 15000; 130 | // Proofreading a document likely takes longer, we want to keep the finishing 131 | // message in case the user went afk. (In the Notice API, duration 0 means 132 | // keeping the notice until the user dismisses it.) 133 | const notifDuration = longInput ? 0 : 4_000; 134 | 135 | // notify on start 136 | let msgBeforeRequest = `🤖 ${scope} is being proofread…`; 137 | if (longInput) { 138 | msgBeforeRequest += "\n\nDue to the length of the text, this may take a moment."; 139 | if (veryLongInput) msgBeforeRequest += " (A minute or longer.)"; 140 | msgBeforeRequest += 141 | "\n\nDo not go to a different file or change the original text in the meantime."; 142 | } 143 | const notice = new Notice(msgBeforeRequest, 0); 144 | 145 | // perform request 146 | const requestFunc: ProviderAdapter = PROVIDER_REQUEST_MAP[model.provider]; 147 | const { newText, isOverlength } = (await requestFunc(settings, oldText)) || {}; 148 | notice.hide(); 149 | if (!newText) return; 150 | 151 | // check if active file changed 152 | const fileAfter = app.workspace.getActiveFile()?.path; 153 | if (fileBefore !== fileAfter) { 154 | const errmsg = "! The active file changed since the proofread has been triggered. Aborting."; 155 | new Notice(errmsg, notifDuration); 156 | return; 157 | } 158 | 159 | // check if diff is even needed 160 | const { textWithSuggestions, changeCount } = getDiffMarkdown( 161 | settings, 162 | oldText, 163 | newText, 164 | isOverlength, 165 | ); 166 | if (textWithSuggestions === oldText) { 167 | // eslint-disable-next-line obsidianmd/ui/sentence-case -- PENDING https://github.com/obsidianmd/eslint-plugin/issues/71 168 | new Notice("✅ Text is good, nothing to change.", notifDuration); 169 | return; 170 | } 171 | 172 | // notify on changes 173 | if (isOverlength) { 174 | const msg = 175 | "Text is longer than the maximum output supported by the AI model.\n\n" + 176 | "Suggestions are thus only made until the cut-off point."; 177 | new Notice(msg, 10_000); 178 | } 179 | const pluralS = changeCount === 1 ? "" : "s"; 180 | new Notice(`🤖 ${changeCount} change${pluralS} made.`, notifDuration); 181 | 182 | return textWithSuggestions; 183 | } 184 | 185 | //────────────────────────────────────────────────────────────────────────────── 186 | 187 | // prevent multiple requests, e.g., when accidentally using hotkey twice 188 | let isProofreading = false; 189 | 190 | export async function proofreadDocument(plugin: Proofreader, editor: Editor): Promise { 191 | if (isProofreading) { 192 | new Notice("Already processing a proofreading request."); 193 | return; 194 | } 195 | isProofreading = true; 196 | const noteWithFrontmatter = editor.getValue(); 197 | const bodyStart = getFrontMatterInfo(noteWithFrontmatter).contentStart || 0; 198 | const bodyEnd = noteWithFrontmatter.length; 199 | const oldText = noteWithFrontmatter.slice(bodyStart); 200 | 201 | const changes = await validateAndGetChangesAndNotify(plugin, oldText, "Document"); 202 | isProofreading = false; 203 | if (!changes) return; 204 | 205 | const bodyStartPos = editor.offsetToPos(bodyStart); 206 | const bodyEndPos = editor.offsetToPos(bodyEnd); 207 | editor.replaceRange(changes, bodyStartPos, bodyEndPos); 208 | editor.setCursor(bodyStartPos); // to start of doc 209 | } 210 | 211 | export async function proofreadText(plugin: Proofreader, editor: Editor): Promise { 212 | if (isProofreading) { 213 | new Notice("Already processing a proofreading request."); 214 | return; 215 | } 216 | isProofreading = true; 217 | const hasMultipleSelections = editor.listSelections().length > 1; 218 | if (hasMultipleSelections) { 219 | new Notice("Multiple selections are not supported."); 220 | return; 221 | } 222 | 223 | const cursor = editor.getCursor("from"); // `from` gives start if selection 224 | const selection = editor.getSelection(); 225 | const oldText = selection || editor.getLine(cursor.line); 226 | const scope = selection ? "Selection" : "Paragraph"; 227 | 228 | const changes = await validateAndGetChangesAndNotify(plugin, oldText, scope); 229 | isProofreading = false; 230 | if (!changes) return; 231 | 232 | if (selection) { 233 | editor.replaceSelection(changes); 234 | editor.setCursor(cursor); // to start of selection 235 | } else { 236 | editor.setLine(cursor.line, changes); 237 | editor.setCursor({ line: cursor.line, ch: 0 }); // to start of paragraph 238 | } 239 | } 240 | --------------------------------------------------------------------------------