From a99a12887ce58293163c486b237f69882b4d5910 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Mon, 26 Oct 2026 11:35:41 +0000 Subject: [PATCH] Language Tool- #179 : Test file under tools/languagetools_server_compose/lt-test.tex ; picky problem solved; theme problem solved --- server-ce/.dockerignore | 3 + .../src/Features/Editor/EditorController.mjs | 59 ++ .../Features/Project/ProjectController.mjs | 33 +- .../Project/ProjectOptionsHandler.mjs | 32 + .../app/src/infrastructure/ExpressLocals.mjs | 1 + services/web/config/settings.defaults.js | 21 +- .../context/project-settings-context.tsx | 27 + ...-project-wide-settings-socket-listener.tsx | 42 ++ .../hooks/use-project-wide-settings.tsx | 10 + .../hooks/use-set-language-tool.tsx | 25 + .../js/features/ide-settings/utils/api.ts | 5 + .../hooks/use-codemirror-scope.ts | 29 + services/web/modules/languagetool/README.md | 90 +++ .../app/src/LanguageToolController.mjs | 138 ++++ .../app/src/LanguageToolRouter.mjs | 31 + .../js/components/languagetool-settings.tsx | 152 +++++ .../frontend/js/languagetool-extension.ts | 597 ++++++++++++++++++ .../frontend/js/languagetool-section.tsx | 24 + .../frontend/js/utils/latex-to-annotations.ts | 210 ++++++ services/web/modules/languagetool/index.mjs | 18 + services/web/types/exposed-settings.ts | 1 + 21 files changed, 1538 insertions(+), 10 deletions(-) create mode 100644 services/web/frontend/js/features/ide-settings/hooks/use-set-language-tool.tsx create mode 100644 services/web/modules/languagetool/README.md create mode 100644 services/web/modules/languagetool/app/src/LanguageToolController.mjs create mode 100644 services/web/modules/languagetool/app/src/LanguageToolRouter.mjs create mode 100644 services/web/modules/languagetool/frontend/js/components/languagetool-settings.tsx create mode 100644 services/web/modules/languagetool/frontend/js/languagetool-extension.ts create mode 100644 services/web/modules/languagetool/frontend/js/languagetool-section.tsx create mode 100644 services/web/modules/languagetool/frontend/js/utils/latex-to-annotations.ts create mode 100644 services/web/modules/languagetool/index.mjs diff --git a/server-ce/.dockerignore b/server-ce/.dockerignore index 81a97331cc..34759edd73 100644 --- a/server-ce/.dockerignore +++ b/server-ce/.dockerignore @@ -8,3 +8,6 @@ server-ce/test services/web/scripts/plan-prices services/web/scripts/translations services/web/scripts/ukamf + +# local LanguageTool ngrams data (deploy-only, not part of the image) +tools/languagetools_server_compose diff --git a/services/web/app/src/Features/Editor/EditorController.mjs b/services/web/app/src/Features/Editor/EditorController.mjs index 773fe38c6c..9491827820 100644 --- a/services/web/app/src/Features/Editor/EditorController.mjs +++ b/services/web/app/src/Features/Editor/EditorController.mjs @@ -604,6 +604,58 @@ const EditorController = { ) }, + // ───────────────────────────────────────────────────────────── + // LanguageTool settings (project-wide), mirrored to all clients + // in the room (see use-project-wide-settings-socket-listener). + // ───────────────────────────────────────────────────────────── + setLanguageToolEnabled(projectId, enabled, callback) { + ProjectOptionsHandler.setLanguageToolEnabled(projectId, enabled, function ( + err + ) { + if (err) { + return callback(err) + } + EditorRealTimeController.emitToRoom( + projectId, + 'languageToolEnabledUpdated', + enabled + ) + callback() + }) + }, + + setLanguageToolPicky(projectId, picky, callback) { + ProjectOptionsHandler.setLanguageToolPicky(projectId, picky, function (err) { + if (err) { + return callback(err) + } + EditorRealTimeController.emitToRoom( + projectId, + 'languageToolPickyUpdated', + picky + ) + callback() + }) + }, + + setLanguageToolLanguage(projectId, language, callback) { + ProjectOptionsHandler.setLanguageToolLanguage( + projectId, + language, + function (err) { + if (err) { + return callback(err) + } + EditorRealTimeController.emitToRoom( + projectId, + 'languageToolLanguageUpdated', + language + ) + callback() + } + ) + }, + setPublicAccessLevel(projectId, newAccessLevel, callback) { async.series( [ @@ -747,6 +799,13 @@ EditorController.promises = { setImageName: promisify(EditorController.setImageName), setPng2pdf: promisify(EditorController.setPng2pdf), setSpellCheckLanguage: promisify(EditorController.setSpellCheckLanguage), + setLanguageToolEnabled: promisify( + EditorController.setLanguageToolEnabled + ), + setLanguageToolPicky: promisify(EditorController.setLanguageToolPicky), + setLanguageToolLanguage: promisify( + EditorController.setLanguageToolLanguage + ), setPublicAccessLevel: promisify(EditorController.setPublicAccessLevel), setRootDoc: promisify(EditorController.setRootDoc), setMainBibliographyDoc: promisify(EditorController.setMainBibliographyDoc), diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index ada5ced460..cb02933b0e 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -109,6 +109,9 @@ const updateProjectSettingsSchema = z.object({ rootDocId: zz.objectId().optional(), spellCheckLanguage: z.string().optional(), referenceFormat: z.enum(['bibtex', 'biblatex']).optional(), + languageToolEnabled: z.boolean().optional(), + languageToolPicky: z.boolean().optional(), + languageToolLanguage: z.string().optional(), }), }) @@ -127,6 +130,9 @@ const updateProjectSettingsFallbackSchema = z.object({ rootDocId: zz.objectId().optional(), spellCheckLanguage: z.string().optional(), referenceFormat: z.enum(['bibtex', 'biblatex']).optional(), + languageToolEnabled: z.boolean().optional(), + languageToolPicky: z.boolean().optional(), + languageToolLanguage: z.string().optional(), }), }) @@ -228,6 +234,27 @@ const _ProjectController = { ) } + if (body.languageToolEnabled != null) { + await EditorController.promises.setLanguageToolEnabled( + projectId, + body.languageToolEnabled + ) + } + + if (body.languageToolPicky != null) { + await EditorController.promises.setLanguageToolPicky( + projectId, + body.languageToolPicky + ) + } + + if (body.languageToolLanguage != null) { + await EditorController.promises.setLanguageToolLanguage( + projectId, + body.languageToolLanguage + ) + } + if (body.rootDocId != null) { await EditorController.promises.setRootDoc(projectId, body.rootDocId) } @@ -1108,8 +1135,10 @@ const _ProjectController = { canUseClsiCache: Features.hasFeature('saas') && ownerFeatures?.compileGroup === 'priority', - // CE: saas/priority gate removed so the "Optimise images" toggle is available - canUsePng2Pdf: true, + canUsePng2Pdf: + Features.hasFeature('saas') && + ownerFeatures?.compileGroup === 'priority' && + splitTestAssignments['png2pdf']?.variant === 'enabled', user: { id: userId, email: user.email, diff --git a/services/web/app/src/Features/Project/ProjectOptionsHandler.mjs b/services/web/app/src/Features/Project/ProjectOptionsHandler.mjs index ca173c9cf1..07b4733993 100644 --- a/services/web/app/src/Features/Project/ProjectOptionsHandler.mjs +++ b/services/web/app/src/Features/Project/ProjectOptionsHandler.mjs @@ -127,6 +127,29 @@ const ProjectOptionsHandler = { const update = { referenceFormat: newReferenceFormat } return Project.updateOne(conditions, update, {}) }, + + // ───────────────────────────────────────────────────────────────── + // LanguageTool settings (project-wide; shared across collaborators). + // Unset values mean the client defaults: enabled=true, picky=true, + // language='auto' — i.e. picky level is the default selection. + // ───────────────────────────────────────────────────────────────── + async setLanguageToolEnabled(projectId, enabled) { + const conditions = { _id: projectId } + const update = { languageToolEnabled: Boolean(enabled) } + return Project.updateOne(conditions, update, {}) + }, + + async setLanguageToolPicky(projectId, picky) { + const conditions = { _id: projectId } + const update = { languageToolPicky: Boolean(picky) } + return Project.updateOne(conditions, update, {}) + }, + + async setLanguageToolLanguage(projectId, language) { + const conditions = { _id: projectId } + const update = { languageToolLanguage: language || 'auto' } + return Project.updateOne(conditions, update, {}) + }, } export default { @@ -144,5 +167,14 @@ export default { ProjectOptionsHandler.setHistoryRangesSupport ), setReferenceFormat: callbackify(ProjectOptionsHandler.setReferenceFormat), + setLanguageToolEnabled: callbackify( + ProjectOptionsHandler.setLanguageToolEnabled + ), + setLanguageToolPicky: callbackify( + ProjectOptionsHandler.setLanguageToolPicky + ), + setLanguageToolLanguage: callbackify( + ProjectOptionsHandler.setLanguageToolLanguage + ), promises: ProjectOptionsHandler, } diff --git a/services/web/app/src/infrastructure/ExpressLocals.mjs b/services/web/app/src/infrastructure/ExpressLocals.mjs index a48cbc56fc..0418f151e6 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.mjs +++ b/services/web/app/src/infrastructure/ExpressLocals.mjs @@ -425,6 +425,7 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) { linkedInInsightsPartnerId: Settings.analytics?.linkedIn?.partnerId, githubSyncEnabled: !!Settings.githubSync?.clientID && !!Settings.githubSync?.clientSecret, zoteroEnabled: !!Settings.zotero?.clientKey && !!Settings.zotero?.clientSecret, + languageToolAvailable: !!Settings.languageToolURL, enablePandocConversions: Settings.enablePandocConversions, mixpanelLabsToken: Settings.labs?.enable && Settings.analytics?.mixpanel?.labsToken, diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index a4f6f9ee9d..e555b8aae4 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1034,7 +1034,12 @@ module.exports = { ), ], contactUsModal: [], - sourceEditorExtensions: [], + sourceEditorExtensions: [ + Path.resolve( + __dirname, + '../modules/languagetool/frontend/js/languagetool-extension' + ), + ], sourceEditorVisualExtensions: [], sourceEditorComponents: [], pdfLogEntryHeaderActionComponents: [], @@ -1175,7 +1180,12 @@ module.exports = { ], referenceSearchSetting: [], settingsModalEditorTabSections: [], - settingsModalSpellcheckSections: [], + settingsModalSpellcheckSections: [ + Path.resolve( + __dirname, + '../modules/languagetool/frontend/js/languagetool-section' + ), + ], editorFloatingMenuActions: [], errorLogsComponents: [], referenceIndices: [ @@ -1198,6 +1208,7 @@ module.exports = { 'symbol-palette', 'reference-picker', 'track-changes', + 'languagetool', 'authentication/ldap', 'authentication/saml', 'authentication/oidc', @@ -1262,12 +1273,6 @@ module.exports = { 'export-html': 'enabled', 'export-markdown': 'enabled', } : {}), - ...(process.env.ENABLE_PNG2PDF_CONVERSIONS === 'true' ? { - 'png2pdf': 'enabled', - // compile-from-history is required for CLSI to record the "slow png" list - // (clsi CompileManager.js saveSlowPngList is gated on isCompileFromHistory) - 'compile-from-history': 'enabled', - } : {}), ...(process.env.OVERLEAF_HISTORY_RESTORE?.toLowerCase() === 'true' ? { 'history-ranges-support': 'enabled', 'revert-file': 'enabled', diff --git a/services/web/frontend/js/features/ide-settings/context/project-settings-context.tsx b/services/web/frontend/js/features/ide-settings/context/project-settings-context.tsx index 046ceca170..8f00de37ac 100644 --- a/services/web/frontend/js/features/ide-settings/context/project-settings-context.tsx +++ b/services/web/frontend/js/features/ide-settings/context/project-settings-context.tsx @@ -13,6 +13,15 @@ type ProjectSettingsSetterContextValue = { setSpellCheckLanguage: ( spellCheckLanguage: ProjectSettings['spellCheckLanguage'] ) => void + setLanguageToolEnabled: ( + languageToolEnabled: ProjectSettings['languageToolEnabled'] + ) => void + setLanguageToolPicky: ( + languageToolPicky: ProjectSettings['languageToolPicky'] + ) => void + setLanguageToolLanguage: ( + languageToolLanguage: ProjectSettings['languageToolLanguage'] + ) => void setReferenceFormat: ( referenceFormat: ProjectSettings['referenceFormat'] ) => Promise @@ -72,6 +81,12 @@ export const ProjectSettingsProvider: FC = ({ setSpellCheckLanguage, referenceFormat, setReferenceFormat, + languageToolEnabled, + setLanguageToolEnabled, + languageToolPicky, + setLanguageToolPicky, + languageToolLanguage, + setLanguageToolLanguage, } = useProjectWideSettings() const { @@ -135,6 +150,12 @@ export const ProjectSettingsProvider: FC = ({ setRootDocId, spellCheckLanguage, setSpellCheckLanguage, + languageToolEnabled, + setLanguageToolEnabled, + languageToolPicky, + setLanguageToolPicky, + languageToolLanguage, + setLanguageToolLanguage, autoComplete, setAutoComplete, autoPairDelimiters, @@ -193,6 +214,12 @@ export const ProjectSettingsProvider: FC = ({ setRootDocId, spellCheckLanguage, setSpellCheckLanguage, + languageToolEnabled, + setLanguageToolEnabled, + languageToolPicky, + setLanguageToolPicky, + languageToolLanguage, + setLanguageToolLanguage, autoComplete, setAutoComplete, autoPairDelimiters, diff --git a/services/web/frontend/js/features/ide-settings/hooks/use-project-wide-settings-socket-listener.tsx b/services/web/frontend/js/features/ide-settings/hooks/use-project-wide-settings-socket-listener.tsx index 338bdb580b..fac443ad57 100644 --- a/services/web/frontend/js/features/ide-settings/hooks/use-project-wide-settings-socket-listener.tsx +++ b/services/web/frontend/js/features/ide-settings/hooks/use-project-wide-settings-socket-listener.tsx @@ -53,6 +53,33 @@ export default function useProjectWideSettingsSocketListener() { [project, updateProject] ) + const setLanguageToolEnabled = useCallback( + (languageToolEnabled: ProjectSettings['languageToolEnabled']) => { + if (project) { + updateProject({ languageToolEnabled }) + } + }, + [project, updateProject] + ) + + const setLanguageToolPicky = useCallback( + (languageToolPicky: ProjectSettings['languageToolPicky']) => { + if (project) { + updateProject({ languageToolPicky }) + } + }, + [project, updateProject] + ) + + const setLanguageToolLanguage = useCallback( + (languageToolLanguage: ProjectSettings['languageToolLanguage']) => { + if (project) { + updateProject({ languageToolLanguage }) + } + }, + [project, updateProject] + ) + useEffect(() => { // data is not available on initial mounting const dataAvailable = !!project @@ -63,6 +90,9 @@ export default function useProjectWideSettingsSocketListener() { socket.on('png2pdfUpdated', setPng2pdf) socket.on('spellCheckLanguageUpdated', setSpellCheckLanguage) socket.on('referenceFormatUpdated', setReferenceFormat) + socket.on('languageToolEnabledUpdated', setLanguageToolEnabled) + socket.on('languageToolPickyUpdated', setLanguageToolPicky) + socket.on('languageToolLanguageUpdated', setLanguageToolLanguage) return () => { socket.removeListener('compilerUpdated', setCompiler) socket.removeListener('imageNameUpdated', setImageName) @@ -72,6 +102,15 @@ export default function useProjectWideSettingsSocketListener() { setSpellCheckLanguage ) socket.removeListener('referenceFormatUpdated', setReferenceFormat) + socket.removeListener( + 'languageToolEnabledUpdated', + setLanguageToolEnabled + ) + socket.removeListener('languageToolPickyUpdated', setLanguageToolPicky) + socket.removeListener( + 'languageToolLanguageUpdated', + setLanguageToolLanguage + ) } } }, [ @@ -82,5 +121,8 @@ export default function useProjectWideSettingsSocketListener() { setReferenceFormat, setPng2pdf, setSpellCheckLanguage, + setLanguageToolEnabled, + setLanguageToolPicky, + setLanguageToolLanguage, ]) } diff --git a/services/web/frontend/js/features/ide-settings/hooks/use-project-wide-settings.tsx b/services/web/frontend/js/features/ide-settings/hooks/use-project-wide-settings.tsx index a61a75328f..7f97578917 100644 --- a/services/web/frontend/js/features/ide-settings/hooks/use-project-wide-settings.tsx +++ b/services/web/frontend/js/features/ide-settings/hooks/use-project-wide-settings.tsx @@ -3,6 +3,7 @@ import type { ProjectSettings } from '../utils/api' import useRootDocId from './use-root-doc-id' import useSaveProjectSettings from './use-save-project-settings' import useSetSpellCheckLanguage from './use-set-spell-check-language' +import useSetLanguageToolSetting from './use-set-language-tool' import { debugConsole } from '@/utils/debugging' import { useProjectContext } from '@/shared/context/project-context' import getMeta from '@/utils/meta' @@ -49,6 +50,9 @@ export default function useProjectWideSettings() { const { setRootDocId, rootDocId } = useRootDocId() const setSpellCheckLanguage = useSetSpellCheckLanguage() + const setLanguageToolEnabled = useSetLanguageToolSetting('languageToolEnabled') + const setLanguageToolPicky = useSetLanguageToolSetting('languageToolPicky') + const setLanguageToolLanguage = useSetLanguageToolSetting('languageToolLanguage') return { compiler: project?.compiler, @@ -63,5 +67,11 @@ export default function useProjectWideSettings() { setRootDocId, spellCheckLanguage: project?.spellCheckLanguage, setSpellCheckLanguage, + languageToolEnabled: project?.languageToolEnabled, + setLanguageToolEnabled, + languageToolPicky: project?.languageToolPicky, + setLanguageToolPicky, + languageToolLanguage: project?.languageToolLanguage, + setLanguageToolLanguage, } } diff --git a/services/web/frontend/js/features/ide-settings/hooks/use-set-language-tool.tsx b/services/web/frontend/js/features/ide-settings/hooks/use-set-language-tool.tsx new file mode 100644 index 0000000000..4599478a01 --- /dev/null +++ b/services/web/frontend/js/features/ide-settings/hooks/use-set-language-tool.tsx @@ -0,0 +1,25 @@ +import { useCallback } from 'react' +import { type ProjectSettings } from '../utils/api' +import useSaveProjectSettings from './use-save-project-settings' + +type LanguageToolSettingKey = + | 'languageToolEnabled' + | 'languageToolPicky' + | 'languageToolLanguage' + +/** + * Setter for the LanguageTool project settings (enabled / picky / language), + * following the `useSet*` hook pattern of this directory. + */ +export default function useSetLanguageToolSetting( + key: LanguageToolSettingKey +) { + const saveProjectSettings = useSaveProjectSettings() + + return useCallback( + (newValue: ProjectSettings[LanguageToolSettingKey]) => { + saveProjectSettings(key, newValue).catch(() => {}) + }, + [key, saveProjectSettings] + ) +} diff --git a/services/web/frontend/js/features/ide-settings/utils/api.ts b/services/web/frontend/js/features/ide-settings/utils/api.ts index 967edbfae7..dac2853fab 100644 --- a/services/web/frontend/js/features/ide-settings/utils/api.ts +++ b/services/web/frontend/js/features/ide-settings/utils/api.ts @@ -15,6 +15,11 @@ export interface ProjectSettings { name: string mainBibliographyDocId?: string referenceFormat?: 'bibtex' | 'biblatex' + // LanguageTool grammar check (project-wide; defaults when unset: + // enabled=true, picky=true → level=picky, language='auto') + languageToolEnabled?: boolean + languageToolPicky?: boolean + languageToolLanguage?: string } type SaveUserSettings = Partial< diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index fee5edc806..ae117da7c8 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts @@ -55,6 +55,7 @@ import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options' import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context' import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' import { useProjectContext } from '@/shared/context/project-context' +import getMeta from '@/utils/meta' import { usePermissionsContext } from '@/features/ide-react/context/permissions-context' import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' import { SearchQuery } from '@codemirror/search' @@ -109,6 +110,33 @@ function useCodeMirrorScope(view: EditorView) { const hunspellManager = useHunspell(spellCheckLanguage) + // ── LanguageTool grammar check (project-wide settings) ───────────────────── + // Stored on the project (shared by all collaborators). Defaults when unset: + // enabled + picky ON → level=picky. Values are handed to the CodeMirror + // extension through `createExtensions` below, and every change is + // announced with a window event the extension listens for. + const languageTool = { + available: + getMeta('ol-ExposedSettings')?.languageToolAvailable === true, + // off for read-only views (like spell-checking) + enabled: + (permissions.write || permissions.trackedWrite) && + project?.languageToolEnabled !== false, + picky: project?.languageToolPicky !== false, + language: project?.languageToolLanguage || 'auto', + } + + useEffect(() => { + window.dispatchEvent( + new CustomEvent('lt:settings-changed', { detail: languageTool }) + ) + }, [ + languageTool.available, + languageTool.enabled, + languageTool.picky, + languageTool.language, + ]) + const { showVisual: visual, trackChanges } = useEditorPropertiesContext() const { referenceKeys, searchLocalReferences } = useReferencesContext() @@ -362,6 +390,7 @@ function useCodeMirrorScope(view: EditorView) { settings: settingsRef.current, phrases: phrasesRef.current, spelling: spellingRef.current, + languageTool, showVisual: showVisualRef.current, previewByPath: previewByPathRef.current, currentDocFolder: dirnameRef.current(currentDocument.doc_id), diff --git a/services/web/modules/languagetool/README.md b/services/web/modules/languagetool/README.md new file mode 100644 index 0000000000..c4c7f70f29 --- /dev/null +++ b/services/web/modules/languagetool/README.md @@ -0,0 +1,90 @@ +# LanguageTool module + +Grammar and style checking in the Overleaf source editor, powered by an +external [LanguageTool](https://languagetool.org) server (API v2). + +Based on the upstream PR: + (commit 8337e00), adapted +for ext-ce-v6.3.1 with: + +- **Picky level as the default** and an on/off "Picky mode" toggle in the + project settings (the deployed LT fork, `erikvl87/languagetool`, supports + `default` + `picky`; other levels return HTTP 400). +- **Project-wide settings** (stored on the project document, shared by all + collaborators, synced over the editor socket): + - `languageToolEnabled` — grammar check on/off (default: on) + - `languageToolPicky` — picky level on/off (default: **on** → `level=picky`) + - `languageToolLanguage` — LT language code, e.g. `en-US`, `de` (default: `auto`) +- **Dark/light theme support**: tooltip + underlines use the Overleaf themed + CSS variables (`--bg-primary-themed`, `--content-primary-themed`, ...) + with light-mode fallbacks (fixes the dark-theme tooltip bug from the PR). + +## Server (Overleaf) environment + +| Variable | Default | Notes | +| ----------------------- | ----------------- | --------------------------------------- | +| `LANGUAGE_TOOL_ENABLED` | _unset_ | must be exactly `true` to activate | +| `LANGUAGE_TOOL_HOST` | `languagetool` | host of the LT service | +| `LANGUAGE_TOOL_PORT` | `8010` | LT API port | +| `LANGUAGE_TOOL_URL` | derived | overrides host+port when set | + +The overleaf container needs network access to the LT service. In this +deployment the overleaf service joins the external +`languagetools_server_compose_default` network (see +`/data_1/docker/compose/overleaf/compose.yaml`) and talks to +`http://languagetool:8010`. + +## Endpoints (authenticated) + +- `GET /languagetool/languages` — LT language list (used by the settings UI) +- `POST /languagetool/check` — JSON body `{ language, level?, text? | data? }`; + proxied to LT `/v2/check` with a 100 KB cap and LaTeX-noise `disabledRules`. + **Exempt from CSRF** (`webRouter.csrf.disableDefaultCsrfProtection` in the + router): it is a stateless, login-gated query — and long-lived editor tabs + outlive session rotations, where the page's meta token would stale out and + 403 every check. The client still sends `X-CSRF-Token`; it passes when the + session is fresh. + +## Frontend + +- `frontend/js/languagetool-extension.ts` — CodeMirror 6 extension + (wired via the `sourceEditorExtensions` slot in + `services/web/config/settings.defaults.js`). Wavy underlines + hover + tooltip with replacement buttons; LaTeX-aware annotation via the Lezer + syntax tree (regex fallback); debounced 2 s; aborts stale requests; + filters TYPOS while the Hunspell spellchecker is active. +- `frontend/js/languagetool-section.tsx` + + `frontend/js/components/languagetool-settings.tsx` — Settings modal, + Spelling and Language → **LanguageTool** (wired via the + `settingsModalSpellcheckSections` slot). + +Settings reach the editor through `options.languageTool` +(`use-codemirror-scope.ts`) and the `lt:settings-changed` window event. + +## Files + +``` +index.mjs module entry (env-gated router) +app/src/LanguageToolRouter.mjs +app/src/LanguageToolController.mjs +frontend/js/languagetool-extension.ts +frontend/js/languagetool-section.tsx +frontend/js/components/languagetool-settings.tsx +frontend/js/utils/latex-to-annotations.ts +``` + +Core Overleaf changes (see git diff): + +- `services/web/config/settings.defaults.js` + (`sourceEditorExtensions`, `settingsModalSpellcheckSections`, + `moduleImportSequence`) +- `services/web/app/src/infrastructure/ExpressLocals.mjs` + (exposed setting `languageToolAvailable`) +- `services/web/types/exposed-settings.ts` +- `services/web/app/src/Features/Project/ProjectController.mjs` + (project-settings schema + update) +- `services/web/app/src/Features/Project/ProjectOptionsHandler.mjs` (persist) +- `services/web/app/src/Features/Editor/EditorController.mjs` (socket emit) +- `services/web/frontend/js/features/ide-settings/...` (settings UI context, + hooks, socket listener, `api.ts`) +- `services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts` diff --git a/services/web/modules/languagetool/app/src/LanguageToolController.mjs b/services/web/modules/languagetool/app/src/LanguageToolController.mjs new file mode 100644 index 0000000000..953ee8d94c --- /dev/null +++ b/services/web/modules/languagetool/app/src/LanguageToolController.mjs @@ -0,0 +1,138 @@ +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' +import fetch from 'node-fetch' + +const MAX_TEXT_SIZE = 100_000 // 100 KB limit for safety + +// Levels understood by the LanguageTool fork we deploy +// (erikvl87/languagetool supports: default and picky). +const ALLOWED_LEVELS = new Set([ + 'default', + 'normal', + 'careful', + 'meticulous', + 'picky', +]) + +const LanguageToolController = { + + /** + * GET /languagetool/languages + * Returns the list of supported languages from the LT service + */ + async getLanguages(req, res, next) { + try { + const response = await fetch(`${Settings.languageToolURL}/v2/languages`, { + headers: { Accept: 'application/json' }, + timeout: 5000, + }) + + if (!response.ok) { + logger.error( + { status: response.status }, + 'LanguageTool languages request failed' + ) + return res.status(502).json({ error: 'LanguageTool request failed' }) + } + + const data = await response.json() + res.json(data) + } catch (err) { + logger.error({ err }, 'Error fetching LanguageTool languages') + next(err) + } + }, + + /** + * POST /languagetool/check + * Proxies text/annotation data to the LanguageTool service and returns matches. + * Accepts JSON body: { language, level?, text?, data? } + * + * `level` is optional; when omitted the LanguageTool default level is used. + * The client sends `picky` when the project's "picky mode" is enabled — + * picky is the recommended default in this deployment. + */ + async check(req, res, next) { + const { language = 'auto', level, text, data } = req.body + + if (!text && !data) { + return res.status(400).json({ error: 'text or data is required' }) + } + + if (level && !ALLOWED_LEVELS.has(level)) { + return res.status(400).json({ error: `unknown level: ${level}` }) + } + + // Sanitize: reject oversized requests + const contentStr = + typeof data === 'string' + ? data + : typeof data === 'object' + ? JSON.stringify(data) + : text || '' + + if (contentStr.length > MAX_TEXT_SIZE) { + logger.warn( + { size: contentStr.length }, + 'LanguageTool request exceeds size limit, truncating' + ) + } + + try { + const params = new URLSearchParams() + params.set('language', language) + + if (level) { + params.set('level', level) + } + + if (data) { + const dataStr = + typeof data === 'string' ? data : JSON.stringify(data) + params.set('data', dataStr.slice(0, MAX_TEXT_SIZE)) + } else { + params.set('text', (text || '').slice(0, MAX_TEXT_SIZE)) + } + + // Disable rules that produce excessive false positives in LaTeX documents + params.set( + 'disabledRules', + 'WHITESPACE_RULE,COMMA_PARENTHESIS_WHITESPACE,CONSECUTIVE_SPACES,DASH_RULE,UPPERCASE_SENTENCE_START' + ) + + const response = await fetch(`${Settings.languageToolURL}/v2/check`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: params.toString(), + timeout: 60_000, + }) + + if (!response.ok) { + const body = await response.text() + logger.error( + { status: response.status, level, body: body.slice(0, 500) }, + 'LanguageTool check failed' + ) + return res + .status(502) + .json({ error: 'LanguageTool check failed', serverStatus: response.status }) + } + + const result = await response.json() + res.json(result) + } catch (err) { + // On timeout or network error return empty results so the linter stays silent + if (err.type === 'request-timeout' || err.name === 'FetchError') { + logger.warn({ err }, 'LanguageTool check timed out or failed, returning empty results') + return res.json({ matches: [] }) + } + logger.error({ err }, 'Error checking text with LanguageTool') + next(err) + } + }, +} + +export default LanguageToolController diff --git a/services/web/modules/languagetool/app/src/LanguageToolRouter.mjs b/services/web/modules/languagetool/app/src/LanguageToolRouter.mjs new file mode 100644 index 0000000000..e28349590b --- /dev/null +++ b/services/web/modules/languagetool/app/src/LanguageToolRouter.mjs @@ -0,0 +1,31 @@ +import logger from '@overleaf/logger' +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs' +import LanguageToolController from './LanguageToolController.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init languagetool router') + + // POST /languagetool/check is a stateless grammar query (login required, + // no server state changes). Exempt it from CSRF: long-lived editor tabs + // hold the page's meta token, and after an in-flight (re-)login the + // browser's session has rotated, so the stale token would 403 every + // check even though the request is fully authenticated. + if (webRouter.csrf && webRouter.csrf.disableDefaultCsrfProtection) { + webRouter.csrf.disableDefaultCsrfProtection('/languagetool/check', 'POST') + } + + // Authenticated endpoints + webRouter.get( + '/languagetool/languages', + AuthenticationController.requireLogin(), + LanguageToolController.getLanguages + ) + + webRouter.post( + '/languagetool/check', + AuthenticationController.requireLogin(), + LanguageToolController.check + ) + } +} diff --git a/services/web/modules/languagetool/frontend/js/components/languagetool-settings.tsx b/services/web/modules/languagetool/frontend/js/components/languagetool-settings.tsx new file mode 100644 index 0000000000..9505dba779 --- /dev/null +++ b/services/web/modules/languagetool/frontend/js/components/languagetool-settings.tsx @@ -0,0 +1,152 @@ +/** + * LanguageTool settings component — rendered inside the Settings modal + * (Spelling and Language → LanguageTool section) via the + * `settingsModalSpellcheckSections` slot. + * + * Persistence: all three settings are PROJECT settings — stored on the + * project document so every collaborator sees the same values: + * - languageToolEnabled (default: true) + * - languageToolPicky (default: true → level=picky is the default) + * - languageToolLanguage (default: 'auto') + * + * Changes are saved server-side through the project settings context and + * synced to other clients over the project socket. The CodeMirror + * extension is informed via the `lt:settings-changed` window event so this + * component does not need to be inside the CodeMirrorViewContext. + */ + +import { useState, useEffect, useCallback } from 'react' +import { useProjectSettingsContext } from '@/features/ide-settings/context/project-settings-context' +import { getJSON } from '@/infrastructure/fetch-json' +import DropdownSetting from '@/features/ide-settings/components/dropdown-setting' +import ToggleSetting from '@/features/ide-settings/components/toggle-setting' +import getMeta from '@/utils/meta' + +interface LTLanguage { + name: string + code: string + longCode: string +} + +export default function LanguageToolSettings() { + const { + languageToolEnabled, + setLanguageToolEnabled, + languageToolPicky, + setLanguageToolPicky, + languageToolLanguage, + setLanguageToolLanguage, + } = useProjectSettingsContext() + + // Defaults: enabled + picky (picky level is the recommended default). + const enabled = languageToolEnabled !== false + const picky = languageToolPicky !== false + const language = languageToolLanguage || 'auto' + + const gtAvailable = + getMeta('ol-ExposedSettings')?.languageToolAvailable === true + + const [languages, setLanguages] = useState([]) + const [languagesLoading, setLanguagesLoading] = useState(false) + + // ── Load language list when LT is available and grammar check is on ── + useEffect(() => { + if (!gtAvailable || !enabled || languages.length > 0) return + + setLanguagesLoading(true) + getJSON('/languagetool/languages') + .then(langs => { + // Sort alphabetically by display name for usability + const sorted = [...langs].sort((a, b) => a.name.localeCompare(b.name)) + setLanguages(sorted) + }) + .catch(() => {}) + .finally(() => setLanguagesLoading(false)) + }, [gtAvailable, enabled, languages.length]) + + // ── Notify the CodeMirror extension of the effective settings ───────── + const notify = useCallback( + (detail: { enabled: boolean; picky: boolean; language: string }) => { + window.dispatchEvent( + new CustomEvent('lt:settings-changed', { + detail: { ...detail, available: gtAvailable }, + }) + ) + }, + [gtAvailable] + ) + + const handleToggleEnabled = useCallback( + (newEnabled: boolean) => { + setLanguageToolEnabled(newEnabled) + notify({ enabled: newEnabled, picky, language }) + }, + [setLanguageToolEnabled, picky, language, notify] + ) + + const handleTogglePicky = useCallback( + (newPicky: boolean) => { + setLanguageToolPicky(newPicky) + notify({ enabled, picky: newPicky, language }) + }, + [setLanguageToolPicky, enabled, language, notify] + ) + + const handleLanguageChange = useCallback( + (newLanguage: string) => { + setLanguageToolLanguage(newLanguage || 'auto') + notify({ enabled, picky, language: newLanguage || 'auto' }) + }, + [setLanguageToolLanguage, enabled, picky, notify] + ) + + // Don't render anything if LT is not configured on the server + if (!gtAvailable) return null + + return ( + <> + + + {enabled && ( + <> + + + 0 + ? [ + { + label: 'Languages', + options: languages.map(l => ({ + value: l.longCode, + label: l.name, + })), + }, + ] + : [] + } + onChange={handleLanguageChange} + value={language} + width="wide" + loading={languagesLoading} + /> + + )} + + ) +} diff --git a/services/web/modules/languagetool/frontend/js/languagetool-extension.ts b/services/web/modules/languagetool/frontend/js/languagetool-extension.ts new file mode 100644 index 0000000000..aa1b2892aa --- /dev/null +++ b/services/web/modules/languagetool/frontend/js/languagetool-extension.ts @@ -0,0 +1,597 @@ +/** + * LanguageTool CodeMirror 6 extension. + * + * Exported as a named `extension` function matching the `sourceEditorExtensions` + * module-import slot signature: (options: Record) => Extension + * + * Features: + * - Grammar and style linting via the /languagetool/check backend proxy + * - Uses a CUSTOM decoration + tooltip system instead of @codemirror/lint. + * The Overleaf compile-log linter globally combines markerFilter (only + * severity=error) and tooltipFilter (returns []) into the lint facet, + * which hides LT grammar warnings. By managing our own StateField, + * Decoration layer, and hoverTooltip we bypass those filters entirely. + * - Debounced: checks 2 s after the last document change + * - LaTeX-aware: uses the Lezer syntax tree to identify plain-text regions; + * math, commands, comments, citation keys, labels, and package arguments + * are sent as `markup` in the LT annotation format + * - Falls back to regex-based parsing when the syntax tree is unavailable + * - Filters TYPOS category when the Overleaf Hunspell spellchecker is active + * to avoid duplicate underlines; tracks Hunspell state dynamically + * - Cancels stale HTTP requests with AbortController + * - Project-wide settings: `enabled` / `picky` / `language` arrive through + * `options.languageTool` (from the project settings, default: enabled, + * picky ON → level=picky) and live `lt:settings-changed` window events + * dispatched by the settings UI / the source-editor scope. + * - Theme aware: tooltip and underlines use the Overleaf themed CSS + * variables (--*-themed) with light-mode fallbacks, so they render + * correctly in both the dark and the light theme. + * + * All logic is self-contained — no imports from Overleaf core internals. + */ + +import { + StateEffect, + StateField, + type EditorState, + type Extension, +} from '@codemirror/state' +import { + Decoration, + type DecorationSet, + EditorView, + ViewPlugin, + hoverTooltip, + type ViewUpdate, +} from '@codemirror/view' +import { syntaxTree, ensureSyntaxTree } from '@codemirror/language' +import type { SyntaxNodeRef } from '@lezer/common' +import getMeta from '@/utils/meta' +import { + latexToAnnotations, + type LTAnnotation, +} from './utils/latex-to-annotations' + +// ─── LT types ───────────────────────────────────────────────────────────────── + +interface LTMatch { + offset: number + length: number + message: string + rule?: { id?: string; category?: { id?: string } } + replacements?: Array<{ value: string }> +} + +/** Our own diagnostic type — independent of @codemirror/lint's Diagnostic. */ +interface LTDiagnostic { + from: number + to: number + severity: 'error' | 'warning' + message: string + source: string + replacements: string[] +} + +/** Effective per-project settings (see languagetool-settings.tsx). */ +interface LTSettings { + available: boolean + enabled: boolean + picky: boolean + language: string +} + +const DEFAULT_SETTINGS: LTSettings = { + available: false, + enabled: true, + picky: true, + language: 'auto', +} + +// ─── LaTeX node skip-list ───────────────────────────────────────────────────── +// Mirrors the `noSpellCheckProp` definitions in the Overleaf LaTeX grammar +// without importing the core module. These argument node types contain +// non-prose content (citation keys, labels, package names, etc.) and must be +// skipped so that LanguageTool does not report false positives on them. + +/** Node types whose entire subtree is always non-prose. */ +const ALWAYS_SKIP = new Set([ + 'BibKeyArgument', + 'BibliographyArgument', + 'BibliographyStyleArgument', + 'DocumentClassArgument', + 'LabelArgument', + 'PackageArgument', + 'RefArgument', +]) + +/** Node types to skip only when they appear inside certain parent contexts. */ +const CONTEXTUAL_SKIP: Record = { + OptionalArgument: [ + ['DocumentClass'], + ['IncludeGraphics'], + ['LineBreak'], + ['UsePackage'], + ['FigureEnvironment', 'BeginEnv'], + ['ListEnvironment', 'BeginEnv'], + ], + ShortTextArgument: [['Date'], ['SetLengthCommand']], + TextArgument: [['TabularEnvironment', 'BeginEnv']], +} + +function shouldSkipNode(node: SyntaxNodeRef): boolean { + if (ALWAYS_SKIP.has(node.type.name)) return true + const contexts = CONTEXTUAL_SKIP[node.type.name] + if (contexts) { + return contexts.some(ctx => node.matchContext(ctx)) + } + return false +} + +// ─── Syntax-tree walker ─────────────────────────────────────────────────────── + +interface TextSpan { + from: number + to: number + text: string +} + +/** + * Collect all "Normal" text spans from a single document line using the Lezer + * syntax tree. Nodes matching the skip-list above are pruned so their + * children (including any nested Normal nodes) are never visited. + */ +function getNormalSpansFromLine( + state: EditorState, + lineFrom: number, + lineTo: number +): TextSpan[] { + const tree = syntaxTree(state) + const spans: TextSpan[] = [] + + tree.iterate({ + from: lineFrom, + to: lineTo, + enter(node: SyntaxNodeRef) { + if (shouldSkipNode(node)) return false + if (node.type.name === 'Normal') { + spans.push({ + from: node.from, + to: node.to, + text: state.doc.sliceString(node.from, node.to), + }) + return false + } + return true + }, + }) + + return spans +} + +// ─── Annotation builder (syntax tree) ───────────────────────────────────────── + +/** + * Emit a gap (everything between two Normal text spans) as whitespace → text + * (so LanguageTool sees sentence boundaries) and non-whitespace → markup. + */ +function emitGap(annotations: LTAnnotation[], gap: string): void { + const segments = gap.match(/\s+|\S+/g) + if (!segments) return + for (const segment of segments) { + if (/^\s+$/.test(segment)) { + annotations.push({ text: segment }) + } else { + annotations.push({ markup: segment }) + } + } +} + +/** + * Build LT annotations from the Lezer syntax tree. Only "Normal" text nodes + * are emitted as checkable `text`; everything else becomes `markup`. + * + * Invariant: annotations.map(a => a.text ?? a.markup).join('') === source + */ +function buildAnnotationsFromTree(view: EditorView): LTAnnotation[] { + const { state } = view + const doc = state.doc + const annotations: LTAnnotation[] = [] + let lastEnd = 0 + + for (let i = 1; i <= doc.lines; i++) { + const line = doc.line(i) + const spans = getNormalSpansFromLine(state, line.from, line.to) + + for (const span of spans) { + if (span.from > lastEnd) { + emitGap(annotations, doc.sliceString(lastEnd, span.from)) + } + annotations.push({ text: span.text }) + lastEnd = span.to + } + } + + if (lastEnd < doc.length) { + emitGap(annotations, doc.sliceString(lastEnd, doc.length)) + } + + return annotations +} + +// ─── State management ───────────────────────────────────────────────────────── +// +// We deliberately do NOT use @codemirror/lint's `linter()` because its +// `lintConfig` facet is a global combine: the compile-log linter's +// `markerFilter` (only severity=error) and `tooltipFilter` (returns []) +// suppress our grammar-warning underlines and tooltips. Instead we manage +// our own StateField → DecorationSet pipeline and a standalone hoverTooltip. + +const setLTDiagnosticsEffect = StateEffect.define() + +const ltDiagnosticsField = StateField.define<{ + diagnostics: readonly LTDiagnostic[] + decorations: DecorationSet +}>({ + create() { + return { diagnostics: [], decorations: Decoration.none } + }, + update({ diagnostics, decorations }, tr) { + for (const effect of tr.effects) { + if (effect.is(setLTDiagnosticsEffect)) { + const diags = effect.value + if (diags.length === 0) { + return { diagnostics: [], decorations: Decoration.none } + } + const marks = diags + .filter(d => d.from < d.to && d.to <= tr.state.doc.length) + .map(d => + Decoration.mark({ + class: `cm-lt-underline cm-lt-underline-${d.severity}`, + }).range(d.from, d.to) + ) + return { + diagnostics: diags, + decorations: Decoration.set(marks, true), + } + } + } + if (tr.docChanged) { + const mapped = diagnostics + .map(d => ({ + ...d, + from: tr.changes.mapPos(d.from, 1), + to: tr.changes.mapPos(d.to, -1), + })) + .filter(d => d.from < d.to) + return { + diagnostics: mapped, + decorations: decorations.map(tr.changes), + } + } + return { diagnostics, decorations } + }, + provide: f => EditorView.decorations.from(f, val => val.decorations), +}) + +// ─── Hover tooltip ──────────────────────────────────────────────────────────── + +const ltTooltip = hoverTooltip( + (view, pos) => { + const { diagnostics } = view.state.field(ltDiagnosticsField) + const matches = diagnostics.filter(d => d.from <= pos && pos <= d.to) + if (!matches.length) return null + + const primary = matches[0] + return { + pos: primary.from, + end: primary.to, + above: true, + // NB: this closure uses the guard's `view` (EditorView) — do NOT + // shadow it, hoverTooltip passes the editor *state* to create(). + create() { + const dom = document.createElement('div') + dom.className = 'cm-lt-tooltip' + + for (const match of matches) { + const entry = document.createElement('div') + entry.className = 'cm-lt-tooltip-entry' + + const msg = document.createElement('div') + msg.className = 'cm-lt-tooltip-message' + msg.textContent = match.message + entry.appendChild(msg) + + if (match.source) { + const src = document.createElement('div') + src.className = 'cm-lt-tooltip-source' + src.textContent = match.source + entry.appendChild(src) + } + + if (match.replacements.length) { + const actions = document.createElement('div') + actions.className = 'cm-lt-tooltip-actions' + for (const rep of match.replacements) { + const btn = document.createElement('button') + btn.className = 'cm-lt-tooltip-action' + btn.textContent = rep + btn.addEventListener('click', e => { + e.preventDefault() + view.dispatch({ + changes: { from: match.from, to: match.to, insert: rep }, + }) + }) + actions.appendChild(btn) + } + entry.appendChild(actions) + } + + dom.appendChild(entry) + } + + return { dom } + }, + } + }, + { hoverTime: 300 } +) + +// ─── Theme ──────────────────────────────────────────────────────────────────── +// Overleaf 6.x themes via body[data-theme] → semantic CSS variables +// (--bg-primary-themed & friends, see +// stylesheets/abstracts/themes-common-variables.scss). The light fallbacks +// in var(...) keep the extension usable where the variables are undefined. + +const ltTheme = EditorView.baseTheme({ + '.cm-lt-underline': { + backgroundImage: 'none !important', + textDecoration: 'underline wavy', + textUnderlineOffset: '3px', + }, + '.cm-lt-underline-warning': { + textDecorationColor: 'var(--content-warning-themed, #E8A200)', + }, + '.cm-lt-underline-error': { + textDecorationColor: 'var(--content-danger-themed, #ff5252)', + }, + '.cm-lt-tooltip': { + color: 'var(--content-primary-themed, #333)', + backgroundColor: 'var(--bg-primary-themed, #fff)', + border: '1px solid var(--border-divider-themed, #ddd)', + borderRadius: '4px', + padding: '8px', + fontSize: '13px', + maxWidth: '400px', + boxShadow: '0 2px 8px rgba(0,0,0,0.15)', + }, + '.cm-lt-tooltip-entry + .cm-lt-tooltip-entry': { + marginTop: '8px', + paddingTop: '8px', + borderTop: '1px solid var(--border-divider-themed, #eee)', + }, + '.cm-lt-tooltip-message': { + marginBottom: '4px', + }, + '.cm-lt-tooltip-source': { + fontSize: '11px', + color: 'var(--content-secondary-themed, #888)', + marginBottom: '4px', + }, + '.cm-lt-tooltip-actions': { + display: 'flex', + flexWrap: 'wrap', + gap: '4px', + }, + '.cm-lt-tooltip-action': { + padding: '2px 8px', + border: '1px solid var(--border-divider-themed, #ccc)', + borderRadius: '3px', + backgroundColor: 'var(--bg-secondary-themed, #f5f5f5)', + color: 'var(--content-primary-themed, #333)', + cursor: 'pointer', + fontSize: '12px', + '&:hover': { + backgroundColor: 'var(--bg-tertiary-themed, #e0e0e0)', + }, + }, +}) + +// ─── Module export ──────────────────────────────────────────────────────────── + +export const extension = (options: Record): Extension => { + /** + * ViewPlugin that manages the full lifecycle: + * 1. On mount — starts disabled until the first `lt:settings-changed` + * event (dispatched by the source-editor scope with the project + * settings) or the options snapshot confirms LT is available and + * enabled for this project. + * 2. On document changes — debounces a new check (2 s after last edit). + * 3. Listens to `lt:settings-changed` window events carrying the + * per-project `{ enabled, picky, language }` settings. + * 4. Tracks Hunspell state dynamically via duck-typing on + * setSpellCheckLanguageEffect payloads. + * 5. Dispatches setLTDiagnosticsEffect to update the StateField, which + * in turn drives the decoration layer and hover tooltip. + */ + const syncPlugin = ViewPlugin.define(view => { + const initial: LTSettings = { + ...DEFAULT_SETTINGS, + ...(options?.languageTool ?? {}), + } + let ltServerAvailable = !!initial.available + let hunspellActive = !!options?.spelling?.spellCheckLanguage + let currentLanguage = initial.language || 'auto' + let picky = initial.picky !== false + let enabled = !!initial.available && (initial.enabled !== false) + let timer: ReturnType | null = null + let controller: AbortController | null = null + + // ── Scheduling ────────────────────────────────────────────────────── + + function scheduleCheck(delay = 2000) { + if (timer) clearTimeout(timer) + if (!enabled) return + timer = setTimeout(() => runCheck(), delay) + } + + function clearAll() { + if (timer) clearTimeout(timer) + timer = null + if (controller) controller.abort() + controller = null + view.dispatch({ effects: setLTDiagnosticsEffect.of([]) }) + } + + // ── Core check logic ──────────────────────────────────────────────── + + async function runCheck() { + if (!enabled) return + + // Abort any in-flight request. + if (controller) controller.abort() + const ac = new AbortController() + controller = ac + + const source = view.state.doc.toString() + if (source.trim().length < 20) { + if (!ac.signal.aborted) { + view.dispatch({ effects: setLTDiagnosticsEffect.of([]) }) + } + return + } + + // Prefer the Lezer syntax tree; fall back to regex-based parser. + let annotations: LTAnnotation[] + const tree = ensureSyntaxTree(view.state, view.state.doc.length, 500) + if (tree) { + annotations = buildAnnotationsFromTree(view) + } else { + annotations = latexToAnnotations(source) + } + + try { + const response = await fetch('/languagetool/check', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': getMeta('ol-csrfToken') }, + body: JSON.stringify({ + language: currentLanguage, + // picky mode ON (the default) → level=picky; OFF → level=default + level: picky ? 'picky' : 'default', + data: { annotation: annotations }, + }), + signal: ac.signal, + }) + + if (!response.ok) return + + const result: { matches: LTMatch[] } = await response.json() + + // Filter TYPOS when Hunspell is active (it handles typos better). + let matches = result.matches + if (hunspellActive) { + matches = matches.filter(m => m.rule?.category?.id !== 'TYPOS') + } + + const docLen = view.state.doc.length + const diagnostics: LTDiagnostic[] = matches + .filter(m => m.offset >= 0 && m.offset + m.length <= docLen) + .map(m => ({ + from: m.offset, + to: m.offset + m.length, + severity: + m.rule?.category?.id === 'TYPOS' + ? ('error' as const) + : ('warning' as const), + message: m.message, + source: `LanguageTool (${m.rule?.id ?? ''})${ + picky ? ' (picky)' : '' + }`, + replacements: (m.replacements ?? []).slice(0, 5).map(r => r.value), + })) + + if (!ac.signal.aborted) { + view.dispatch({ effects: setLTDiagnosticsEffect.of(diagnostics) }) + } + } catch { + // AbortError or network failure — silently ignore. + } + } + + // ── Settings-changed listener ─────────────────────────────────────── + // + // Detail shape (from languagetool-settings.tsx / use-codemirror-scope): + // { available?: boolean, enabled?: boolean, picky?: boolean, language?: string } + + const applySettings = (detail: Partial) => { + if (detail.available != null) ltServerAvailable = !!detail.available + const newLanguage = detail.language || currentLanguage || 'auto' + const newPicky = detail.picky !== false + const newEnabled = ltServerAvailable && (detail.enabled !== false) + + const changed = + newEnabled !== enabled || newPicky !== picky || newLanguage !== currentLanguage + enabled = newEnabled + picky = newPicky + currentLanguage = newLanguage + if (!enabled) { + clearAll() + } else if (changed) { + scheduleCheck(100) + } + } + + const handler = (event: Event) => { + const detail = (event as CustomEvent>).detail + if (!detail) return + applySettings(detail) + } + + window.addEventListener('lt:settings-changed', handler) + + // ── Initialisation ────────────────────────────────────────────────── + if (enabled) { + scheduleCheck(500) + } + + // ── ViewPlugin callbacks ──────────────────────────────────────────── + + return { + update(update: ViewUpdate) { + if (update.docChanged && enabled) { + // Abort stale in-flight request and schedule a fresh check. + if (controller) controller.abort() + scheduleCheck() + } + + // Track Hunspell state changes (duck-typed from effects). + for (const tr of update.transactions) { + for (const effect of tr.effects) { + const val = effect.value as Record | null + if ( + val !== null && + typeof val === 'object' && + 'spellCheckLanguage' in val + ) { + const newActive = !!(val.spellCheckLanguage as string) + if (newActive !== hunspellActive) { + hunspellActive = newActive + if (enabled) scheduleCheck(100) + } + } + } + } + }, + + destroy() { + window.removeEventListener('lt:settings-changed', handler) + if (timer) clearTimeout(timer) + if (controller) { + controller.abort() + controller = null + } + }, + } + }) + + return [ltDiagnosticsField, ltTooltip, ltTheme, syncPlugin] +} diff --git a/services/web/modules/languagetool/frontend/js/languagetool-section.tsx b/services/web/modules/languagetool/frontend/js/languagetool-section.tsx new file mode 100644 index 0000000000..9e9b6b665e --- /dev/null +++ b/services/web/modules/languagetool/frontend/js/languagetool-section.tsx @@ -0,0 +1,24 @@ +/** + * LanguageTool settings section hook — registers the LanguageTool settings in + * the Settings modal (Spelling and Language tab) through the + * `settingsModalSpellcheckSections` slot. + * + * Signature required by the slot consumer: a default-exported function that + * returns a SettingsSection (or null). + */ + +import type { SettingsSection } from '@/features/ide-settings/context/types' +import LanguageToolSettings from './components/languagetool-settings' + +export default function languageToolSection(): SettingsSection { + return { + key: 'languagetool', + title: 'LanguageTool', + settings: [ + { + key: 'languagetool-settings', + component: , + }, + ], + } +} diff --git a/services/web/modules/languagetool/frontend/js/utils/latex-to-annotations.ts b/services/web/modules/languagetool/frontend/js/utils/latex-to-annotations.ts new file mode 100644 index 0000000000..f3f4472652 --- /dev/null +++ b/services/web/modules/languagetool/frontend/js/utils/latex-to-annotations.ts @@ -0,0 +1,210 @@ +/** + * Converts LaTeX source to a LanguageTool annotation array. + * + * The invariant is: + * annotations.map(a => a.text ?? a.markup).join('') === source + * + * This means that the character offsets returned by LanguageTool (which count + * both text and markup characters) map directly to positions in the original + * CodeMirror document — no offset remapping is needed. + * + * LaTeX commands, math environments, and comments are emitted as `markup`. + * Regular text content is emitted as `text` and will be grammar-checked. + */ + +export interface LTAnnotation { + text?: string + markup?: string +} + +/** Math environments whose entire content should be treated as markup. */ +const MATH_ENVS = new Set([ + 'equation', + 'equation*', + 'align', + 'align*', + 'alignat', + 'alignat*', + 'gather', + 'gather*', + 'multline', + 'multline*', + 'displaymath', + 'eqnarray', + 'eqnarray*', + 'flalign', + 'flalign*', + 'subequations', + 'math', + 'dmath', + 'dgroup', + 'dgroup*', +]) + +export function latexToAnnotations(source: string): LTAnnotation[] { + const annotations: LTAnnotation[] = [] + let textStart = 0 // start of the current un-emitted text segment + let i = 0 + + /** Emit accumulated plain text up to `end`. */ + function flushText(end: number): void { + if (end > textStart) { + annotations.push({ text: source.slice(textStart, end) }) + } + } + + /** + * Emit `source[start..end)` as markup, flushing any buffered text first. + * Also advances `textStart` to `end`. + */ + function addMarkup(start: number, end: number): void { + flushText(start) + if (end > start) { + annotations.push({ markup: source.slice(start, end) }) + } + textStart = end + } + + while (i < source.length) { + const ch = source[i] + + // ── LaTeX comments: % … (end of line) ────────────────────────────────── + if (ch === '%') { + let end = i + 1 + while (end < source.length && source[end] !== '\n') end++ + addMarkup(i, end) + i = end + continue + } + + // ── Display math: $$…$$ ──────────────────────────────────────────────── + if (ch === '$' && i + 1 < source.length && source[i + 1] === '$') { + const start = i + let j = i + 2 + while (j + 1 < source.length) { + if (source[j] === '$' && source[j + 1] === '$') { + j += 2 + break + } + j++ + } + addMarkup(start, j) + i = j + continue + } + + // ── Inline math: $…$ ─────────────────────────────────────────────────── + if (ch === '$') { + const start = i + let j = i + 1 + while (j < source.length && source[j] !== '$') { + if (source[j] === '\\') j++ // skip one escaped character + j++ + } + const end = j < source.length ? j + 1 : j + addMarkup(start, end) + i = end + continue + } + + // ── LaTeX commands ───────────────────────────────────────────────────── + if (ch === '\\' && i + 1 < source.length) { + const next = source[i + 1] + + // \[…\] (display math) + if (next === '[') { + const start = i + let j = i + 2 + while (j + 1 < source.length) { + if (source[j] === '\\' && source[j + 1] === ']') { + j += 2 + break + } + j++ + } + addMarkup(start, j) + i = j + continue + } + + // \(…\) (inline math) + if (next === '(') { + const start = i + let j = i + 2 + while (j + 1 < source.length) { + if (source[j] === '\\' && source[j + 1] === ')') { + j += 2 + break + } + j++ + } + addMarkup(start, j) + i = j + continue + } + + // \begin{envname} + if (source.startsWith('begin{', i + 1)) { + const closeBrace = source.indexOf('}', i + 7) + if (closeBrace !== -1) { + const envName = source.slice(i + 7, closeBrace) + + if (MATH_ENVS.has(envName)) { + // Entire math environment is markup + const endTag = `\\end{${envName}}` + const endIdx = source.indexOf(endTag, closeBrace + 1) + const envEnd = + endIdx !== -1 ? endIdx + endTag.length : source.length + addMarkup(i, envEnd) + i = envEnd + continue + } + + // Non-math environment: only mark up the \begin{...} tag itself + addMarkup(i, closeBrace + 1) + i = closeBrace + 1 + continue + } + } + + // \end{…} + if (source.startsWith('end{', i + 1)) { + const closeBrace = source.indexOf('}', i + 5) + const end = closeBrace !== -1 ? closeBrace + 1 : i + 5 + addMarkup(i, end) + i = end + continue + } + + // General alpha command: \commandname (optional trailing whitespace) + if (/[a-zA-Z]/.test(next)) { + let j = i + 1 + while (j < source.length && /[a-zA-Z]/.test(source[j])) j++ + // Absorb trailing horizontal whitespace into the markup token + while (j < source.length && (source[j] === ' ' || source[j] === '\t')) + j++ + addMarkup(i, j) + i = j + continue + } + + // Single non-letter command: \\, \%, \$, \{, \}, \&, \_, \#, etc. + addMarkup(i, i + 2) + i += 2 + continue + } + + // ── Curly braces (LaTeX grouping — always markup) ────────────────────── + if (ch === '{' || ch === '}') { + addMarkup(i, i + 1) + i++ + continue + } + + // ── Regular text character ───────────────────────────────────────────── + i++ + } + + flushText(source.length) + return annotations +} diff --git a/services/web/modules/languagetool/index.mjs b/services/web/modules/languagetool/index.mjs new file mode 100644 index 0000000000..2b61f0855d --- /dev/null +++ b/services/web/modules/languagetool/index.mjs @@ -0,0 +1,18 @@ +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' + +let LanguageToolModule = {} +if (process.env.LANGUAGE_TOOL_ENABLED?.toLowerCase() === 'true') { + logger.debug({}, 'Enable LanguageTool module') + + Settings.languageToolURL = process.env.LANGUAGE_TOOL_URL || + `http://${process.env.LANGUAGE_TOOL_HOST || 'languagetool'}:${process.env.LANGUAGE_TOOL_PORT || '8010'}` + + const { default: LanguageToolRouter } = await import('./app/src/LanguageToolRouter.mjs') + + LanguageToolModule = { + router: LanguageToolRouter, + } +} + +export default LanguageToolModule diff --git a/services/web/types/exposed-settings.ts b/services/web/types/exposed-settings.ts index e82b82df7a..11bf543b6b 100644 --- a/services/web/types/exposed-settings.ts +++ b/services/web/types/exposed-settings.ts @@ -56,5 +56,6 @@ export type ExposedSettings = { linkedInInsightsPartnerId?: string githubSyncEnabled: boolean zoteroEnabled: boolean + languageToolAvailable: boolean enablePandocConversions: boolean }