mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-15 06:33:55 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1b9809f35 |
@@ -8,6 +8,3 @@ 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
|
||||
|
||||
@@ -24,7 +24,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
&& apt-get install -y \
|
||||
unattended-upgrades \
|
||||
build-essential wget net-tools unzip time poppler-utils optipng strace nginx git python3 python-is-python3 zlib1g-dev libpcre3-dev gettext-base libwww-perl ca-certificates curl gnupg \
|
||||
qpdf imagemagick ghostscript \
|
||||
qpdf \
|
||||
# upgrade base-image, batch all the upgrades together, rather than installing them on-by-one (which is slow!)
|
||||
&& unattended-upgrade --verbose --no-minimal-upgrade-steps \
|
||||
# install Node.js https://github.com/nodesource/distributions#nodejs
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
FROM pandoc/core:3.11-alpine
|
||||
|
||||
ENTRYPOINT []
|
||||
|
||||
RUN apk add --no-cache zip
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# replacement for quay.io/sharelatex/pdftocairo:24.02 (clsitex PDF→JPEG)
|
||||
#
|
||||
# ALIASES: pdftocairo-ol:25.12.0
|
||||
#
|
||||
# alpine:3.24 with poppler (25.12.0, >= 24.02 required by CLSI).
|
||||
#
|
||||
# Why: CLSI convertPDFToJPEG runs `pdftocairo -jpeg ...` against this image
|
||||
# for preview (794px, q90) and thumbnail (190px, q50) JPEGs of compiled PDFs.
|
||||
#
|
||||
# The SaaS image is Ubuntu-based; alpine 3.24's poppler 25.12.0 covers exactly
|
||||
# the flags CLSI uses (-jpeg -jpegopt -singlefile -scale-to-x -scale-to-y), and
|
||||
# the image is tiny.
|
||||
#
|
||||
# images/pandoc-amd64/Dockerfile is the sibling (pandoc/core + zip). Same pattern:
|
||||
# ENTRYPOINT [] because CLSI docker-runs with an explicit command (no
|
||||
# --entrypoint override).
|
||||
|
||||
FROM alpine:3.24
|
||||
|
||||
ENTRYPOINT []
|
||||
|
||||
RUN apk update \
|
||||
&& apk add --no-cache poppler-utils \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
@@ -1,51 +0,0 @@
|
||||
# replacement for quay.io/sharelatex/png2pdf:2026-06-24 (clsitex PNG→PDF)
|
||||
#
|
||||
# Engine: img2pdf (josch) — lossless. For PNG input the IDAT streams are
|
||||
# embedded verbatim into the PDF (no re-encode); the alpha channel becomes
|
||||
# a PDF soft mask (/SMask), also lossless. That is the mechanism of the
|
||||
# whole feature: pdfTeX copies PDF image streams cheaply, while PNGs with
|
||||
# alpha/gamma/palette/interlace are re-decoded on every compile, which is
|
||||
# what made them "slow" and what fills CLSI's slow-PNG list.
|
||||
#
|
||||
# The SaaS tool is proprietary. The Go port (go/services/clsitex/png2pdf)
|
||||
# pins the exact container contract this image fulfils:
|
||||
# ENTRYPOINT + ["--in-place", "--", "<relpath>", ...] CWD=/compile
|
||||
# stdout: one "Converted <f> to PDF" line per file (counted by Go)
|
||||
# exit non-zero on first failure (CLSI falls back to the original PNGs)
|
||||
#
|
||||
# Why git@commit instead of `pip install img2pdf==0.6.3`:
|
||||
# 0.6.3 (2025-11-06) is the last PyPI release, and it PRE-DATES the
|
||||
# parse_png quadratic-rewrite fix (2025-11-16, "my 311MB ARCHD at 600dpi
|
||||
# with 17638 IDAT records went from many minutes to 320ms"). The slow-PNG
|
||||
# list is populated by BIG PNGs (min 1MB, no upper bound), so we pin main
|
||||
# at this commit: it is still reproducible (exact hash) and carries the
|
||||
# fix plus the JPEG2000/ICC and pikepdf-writer-close follow-ups.
|
||||
#
|
||||
# pikepdf + pillow have musllinux cp314 wheels for this python version, so
|
||||
# no compiler is needed; git is needed only for the pinned checkout.
|
||||
|
||||
FROM python:3.14-alpine
|
||||
|
||||
ARG IMG2PDF_REF=0fafa2c312be50c68a07e2331a89bf4adbd274ed
|
||||
|
||||
RUN apk update \
|
||||
&& apk add --no-cache git \
|
||||
&& pip install --no-cache-dir --disable-pip-version-check \
|
||||
"git+https://gitlab.mister-muffin.de/josch/img2pdf.git@${IMG2PDF_REF}#egg=img2pdf" \
|
||||
pikepdf==10.15.0 \
|
||||
pillow==12.3.0 \
|
||||
packaging \
|
||||
&& rm -rf /root/.cache/pip /usr/src/python/lib*
|
||||
|
||||
# shim: arg parsing, leading-dash path handling (paths derive from URL
|
||||
# paths and start with '-'), temp-file + atomic replace (input must survive
|
||||
# a failed conversion — CLSI renames the untouched original as fallback),
|
||||
# and the "Converted <f> to PDF" stdout line Go counts.
|
||||
COPY server-ce/png2pdf/png2pdf.sh /usr/local/bin/png2pdf
|
||||
RUN chmod 0755 /usr/local/bin/png2pdf
|
||||
|
||||
# The Go runner passes the arg list ["--in-place", "--", ...] as the container
|
||||
# command, never a binary name — see the pdftocairo image (ENTRYPOINT []) for
|
||||
# the contrast: that image provides the binary NAME in its command, this one
|
||||
# relies on the image's own ENTRYPOINT.
|
||||
ENTRYPOINT ["/usr/local/bin/png2pdf"]
|
||||
+4
-58
@@ -11,33 +11,20 @@ export OVERLEAF_BASE_TAG ?= sharelatex/sharelatex-base:$(BRANCH_NAME_TAG_SAFE)-$
|
||||
export OVERLEAF_BRANCH ?= sharelatex/sharelatex:$(BRANCH_NAME_TAG_SAFE)
|
||||
export OVERLEAF_LATEST ?= sharelatex/sharelatex
|
||||
export OVERLEAF_TAG ?= sharelatex/sharelatex:$(BRANCH_NAME_TAG_SAFE)-$(MONOREPO_REVISION)
|
||||
export OVERLEAF_PANDOC_BRANCH ?= sharelatex/sharelatex-pandoc:$(BRANCH_NAME_TAG_SAFE)
|
||||
export OVERLEAF_PANDOC_LATEST ?= sharelatex/sharelatex-pandoc
|
||||
export OVERLEAF_PANDOC_TAG ?= sharelatex/sharelatex-pandoc:$(BRANCH_NAME_TAG_SAFE)-$(MONOREPO_REVISION)
|
||||
export OVERLEAF_PDFTOCAIRO ?= sharelatex/pdftocairo:24.02
|
||||
export OVERLEAF_PNG2PDF ?= sharelatex/png2pdf:2026-06-24
|
||||
|
||||
|
||||
all: build-base build-community build-pandoc build-pdftocairo build-png2pdf
|
||||
all: build-base build-community
|
||||
|
||||
clean:
|
||||
-docker rmi --force $(OVERLEAF_BASE_TAG) $(OVERLEAF_TAG) $(OVERLEAF_PANDOC_TAG) $(OVERLEAF_PDFTOCAIRO) $(OVERLEAF_PNG2PDF)
|
||||
-docker rmi --force $(OVERLEAF_BASE_TAG) $(OVERLEAF_TAG)
|
||||
|
||||
refresh-cache: refresh-cache-branch
|
||||
refresh-cache-branch:
|
||||
docker inspect $(OVERLEAF_BASE_BRANCH) > /dev/null && docker pull $(OVERLEAF_BASE_BRANCH) || true
|
||||
docker inspect $(OVERLEAF_BRANCH) > /dev/null && docker pull $(OVERLEAF_BRANCH) || true
|
||||
docker inspect $(OVERLEAF_PANDOC_BRANCH) > /dev/null && docker pull $(OVERLEAF_PANDOC_BRANCH) || true
|
||||
docker inspect $(OVERLEAF_PDFTOCAIRO) > /dev/null && docker pull $(OVERLEAF_PDFTOCAIRO) || true
|
||||
docker inspect $(OVERLEAF_PNG2PDF) > /dev/null && docker pull $(OVERLEAF_PNG2PDF) || true
|
||||
|
||||
refresh-cache: refresh-cache-latest
|
||||
refresh-cache-latest:
|
||||
docker inspect $(OVERLEAF_BASE_LATEST) > /dev/null && docker pull $(OVERLEAF_BASE_LATEST) || true
|
||||
docker inspect $(OVERLEAF_LATEST) > /dev/null && docker pull $(OVERLEAF_LATEST) || true
|
||||
docker inspect $(OVERLEAF_PANDOC_LATEST) > /dev/null && docker pull $(OVERLEAF_PANDOC_LATEST) || true
|
||||
docker inspect $(OVERLEAF_PDFTOCAIRO) > /dev/null && docker pull $(OVERLEAF_PDFTOCAIRO) || true
|
||||
docker inspect $(OVERLEAF_PNG2PDF) > /dev/null && docker pull $(OVERLEAF_PNG2PDF) || true
|
||||
|
||||
build-base:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
@@ -52,6 +39,7 @@ build-base:
|
||||
--tag $(OVERLEAF_BASE_BRANCH) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
|
||||
build-community:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
docker build \
|
||||
@@ -66,53 +54,11 @@ build-community:
|
||||
--tag $(OVERLEAF_BRANCH) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
build-pandoc:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
docker build \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--progress=plain \
|
||||
--build-arg OVERLEAF_BASE_TAG \
|
||||
--label "com.overleaf.ce.revision=$(MONOREPO_REVISION)" \
|
||||
--cache-from $(OVERLEAF_PANDOC_LATEST) \
|
||||
--cache-from $(OVERLEAF_PANDOC_BRANCH) \
|
||||
--file Dockerfile-pandoc \
|
||||
--tag $(OVERLEAF_PANDOC_TAG) \
|
||||
--tag $(OVERLEAF_PANDOC_BRANCH) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
build-pdftocairo:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
docker build \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--progress=plain \
|
||||
--build-arg OVERLEAF_BASE_TAG \
|
||||
--label "com.overleaf.ce.revision=$(MONOREPO_REVISION)" \
|
||||
--cache-from $(OVERLEAF_PDFTOCAIRO) \
|
||||
--file Dockerfile-pdftocairo \
|
||||
--tag $(OVERLEAF_PDFTOCAIRO) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
build-png2pdf:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
docker build \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--progress=plain \
|
||||
--build-arg OVERLEAF_BASE_TAG \
|
||||
--label "com.overleaf.ce.revision=$(MONOREPO_REVISION)" \
|
||||
--cache-from $(OVERLEAF_PNG2PDF) \
|
||||
--file Dockerfile-png2pdf \
|
||||
--tag $(OVERLEAF_PNG2PDF) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
push:
|
||||
docker push $(OVERLEAF_BASE_TAG)
|
||||
docker push $(OVERLEAF_BASE_BRANCH)
|
||||
docker push $(OVERLEAF_TAG)
|
||||
docker push $(OVERLEAF_BRANCH)
|
||||
docker push $(OVERLEAF_PANDOC_TAG)
|
||||
docker push $(OVERLEAF_PANDOC_BRANCH)
|
||||
docker push $(OVERLEAF_PDFTOCAIRO)
|
||||
docker push $(OVERLEAF_PNG2PDF)
|
||||
|
||||
SHELLCHECK_OPTS = \
|
||||
--shell=bash \
|
||||
@@ -134,5 +80,5 @@ shellcheck_fix:
|
||||
done
|
||||
|
||||
.PHONY: all \
|
||||
build-base build-community build-pandoc build-pdftocairo build-png2pdf\
|
||||
build-base build-community \
|
||||
shellcheck shellcheck_fix
|
||||
|
||||
@@ -21,14 +21,7 @@ server {
|
||||
proxy_send_timeout 10m;
|
||||
}
|
||||
|
||||
# Only the git smart-HTTP endpoints of a project (/git/<project id>[.git]/...) are
|
||||
# public. git-bridge's other routes (/status, /health_check, /metrics, /diags,
|
||||
# /api/...) are meant for the internal network only and must not be reachable.
|
||||
location /git/ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location ~ "^/git/[0-9a-f]{24}(\.git)?(/|$)" {
|
||||
location ^~ /git/ {
|
||||
resolver_timeout 2s;
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $git_upstream http://${GIT_BRIDGE_HOST}:${GIT_BRIDGE_PORT};
|
||||
@@ -70,17 +63,6 @@ server {
|
||||
expires 1y;
|
||||
}
|
||||
|
||||
# handle output.zip, which clsi builds on demand rather than writing to disk
|
||||
location ~ ^/project/[0-9a-f]+(/user/[0-9a-f]+)?/build/[0-9a-f-]+/output/output\.zip$ {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 10m;
|
||||
proxy_send_timeout 10m;
|
||||
}
|
||||
|
||||
# handle output files for specific users
|
||||
location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/output\.([a-z.]+)$ {
|
||||
proxy_pass http://127.0.0.1:8080; # clsi-nginx.conf
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/bin/sh
|
||||
# CLSI "png2pdf" container contract shim (engine: img2pdf 0.6.3, josch).
|
||||
# Replaces the proprietary quay.io/sharelatex/png2pdf:2026-06-24.
|
||||
#
|
||||
# Invoked by the CLSI docker runner (go/services/clsitex/dockerrunner.go, go/services/clsitex/png2pdf/png2pdf.go):
|
||||
# args: ["--in-place", "--", "<relpath1>", ...] CWD=/compile
|
||||
# the docker runner does NOT supply a binary name in the command, so the
|
||||
# image ENTRYPOINT is this script.
|
||||
#
|
||||
# Paths: each arg is a conversion path CLSI downloaded, RELATIVE to the
|
||||
# mounted cache dir (CWD /compile). Real CLSI derives it from the file
|
||||
# URL path (u.Path: '/'->'-') + mtime + ".opt<10-hex cacheKey>",
|
||||
# e.g. ".-uploads-filestore-a1b2c3d4-0-1-1756600000000.opta1b2c3d4a1bc"
|
||||
# (leading dash, no directory separator).
|
||||
#
|
||||
# "IN-PLACE" means "rewrite each input PATH as a PDF". CLSI then renames
|
||||
# each path to its own ".opt" cache entry and copies it into the compile
|
||||
# dir (urlcache.CommitConversion) — PER FILE and UNCONDITIONALLY (sync.go:
|
||||
# a conversion error only logs a warning). So each converted file lands
|
||||
# in the cache even in a partially-failed run; an unconverted file is
|
||||
# committed as the original-PNG fallback.
|
||||
#
|
||||
# Contract (go/services/clsitex/png2pdf/png2pdf.go):
|
||||
# - stdout: one "Converted <f> to PDF" per converted file (Go counts
|
||||
# lines matching ^Converted .+ to PDF$ -> stats.png2pdf).
|
||||
# - exit non-zero if anything fails: CLSI logs a warning and commits
|
||||
# each file's current on-disk state (converted or original).
|
||||
#
|
||||
# Why img2pdf: LOSSLESS. PNG IDAT is embedded verbatim into the PDF (no
|
||||
# re-encode); the alpha channel becomes a PDF /SMask, also lossless.
|
||||
# That is the mechanism of the feature: pdfTeX copies PDF image streams
|
||||
# cheaply, whereas PNGs with alpha/gamma/palette/interlace are re-decoded
|
||||
# from the PNG on every compile — what made them "slow". Without
|
||||
# --imgsize the image keeps its pHYs DPI, falling back to 96dpi (the same
|
||||
# default pdfLaTeX itself uses).
|
||||
#
|
||||
# Failure handling: convert to a temp + atomic replace, and CONTINUE with
|
||||
# the remaining files after a failure (their valid conversions are
|
||||
# committed by CLSI even though the run is reported failed). A failed
|
||||
# file stays exactly the original PNG CLSI downloaded.
|
||||
|
||||
set -u
|
||||
|
||||
# argv is exactly [--in-place]... ["--"] <files...>. The loop terminates:
|
||||
# every iteration either shifts, breaks, or exits.
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--) shift ; break ;;
|
||||
--in-place) shift ;;
|
||||
*) echo "png2pdf: unexpected argument: $1" >&2 ; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
echo "png2pdf: no input files (usage: --in-place -- <png>...)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
rc=0
|
||||
for file in "$@"; do
|
||||
# Resolve against CWD so a leading '-' (or '.') never lands in an argv
|
||||
# slot of img2pdf/mv, where it would be parsed as an option.
|
||||
case "$file" in
|
||||
/ | /*) abs="$file" ;;
|
||||
*) abs="$(pwd)/$file" ;;
|
||||
esac
|
||||
tmp="${abs}.tmp"
|
||||
|
||||
if ! img2pdf --output "$tmp" "$abs"; then
|
||||
echo "png2pdf: conversion failed for $file" >&2
|
||||
rm -f -- "$tmp"
|
||||
rc=1
|
||||
continue
|
||||
fi
|
||||
if ! mv -- "$tmp" "$abs"; then
|
||||
echo "png2pdf: failed to replace $file" >&2
|
||||
rm -f -- "$tmp"
|
||||
rc=1
|
||||
continue
|
||||
fi
|
||||
echo "Converted $file to PDF"
|
||||
done
|
||||
exit "$rc"
|
||||
@@ -1,18 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Grant the service user access to the Docker socket group, same as the CLSI
|
||||
# run script. Needed for CONVERTER=pdftocairo-sandbox (filestore launches the
|
||||
# sibling conversion container through /var/run/docker.sock). Idempotent: a
|
||||
# fresh container may have no such group yet, and CLSI's run script does the
|
||||
# same thing, so just append to the membership if it exists.
|
||||
if [ -e '/var/run/docker.sock' ]; then
|
||||
DOCKER_GROUP=$(stat -c '%g' /var/run/docker.sock)
|
||||
if ! getent group dockeronhost >/dev/null; then
|
||||
groupadd --non-unique --gid "${DOCKER_GROUP}" dockeronhost
|
||||
fi
|
||||
usermod -aG dockeronhost www-data
|
||||
fi
|
||||
|
||||
source /etc/overleaf/env.sh
|
||||
export LISTEN_ADDRESS=127.0.0.1
|
||||
|
||||
|
||||
@@ -48,20 +48,7 @@ const DockerRunner = {
|
||||
image = `${Settings.texliveImageNameOveride}/${img}`
|
||||
}
|
||||
|
||||
if (
|
||||
Settings.path.sandboxedCompilesHostDirCache &&
|
||||
directory.startsWith(Settings.path.clsiCacheDir + Path.sep)
|
||||
) {
|
||||
// In: directory = '<clsiCacheDir>/projectId'
|
||||
// sandboxedCompilesHostDirCache = '/host/cache'
|
||||
// Out: directory = '/host/cache/projectId'
|
||||
// (png2pdf & friends operate on CLSI's per-project cache dir, which
|
||||
// is not under the compiles dir)
|
||||
directory = Path.join(
|
||||
Settings.path.sandboxedCompilesHostDirCache,
|
||||
Path.basename(directory)
|
||||
)
|
||||
} else if (compileGroup === 'synctex-output') {
|
||||
if (compileGroup === 'synctex-output') {
|
||||
// In: directory = '/overleaf/services/clsi/output/projectId-userId/generated-files/buildId'
|
||||
// directory.split('/').slice(-3) === 'projectId-userId/generated-files/buildId'
|
||||
// sandboxedCompilesHostDirOutput = '/host/output'
|
||||
@@ -106,35 +93,32 @@ const DockerRunner = {
|
||||
// logOptions = _.clone(options)
|
||||
// logOptions?.HostConfig?.SecurityOpt = "secomp used, removed in logging"
|
||||
logger.debug({ projectId }, 'running docker container')
|
||||
// Bounded retry: a permanent container create/start failure (e.g.
|
||||
// "unable to find user" on a FROM-scratch image) must not busy-loop
|
||||
// forever — surface the error to the caller after a couple of attempts.
|
||||
let retriesLeft = 2
|
||||
const runAttempt = () => {
|
||||
DockerRunner._runAndWaitForContainer(
|
||||
options,
|
||||
volumes,
|
||||
timeout,
|
||||
(error, output) => {
|
||||
if (error && error.statusCode === 500 && retriesLeft > 0) {
|
||||
retriesLeft -= 1
|
||||
logger.debug(
|
||||
{ err: error, projectId, retriesLeft },
|
||||
'error running container so destroying and retrying'
|
||||
DockerRunner._runAndWaitForContainer(
|
||||
options,
|
||||
volumes,
|
||||
timeout,
|
||||
(error, output) => {
|
||||
if (error && error.statusCode === 500) {
|
||||
logger.debug(
|
||||
{ err: error, projectId },
|
||||
'error running container so destroying and retrying'
|
||||
)
|
||||
DockerRunner.destroyContainer(name, null, true, error => {
|
||||
if (error != null) {
|
||||
return callback(error)
|
||||
}
|
||||
DockerRunner._runAndWaitForContainer(
|
||||
options,
|
||||
volumes,
|
||||
timeout,
|
||||
callback
|
||||
)
|
||||
DockerRunner.destroyContainer(name, null, true, err => {
|
||||
if (err != null) {
|
||||
return callback(err)
|
||||
}
|
||||
runAttempt()
|
||||
})
|
||||
} else {
|
||||
callback(error, output)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
callback(error, output)
|
||||
}
|
||||
)
|
||||
}
|
||||
runAttempt()
|
||||
}
|
||||
)
|
||||
|
||||
// pass back the container name to allow it to be killed
|
||||
return name
|
||||
|
||||
@@ -172,15 +172,7 @@ if ((process.env.DOCKER_RUNNER || process.env.SANDBOXED_COMPILES) === 'true') {
|
||||
wordcount: { 'HostConfig.AutoRemove': true },
|
||||
synctex: { 'HostConfig.AutoRemove': true },
|
||||
'synctex-output': { 'HostConfig.AutoRemove': true },
|
||||
// The pdftocairo image is also built FROM scratch with no passwd file,
|
||||
// so the default named user (www-data) fails at container create with
|
||||
// "unable to find user ... no matching entries in passwd file". Run it
|
||||
// as the numeric uid/gid of this clsi process (which owns the files it
|
||||
// converts), same as the png2pdf image below.
|
||||
conversions: {
|
||||
'HostConfig.AutoRemove': true,
|
||||
User: `${process.getuid()}:${process.getgid()}`,
|
||||
},
|
||||
conversions: { 'HostConfig.AutoRemove': true },
|
||||
// The png2pdf image is built FROM scratch and has no 'tex' user, so run
|
||||
// it as the same uid/gid as this clsi process, which owns the cache files
|
||||
// it converts in place.
|
||||
|
||||
@@ -2,7 +2,6 @@ import metrics from '@overleaf/metrics'
|
||||
import Settings from '@overleaf/settings'
|
||||
import { callbackify } from 'node:util'
|
||||
import SafeExec from './SafeExec.js'
|
||||
import SandboxConverterRunner from './SandboxConverterRunner.js'
|
||||
import Errors from './Errors.js'
|
||||
|
||||
const { ConversionError } = Errors
|
||||
@@ -23,9 +22,6 @@ export default {
|
||||
}
|
||||
|
||||
async function convert(sourcePath, requestedFormat) {
|
||||
if (Settings.converter === 'pdftocairo-sandbox') {
|
||||
return await _convertInSandbox(sourcePath, requestedFormat, 1500)
|
||||
}
|
||||
if (Settings.converter === 'pdftocairo') {
|
||||
const width = 1500
|
||||
return await _convert(sourcePath, requestedFormat, [
|
||||
@@ -53,9 +49,6 @@ async function convert(sourcePath, requestedFormat) {
|
||||
}
|
||||
|
||||
async function thumbnail(sourcePath) {
|
||||
if (Settings.converter === 'pdftocairo-sandbox') {
|
||||
return await _convertInSandbox(sourcePath, 'png', 700)
|
||||
}
|
||||
if (Settings.converter === 'pdftocairo') {
|
||||
const width = 700
|
||||
return await _convert(sourcePath, 'png', [
|
||||
@@ -88,9 +81,6 @@ async function thumbnail(sourcePath) {
|
||||
|
||||
async function preview(sourcePath) {
|
||||
const width = 1000
|
||||
if (Settings.converter === 'pdftocairo-sandbox') {
|
||||
return await _convertInSandbox(sourcePath, 'png', width)
|
||||
}
|
||||
if (Settings.converter === 'pdftocairo') {
|
||||
return await _convert(sourcePath, 'png', [
|
||||
'pdftocairo',
|
||||
@@ -119,50 +109,6 @@ async function preview(sourcePath) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pdftocairo conversion inside a sandboxed sibling container (no
|
||||
* network, all caps dropped, memory cap — see SandboxConverterRunner.js).
|
||||
*
|
||||
* Keeps the same contract as the local path: the result is written to
|
||||
* `${sourcePath}.${requestedFormat}`.
|
||||
*
|
||||
* @param {string} sourcePath
|
||||
* @param {string} requestedFormat 'png' or 'jpg'
|
||||
* @param {number} width output width in px
|
||||
* @return {Promise<{outputHostPath: string}>}
|
||||
*/
|
||||
async function _convertInSandbox(sourcePath, requestedFormat, width) {
|
||||
if (!APPROVED_FORMATS.includes(requestedFormat)) {
|
||||
throw new ConversionError('invalid format requested', { format: requestedFormat })
|
||||
}
|
||||
const ext = requestedFormat
|
||||
const workDir = '/work'
|
||||
// poppler appends the device extension to the output argument, so the
|
||||
// argument must end WITHOUT an extension; the result lands at
|
||||
// /work/input.pdf.<png|jpg> next to the input.
|
||||
const device = requestedFormat === 'png' ? '-png' : '-jpeg'
|
||||
const result = await SandboxConverterRunner.runConversion({
|
||||
sourcePath,
|
||||
containerArgs: [
|
||||
'pdftocairo',
|
||||
device,
|
||||
'-singlefile',
|
||||
'-scale-to-x',
|
||||
width.toString(),
|
||||
'-scale-to-y',
|
||||
'-1', // maintain aspect ratio
|
||||
`${workDir}/input.pdf`,
|
||||
`${workDir}/input.pdf`,
|
||||
],
|
||||
workDir,
|
||||
outputBase: 'input.pdf',
|
||||
outputExt: ext,
|
||||
destPath: `${sourcePath}.${requestedFormat}`,
|
||||
})
|
||||
// Same contract as the local converters: return the destination path string.
|
||||
return result.outputHostPath
|
||||
}
|
||||
|
||||
async function _convert(sourcePath, requestedFormat, command) {
|
||||
if (!APPROVED_FORMATS.includes(requestedFormat)) {
|
||||
throw new ConversionError('invalid format requested', {
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
// SandboxConverterRunner.js
|
||||
//
|
||||
// Runs a PDF→image conversion (pdftocairo) in a throwaway sibling container,
|
||||
// the same isolation profile CLSI's DockerRunner gives its conversion
|
||||
// containers (see services/clsi/app/js/DockerRunner.mjs and the `conversions`
|
||||
// / `png2pdf` compile groups in services/clsi/config/settings.defaults.cjs):
|
||||
//
|
||||
// - no network access (HostConfig.NetworkMode: 'none')
|
||||
// - all capabilities dropped (HostConfig.CapDrop: ['ALL'])
|
||||
// - no new privileges (SecurityOpt: 'no-new-privileges')
|
||||
// - hard memory cap
|
||||
// - numeric uid:gid of the overleaf service user (the scratch-based
|
||||
// converter images have no passwd entries, so named users fail)
|
||||
// - container is always removed afterwards
|
||||
//
|
||||
// Implemented as a minimal Docker Engine API client over the unix socket so
|
||||
// that no new npm dependency is required (this service is installed with
|
||||
// Yarn PnP; cross-service packages such as dockerode are not resolvable
|
||||
// here).
|
||||
//
|
||||
// IMPORTANT (bind mounts): the daemon runs on the Docker host, so the
|
||||
// bind source MUST be a daemon-host-visible path (in this deployment:
|
||||
// under the overleaf `./data` volume), never a container-internal path
|
||||
// such as /tmp. The caller passes a `hostDir` that satisfies this.
|
||||
|
||||
import http from 'node:http'
|
||||
import Path from 'node:path'
|
||||
import Fs from 'node:fs'
|
||||
import crypto from 'node:crypto'
|
||||
import Settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import Errors from './Errors.js'
|
||||
|
||||
const { ConversionError } = Errors
|
||||
|
||||
const API_VERSION = 'v1.43' // conservative; the engine API is backwards compatible
|
||||
|
||||
/**
|
||||
* One request against the Docker Engine API on a unix socket.
|
||||
*
|
||||
* @param {string} socketPath
|
||||
* @param {string} method
|
||||
* @param {string} apiPath e.g. `/v1.43/containers/create?name=xyz`
|
||||
* @param {object} [opts]
|
||||
* @param {object|string} [opts.body] JSON body
|
||||
* @param {number} [opts.timeoutMs] hard deadline for the whole request
|
||||
* @param {Buffer|null} [opts.expectRaw] pass a truthy value to keep the body
|
||||
* as a Buffer (used for the log stream)
|
||||
* @return {Promise<{status: number, headers: object, body: any, raw: Buffer}>}
|
||||
*/
|
||||
function request(socketPath, method, apiPath, { body, timeoutMs = 60000, raw = false } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = body === undefined || body === null ? null : JSON.stringify(body)
|
||||
let settled = false
|
||||
const req = http.request(
|
||||
{
|
||||
socketPath,
|
||||
method,
|
||||
path: apiPath,
|
||||
headers: data
|
||||
? {
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(data),
|
||||
}
|
||||
: {},
|
||||
},
|
||||
(res) => {
|
||||
const chunks = []
|
||||
res.on('data', (c) => chunks.push(c))
|
||||
res.on('end', () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
const buf = Buffer.concat(chunks)
|
||||
if (raw) return resolve({ status: res.statusCode, headers: res.headers, body: buf, raw: buf })
|
||||
let parsed = buf
|
||||
if (buf.length && (buf[0] === 0x7b || buf[0] === 0x5b)) {
|
||||
try {
|
||||
parsed = JSON.parse(buf.toString('utf8'))
|
||||
} catch (e) {
|
||||
// non-JSON body (e.g. plain text) — keep buffer
|
||||
parsed = buf
|
||||
}
|
||||
}
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: parsed, raw: buf })
|
||||
})
|
||||
}
|
||||
)
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
req.destroy()
|
||||
reject(new Error(`docker api ${method} ${apiPath} timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
timer.unref?.()
|
||||
req.on('error', (err) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
})
|
||||
if (data) req.write(data)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
/** Decode the framed (TTY=false) docker log stream. */
|
||||
function decodeLogStream(buf) {
|
||||
if (!buf || !buf.length) return ''
|
||||
// 8-byte header per frame: [stream(1)] [size_be32(4)] [reserved(3)] + payload
|
||||
// (older daemons use a 4-byte header; try the 8-byte layout first)
|
||||
let out = ''
|
||||
let i = 0
|
||||
while (i + 8 <= buf.length) {
|
||||
const size = buf.readUInt32BE(i + 4)
|
||||
if (i + 8 + size > buf.length) break
|
||||
if (buf[i] === 1 || buf[i] === 2) out += buf.subarray(i + 8, i + 8 + size).toString('utf8')
|
||||
i += 8 + size
|
||||
}
|
||||
return out || buf.toString('utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort sweep of leftover `filestore-convert-*` containers from crashes
|
||||
* or service restarts mid-run (a container that was created but never started
|
||||
* or removed otherwise would leak forever).
|
||||
*/
|
||||
async function _sweepLeftovers(socketPath) {
|
||||
let res
|
||||
try {
|
||||
res = await request(
|
||||
socketPath,
|
||||
'GET',
|
||||
`/${API_VERSION}/containers/json?all=1&limit=200&filters=%7B%22name%22%3A%5B%22filestore-convert-%22%5D%7D`,
|
||||
{ timeoutMs: 10000 }
|
||||
)
|
||||
} catch (err) {
|
||||
return // not fatal
|
||||
}
|
||||
if (res.status !== 200 || !Array.isArray(res.body)) return
|
||||
for (const c of res.body) {
|
||||
if (typeof c.Names?.[0] === 'string' && c.Names[0].startsWith('/filestore-convert-')) {
|
||||
const name = c.Names[0].slice(1)
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
await request(socketPath, 'DELETE', `/${API_VERSION}/containers/${name}?force=1`, { timeoutMs: 10000 })
|
||||
logger.warn({ name }, 'sandbox conversion: removed leftover container')
|
||||
break
|
||||
} catch (err) {
|
||||
if (attempt === 0) continue
|
||||
logger.warn({ err, name }, 'sandbox conversion: failed to remove leftover container')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a conversion in a sandboxed container.
|
||||
*
|
||||
* Layout contract (mirrors CLSI's conversion runner):
|
||||
* host: <hostDir>/<runId>/input.pdf <- the source file (copied in)
|
||||
* host: <hostDir>/<runId>/<outputBase>.<ext> <- produced by the container
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.sourcePath absolute path to the source PDF (must be
|
||||
* inside a daemon-host-visible directory — the bind source is derived from it)
|
||||
* @param {string} opts.containerArgs full argv inside the container, e.g.
|
||||
* ['pdftocairo','-png','-singlefile','-scale-to-x','700','-scale-to-y','-1',
|
||||
* '/work/input.pdf','/work/output']
|
||||
* @param {string} opts.workDir basename mapping used in `containerArgs`; the
|
||||
* source file is placed at <workDir>/input.pdf inside the container
|
||||
* @param {string} opts.outputBase container-side output basename (without ext)
|
||||
* @param {string} opts.outputExt 'png' or 'jpg'
|
||||
* @param {string} opts.destPath absolute host path where the result must end up
|
||||
* @return {Promise<{container: string, exitCode: number, outputHostPath: string, durationMs: number}>}
|
||||
*/
|
||||
export async function runConversion(opts) {
|
||||
const {
|
||||
sourcePath,
|
||||
containerArgs,
|
||||
workDir = '/work',
|
||||
outputBase = 'output',
|
||||
outputExt = 'png',
|
||||
destPath,
|
||||
} = opts
|
||||
|
||||
const sandbox = Settings.sandboxConversion || {}
|
||||
const image = sandbox.image || 'sharelatex/pdftocairo:24.02'
|
||||
const socketPath = sandbox.dockerSocketPath || '/var/run/docker.sock'
|
||||
const hostDir = sandbox.hostDir // daemon-host side of the scratch dir
|
||||
const containerDir = sandbox.containerDir // overleaf-container side
|
||||
const memoryBytes = sandbox.memoryBytes || 512 * 1024 * 1024
|
||||
const timeoutMs = sandbox.timeoutMs || 40000
|
||||
|
||||
if (!hostDir || !containerDir) {
|
||||
throw new ConversionError(
|
||||
'sandbox conversion is not configured: set CONVERSION_SANDBOX_HOST_DIR (Docker-daemon side) and CONVERSION_SANDBOX_DIR (overleaf-container side) to the two views of the same directory'
|
||||
)
|
||||
}
|
||||
|
||||
const runId = crypto.randomBytes(8).toString('hex')
|
||||
const runName = `filestore-convert-${runId}`
|
||||
const runDir = Path.join(containerDir, runId) // path used by THIS process (container side)
|
||||
const hostRunDir = Path.join(hostDir, runId) // same dir from the daemon side (bind source)
|
||||
const inputHostPath = Path.join(runDir, 'input.pdf')
|
||||
const outputHostPath = Path.join(runDir, `${outputBase}.${outputExt}`)
|
||||
|
||||
const t0 = Date.now()
|
||||
let containerCreated = false
|
||||
try {
|
||||
// Guard against leftover containers from an interrupted previous run.
|
||||
await _sweepLeftovers(socketPath)
|
||||
|
||||
Fs.mkdirSync(runDir, { mode: 0o770 })
|
||||
Fs.copyFileSync(sourcePath, inputHostPath)
|
||||
|
||||
// Container-absolute path of the source file, for argv remapping sanity
|
||||
const sourceInContainer = Path.join(workDir, 'input.pdf')
|
||||
if (!containerArgs.some((a) => a === sourceInContainer)) {
|
||||
throw new ConversionError(
|
||||
`sandbox argv does not reference ${sourceInContainer} — refusing to run`
|
||||
)
|
||||
}
|
||||
|
||||
// Create (the converter images are scratch-based: no ENTRYPOINT, so a bare
|
||||
// Cmd list is what CLSI runs successfully too).
|
||||
const createBody = {
|
||||
Image: image,
|
||||
Cmd: containerArgs,
|
||||
WorkingDir: workDir,
|
||||
// scratch-based images have no passwd entries — run as the numeric
|
||||
// uid:gid of this service (it owns the files on both sides of the bind).
|
||||
User: `${process.getuid()}:${process.getgid()}`,
|
||||
HostConfig: {
|
||||
Binds: [`${hostRunDir}:${workDir}:rw`],
|
||||
CapDrop: ['ALL'],
|
||||
SecurityOpt: ['no-new-privileges'],
|
||||
NetworkMode: 'none',
|
||||
Memory: memoryBytes,
|
||||
MemorySwap: 0,
|
||||
PidsLimit: 64,
|
||||
},
|
||||
}
|
||||
const created = await request(socketPath, 'POST', `/${API_VERSION}/containers/create?name=${runName}`, {
|
||||
body: createBody,
|
||||
timeoutMs: 60000,
|
||||
})
|
||||
if (created.status >= 300 || !created.body?.Id) {
|
||||
const msg = created.body?.message || JSON.stringify(created.body) || `status ${created.status}`
|
||||
throw new ConversionError(`failed to create sandbox container: ${msg}`)
|
||||
}
|
||||
containerCreated = true
|
||||
logger.info({ image, runName, hostDir: hostRunDir, containerDir: runDir }, 'sandbox conversion: container created')
|
||||
|
||||
await request(socketPath, 'POST', `/${API_VERSION}/containers/${runName}/start`, { timeoutMs: 60000 })
|
||||
|
||||
// Wait with a real deadline; kill on breach (the /wait call is a blocking
|
||||
// HTTP call that carries no data, so it gets its own generous budget).
|
||||
let exitCode = -1
|
||||
let killed = false
|
||||
try {
|
||||
// NOTE: this daemon (Docker 29 / API ≥1.44) requires POST for /wait; GET
|
||||
// returns 404. (dockerode does the same, which is how CLSI waits.)
|
||||
const waited = await request(socketPath, 'POST', `/${API_VERSION}/containers/${runName}/wait`, {
|
||||
timeoutMs: timeoutMs + 15000,
|
||||
})
|
||||
exitCode = waited.body?.StatusCode ?? -1
|
||||
if (exitCode === -1 && waited.status !== 200) {
|
||||
throw new Error(`unexpected /wait status ${waited.status}: ${waited.raw?.toString?.('utf8') || 'no body'}`)
|
||||
}
|
||||
} catch (waitErr) {
|
||||
killed = true
|
||||
await _killAndRemove(socketPath, runName)
|
||||
throw new ConversionError(`sandbox conversion timed out (${timeoutMs}ms): ${waitErr.message}`)
|
||||
}
|
||||
|
||||
let logs = ''
|
||||
try {
|
||||
const logsRes = await request(
|
||||
socketPath,
|
||||
'GET',
|
||||
`/${API_VERSION}/containers/${runName}/logs?stdout=1&stderr=1&stdin=0&follow=1`,
|
||||
{ raw: true, timeoutMs: 10000 }
|
||||
)
|
||||
logs = decodeLogStream(logsRes.raw)
|
||||
} catch (err) {
|
||||
logger.warn({ err, runName }, 'sandbox conversion: could not read container logs')
|
||||
}
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new ConversionError(
|
||||
`sandbox conversion failed (exit ${exitCode}${killed ? ', killed' : ''})`,
|
||||
{ stderr: logs.trim().slice(-4000), exitCode }
|
||||
)
|
||||
}
|
||||
|
||||
let stat
|
||||
try {
|
||||
stat = Fs.statSync(outputHostPath)
|
||||
} catch (err) {
|
||||
throw new ConversionError(`sandbox conversion produced no output file`, { stderr: logs.trim().slice(-4000) })
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new ConversionError('sandbox conversion output is not a regular file', {
|
||||
stderr: logs.trim().slice(-4000),
|
||||
})
|
||||
}
|
||||
|
||||
Fs.copyFileSync(outputHostPath, destPath)
|
||||
logger.info(
|
||||
{ runName, sizeBytes: stat.size, durationMs: Date.now() - t0 },
|
||||
'sandbox conversion completed'
|
||||
)
|
||||
return { container: runName, exitCode, outputHostPath: destPath, durationMs: Date.now() - t0 }
|
||||
} finally {
|
||||
// Best-effort cleanup: container + scratch dir (the dir holds the original
|
||||
// PDF — do not leave it behind).
|
||||
if (containerCreated) {
|
||||
await _killAndRemove(socketPath, runName).catch((e) => {
|
||||
logger.warn({ err: e, runName }, 'sandbox conversion: cleanup failed')
|
||||
})
|
||||
}
|
||||
Fs.rmSync(runDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function _killAndRemove(socketPath, name) {
|
||||
try {
|
||||
// POST /kill (GET is not served by this daemon); 409 = not running → fine
|
||||
await request(socketPath, 'POST', `/${API_VERSION}/containers/${name}/kill`, { timeoutMs: 10000 })
|
||||
} catch (err) {
|
||||
// not running → fine
|
||||
}
|
||||
try {
|
||||
await request(socketPath, 'DELETE', `/${API_VERSION}/containers/${name}?force=1`, { timeoutMs: 10000 })
|
||||
} catch (err) {
|
||||
// already gone → fine
|
||||
}
|
||||
}
|
||||
|
||||
export default { runConversion }
|
||||
@@ -99,29 +99,8 @@ const settings = {
|
||||
|
||||
enableConversions: process.env.ENABLE_CONVERSIONS === 'true',
|
||||
|
||||
// PDF→image conversion backend (template gallery previews/thumbnails):
|
||||
// 'imagemagick' – local, via ImageMagick `convert` (default; binary required)
|
||||
// 'pdftocairo' – local, via the `pdftocairo` binary (poppler)
|
||||
// 'pdftocairo-sandbox' – runs `pdftocairo` in a throwaway sibling container:
|
||||
// no network, CapDrop ALL, no-new-privileges, memory
|
||||
// cap (see app/js/SandboxConverterRunner.js).
|
||||
// requires a Docker socket + a daemon-host-visible
|
||||
// scratch dir (CONVERSION_SANDBOX_HOST_DIR).
|
||||
converter: process.env.CONVERTER || 'imagemagick',
|
||||
|
||||
sandboxConversion: {
|
||||
image: process.env.CONVERSION_SANDBOX_IMAGE || 'sharelatex/pdftocairo:24.02',
|
||||
dockerSocketPath: process.env.CONVERSION_DOCKER_SOCKET || '/var/run/docker.sock',
|
||||
// The scratch dir has two views of the same path (the overleaf container
|
||||
// mounts ./data): `containerDir` for this process, `hostDir` for the Docker
|
||||
// daemon's bind mount. Both must be set when sandbox conversion is used.
|
||||
hostDir: process.env.CONVERSION_SANDBOX_HOST_DIR || null,
|
||||
containerDir: process.env.CONVERSION_SANDBOX_DIR || null,
|
||||
memoryBytes:
|
||||
parseInt(process.env.CONVERSION_SANDBOX_MEMORY_BYTES, 10) || 512 * 1024 * 1024,
|
||||
timeoutMs: parseInt(process.env.CONVERSION_SANDBOX_TIMEOUT_MS, 10) || 40000,
|
||||
},
|
||||
|
||||
gracefulShutdownDelayInMs:
|
||||
parseInt(process.env.GRACEFUL_SHUTDOWN_DELAY_SECONDS ?? '30', 10) * 1000,
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# Build the a8m/envsubst binary, as it supports default values,
|
||||
# which the gnu envsubst (from gettext-base) does not.
|
||||
FROM golang:1.25.5-alpine AS envsubst_builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN go install github.com/a8m/envsubst/cmd/envsubst@latest
|
||||
|
||||
FROM maven:3-amazoncorretto-21-debian AS base
|
||||
|
||||
RUN apt-get update && apt-get install -y make git sqlite3 curl \
|
||||
&& rm -rf /var/lib/apt/lists
|
||||
|
||||
COPY --from=envsubst_builder /go/bin/envsubst /opt/envsubst
|
||||
RUN chmod +x /opt/envsubst
|
||||
|
||||
RUN useradd --create-home node
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
COPY . /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN make package \
|
||||
# The name of the created jar contains the current version tag.
|
||||
# Rename it to a static path that can be used for copying.
|
||||
&& find /app/target \
|
||||
-name 'writelatex-git-bridge*jar-with-dependencies.jar' \
|
||||
-exec mv {} /git-bridge.jar \;
|
||||
|
||||
FROM amazoncorretto:21-alpine
|
||||
|
||||
RUN apk add --update --no-cache bash git sqlite procps htop net-tools jemalloc util-linux
|
||||
|
||||
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
|
||||
|
||||
RUN adduser -D node
|
||||
|
||||
COPY --from=builder /git-bridge.jar /
|
||||
|
||||
COPY --from=envsubst_builder /go/bin/envsubst /opt/envsubst
|
||||
RUN chmod +x /opt/envsubst
|
||||
|
||||
COPY conf/envsubst_template.json envsubst_template.json
|
||||
COPY start.sh start.sh
|
||||
COPY server-pro-start.sh server-pro-start.sh
|
||||
|
||||
RUN mkdir conf
|
||||
RUN chown node:node conf
|
||||
|
||||
USER node
|
||||
|
||||
CMD ["/start.sh"]
|
||||
@@ -1,89 +0,0 @@
|
||||
# git-bridge makefile
|
||||
|
||||
MVN_OPTS := --no-transfer-progress
|
||||
MVN_TARGET := target/writelatex-git-bridge-1.0-SNAPSHOT-jar-with-dependencies.jar
|
||||
|
||||
export BUILD_NUMBER ?= local
|
||||
export BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||
BRANCH_NAME_TAG_SAFE = $(shell echo $(BRANCH_NAME) | sed 's/\//\-\-/g')
|
||||
export COMMIT_SHA ?= $(shell git rev-parse HEAD)
|
||||
PROJECT_NAME = git-bridge
|
||||
|
||||
IMAGE_CI ?= ci/$(PROJECT_NAME):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER)
|
||||
IMAGE_REPO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME)
|
||||
IMAGE_REPO_BRANCH ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE)
|
||||
IMAGE_REPO_MAIN ?= $(IMAGE_REPO):main
|
||||
IMAGE_REPO_FINAL ?= $(IMAGE_REPO_BRANCH)-$(BUILD_NUMBER)
|
||||
|
||||
runtime-conf:
|
||||
/opt/envsubst < conf/envsubst_template.json > conf/runtime.json
|
||||
|
||||
|
||||
run: $(MVN_TARGET) runtime-conf
|
||||
@exec java $(GIT_BRIDGE_JVM_ARGS) -jar $(MVN_TARGET) conf/runtime.json
|
||||
|
||||
|
||||
$(MVN_TARGET): $(shell find src -type f) pom.xml
|
||||
mvn $(MVN_OPTS) package -DskipTests
|
||||
|
||||
build: $(MVN_TARGET)
|
||||
|
||||
docker_build_base:
|
||||
docker build --pull \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--cache-from $(IMAGE_REPO_BRANCH) \
|
||||
--cache-from $(IMAGE_REPO_MAIN) \
|
||||
--tag $(IMAGE_CI) \
|
||||
--target base \
|
||||
.
|
||||
|
||||
docker_build:
|
||||
docker build --pull \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--cache-from $(IMAGE_REPO_BRANCH) \
|
||||
--cache-from $(IMAGE_REPO_MAIN) \
|
||||
--tag $(IMAGE_REPO_BRANCH) \
|
||||
--tag $(IMAGE_REPO_FINAL) \
|
||||
.
|
||||
|
||||
format:
|
||||
mvn $(MVN_OPTS) com.spotify.fmt:fmt-maven-plugin:check
|
||||
|
||||
docker_format:
|
||||
docker run --rm -v $(PWD):$(PWD) -w $(PWD) --user node $(IMAGE_CI) make format
|
||||
|
||||
format_fix:
|
||||
mvn $(MVN_OPTS) com.spotify.fmt:fmt-maven-plugin:format
|
||||
|
||||
|
||||
test:
|
||||
mvn $(MVN_OPTS) test
|
||||
|
||||
docker_test:
|
||||
docker run --rm -v $(PWD):$(PWD) -w $(PWD) --user node $(IMAGE_CI) make test
|
||||
|
||||
clean:
|
||||
mvn $(MVN_OPTS) clean
|
||||
|
||||
|
||||
package: clean
|
||||
mvn $(MVN_OPTS) package -DskipTests
|
||||
|
||||
refresh_cache: refresh_cache_branch
|
||||
refresh_cache_branch:
|
||||
docker inspect $(IMAGE_REPO_BRANCH) > /dev/null && docker pull $(IMAGE_REPO_BRANCH) || true
|
||||
refresh_cache: refresh_cache_latest
|
||||
refresh_cache_latest:
|
||||
docker inspect $(IMAGE_REPO_MAIN) > /dev/null && docker pull $(IMAGE_REPO_MAIN) || true
|
||||
|
||||
push:
|
||||
docker push $(IMAGE_REPO_FINAL)
|
||||
|
||||
push_branch:
|
||||
docker push $(IMAGE_REPO_BRANCH)
|
||||
|
||||
clean_ci:
|
||||
-docker rmi -f $(IMAGE_CI) $(IMAGE_REPO_FINAL)
|
||||
-git clean -xdf .
|
||||
|
||||
.PHONY: run package build clean test runtime-conf
|
||||
@@ -1199,8 +1199,8 @@ function _finaliseRequest(projectId, options, project, docs, files) {
|
||||
timeout: options.timeout,
|
||||
imageName: project.imageName,
|
||||
draft: Boolean(options.draft),
|
||||
// CE: premium-compile (alpha/priority) gate removed
|
||||
png2pdf: Boolean(options.png2pdf),
|
||||
// enable for premium compiles only
|
||||
png2pdf: Boolean(options.png2pdf) && hasPremiumCompiles,
|
||||
// enable for premium compiles on an image that has a checkpointing build
|
||||
enableCheckpoint:
|
||||
Boolean(options.checkpointing) &&
|
||||
|
||||
@@ -453,8 +453,7 @@ const _CompileController = {
|
||||
enablePng2Pdf,
|
||||
enableCheckpointCompiles,
|
||||
} = await _getSplitTestOptions(req, res)
|
||||
// CE: saas feature gate removed so png2pdf / compile-from-history options are sent
|
||||
if (true) {
|
||||
if (Features.hasFeature('saas')) {
|
||||
options.compileFromClsiCache = true
|
||||
options.populateClsiCache = true
|
||||
options.compileFromHistory = compileFromHistory
|
||||
|
||||
@@ -123,12 +123,7 @@ const setDocumentSchema = z.object({
|
||||
version: z.number().int(),
|
||||
ranges: rangesSchemas.ranges,
|
||||
lastUpdatedAt: z.coerce.number().int().positive().nullish(),
|
||||
// document-updater records the acting user of each update it applies and
|
||||
// flushes it back here as lastUpdatedBy (see its RedisManager's
|
||||
// updateMeta.user_id). For a link-sharing visitor with no account that
|
||||
// is the sentinel 'anonymous-user', which real-time sets in Router.js --
|
||||
// never a user id. It is accepted here and normalised to null below.
|
||||
lastUpdatedBy: zz.objectId().or(z.literal('anonymous-user')).nullish(),
|
||||
lastUpdatedBy: zz.objectId().nullish(),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -137,11 +132,7 @@ async function setDocument(req, res) {
|
||||
logOnly: true,
|
||||
})
|
||||
const { Project_id: projectId, doc_id: docId } = params
|
||||
const { lines, version, ranges, lastUpdatedAt } = body
|
||||
// an anonymous editor has no user id to record on the project, and
|
||||
// Project.lastUpdatedBy is an ObjectId ref -- see setDocumentSchema
|
||||
const lastUpdatedBy =
|
||||
body.lastUpdatedBy === 'anonymous-user' ? null : body.lastUpdatedBy
|
||||
const { lines, version, ranges, lastUpdatedAt, lastUpdatedBy } = body
|
||||
const result = await ProjectEntityUpdateHandler.promises.updateDocLines(
|
||||
projectId,
|
||||
docId,
|
||||
|
||||
@@ -604,58 +604,6 @@ 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(
|
||||
[
|
||||
@@ -799,13 +747,6 @@ 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),
|
||||
|
||||
@@ -109,9 +109,6 @@ 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(),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -130,9 +127,6 @@ 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(),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -234,27 +228,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -127,29 +127,6 @@ 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 {
|
||||
@@ -167,14 +144,5 @@ export default {
|
||||
ProjectOptionsHandler.setHistoryRangesSupport
|
||||
),
|
||||
setReferenceFormat: callbackify(ProjectOptionsHandler.setReferenceFormat),
|
||||
setLanguageToolEnabled: callbackify(
|
||||
ProjectOptionsHandler.setLanguageToolEnabled
|
||||
),
|
||||
setLanguageToolPicky: callbackify(
|
||||
ProjectOptionsHandler.setLanguageToolPicky
|
||||
),
|
||||
setLanguageToolLanguage: callbackify(
|
||||
ProjectOptionsHandler.setLanguageToolLanguage
|
||||
),
|
||||
promises: ProjectOptionsHandler,
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ function _canHaveNoInitiatorId(operation, info) {
|
||||
if (operation === 'must-reset-password-set') return true
|
||||
if (operation === 'must-reset-password-unset') return true
|
||||
if (operation === 'account-suspension' && info.script) return true
|
||||
if (operation === 'account-suspension-lifted' && info.script) return true
|
||||
if (operation === 'release-managed-user' && info.script) return true
|
||||
}
|
||||
|
||||
|
||||
@@ -306,7 +306,6 @@ const UserPagesController = {
|
||||
title: Settings.nav?.login_support_title || 'login',
|
||||
login_support_title: Settings.nav?.login_support_title,
|
||||
login_support_text: Settings.nav?.login_support_text,
|
||||
local_login_mask_enable: Settings.enableLocalLoginMask,
|
||||
metadata,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -633,23 +633,6 @@ async function suspendUser(userId, auditLog = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
async function unsuspendUser(userId, auditLog = {}) {
|
||||
const res = await updateUser(
|
||||
{ _id: userId, suspended: true },
|
||||
{ $set: { suspended: false } }
|
||||
)
|
||||
if (res.matchedCount !== 1) {
|
||||
throw new Errors.NotFoundError('user id not found or not suspended')
|
||||
}
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId,
|
||||
'account-suspension-lifted',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ip,
|
||||
auditLog.info || {}
|
||||
)
|
||||
}
|
||||
|
||||
function _securityAlertPrimaryEmailChangedExtraRecipients(
|
||||
emailsData,
|
||||
oldEmail,
|
||||
@@ -706,7 +689,6 @@ export default {
|
||||
migrateDefaultEmailAddress: callbackify(migrateDefaultEmailAddress),
|
||||
updateUser: callbackify(updateUser),
|
||||
suspendUser: callbackify(suspendUser),
|
||||
unsuspendUser: callbackify(unsuspendUser),
|
||||
promises: {
|
||||
addAffiliationForNewUser,
|
||||
addEmailAddress,
|
||||
@@ -720,6 +702,5 @@ export default {
|
||||
migrateDefaultEmailAddress,
|
||||
updateUser,
|
||||
suspendUser,
|
||||
unsuspendUser,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,706 +0,0 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import OError from '@overleaf/o-error'
|
||||
import { db } from '../../infrastructure/mongodb.mjs'
|
||||
import Mongo from '../Helpers/Mongo.mjs'
|
||||
import { callbackify } from 'node:util'
|
||||
import UserGetter from './UserGetter.mjs'
|
||||
import InstitutionsAPI from '../Institutions/InstitutionsAPI.mjs'
|
||||
import Features from '../../infrastructure/Features.mjs'
|
||||
import FeaturesUpdater from '../Subscription/FeaturesUpdater.mjs'
|
||||
import EmailHandler from '../Email/EmailHandler.mjs'
|
||||
import EmailHelper from '../Helpers/EmailHelper.mjs'
|
||||
import Errors from '../Errors/Errors.js'
|
||||
import UserAuditLogHandler from './UserAuditLogHandler.mjs'
|
||||
import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
|
||||
import EmailChangeHelper from '../Analytics/EmailChangeHelper.mjs'
|
||||
import SubscriptionLocator from '../Subscription/SubscriptionLocator.mjs'
|
||||
import NotificationsBuilder from '../Notifications/NotificationsBuilder.mjs'
|
||||
import _ from 'lodash'
|
||||
import Modules from '../../infrastructure/Modules.mjs'
|
||||
import UserSessionsManager from './UserSessionsManager.mjs'
|
||||
import ThirdPartyIdentityManager from './ThirdPartyIdentityManager.mjs'
|
||||
import AsyncLocalStorage from '../../infrastructure/AsyncLocalStorage.mjs'
|
||||
|
||||
const { normalizeQuery } = Mongo
|
||||
|
||||
async function _sendSecurityAlertPrimaryEmailChanged(
|
||||
userId,
|
||||
oldEmail,
|
||||
email,
|
||||
deleteOldEmail
|
||||
) {
|
||||
// here
|
||||
// Send email to the following:
|
||||
// - the old primary
|
||||
// - the new primary
|
||||
// - for all other current (confirmed or recently-enough reconfirmed) email addresses, group by institution if we
|
||||
// have it, or domain if we don’t, and for each group send to the most recently reconfirmed (or confirmed if never
|
||||
// reconfirmed) address in that group.
|
||||
// See #6101.
|
||||
const emailOptions = {
|
||||
actionDescribed: `the primary email address on your account was changed to ${email}`,
|
||||
action: 'change of primary email address',
|
||||
message: deleteOldEmail
|
||||
? [
|
||||
`We also removed the previous primary email ${oldEmail} from the account.`,
|
||||
]
|
||||
: [],
|
||||
}
|
||||
|
||||
async function sendToRecipients(recipients) {
|
||||
// On failure, log the error and carry on so that one email failing does not prevent other emails sending
|
||||
for await (const recipient of recipients) {
|
||||
try {
|
||||
const opts = Object.assign({}, emailOptions, { to: recipient })
|
||||
await EmailHandler.promises.sendEmail('securityAlert', opts)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, userId },
|
||||
'could not send security alert email when primary email changed'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First, send notification to the old and new primary emails before getting other emails from v1 to ensure that these
|
||||
// are still sent in the event of not being able to reach v1
|
||||
const oldAndNewPrimaryEmails = [oldEmail, email]
|
||||
await sendToRecipients(oldAndNewPrimaryEmails)
|
||||
|
||||
// Next, get extra recipients with affiliation data
|
||||
const emailsData = await UserGetter.promises.getUserFullEmails(userId)
|
||||
const extraRecipients = _securityAlertPrimaryEmailChangedExtraRecipients(
|
||||
emailsData,
|
||||
oldEmail,
|
||||
email
|
||||
)
|
||||
|
||||
await sendToRecipients(extraRecipients)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new email address for the user. Email cannot be already used by this
|
||||
* or any other user
|
||||
*/
|
||||
async function addEmailAddress(userId, newEmail, affiliationOptions, auditLog) {
|
||||
AsyncLocalStorage.removeItem('userFullEmails')
|
||||
newEmail = EmailHelper.parseEmail(newEmail)
|
||||
if (!newEmail) {
|
||||
throw new Error('invalid email')
|
||||
}
|
||||
|
||||
await UserGetter.promises.ensureUniqueEmailAddress(newEmail)
|
||||
|
||||
AnalyticsManager.recordEventForUserInBackground(
|
||||
userId,
|
||||
'secondary-email-added'
|
||||
)
|
||||
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId,
|
||||
'add-email',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ipAddress,
|
||||
{
|
||||
...auditLog.info,
|
||||
newSecondaryEmail: newEmail,
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
await InstitutionsAPI.promises.addAffiliation(
|
||||
userId,
|
||||
newEmail,
|
||||
affiliationOptions
|
||||
)
|
||||
} catch (error) {
|
||||
throw OError.tag(error, 'problem adding affiliation while adding email')
|
||||
}
|
||||
|
||||
const createdAt = new Date()
|
||||
let res
|
||||
try {
|
||||
const reversedHostname = newEmail.split('@')[1].split('').reverse().join('')
|
||||
const update = {
|
||||
$push: {
|
||||
emails: { email: newEmail, createdAt, reversedHostname },
|
||||
},
|
||||
}
|
||||
res = await updateUser(
|
||||
{ _id: userId, 'emails.email': { $ne: newEmail } },
|
||||
update
|
||||
)
|
||||
} catch (error) {
|
||||
throw OError.tag(error, 'problem updating users emails')
|
||||
}
|
||||
|
||||
if (res.matchedCount !== 1) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await EmailChangeHelper.registerEmailCreation(userId, newEmail, {
|
||||
createdAt: new Date(),
|
||||
emailCreatedAt: createdAt,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error, userId, newEmail },
|
||||
'Error registering email creation with analytics'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function clearSAMLData(userId, auditLog, sendEmail) {
|
||||
const user = await UserGetter.promises.getUser(userId, {
|
||||
email: 1,
|
||||
emails: 1,
|
||||
})
|
||||
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId,
|
||||
'clear-institution-sso-data',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ipAddress,
|
||||
{}
|
||||
)
|
||||
|
||||
const update = {
|
||||
$unset: {
|
||||
samlIdentifiers: 1,
|
||||
'emails.$[].samlProviderId': 1,
|
||||
'enrollment.sso': 1,
|
||||
},
|
||||
}
|
||||
|
||||
await updateUser(userId, update)
|
||||
|
||||
for (const emailData of user.emails) {
|
||||
await InstitutionsAPI.promises.removeEntitlement(userId, emailData.email)
|
||||
}
|
||||
|
||||
await FeaturesUpdater.promises.refreshFeatures(
|
||||
userId,
|
||||
'clear-institution-sso-data'
|
||||
)
|
||||
|
||||
if (sendEmail) {
|
||||
await EmailHandler.promises.sendEmail('SAMLDataCleared', { to: user.email })
|
||||
}
|
||||
}
|
||||
|
||||
async function clearThirdPartyIdentifiers(userId, auditLog) {
|
||||
const user = await UserGetter.promises.getUser(userId, {
|
||||
thirdPartyIdentifiers: 1,
|
||||
})
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId,
|
||||
'clear-third-party-identifiers',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ipAddress,
|
||||
{}
|
||||
)
|
||||
for (const thirdPartyIdentifier of user.thirdPartyIdentifiers || []) {
|
||||
await ThirdPartyIdentityManager.promises.unlink(
|
||||
userId,
|
||||
thirdPartyIdentifier.providerId,
|
||||
auditLog
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* set the default email address by setting the `email` attribute. The email
|
||||
* must be one of the user's multiple emails (`emails` attribute)
|
||||
*/
|
||||
async function setDefaultEmailAddress(
|
||||
userId,
|
||||
email,
|
||||
allowUnconfirmed,
|
||||
auditLog,
|
||||
sendSecurityAlert,
|
||||
deleteOldEmail = false
|
||||
) {
|
||||
AsyncLocalStorage.removeItem('userFullEmails')
|
||||
email = EmailHelper.parseEmail(email)
|
||||
if (email == null) {
|
||||
throw new Error('invalid email')
|
||||
}
|
||||
|
||||
const user = await UserGetter.promises.getUser(userId, {
|
||||
email: 1,
|
||||
emails: 1,
|
||||
})
|
||||
if (!user) {
|
||||
throw new Error('invalid userId')
|
||||
}
|
||||
|
||||
const oldEmail = user.email
|
||||
const userEmail = user.emails.find(e => e.email === email)
|
||||
if (!userEmail) {
|
||||
throw new Error('Default email does not belong to user')
|
||||
}
|
||||
if (!userEmail.confirmedAt && !allowUnconfirmed) {
|
||||
throw new Errors.UnconfirmedEmailError()
|
||||
}
|
||||
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId,
|
||||
'change-primary-email',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ipAddress,
|
||||
{
|
||||
newPrimaryEmail: email,
|
||||
oldPrimaryEmail: oldEmail,
|
||||
}
|
||||
)
|
||||
|
||||
const query = { _id: userId, 'emails.email': email }
|
||||
const update = { $set: { email, lastPrimaryEmailCheck: new Date() } }
|
||||
const res = await updateUser(query, update)
|
||||
|
||||
// this should not happen
|
||||
if (res.matchedCount !== 1) {
|
||||
throw new Error('email update error')
|
||||
}
|
||||
|
||||
AnalyticsManager.recordEventForUserInBackground(
|
||||
userId,
|
||||
'primary-email-address-updated'
|
||||
)
|
||||
|
||||
try {
|
||||
await EmailChangeHelper.registerEmailUpdate(userId, email, {
|
||||
isPrimary: true,
|
||||
action: 'updated',
|
||||
createdAt: new Date(),
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, userId, email },
|
||||
'Error registering email change with analytics'
|
||||
)
|
||||
}
|
||||
|
||||
if (sendSecurityAlert) {
|
||||
// no need to wait, errors are logged and not passed back
|
||||
_sendSecurityAlertPrimaryEmailChanged(
|
||||
userId,
|
||||
oldEmail,
|
||||
email,
|
||||
deleteOldEmail
|
||||
).catch(err => {
|
||||
logger.error({ err }, 'failed to send security alert email')
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await Modules.promises.hooks.fire('userEmailChanged', user, email)
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, oldEmail, newEmail: email },
|
||||
'Failed to fire "userEmailChanged" hook'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await Modules.promises.hooks.fire(
|
||||
'updateAccountEmailAddress',
|
||||
user._id,
|
||||
email
|
||||
)
|
||||
} catch (error) {
|
||||
// errors are ignored
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrites the primary email address of a user in the database in-place.
|
||||
* This function is only intended for use in scripts to migrate email addresses
|
||||
* where we do not want to trigger all the actions that happen when a user
|
||||
* changes their own email. It should not be used in any other circumstances.
|
||||
*/
|
||||
async function migrateDefaultEmailAddress(
|
||||
userId,
|
||||
oldEmail,
|
||||
newEmail,
|
||||
auditLog
|
||||
) {
|
||||
oldEmail = EmailHelper.parseEmail(oldEmail)
|
||||
if (oldEmail == null) {
|
||||
throw new Error('invalid old email')
|
||||
}
|
||||
newEmail = EmailHelper.parseEmail(newEmail)
|
||||
if (newEmail == null) {
|
||||
throw new Error('invalid new email')
|
||||
}
|
||||
const reversedHostname = newEmail.split('@')[1].split('').reverse().join('')
|
||||
const query = {
|
||||
_id: userId,
|
||||
email: oldEmail,
|
||||
'emails.email': oldEmail,
|
||||
}
|
||||
const update = {
|
||||
$set: {
|
||||
email: newEmail,
|
||||
'emails.$.email': newEmail,
|
||||
'emails.$.reversedHostname': reversedHostname,
|
||||
},
|
||||
}
|
||||
const result = await updateUser(query, update)
|
||||
if (result.modifiedCount !== 1) {
|
||||
throw new Error('email update error')
|
||||
}
|
||||
// add a user audit log entry for the email change
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId,
|
||||
'migrate-default-email',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ipAddress,
|
||||
{
|
||||
oldEmail,
|
||||
newEmail,
|
||||
// Add optional extra info
|
||||
...(auditLog.extraInfo || {}),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function confirmEmail(userId, email, affiliationOptions) {
|
||||
AsyncLocalStorage.removeItem('userFullEmails')
|
||||
// used for initial email confirmation (non-SSO and SSO)
|
||||
// also used for reconfirmation of non-SSO emails
|
||||
const confirmedAt = new Date()
|
||||
email = EmailHelper.parseEmail(email)
|
||||
if (email == null) {
|
||||
throw new Error('invalid email')
|
||||
}
|
||||
logger.debug({ userId, email }, 'confirming user email')
|
||||
|
||||
try {
|
||||
affiliationOptions = affiliationOptions || {}
|
||||
affiliationOptions.confirmedAt = confirmedAt
|
||||
await InstitutionsAPI.promises.addAffiliation(
|
||||
userId,
|
||||
email,
|
||||
affiliationOptions
|
||||
)
|
||||
} catch (error) {
|
||||
throw OError.tag(error, 'problem adding affiliation while confirming email')
|
||||
}
|
||||
|
||||
const query = {
|
||||
_id: userId,
|
||||
'emails.email': email,
|
||||
}
|
||||
|
||||
// only update confirmedAt if it was not previously set
|
||||
const update = {
|
||||
$set: {
|
||||
'emails.$.reconfirmedAt': confirmedAt,
|
||||
},
|
||||
$min: {
|
||||
'emails.$.confirmedAt': confirmedAt,
|
||||
},
|
||||
}
|
||||
|
||||
if (Features.hasFeature('affiliations')) {
|
||||
update.$unset = {
|
||||
'emails.$.affiliationUnchecked': 1,
|
||||
}
|
||||
}
|
||||
|
||||
const res = await updateUser(query, update)
|
||||
|
||||
if (res.matchedCount !== 1) {
|
||||
throw new Errors.NotFoundError('user id and email do no match')
|
||||
}
|
||||
await FeaturesUpdater.promises.refreshFeatures(userId, 'confirm-email')
|
||||
|
||||
try {
|
||||
await EmailChangeHelper.registerEmailUpdate(userId, email, {
|
||||
emailConfirmedAt: confirmedAt,
|
||||
action: 'updated',
|
||||
isPrimary: false,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error, userId, email },
|
||||
'Error registering email confirmation with analytics'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await maybeCreateRedundantSubscriptionNotification(userId, email)
|
||||
} catch (error) {
|
||||
logger.err(
|
||||
{ err: error },
|
||||
'error checking redundant subscription on email confirmation'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeCreateRedundantSubscriptionNotification(userId, email) {
|
||||
const subscription =
|
||||
await SubscriptionLocator.promises.getUserIndividualSubscription(userId)
|
||||
if (!subscription || subscription.groupPlan) {
|
||||
return
|
||||
}
|
||||
|
||||
const affiliations =
|
||||
await InstitutionsAPI.promises.getUserAffiliations(userId)
|
||||
const confirmedAffiliation = affiliations.find(a => a.email === email)
|
||||
if (!confirmedAffiliation || confirmedAffiliation.licence === 'free') {
|
||||
return
|
||||
}
|
||||
|
||||
await NotificationsBuilder.promises
|
||||
.redundantPersonalSubscription(
|
||||
{
|
||||
institutionId: confirmedAffiliation.institution.id,
|
||||
institutionName: confirmedAffiliation.institution.name,
|
||||
},
|
||||
{ _id: userId }
|
||||
)
|
||||
.create()
|
||||
}
|
||||
|
||||
async function removeEmailAddress(
|
||||
userId,
|
||||
email,
|
||||
auditLog,
|
||||
skipParseEmail = false
|
||||
) {
|
||||
AsyncLocalStorage.removeItem('userFullEmails')
|
||||
// remove one of the user's email addresses. The email cannot be the user's
|
||||
// default email address
|
||||
if (!skipParseEmail) {
|
||||
email = EmailHelper.parseEmail(email)
|
||||
} else if (skipParseEmail && typeof email !== 'string') {
|
||||
throw new Error('email must be a string')
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
throw new Error('invalid email')
|
||||
}
|
||||
|
||||
const isMainEmail = await UserGetter.promises.getUserByMainEmail(email, {
|
||||
_id: 1,
|
||||
})
|
||||
if (isMainEmail) {
|
||||
throw new Error('cannot remove primary email')
|
||||
}
|
||||
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId,
|
||||
'remove-email',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ipAddress,
|
||||
{
|
||||
removedEmail: email,
|
||||
// Add optional extra info
|
||||
...(auditLog.extraInfo || {}),
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
await InstitutionsAPI.promises.removeAffiliation(userId, email)
|
||||
} catch (error) {
|
||||
OError.tag(error, 'problem removing affiliation')
|
||||
throw error
|
||||
}
|
||||
|
||||
const query = { _id: userId, email: { $ne: email } }
|
||||
const update = { $pull: { emails: { email } } }
|
||||
|
||||
let res
|
||||
try {
|
||||
res = await updateUser(query, update)
|
||||
} catch (error) {
|
||||
OError.tag(error, 'problem removing users email')
|
||||
throw error
|
||||
}
|
||||
|
||||
if (res.matchedCount !== 1) {
|
||||
throw new Error('Cannot remove email')
|
||||
}
|
||||
|
||||
try {
|
||||
await EmailChangeHelper.registerEmailDeletion(userId, email, {
|
||||
isPrimary: false,
|
||||
emailDeletedAt: new Date(),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error, userId, email },
|
||||
'Error registering email deletion with analytics'
|
||||
)
|
||||
}
|
||||
|
||||
await FeaturesUpdater.promises.refreshFeatures(userId, 'remove-email')
|
||||
}
|
||||
|
||||
async function addAffiliationForNewUser(
|
||||
userId,
|
||||
email,
|
||||
affiliationOptions = {}
|
||||
) {
|
||||
AsyncLocalStorage.removeItem('userFullEmails')
|
||||
await InstitutionsAPI.promises.addAffiliation(
|
||||
userId,
|
||||
email,
|
||||
affiliationOptions
|
||||
)
|
||||
try {
|
||||
await updateUser(
|
||||
{ _id: userId, 'emails.email': email },
|
||||
{ $unset: { 'emails.$.affiliationUnchecked': 1 } }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
OError.tag(
|
||||
error,
|
||||
'could not remove affiliationUnchecked flag for user on create',
|
||||
{
|
||||
userId,
|
||||
email,
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUser(query, update) {
|
||||
query = normalizeQuery(query)
|
||||
const result = await db.users.updateOne(query, update)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* DEPRECATED
|
||||
*
|
||||
* Change the user's main email address by adding a new email, switching the
|
||||
* default email and removing the old email. Prefer manipulating multiple
|
||||
* emails and the default rather than calling this method directly
|
||||
*/
|
||||
async function changeEmailAddress(userId, newEmail, auditLog) {
|
||||
newEmail = EmailHelper.parseEmail(newEmail)
|
||||
if (newEmail == null) {
|
||||
throw new Error('invalid email')
|
||||
}
|
||||
|
||||
const oldEmail = await UserGetter.promises.getUserEmail(userId)
|
||||
await addEmailAddress(userId, newEmail, {}, auditLog)
|
||||
await setDefaultEmailAddress(userId, newEmail, true, auditLog, true)
|
||||
await removeEmailAddress(userId, oldEmail, auditLog)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} userId
|
||||
* @param {{initiatorId: string, ip: string}} auditLog
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function removeReconfirmFlag(userId, auditLog) {
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId.toString(),
|
||||
'must-reset-password-unset',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ip
|
||||
)
|
||||
await updateUser(userId.toString(), { $set: { must_reconfirm: false } })
|
||||
}
|
||||
|
||||
async function suspendUser(userId, auditLog = {}) {
|
||||
const res = await updateUser(
|
||||
{ _id: userId, suspended: { $ne: true } },
|
||||
{ $set: { suspended: true } }
|
||||
)
|
||||
if (res.matchedCount !== 1) {
|
||||
throw new Errors.NotFoundError('user id not found or already suspended')
|
||||
}
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId,
|
||||
'account-suspension',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ip,
|
||||
auditLog.info || {}
|
||||
)
|
||||
await UserSessionsManager.promises.removeSessionsFromRedis({ _id: userId })
|
||||
await Modules.promises.hooks.fire(
|
||||
'removeDropbox',
|
||||
userId,
|
||||
'account-suspension'
|
||||
)
|
||||
}
|
||||
|
||||
function _securityAlertPrimaryEmailChangedExtraRecipients(
|
||||
emailsData,
|
||||
oldEmail,
|
||||
email
|
||||
) {
|
||||
// Group by institution if we have it, or domain if we don’t, and for each group send to the most recently
|
||||
// reconfirmed (or confirmed if never reconfirmed) address in that group. We also remove the original and new
|
||||
// primary email addresses because they are emailed separately
|
||||
// See #6101.
|
||||
function sortEmailsByConfirmation(emails) {
|
||||
return emails.sort((e1, e2) => e2.lastConfirmedAt - e1.lastConfirmedAt)
|
||||
}
|
||||
|
||||
const recipients = new Set()
|
||||
const emailsToIgnore = new Set([oldEmail, email])
|
||||
|
||||
// Remove non-confirmed emails
|
||||
const confirmedEmails = emailsData.filter(email => !!email.lastConfirmedAt)
|
||||
|
||||
// Group other emails by institution, separating out those with no institution and grouping them instead by domain.
|
||||
// The keys for each group are not used for anything other than the grouping, so can have a slightly paranoid format
|
||||
// to avoid any potential clash
|
||||
const groupedEmails = _.groupBy(confirmedEmails, emailData => {
|
||||
if (!emailData.affiliation || !emailData.affiliation.institution) {
|
||||
return `domain:${EmailHelper.getDomain(emailData.email)}`
|
||||
}
|
||||
return `institution_id:${emailData.affiliation.institution.id}`
|
||||
})
|
||||
|
||||
// For each group of emails, order the emails by (re-)confirmation date and pick the first
|
||||
for (const emails of Object.values(groupedEmails)) {
|
||||
// Sort by confirmation and pick the first
|
||||
sortEmailsByConfirmation(emails)
|
||||
|
||||
// Ignore original and new primary email addresses
|
||||
const recipient = emails[0].email
|
||||
if (!emailsToIgnore.has(recipient)) {
|
||||
recipients.add(emails[0].email)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(recipients)
|
||||
}
|
||||
|
||||
export default {
|
||||
addAffiliationForNewUser: callbackify(addAffiliationForNewUser),
|
||||
addEmailAddress: callbackify(addEmailAddress),
|
||||
changeEmailAddress: callbackify(changeEmailAddress),
|
||||
clearSAMLData: callbackify(clearSAMLData),
|
||||
confirmEmail: callbackify(confirmEmail),
|
||||
removeEmailAddress: callbackify(removeEmailAddress),
|
||||
removeReconfirmFlag: callbackify(removeReconfirmFlag),
|
||||
setDefaultEmailAddress: callbackify(setDefaultEmailAddress),
|
||||
migrateDefaultEmailAddress: callbackify(migrateDefaultEmailAddress),
|
||||
updateUser: callbackify(updateUser),
|
||||
suspendUser: callbackify(suspendUser),
|
||||
promises: {
|
||||
addAffiliationForNewUser,
|
||||
addEmailAddress,
|
||||
changeEmailAddress,
|
||||
clearSAMLData,
|
||||
clearThirdPartyIdentifiers,
|
||||
confirmEmail,
|
||||
removeEmailAddress,
|
||||
removeReconfirmFlag,
|
||||
setDefaultEmailAddress,
|
||||
migrateDefaultEmailAddress,
|
||||
updateUser,
|
||||
suspendUser,
|
||||
},
|
||||
}
|
||||
@@ -425,7 +425,6 @@ 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,
|
||||
|
||||
@@ -36,7 +36,7 @@ footer.site-footer
|
||||
a(href=item.url class=item.class aria-label=item.label) !{item.text}
|
||||
else
|
||||
| !{item.text}
|
||||
if settings.env === 'server-ce' && settings.show_unsupport_warning
|
||||
if settings.env === 'server-ce'
|
||||
.row
|
||||
.col-12
|
||||
p.copyright-notice
|
||||
|
||||
@@ -2,7 +2,6 @@ extends ../layout-website-redesign
|
||||
|
||||
block vars
|
||||
- isWebsiteRedesign = true
|
||||
- suppressNavbarRight = true
|
||||
|
||||
block content
|
||||
main#main-content.content
|
||||
@@ -14,42 +13,41 @@ block content
|
||||
h1 !{login_support_title}
|
||||
else
|
||||
h1 #{translate("log_in")}
|
||||
if local_login_mask_enable
|
||||
form(name='loginForm' data-ol-async-form action='/login' method='POST')
|
||||
input(name='_csrf' type='hidden' value=csrfToken)
|
||||
+formMessagesNewStyle
|
||||
+customFormMessageNewStyle('invalid-password-retry-or-reset', 'danger')
|
||||
| !{translate('email_or_password_wrong_try_again_or_reset', {}, [{ name: 'a', attrs: { href: '/user/password/reset', 'aria-describedby': 'resetPasswordDescription' } }])}
|
||||
span.visually-hidden(id='resetPasswordDescription')
|
||||
| #{translate('reset_password_link')}
|
||||
+customFormMessageNewStyle('password-compromised')
|
||||
| !{translate('password_compromised_try_again_or_use_known_device_or_reset', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: '/user/password/reset', target: '_blank'}}])}.
|
||||
.form-group
|
||||
label(for='email') #{translate("email")}
|
||||
input#email.form-control(
|
||||
name='email'
|
||||
type=(settings.ldap && settings.ldap.enable) ? 'text' : 'email'
|
||||
required
|
||||
placeholder=(settings.ldap && settings.ldap.enable) ? settings.ldap.placeholder : 'email@example.com'
|
||||
autofocus='true'
|
||||
autocomplete='username'
|
||||
)
|
||||
.form-group
|
||||
label(for='password') #{translate("password")}
|
||||
input#password.form-control(
|
||||
name='password'
|
||||
type='password'
|
||||
autocomplete='current-password'
|
||||
required
|
||||
)
|
||||
.actions
|
||||
button.btn-primary.btn(type='submit' data-ol-disabled-inflight)
|
||||
span(data-ol-inflight='idle') #{translate("login")}
|
||||
span(hidden data-ol-inflight='pending') #{translate("logging_in")}…
|
||||
a.float-end(href='/user/password/reset') #{translate("forgot_your_password")}?
|
||||
if login_support_text
|
||||
hr
|
||||
p.text-center !{login_support_text}
|
||||
form(name='loginForm' data-ol-async-form action='/login' method='POST')
|
||||
input(name='_csrf' type='hidden' value=csrfToken)
|
||||
+formMessagesNewStyle
|
||||
+customFormMessageNewStyle('invalid-password-retry-or-reset', 'danger')
|
||||
| !{translate('email_or_password_wrong_try_again_or_reset', {}, [{ name: 'a', attrs: { href: '/user/password/reset', 'aria-describedby': 'resetPasswordDescription' } }])}
|
||||
span.visually-hidden(id='resetPasswordDescription')
|
||||
| #{translate('reset_password_link')}
|
||||
+customFormMessageNewStyle('password-compromised')
|
||||
| !{translate('password_compromised_try_again_or_use_known_device_or_reset', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: '/user/password/reset', target: '_blank'}}])}.
|
||||
.form-group
|
||||
label(for='email') #{translate("email")}
|
||||
input#email.form-control(
|
||||
name='email'
|
||||
type=(settings.ldap && settings.ldap.enable) ? 'text' : 'email'
|
||||
required
|
||||
placeholder=(settings.ldap && settings.ldap.enable) ? settings.ldap.placeholder : 'email@example.com'
|
||||
autofocus='true'
|
||||
autocomplete='username'
|
||||
)
|
||||
.form-group
|
||||
label(for='password') #{translate("password")}
|
||||
input#password.form-control(
|
||||
name='password'
|
||||
type='password'
|
||||
autocomplete='current-password'
|
||||
required
|
||||
)
|
||||
.actions
|
||||
button.btn-primary.btn(type='submit' data-ol-disabled-inflight)
|
||||
span(data-ol-inflight='idle') #{translate("login")}
|
||||
span(hidden data-ol-inflight='pending') #{translate("logging_in")}…
|
||||
a.float-end(href='/user/password/reset') #{translate("forgot_your_password")}?
|
||||
if login_support_text
|
||||
hr
|
||||
p.text-center !{login_support_text}
|
||||
if settings.saml && settings.saml.enable
|
||||
.actions(style='margin-top: 30px;')
|
||||
a.button.btn-secondary.btn(
|
||||
|
||||
@@ -79,8 +79,6 @@ const safeCompilers = ['xelatex', 'pdflatex', 'latex', 'lualatex']
|
||||
|
||||
module.exports = {
|
||||
env: 'server-ce',
|
||||
// show the CE "free unsupported software" disclaimer in the footer; set SHOW_UNSUPPORT_WARNING=false to hide it
|
||||
show_unsupport_warning: process.env.SHOW_UNSUPPORT_WARNING !== 'false',
|
||||
|
||||
limits: {
|
||||
httpGlobalAgentMaxSockets: 300,
|
||||
@@ -1034,12 +1032,7 @@ module.exports = {
|
||||
),
|
||||
],
|
||||
contactUsModal: [],
|
||||
sourceEditorExtensions: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/languagetool/frontend/js/languagetool-extension'
|
||||
),
|
||||
],
|
||||
sourceEditorExtensions: [],
|
||||
sourceEditorVisualExtensions: [],
|
||||
sourceEditorComponents: [],
|
||||
pdfLogEntryHeaderActionComponents: [],
|
||||
@@ -1180,12 +1173,7 @@ module.exports = {
|
||||
],
|
||||
referenceSearchSetting: [],
|
||||
settingsModalEditorTabSections: [],
|
||||
settingsModalSpellcheckSections: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/languagetool/frontend/js/languagetool-section'
|
||||
),
|
||||
],
|
||||
settingsModalSpellcheckSections: [],
|
||||
editorFloatingMenuActions: [],
|
||||
errorLogsComponents: [],
|
||||
referenceIndices: [
|
||||
@@ -1208,7 +1196,6 @@ module.exports = {
|
||||
'symbol-palette',
|
||||
'reference-picker',
|
||||
'track-changes',
|
||||
'languagetool',
|
||||
'authentication/ldap',
|
||||
'authentication/saml',
|
||||
'authentication/oidc',
|
||||
@@ -1245,8 +1232,6 @@ module.exports = {
|
||||
},
|
||||
|
||||
enablePandocConversions: process.env.ENABLE_PANDOC_CONVERSIONS === 'true',
|
||||
// local login form is shown by default; set ENABLE_LOCAL_LOGIN_MASK=false to hide it (e.g. SSO-only login)
|
||||
enableLocalLoginMask: process.env.ENABLE_LOCAL_LOGIN_MASK !== 'false',
|
||||
|
||||
oauthProviders: {
|
||||
...(process.env.EXTERNAL_AUTH && process.env.EXTERNAL_AUTH.includes('oidc') && {
|
||||
|
||||
@@ -13,15 +13,6 @@ 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<void>
|
||||
@@ -81,12 +72,6 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
|
||||
setSpellCheckLanguage,
|
||||
referenceFormat,
|
||||
setReferenceFormat,
|
||||
languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
} = useProjectWideSettings()
|
||||
|
||||
const {
|
||||
@@ -150,12 +135,6 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
|
||||
setRootDocId,
|
||||
spellCheckLanguage,
|
||||
setSpellCheckLanguage,
|
||||
languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
autoComplete,
|
||||
setAutoComplete,
|
||||
autoPairDelimiters,
|
||||
@@ -214,12 +193,6 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
|
||||
setRootDocId,
|
||||
spellCheckLanguage,
|
||||
setSpellCheckLanguage,
|
||||
languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
autoComplete,
|
||||
setAutoComplete,
|
||||
autoPairDelimiters,
|
||||
|
||||
-42
@@ -53,33 +53,6 @@ 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
|
||||
@@ -90,9 +63,6 @@ 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)
|
||||
@@ -102,15 +72,6 @@ export default function useProjectWideSettingsSocketListener() {
|
||||
setSpellCheckLanguage
|
||||
)
|
||||
socket.removeListener('referenceFormatUpdated', setReferenceFormat)
|
||||
socket.removeListener(
|
||||
'languageToolEnabledUpdated',
|
||||
setLanguageToolEnabled
|
||||
)
|
||||
socket.removeListener('languageToolPickyUpdated', setLanguageToolPicky)
|
||||
socket.removeListener(
|
||||
'languageToolLanguageUpdated',
|
||||
setLanguageToolLanguage
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -121,8 +82,5 @@ export default function useProjectWideSettingsSocketListener() {
|
||||
setReferenceFormat,
|
||||
setPng2pdf,
|
||||
setSpellCheckLanguage,
|
||||
setLanguageToolEnabled,
|
||||
setLanguageToolPicky,
|
||||
setLanguageToolLanguage,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
@@ -50,9 +49,6 @@ 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,
|
||||
@@ -67,11 +63,5 @@ export default function useProjectWideSettings() {
|
||||
setRootDocId,
|
||||
spellCheckLanguage: project?.spellCheckLanguage,
|
||||
setSpellCheckLanguage,
|
||||
languageToolEnabled: project?.languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky: project?.languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage: project?.languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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]
|
||||
)
|
||||
}
|
||||
@@ -15,11 +15,6 @@ 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<
|
||||
|
||||
@@ -55,7 +55,6 @@ 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'
|
||||
@@ -110,33 +109,6 @@ 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()
|
||||
@@ -390,7 +362,6 @@ 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),
|
||||
|
||||
@@ -50,13 +50,7 @@ body {
|
||||
.navbar-default {
|
||||
position: relative;
|
||||
|
||||
// Nudge the logo down and to the right so that it lines up with the
|
||||
// sidebar items below it. Instances that set both a custom header image
|
||||
// and a nav title (OVERLEAF_HEADER_IMAGE_URL and OVERLEAF_NAV_TITLE on
|
||||
// Server Pro) show the title next to the logo, and the nudge would move
|
||||
// only the logo and leave the two misaligned, so skip it in that case and
|
||||
// keep the pair centred in the navbar as it is on every other page.
|
||||
.navbar-header .navbar-brand:not(:has(.navbar-title)) .navbar-logo {
|
||||
.navbar-header .navbar-logo {
|
||||
@include media-breakpoint-up(md) {
|
||||
position: relative;
|
||||
top: calc(var(--spacing-05) * 2);
|
||||
|
||||
@@ -101,14 +101,14 @@ const GitBridgePATManager = {
|
||||
|
||||
if (!objToken?.user_id) return null
|
||||
|
||||
// does the user still exists and not deleted and not suspended?
|
||||
// does the user still exists and not deleted?
|
||||
// tokens of a deleted user are not deleted until user expires
|
||||
const user = await db.users.findOne(
|
||||
{ _id: new ObjectId(objToken.user_id) },
|
||||
{ projection: { _id: 1, suspended: 1 } }
|
||||
{ projection: { _id: 1 } }
|
||||
)
|
||||
|
||||
if (!user || user.suspended) return null
|
||||
if (!user) return null
|
||||
|
||||
// non-blocking
|
||||
db.oauthAccessTokens.updateOne(
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
# 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:
|
||||
<https://github.com/yu-i-i/overleaf-cep/pull/179> (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`
|
||||
@@ -1,138 +0,0 @@
|
||||
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
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* 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<LTLanguage[]>([])
|
||||
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<LTLanguage[]>('/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 (
|
||||
<>
|
||||
<ToggleSetting
|
||||
id="languagetool-enabled"
|
||||
label="LanguageTool grammar check"
|
||||
description="Grammar and style checking powered by LanguageTool"
|
||||
checked={enabled}
|
||||
onChange={handleToggleEnabled}
|
||||
/>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<ToggleSetting
|
||||
id="languagetool-picky"
|
||||
label="Picky mode"
|
||||
description="Picky level catches more (subtle) grammar and style issues; off uses LanguageTool's default level"
|
||||
checked={picky}
|
||||
onChange={handleTogglePicky}
|
||||
/>
|
||||
|
||||
<DropdownSetting
|
||||
id="languagetool-language"
|
||||
label="Language"
|
||||
options={[{ value: 'auto', label: 'Auto-detect' }]}
|
||||
optgroups={
|
||||
languages.length > 0
|
||||
? [
|
||||
{
|
||||
label: 'Languages',
|
||||
options: languages.map(l => ({
|
||||
value: l.longCode,
|
||||
label: l.name,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
onChange={handleLanguageChange}
|
||||
value={language}
|
||||
width="wide"
|
||||
loading={languagesLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,597 +0,0 @@
|
||||
/**
|
||||
* LanguageTool CodeMirror 6 extension.
|
||||
*
|
||||
* Exported as a named `extension` function matching the `sourceEditorExtensions`
|
||||
* module-import slot signature: (options: Record<string, any>) => 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<string, string[][]> = {
|
||||
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<readonly LTDiagnostic[]>()
|
||||
|
||||
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<string, any>): 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<typeof setTimeout> | 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<LTSettings>) => {
|
||||
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<Partial<LTSettings>>).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<string, unknown> | 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]
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* 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: <LanguageToolSettings />,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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
|
||||
@@ -12,7 +12,7 @@ export default async function registerNewUserAndSendActivationEmail(userData) {
|
||||
let user
|
||||
try {
|
||||
const password = crypto.randomBytes(32).toString('hex')
|
||||
user = await UserRegistrationHandler.promises.registerNewUser({ ...userData, password })
|
||||
user = await UserRegistrationHandler.promises.registerNewUser({ ...userData, password, analyticsId: crypto.randomUUID() })
|
||||
} catch (error) {
|
||||
if (error.message === 'EmailAlreadyRegistered') {
|
||||
logger.debug({ email: userData.email }, 'user already registered')
|
||||
|
||||
@@ -96,9 +96,7 @@ async function exportUserProjectsToZip(userId, output) {
|
||||
`Exporting ${allIds.length} projects for user ${userId} to ${output}`
|
||||
)
|
||||
|
||||
const zipStream = await createZipStreamForMultipleProjectsAsync(allIds, false)
|
||||
// NOTE: createZipStreamForMultipleProjects(projectIds, zipFromHistory, callback) has no
|
||||
// historyId slot (unlike createZipStreamForProject), so only one argument besides the ids.
|
||||
const zipStream = await createZipStreamForMultipleProjectsAsync(allIds)
|
||||
|
||||
zipStream.on('progress', progress => {
|
||||
updateProgress(progress.entries.total, allIds.length)
|
||||
@@ -134,7 +132,7 @@ async function exportSingleProject(projectId, output) {
|
||||
console.log('Flushing project to MongoDB...')
|
||||
await DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete(projectId)
|
||||
console.log(`Exporting project ${projectId} to ${output}`)
|
||||
const zipStream = await createZipStreamForProjectAsync(projectId, false, null)
|
||||
const zipStream = await createZipStreamForProjectAsync(projectId)
|
||||
await writeStreamToFileAtomically(zipStream, output)
|
||||
console.log('Exported project to', output)
|
||||
}
|
||||
|
||||
@@ -37,11 +37,8 @@ const TrackChangesController = {
|
||||
async getAllRanges(req, res, next) {
|
||||
try {
|
||||
const { project_id } = req.params
|
||||
// document-updater only knows the docs loaded in Redis. Flush them to
|
||||
// docstore (without unloading) and read every non-deleted doc from there.
|
||||
await DocumentUpdaterHandler.promises.flushProjectToMongo(project_id)
|
||||
const docs = await DocstoreManager.promises.getAllRanges(project_id)
|
||||
res.json(docs.map(doc => ({ id: doc._id, ranges: doc.ranges ?? {} })))
|
||||
const ranges = await DocumentUpdaterHandler.promises.getProjectRanges(project_id)
|
||||
res.json(ranges)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
@@ -53,9 +50,6 @@ const TrackChangesController = {
|
||||
// provided here to assign names to authors who have left the project but still have unaccepted changes.
|
||||
try {
|
||||
const { project_id } = req.params
|
||||
// Same as getAllRanges: a tracked change still in document-updater is
|
||||
// not in docstore yet, so its author would be missing here.
|
||||
await DocumentUpdaterHandler.promises.flushProjectToMongo(project_id)
|
||||
const memberIds = await DocstoreManager.promises.getTrackedChangesUserIds(project_id)
|
||||
const limit = pLimit(3)
|
||||
const users = await Promise.all(
|
||||
|
||||
@@ -68,7 +68,6 @@ async function refreshLinkedFile(
|
||||
parentFolderId,
|
||||
userId
|
||||
) {
|
||||
linkedFileData = _normalizeLegacyData(linkedFileData)
|
||||
logger.debug(
|
||||
{ projectId, userId, linkedFileData },
|
||||
'refreshing Zotero linked file'
|
||||
@@ -109,7 +108,7 @@ async function _getBibtex(linkedFileData) {
|
||||
return await ZoteroApiClient.getLibraryBibtex(
|
||||
userId,
|
||||
linkedFileData.group_id, // == null for main library
|
||||
linkedFileData.format
|
||||
linkedFileData.format || 'bibtex'
|
||||
)
|
||||
} catch (err) {
|
||||
|
||||
@@ -130,24 +129,6 @@ async function _getBibtex(linkedFileData) {
|
||||
}
|
||||
}
|
||||
|
||||
// Files imported before "Zotero: fix for 6.3.0" use the old key names.
|
||||
function _normalizeLegacyData(data) {
|
||||
const {
|
||||
zoteroGroupId,
|
||||
importedByUserId,
|
||||
importedByName,
|
||||
bibFormat,
|
||||
...rest
|
||||
} = data
|
||||
return {
|
||||
...rest,
|
||||
group_id: rest.group_id ?? zoteroGroupId ?? null,
|
||||
importer_id: rest.importer_id ?? importedByUserId,
|
||||
importer_name: rest.importer_name ?? importedByName,
|
||||
format: rest.format ?? bibFormat,
|
||||
}
|
||||
}
|
||||
|
||||
function _sanitizeData(data) {
|
||||
return {
|
||||
provider: 'zotero',
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
/*
|
||||
* Read a list of users from a file and suspend their accounts.
|
||||
*
|
||||
* Each line is either a user ID or an email address; the two can be mixed in
|
||||
* one file. Emails are matched against any of a user's addresses, not just the
|
||||
* primary one. An email that matches no account is reported as a failure and
|
||||
* the rest of the file is still processed.
|
||||
*
|
||||
* Use the --reenable option to roll back account suspension.
|
||||
*
|
||||
* Usage: node scripts/suspend_users.mjs [--reenable] <filename>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import { ObjectId } from '../app/src/infrastructure/mongodb.mjs'
|
||||
import UserGetter from '../app/src/Features/User/UserGetter.mjs'
|
||||
import UserUpdater from '../app/src/Features/User/UserUpdater.mjs'
|
||||
import { promiseMapWithLimit } from '@overleaf/promise-utils'
|
||||
|
||||
const ASYNC_LIMIT = 10
|
||||
const reenable = process.argv.includes('--reenable')
|
||||
const action = reenable ? 're-enable' : 'suspend'
|
||||
const updateUser = reenable
|
||||
? UserUpdater.promises.unsuspendUser
|
||||
: UserUpdater.promises.suspendUser
|
||||
|
||||
const processLogger = {
|
||||
failed: [],
|
||||
success: [],
|
||||
printSummary: () => {
|
||||
console.log(
|
||||
{
|
||||
success: processLogger.success,
|
||||
failed: processLogger.failed,
|
||||
},
|
||||
`\nDONE. ${processLogger.success.length} successful. ${processLogger.failed.length} failed to ${action}.`
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
function _isEmail(identifier) {
|
||||
return identifier.includes('@')
|
||||
}
|
||||
|
||||
function _validateIdentifierList(identifiers) {
|
||||
if (!Array.isArray(identifiers)) throw new Error('users is not an array')
|
||||
|
||||
identifiers.forEach(identifier => {
|
||||
if (!ObjectId.isValid(identifier) && !_isEmail(identifier)) {
|
||||
throw new Error(`not a valid user ID or email: ${identifier}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function _resolveUserId(identifier) {
|
||||
if (!_isEmail(identifier)) return identifier
|
||||
|
||||
const user = await UserGetter.promises.getUserByAnyEmail(identifier, {
|
||||
_id: 1,
|
||||
})
|
||||
if (user == null) {
|
||||
throw new Error(`no user found with email ${identifier}`)
|
||||
}
|
||||
return user._id
|
||||
}
|
||||
|
||||
async function _handleUser(identifier) {
|
||||
let userId
|
||||
try {
|
||||
userId = await _resolveUserId(identifier)
|
||||
} catch (error) {
|
||||
console.log(`Failed to resolve ${identifier}`, error)
|
||||
processLogger.failed.push(identifier)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateUser(userId, {
|
||||
ip: '0.0.0.0',
|
||||
info: { script: true },
|
||||
})
|
||||
} catch (error) {
|
||||
console.log(`Failed to ${action} ${identifier}`, error)
|
||||
processLogger.failed.push(identifier)
|
||||
return
|
||||
}
|
||||
processLogger.success.push(identifier)
|
||||
}
|
||||
|
||||
async function _loopUsers(identifiers) {
|
||||
return promiseMapWithLimit(ASYNC_LIMIT, identifiers, _handleUser)
|
||||
}
|
||||
|
||||
const fileName = process.argv.slice(2).find(arg => arg !== '--reenable')
|
||||
if (!fileName) throw new Error('missing filename')
|
||||
const usersFile = fs.readFileSync(fileName, 'utf8')
|
||||
const identifiers = usersFile
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(identifier => identifier.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
async function processUsers(identifiers) {
|
||||
console.log('---Starting suspend_users script---')
|
||||
_validateIdentifierList(identifiers)
|
||||
console.log(`---Starting to ${action} ${identifiers.length} users---`)
|
||||
await _loopUsers(identifiers)
|
||||
|
||||
processLogger.printSummary()
|
||||
process.exit()
|
||||
}
|
||||
|
||||
processUsers(identifiers)
|
||||
@@ -56,6 +56,5 @@ export type ExposedSettings = {
|
||||
linkedInInsightsPartnerId?: string
|
||||
githubSyncEnabled: boolean
|
||||
zoteroEnabled: boolean
|
||||
languageToolAvailable: boolean
|
||||
enablePandocConversions: boolean
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
. ./env.sh
|
||||
docker history --no-trunc --format '{{.CreatedBy}}' ${OLD_VERSION} > history-old.txt
|
||||
docker history --no-trunc --format '{{.CreatedBy}}' ${NEW_VERSION} > history-new.txt
|
||||
diff history-old.txt history-new.txt > history-diff.txt
|
||||
|
||||
rm -rf old
|
||||
rm -rf new
|
||||
|
||||
id=$(docker create ${OLD_VERSION})
|
||||
mkdir -p old
|
||||
docker export "$id" | tar -x -C old
|
||||
docker rm "$id"
|
||||
|
||||
id=$(docker create ${NEW_VERSION})
|
||||
mkdir -p new
|
||||
docker export "$id" | tar -x -C new
|
||||
docker rm "$id"
|
||||
|
||||
rm -rf delta
|
||||
mkdir -p delta
|
||||
rsync -a -c -m --compare-dest="$PWD/old" new/ delta/
|
||||
find delta -mindepth 1 -type d -empty -delete
|
||||
@@ -1,2 +0,0 @@
|
||||
export OLD_VERSION="sharelatex/sharelatex:6.3.0"
|
||||
export NEW_VERSION="sharelatex/sharelatex:6.3.1"
|
||||
@@ -1,101 +0,0 @@
|
||||
# Credits & Attribution
|
||||
|
||||
This project wraps two upstream bodies of work. Neither is redistributed in
|
||||
unattributed form; the specifics of what comes from where are listed below.
|
||||
|
||||
## 1. grammar-llm — functional core
|
||||
|
||||
**Repo:** <https://github.com/whiteh4cker-tr/grammar-llm>
|
||||
**License:** MIT — Copyright (c) 2025 whiteh4cker-tr
|
||||
**What this project takes:**
|
||||
|
||||
- **The whole correction pipeline** — the pure engine under
|
||||
`grammar-llm/src/electron/core/`, vendored byte-for-byte into this repo's
|
||||
`core/` (7 files: `apply.ts`, `clean.ts`, `correction.ts`, `diff.ts`,
|
||||
`reconstruct.ts`, `sentences.ts`, `types.ts`): sentence splitting, model
|
||||
output cleaning, word-diff/word-fix extraction, suggestion reconstruction.
|
||||
Provenance (source commit `ceff65a` + per-file SHA-256) is recorded in
|
||||
`core/.sync-info`; refresh with `scripts/sync-core.sh`.
|
||||
- **LLM call semantics** — system prompt, stop sequences, sampling defaults
|
||||
(temperature 0.7 / topK 40 / topP 0.95 / minP 0.01), and
|
||||
`node-llama-cpp` session usage mirror
|
||||
`grammar-llm/src/electron/llamaService.ts` so container output stays
|
||||
byte-identical to the desktop app for a given model.
|
||||
- **Model** — GRMR-V3-G4B (fine-tuned by the grammar-llm project; GGUF
|
||||
weights are fetched from Hugging Face at
|
||||
`icecubetr/GRMR-V3-G4B-GGUF`, Q4_K_M by default).
|
||||
- **Model downloader usage** — the download flow in `src/download-model.ts`
|
||||
follows `grammar-llm`'s use of `createModelDownloader` from
|
||||
`modelManager.ts`.
|
||||
|
||||
> grammar-llm is itself an Electron/React desktop migration of the original
|
||||
> GrammarLLM Python app (`whiteh4cker-tr/grammar-llm`, `python` branch); the
|
||||
> engine core it ships is what this container is built on.
|
||||
|
||||
Full MIT license text (retained with the vendored `core/` files):
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 whiteh4cker-tr
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## 2. LanguageTool — API compatibility reference
|
||||
|
||||
**Project:** <https://languagetool.org> (source:
|
||||
<https://github.com/languagetool-org/languagetool>)
|
||||
**License:** GNU Lesser General Public License (LGPL) — see `COPYING.txt` in
|
||||
the LanguageTool source tree
|
||||
**What this project takes:** *the wire protocol, not the code.*
|
||||
|
||||
- **The v2 REST API contract** this container emulates: `GET /v2/languages`,
|
||||
`POST /v2/check`, and the response envelope (`software`, `warnings`,
|
||||
`language`, `matches[]`, `sentenceRanges`) — schema taken from
|
||||
`languagetool-core/src/main/java/org/languagetool/tools/RuleMatchesAsJsonSerializer.java`,
|
||||
`languagetool-server/.../ApiV2.java` and `TextChecker.java`, so that
|
||||
existing LanguageTool clients (Overleaf, editors, CI linters) work
|
||||
unmodified.
|
||||
- **Error/status semantics** (`400` missing text, `413` too long, `503`
|
||||
degraded, `warnings.incompleteResults`) follow LanguageTool's behavior so
|
||||
clients that special-case it keep working.
|
||||
- **Drop-in target packaging** — port `8010`, `--allow-origin '*'` behavior
|
||||
and the "run-a-side" admin story follow the widely deployed
|
||||
`erikvl87/languagetool` / `Erikvl87/docker-languagetool` images.
|
||||
|
||||
No LanguageTool source code is included in this repository; compatibility is
|
||||
at the HTTP/JSON level only, so the LGPL obligations of the Java source do
|
||||
not attach to this container. (If you embed this as a *library* rather than a
|
||||
service, review the LGPL implications yourself.)
|
||||
|
||||
## 3. Runtime dependencies
|
||||
|
||||
| Dependency | License | Role |
|
||||
|---|---|---|
|
||||
| [node-llama-cpp](https://github.com/withcatai/node-llama-cpp) (Gilad S. / withcatai), v3.19.1 | MIT | GGUF inference engine (`llama.cpp` bindings) + `createModelDownloader` used for model fetches |
|
||||
| [esbuild](https://github.com/evanw/esbuild) | MIT | bundling `src/` → `dist/` |
|
||||
| Node.js LTS (≥ 20) | MIT (nodejs org) | runtime |
|
||||
|
||||
## 4. Integration context
|
||||
|
||||
Serves the Overleaf LanguageTool module in this repo
|
||||
(`services/web/modules/languagetool/`, based on upstream
|
||||
[yu-i-i/overleaf-cep#179](https://github.com/yu-i-i/overleaf-cep/pull/179))
|
||||
as a one-for-one replacement for a `languagetool` service container.
|
||||
@@ -1,72 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# grammar-llm container — LanguageTool-compatible (v2 API) grammar checker.
|
||||
# Drop-in replacement for erikvl87/languagetool (docker-languagetool):
|
||||
# same port (8010), same /v2/languages + /v2/check wire API, backed by the
|
||||
# grammar-llm engine (GRMR-V3-G4B fine-tuned LLM via node-llama-cpp).
|
||||
#
|
||||
# Build (from this project directory):
|
||||
# docker build -t grammar-llm .
|
||||
#
|
||||
# Run (model auto-downloads on first start unless pre-provisioned):
|
||||
# docker run -p 8010:8010 --name grammar-llm -v grammar-llm-models:/models grammar-llm
|
||||
#
|
||||
# The model (~2.5 GB for Q4_K_M) is NOT baked into the image: put it in the
|
||||
# /models volume (file name GRMR-V3-G4B-Q4_K_M.gguf by default) or let the
|
||||
# container download it from Hugging Face on first start. See README.md.
|
||||
|
||||
ARG NODE_TAG=24
|
||||
|
||||
# ---------- build stage ----------
|
||||
FROM node:${NODE_TAG}-bookworm-slim AS build
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
WORKDIR /build
|
||||
|
||||
# Install all deps (needed for bundling: esbuild; runtime: node-llama-cpp).
|
||||
# The @node-llama-cpp/* prebuilt platform package for THIS build platform is
|
||||
# what makes the bundle loadable; we re-install runtime deps for the TARGET
|
||||
# platform in the next stage (docker build --platform handles cross-arch).
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
COPY build.mjs ./
|
||||
COPY core/ core/
|
||||
COPY src/ src/
|
||||
RUN node build.mjs \
|
||||
&& npm prune --omit=dev
|
||||
|
||||
# Sanity: the bundle must resolve its only external (node-llama-cpp).
|
||||
RUN node --input-type=module -e "import('node-llama-cpp').then(() => process.exit(0), (e) => { console.error(e.message); process.exit(1); })"
|
||||
|
||||
# ---------- runtime stage ----------
|
||||
FROM node:${NODE_TAG}-bookworm-slim
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
NODE_ENV=production \
|
||||
PORT=8010 \
|
||||
GRAMMAR_LLM_MODEL_DIR=/models \
|
||||
GRAMMAR_LLM_PRELOAD=true
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime-only deps (node-llama-cpp + prebuilt native lib) for the
|
||||
# target platform of the image.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --no-audit --no-fund \
|
||||
&& node -e "import('node-llama-cpp').then(() => process.exit(0)).catch(() => process.exit(1))"
|
||||
|
||||
COPY --from=build /build/dist ./dist
|
||||
COPY start.sh ./
|
||||
RUN chmod +x start.sh \
|
||||
&& useradd -m -d /home/grammarllm -s /nonexistent grammarllm 2>/dev/null || true \
|
||||
&& mkdir -p /models \
|
||||
&& chown -R grammarllm:grammarllm /models /app
|
||||
|
||||
USER grammarllm
|
||||
VOLUME ["/models"]
|
||||
EXPOSE 8010
|
||||
|
||||
# Model download can take a while on first start → generous start period.
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=1800s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 8010) + '/v2/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["./start.sh"]
|
||||
@@ -1,133 +0,0 @@
|
||||
# grammar-llm container — development & ops tasks
|
||||
#
|
||||
# Everything maps to the documented npm scripts / docs; pick whichever you
|
||||
# like. Run `make help` (the default) for the list.
|
||||
|
||||
SHELL := /bin/bash
|
||||
NODE ?= node
|
||||
DOCKER ?= docker
|
||||
NODE_TAG ?= 24
|
||||
|
||||
IMAGE ?= grammar-llm
|
||||
TAG ?= latest
|
||||
PORT ?= 8010
|
||||
CONTAINER_NAME := glm-run
|
||||
MODEL_VOLUME := glm-models
|
||||
MODEL_DIR ?= models
|
||||
MODEL_FILE ?= GRMR-V3-G4B-Q4_K_M.gguf
|
||||
MODEL_URL ?= https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/GRMR-V3-G4B-Q4_K_M.gguf
|
||||
# BASE for the smoke script (server to smoke-test against)
|
||||
BASE ?= http://127.0.0.1:8010
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "grammar-llm container — make targets"
|
||||
@echo
|
||||
@echo " Local dev"
|
||||
@echo " install npm install (node-llama-cpp + esbuild)"
|
||||
@echo " build bundle src/ -> dist/ (esbuild)"
|
||||
@echo " test build + node --test test/ (no model needed)"
|
||||
@echo " start run the server locally (needs model; see download-model)"
|
||||
@echo " download-model fetch GRMR-V3-G4B GGUF into ./$(MODEL_DIR)/ (local server)"
|
||||
@echo " sync-core re-vendor core/ from grammar-llm (scripts/sync-core.sh)"
|
||||
@echo " smoke BASE=<url> e2e smoke against a RUNNING server (default $(BASE))"
|
||||
@echo
|
||||
@echo " Docker"
|
||||
@echo " docker-build docker build -t $(IMAGE):$(TAG) ."
|
||||
@echo " docker-smoke docker-build + run on :$(PORT) + smoke against it"
|
||||
@echo " docker-run start container on :$(PORT) (model volume, detached)"
|
||||
@echo " docker-stop stop + rm the container (volume kept)"
|
||||
@echo " docker-clobber docker-stop + delete the model volume"
|
||||
@echo
|
||||
@echo " Misc"
|
||||
@echo " all build the Docker image (compiles inside the node base image — no local Node needed)"
|
||||
@echo " docker-test run the unit tests inside a node base-image container (no local Node needed)"
|
||||
@echo " logs tail the running container's logs"
|
||||
@echo " clean rm -rf dist node_modules (keep core/, models)"
|
||||
|
||||
.PHONY: install
|
||||
install:
|
||||
npm install
|
||||
|
||||
.PHONY: ensure-deps
|
||||
ensure-deps:
|
||||
@if [ ! -d node_modules ]; then \
|
||||
echo "node_modules missing — running npm install…"; \
|
||||
npm install; \
|
||||
fi
|
||||
|
||||
.PHONY: build
|
||||
build: ensure-deps
|
||||
$(NODE) build.mjs
|
||||
|
||||
.PHONY: test
|
||||
test: ensure-deps
|
||||
$(NODE) build.mjs --quiet && $(NODE) --test test/*.test.mjs
|
||||
|
||||
.PHONY: start
|
||||
start: ensure-deps build
|
||||
GRAMMAR_LLM_MODEL_DIR=$(MODEL_DIR) PORT=$${PORT:-8010} $(NODE) dist/server.mjs
|
||||
|
||||
.PHONY: download-model
|
||||
download-model: ensure-deps build
|
||||
mkdir -p $(MODEL_DIR)
|
||||
$(NODE) dist/download-model.mjs --url "$(MODEL_URL)" --out "$(MODEL_DIR)/$(MODEL_FILE)"
|
||||
|
||||
.PHONY: sync-core
|
||||
sync-core:
|
||||
bash scripts/sync-core.sh $(GRAMMAR_LLM_SRC)
|
||||
|
||||
.PHONY: smoke
|
||||
smoke:
|
||||
BASE=$(BASE) bash test/smoke.sh
|
||||
|
||||
# --- docker ---------------------------------------------------------------
|
||||
|
||||
.PHONY: docker-build
|
||||
docker-build:
|
||||
$(DOCKER) build -t $(IMAGE):$(TAG) .
|
||||
|
||||
.PHONY: docker-test
|
||||
docker-test:
|
||||
@echo "running the test suite inside the $(NODE_TAG:24=24)-bookworm-slim base image (no local Node needed)"
|
||||
$(DOCKER) run --rm -v "$(PWD):/src:ro" -w /app node:$(NODE_TAG)-bookworm-slim sh -c \
|
||||
'cp -r /src/. /app && npm ci --no-audit --no-fund && node build.mjs --quiet && node --test test/*.test.mjs'
|
||||
|
||||
.PHONY: docker-smoke
|
||||
docker-smoke: docker-build
|
||||
$(DOCKER) rm -f $(CONTAINER_NAME) 2>/dev/null || true
|
||||
$(DOCKER) run -d --name $(CONTAINER_NAME) -p $(PORT):$(PORT) -v $(MODEL_VOLUME):/models $(IMAGE):$(TAG)
|
||||
@echo "waiting for model load…"
|
||||
@for i in $$(seq 1 120); do \
|
||||
[ "$$($(DOCKER) logs $(CONTAINER_NAME) 2>&1 | grep -c 'model loaded and ready')" -ge 1 ] && break; \
|
||||
sleep 5; \
|
||||
done
|
||||
BASE=http://127.0.0.1:$(PORT) bash test/smoke.sh
|
||||
@echo "container $(CONTAINER_NAME) left running — stop with: make docker-stop"
|
||||
|
||||
.PHONY: docker-run
|
||||
docker-run:
|
||||
$(DOCKER) rm -f $(CONTAINER_NAME) 2>/dev/null || true
|
||||
$(DOCKER) run -d --name $(CONTAINER_NAME) -p $(PORT):$(PORT) -v $(MODEL_VOLUME):/models $(IMAGE):$(TAG)
|
||||
@echo "$(CONTAINER_NAME) is up on :$(PORT) — check with: make smoke BASE=http://127.0.0.1:$(PORT)"
|
||||
|
||||
.PHONY: docker-stop
|
||||
docker-stop:
|
||||
$(DOCKER) stop $(CONTAINER_NAME) 2>/dev/null || true
|
||||
$(DOCKER) rm $(CONTAINER_NAME) 2>/dev/null || true
|
||||
|
||||
.PHONY: docker-clobber
|
||||
docker-clobber: docker-stop
|
||||
$(DOCKER) volume rm $(MODEL_VOLUME)
|
||||
|
||||
.PHONY: logs
|
||||
logs:
|
||||
$(DOCKER) logs -f $(CONTAINER_NAME)
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf dist node_modules
|
||||
|
||||
.PHONY: all
|
||||
all: docker-build
|
||||
@echo "all done — image: $(IMAGE):$(TAG) (compiled inside the node base image; no local Node needed)"
|
||||
@@ -1,373 +0,0 @@
|
||||
# grammar-llm container
|
||||
|
||||
A **drop-in replacement for the LanguageTool container**
|
||||
([docker-languagetool](https://github.com/Erikvl87/docker-languagetool),
|
||||
`erikvl87/languagetool`) for grammar checking — same port, same wire API —
|
||||
powered by the **grammar-llm** engine: a fine-tuned local LLM
|
||||
(`GRMR-V3-G4B`, [whiteh4cker-tr/grammar-llm](https://github.com/whiteh4cker-tr/grammar-llm))
|
||||
running through [`node-llama-cpp`](https://github.com/withcatai/node-llama-cpp).
|
||||
|
||||
The intent: one admin can run the classic `languagetool` container, another
|
||||
can run this container — and the rest of the stack (e.g. the Overleaf
|
||||
LanguageTool module) points at whichever one is up, without code changes.
|
||||
|
||||
```
|
||||
┌────────────┐ LanguageTool v2 API (port 8010) ┌───────────────────────────┐
|
||||
│ Overleaf │ ───────────────────────────────────▶ │ grammar-llm container │
|
||||
│ (or any │ ◀─────────────────────────────────── │ ┌─────────────────────┐ │
|
||||
│ LT client) │ { software, matches[] … } JSON │ │ HTTP server (node) │ │
|
||||
└────────────┘ │ │ grammar-llm core* │ │
|
||||
│ │ node-llama-cpp + │ │
|
||||
│ │ GRMR-V3-G4B model │ │
|
||||
│ └─────────────────────┘ │
|
||||
└───────────────────────────┘
|
||||
* vendored from /data_1/compare/grammar-llm/src/electron/core (see scripts/sync-core.sh)
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
```sh
|
||||
# from this directory
|
||||
docker build -t grammar-llm .
|
||||
docker run -d --name grammar-llm -p 8010:8010 -v grammar-llm-models:/models grammar-llm
|
||||
|
||||
# or with compose
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
First start downloads the model (~2.4 GB) into the `/models` volume from
|
||||
Hugging Face (`icecubetr/GRMR-V3-G4B-GGUF`, Q4_K_M). Subsequent starts use the
|
||||
cached file. The server answers immediately; `/v2/health` reports
|
||||
`state: loading/ready` and `/v2/check` returns `503` until the model is
|
||||
loaded — the same "silent linter" behavior Overleaf already has for timeouts.
|
||||
|
||||
Probe it like LanguageTool:
|
||||
|
||||
```sh
|
||||
curl -s http://localhost:8010/v2/languages | jq '.[0]'
|
||||
curl -s -X POST http://localhost:8010/v2/check \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'language=auto' \
|
||||
--data-urlencode 'level=picky' \
|
||||
--data-urlencode 'text=I has two cats. They is very good.' | jq '.matches'
|
||||
```
|
||||
|
||||
Use it as the Overleaf LT backend (see `services/web/modules/languagetool`):
|
||||
|
||||
```sh
|
||||
# env for the overleaf web service — unchanged from the languagetool setup,
|
||||
# just point at this container:
|
||||
LANGUAGE_TOOL_ENABLED=true
|
||||
LANGUAGE_TOOL_HOST=grammar-llm # or 127.0.0.1 on the host
|
||||
LANGUAGE_TOOL_PORT=8010
|
||||
# …or explicitly:
|
||||
# LANGUAGE_TOOL_URL=http://grammar-llm:8010
|
||||
```
|
||||
|
||||
## API (LanguageTool v2 subset)
|
||||
|
||||
| Method & path | LanguageTool equivalent | Notes |
|
||||
|--------------------|-------------------------|-------|
|
||||
| `GET /v2/languages` | `GET /v2/languages` | English variants only (model is English-only) |
|
||||
| `POST /v2/check` | `POST /v2/check` | form-urlencoded **or** JSON body |
|
||||
| `GET /v2/version` | version info | container/version + engine state |
|
||||
| `GET /v2/health` | — (not an LT endpoint) | model state, counter; used by Docker HEALTHCHECK |
|
||||
| `GET /` | — | JSON info payload (endpoints, software) |
|
||||
|
||||
### `POST /v2/check` parameters
|
||||
|
||||
| Param | Status | Notes |
|
||||
|-------|--------|-------|
|
||||
| `text` | **used** | plain text to check |
|
||||
| `data` | **used** | JSON `{"text": "…"}` or `{"annotation": [{text|markup}, …]}` — Overleaf sends `annotation` (LaTeX markup = "ignore this text"); markup is skipped and offsets stay aligned with the editor document |
|
||||
| `language` | **used** | English codes (`en`, `en-US`, `en-GB`, …) and `auto`. Non-English: by default → `200` with empty matches (linter silent; admin can set `GRAMMAR_LLM_UNSUPPORTED_LANGUAGE=error` for HTTP 400 instead) |
|
||||
| `level` (`default`/`picky`) | **used** | **Picky mode for the LLM** (see below). `picky` → stricter prompt (style + word choice + phrasing on top of base grammar). `default` → base prompt only (byte-identical to the desktop app). Always forwarded verbatim to the real LanguageTool box when one is configured. |
|
||||
| `disabledRules` | forwarded / ignored | Forwarded to the real LanguageTool box (understands LT rule ids); ignored by the LLM (it never produces them) |
|
||||
| `enabled`, `disabled`, `motherTongue`, `replacedText`, … | accepted, ignored | tolerance for other LT clients |
|
||||
|
||||
### Response shape
|
||||
|
||||
Full LanguageTool envelope (schema reference:
|
||||
`org.languagetool.tools.RuleMatchesAsJsonSerializer` in the `languagetool` source):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"software": { "name": "LanguageTool", "version": "6.8", "apiVersion": 1, "premium": false, "…": "…" },
|
||||
"warnings": { "incompleteResults": false }, // true + reason if the time budget cut the check short
|
||||
"language": { "name": "English (US)", "code": "en-US", "detectedLanguage": { … } },
|
||||
"matches": [
|
||||
{
|
||||
"message": "Shouldn't this be 'the'?",
|
||||
"shortMessage": "the",
|
||||
"replacements": [ { "value": "the" } ], // Overleaf shows the first 5 as apply-buttons
|
||||
"offset": 24, // absolute into the submitted document
|
||||
"length": 3,
|
||||
"context": { "text": "… I has two …", "offset": 19, "length": 3 },
|
||||
"type": { "typeName": "grammar" },
|
||||
"rule": {
|
||||
"id": "GRAMMAR_LLM",
|
||||
"description": "Grammar, spelling, or style issue detected by grammar-llm (GRMR-V3-G4B)",
|
||||
"issueType": "Grammar",
|
||||
"category": { "id": "GRAMMAR", "name": "Grammar" }, // deliberately NOT 'TYPOS' (see below)
|
||||
"tags": ["LLM", "GRMR-V3-G4B"]
|
||||
},
|
||||
"ignoreForIncompleteSentence": false,
|
||||
"contextForSureMatch": -1
|
||||
}
|
||||
],
|
||||
"sentenceRanges": [[0, 16], [17, 33]]
|
||||
}
|
||||
```
|
||||
|
||||
Notes on the mapping:
|
||||
|
||||
- **Word-level fixes** (replacement / deletion from the core's `wordFixes`)
|
||||
become one match each. **Pure insertions** (zero-width core fixes) are
|
||||
anchored on the neighboring token — e.g. the core's `slow → slowly` +
|
||||
insert-`,` becomes two matches that re-compose exactly to `slowly, …`.
|
||||
- **Quoted-word artifact unwrapping**: GRMR models sometimes emit the
|
||||
corrected word wrapped in quotes (`goed → "good"`). The desktop app shows
|
||||
that in its review UI; a LanguageTool client would apply it verbatim and
|
||||
inject literal quote characters into the document — so the adapter strips
|
||||
one pair of matching surrounding quotes from single-word replacements.
|
||||
(Vendor core is untouched; this happens in `src/languagetool.ts`.)
|
||||
- **Sentence-level rewrites** without word fixes become one match over the
|
||||
changed span.
|
||||
- `category.id` is **`GRAMMAR`**, not `TYPOS`: Overleaf's editor filters out
|
||||
`TYPOS` matches while the Hunspell spellchecker is active — this way
|
||||
grammar-llm suggestions are never silently filtered, while both tools stay
|
||||
usable together.
|
||||
- Matches never span LaTeX `markup` segments: the core pipeline runs per
|
||||
plain-text run (markup is invisible to the model, as in LanguageTool).
|
||||
- Errors mirror LanguageTool: `400` missing `text`/`data`, `413` text too
|
||||
long, `503` model not ready / server busy, `500` unexpected.
|
||||
|
||||
## Model
|
||||
|
||||
| What | Where |
|
||||
|------|-------|
|
||||
| Default model | `GRMR-V3-G4B-Q4_K_M.gguf` (2.4 GB) from `https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/` |
|
||||
| Stored in | `/models` (container volume) |
|
||||
| Higher quality | `GRMR-V3-G4B-Q8_0.gguf` (set `GRAMMAR_LLM_MODEL_FILE` + matching `GRAMMAR_LLM_MODEL_URL`) |
|
||||
| Any custom GGUF | set `GRAMMAR_LLM_MODEL_FILE` and pre-place the file in `/models` (or point `GRAMMAR_LLM_MODEL_URL` at it) |
|
||||
|
||||
The model download is done by the container's own `dist/download-model.mjs`
|
||||
(node-llama-cpp `createModelDownloader`, the same API grammar-llm's desktop
|
||||
modelManager uses). It's skipped automatically if the file already exists.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `PORT` | `8010` | HTTP port (matches docker-languagetool & Overleaf's default) |
|
||||
| `HOST` | `0.0.0.0` | bind address |
|
||||
| `GRAMMAR_LLM_MODEL_DIR` | `/models` | model storage dir |
|
||||
| `GRAMMAR_LLM_MODEL_FILE` | `GRMR-V3-G4B-Q4_K_M.gguf` | model file name inside the dir |
|
||||
| `GRAMMAR_LLM_MODEL_URL` | HF Q4_K_M URL | download source (only used when file is absent) |
|
||||
| `GRAMMAR_LLM_DOWNLOAD_AT_START` | `true` | auto-download at boot when file is absent |
|
||||
| `GRAMMAR_LLM_PRELOAD` | `true` | start model load at boot (vs. first check) |
|
||||
| `GRAMMAR_LLM_CONTEXT_SIZE` | `8192` | LLM context (desktop-compatible clamp 256…131072) |
|
||||
| `GRAMMAR_LLM_CONCURRENCY` | `1` | (reserved; single shared session today) |
|
||||
| `GRAMMAR_LLM_MAX_TEXT_LENGTH` | `100000` | max characters per request (→ 413; matches Overleaf's 100 KB cap) |
|
||||
| `GRAMMAR_LLM_MAX_RUNS` | `0` (unlimited) | cap on checkable text runs per request (→ `incompleteResults`) |
|
||||
| `GRAMMAR_LLM_REQUEST_TIMEOUT_MS` | `55000` | per-request time budget; remaining sentences pass through unchanged + `warnings.incompleteResults` (keep under Overleaf's 60 s proxy timeout) |
|
||||
| `GRAMMAR_LLM_SENTENCE_TIMEOUT_MS` | `20000` | per-sentence guard (informational) |
|
||||
| `GRAMMAR_LLM_MAX_IN_FLIGHT` | `4` | concurrent checks before 503 |
|
||||
| `GRAMMAR_LLM_MAX_BODY_BYTES` | `2097152` | HTTP body cap |
|
||||
| `GRAMMAR_LLM_LANGUAGES` | `auto,en,en-us,en-gb,en-au,en-ca,en-ie,en-nz,en-in,en-za` | accepted `language` codes (lower-cased) |
|
||||
| `GRAMMAR_LLM_UNSUPPORTED_LANGUAGE` | `empty` | `empty` = 200 + no matches, `error` = 400 |
|
||||
| `GRAMMAR_LLM_BACKEND` | `local` | `local` (node-llama-cpp) or `openai` (OpenAI-compatible remote endpoint) |
|
||||
| `GRAMMAR_LLM_OPENAI_BASE_URL` | `https://api.openai.com/v1` | OpenAI-compatible base URL (OpenAI, OpenRouter, vLLM, llama.cpp `llama-server`, Ollama `/v1`, a self-hosted GRMR endpoint, …) |
|
||||
| `GRAMMAR_LLM_OPENAI_API_KEY` | _empty_ | Bearer token (only if the endpoint requires one) |
|
||||
| `GRAMMAR_LLM_OPENAI_MODEL` | _required for openai_ | model name/id on the server (e.g. `gpt-4o-mini`, or your GRMR model) |
|
||||
| `GRAMMAR_LLM_OPENAI_STYLE` | `openai` | `openai` = standard `/chat/completions`; `ollama` = native `/api/chat` with `think:false` (needed for Ollama Qwen3-family models, see below) |
|
||||
| `GRAMMAR_LLM_OPENAI_TIMEOUT_MS` | `60000` | timeout for one remote call |
|
||||
| `GRAMMAR_LLM_LANGUAGETOOL_URL` | _empty_ | optional **real LanguageTool container to query first** (e.g. `http://languagetool:8010`); empty = LLM only |
|
||||
| `GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS` | `45000` | real-LT call timeout — its window to win the race |
|
||||
| `GRAMMAR_LLM_CASCADE_BUDGET_MS` | `55000` | **shared wall-clock budget for the whole cascaded check** (LT attempt + LLM fallback). Must stay under the LT client's cap — Overleaf's controller times out at **60 s and then silently shows nothing** |
|
||||
| `GRAMMAR_LLM_LLM_GRACE_MS` | `400` | how long a fast real LT can win before the LLM starts in parallel (keeps the compute-saving benefit; 0 = pure parallel) |
|
||||
| `GRAMMAR_LLM_DEFAULT_LEVEL` | `default` | level used when the client sends none (`default` \| `picky`) |
|
||||
| `GRAMMAR_LLM_ALLOW_ORIGIN` | `*` | CORS origin (docker-languagetool runs with `--allow-origin '*'`) |
|
||||
| `GRAMMAR_LLM_TEMPERATURE` / `GRAMMAR_LLM_TOP_K` / `GRAMMAR_LLM_TOP_P` / `GRAMMAR_LLM_MIN_P` | `0.7 / 40 / 0.95 / 0.01` | sampling — identical defaults to the grammar-llm desktop app |
|
||||
| `GRAMMAR_LLM_REPORTED_LT_VERSION` | `6.8` | version string reported as "LanguageTool" |
|
||||
| `GRAMMAR_LLM_LOG_LEVEL` | `info` | `debug\|info\|warn\|error` |
|
||||
| `LLAMA_USE_MLOCK`, `GGML_N_THREADS`, … | — | node-llama-cpp / llama.cpp passthrough env vars work as-is |
|
||||
|
||||
## Check routing & modes
|
||||
|
||||
Three independent, composable knobs control where and how a check is computed.
|
||||
|
||||
### 1) Optional real LanguageTool first (compute-saving cascade)
|
||||
|
||||
Point this container at a real LanguageTool box and it runs **first**; the LLM
|
||||
only runs when LanguageTool found nothing — saving local inference on the
|
||||
cases the rule engine already solved (typo, casing, punctuation):
|
||||
|
||||
```sh
|
||||
GRAMMAR_LLM_LANGUAGETOOL_URL=http://languagetool:8010 # same compose network
|
||||
```
|
||||
|
||||
**How the race works** (all on ONE shared budget, `GRAMMAR_LLM_CASCADE_BUDGET_MS,
|
||||
default 55 s` — chosen to sit under Overleaf's 60 s controller timeout):
|
||||
|
||||
1. The request is forwarded **verbatim** (`text` **or** the original
|
||||
`data.annotation`, `language`, `disabledRules`, and **`level` —
|
||||
`picky`→`picky`**) to `$GRAMMAR_LLM_LANGUAGETOOL_URL/v2/check`.
|
||||
2. After a short grace (`GRAMMAR_LLM_LLM_GRACE_MS`, default 400 ms — long
|
||||
enough that a *warm* real LT, which answers in a few hundred ms, wins for
|
||||
free and the LLM start is **cancelled**), the LLM runs **in parallel**.
|
||||
3. Resolution:
|
||||
- real-LT outcome with **≥1 match** → returned as-is (same envelope
|
||||
Overleaf already consumes);
|
||||
- LLM ready → returned; if the LT is still running it gets a final capped
|
||||
grace (≤ 1.2 s) to land a match ("real LT first", with a lid on it);
|
||||
- an LLM 400/503 outcome only surfaces when the LT had nothing either.
|
||||
|
||||
Why parallel: a *serial* "wait for LT, then run LLM" pushed the total past
|
||||
clients' hard caps (measured 15 s LT timeout + 56 s LLM = 71 s > Overleaf's
|
||||
60 s → the UI silently showed nothing). The shared budget guarantees the
|
||||
response always lands in time — worst case is a *partial* result
|
||||
(`warnings.incompleteResults`), never a client-side timeout. The fallback is
|
||||
silent and automatic, so a dead LanguageTool box never breaks checking — it
|
||||
just costs one attempt. Set the URL back to empty to disable.
|
||||
|
||||
### 2) Optional remote LLM (OpenAI-compatible API)
|
||||
|
||||
When the Overleaf host can't afford local inference, redirect the LLM step to
|
||||
any OpenAI-compatible `chat/completions` endpoint (OpenAI, OpenRouter, vLLM,
|
||||
llama.cpp `llama-server`, Ollama `/v1`, a hosted GRMR/V3 LoRA, …):
|
||||
|
||||
```sh
|
||||
GRAMMAR_LLM_BACKEND=openai
|
||||
GRAMMAR_LLM_OPENAI_BASE_URL=https://api.openai.com/v1 # or your endpoint
|
||||
GRAMMAR_LLM_OPENAI_MODEL=gpt-4o-mini # required
|
||||
GRAMMAR_LLM_OPENAI_API_KEY=sk-… # only if needed
|
||||
```
|
||||
|
||||
**Ollama (Qwen3 and similar reasoning models):** use the native style instead. On
|
||||
Ollama's OpenAI-compat layer the `think:false` flag is ignored for Qwen3-family
|
||||
models and the response can spend *all* completion tokens on hidden reasoning
|
||||
(measured: 111–400 tokens burned for ~15-token answers, occasionally hitting
|
||||
max_tokens and returning an empty answer with HTTP 200). The native
|
||||
`/api/chat` honors top-level `think:false` and suppresses reasoning entirely
|
||||
(measured: ~15 completion tokens, same answer):
|
||||
|
||||
```sh
|
||||
GRAMMAR_LLM_BACKEND=openai
|
||||
GRAMMAR_LLM_OPENAI_STYLE=ollama
|
||||
GRAMMAR_LLM_OPENAI_BASE_URL=http://127.0.0.1:11434 # /v1 suffix is stripped automatically
|
||||
GRAMMAR_LLM_OPENAI_MODEL=qwen3.8:latest
|
||||
GRAMMAR_LLM_OPENAI_API_KEY=ollama # Ollama ignores it
|
||||
```
|
||||
|
||||
If the Ollama server only listens on the host's loopback, add a tiny TCP relay
|
||||
container bound to the docker bridge gateway IP (see
|
||||
`/data_1/docker/compose/ollama-gw/` in this deployment) so containers on the
|
||||
bridge network can reach it without exposing Ollama to the LAN.
|
||||
|
||||
With `backend=openai` **no local model is downloaded or loaded** (`start.sh`
|
||||
skips the model step; the image is unchanged). The same grammar-llm core
|
||||
pipeline (sentence diff, word-fix extraction, quote cleanup, LanguageTool
|
||||
mapping) runs locally — only the per-sentence generation call is remote.
|
||||
Prompt/stop/sampling semantics match the local backend exactly.
|
||||
|
||||
### 3) Picky mode for the LLM (`level`)
|
||||
|
||||
LanguageTool's `default` vs `picky` maps to two LLM prompt modes, mirroring
|
||||
LT's idea that *picky adds rule families on top of the default set*:
|
||||
|
||||
| level | LLM behavior |
|
||||
|------------|--------------|
|
||||
| `default` | base correction prompt only — **byte-identical to the grammar-llm desktop app** (grammar, spelling, punctuation). |
|
||||
| `picky` | base prompt **plus** a "highly picky editor" addendum: word choice, awkward/redundant phrasing, weak punctuation, style. Produces the fuller suggestion set. |
|
||||
|
||||
`/v2/check` sends whatever `level` the client gave (Overleaf default is
|
||||
`picky`). When a client sends no level, `GRAMMAR_LLM_DEFAULT_LEVEL` decides
|
||||
(default `default`). The requested level is always forwarded verbatim to the
|
||||
real LanguageTool box (step 1), so a `picky` Overleaf tab still gets
|
||||
`level=picky` there.
|
||||
|
||||
> Tuning: the picky addendum text is fixed in code (
|
||||
> `DEFAULT_PICKY_EXTRA` in `src/llama.ts`). If you want it stronger or
|
||||
> weaker, send a PR or vendor the constant — the level *mechanism* (two
|
||||
> prompts + LT forwarding) stays the same.
|
||||
|
||||
## Behavior & caveats (vs. LanguageTool)
|
||||
|
||||
| Topic | LanguageTool | grammar-llm container |
|
||||
|-------|--------------|-----------------------|
|
||||
| Languages | 30+ | English only (GRMR-V3-G4B). Non-English → silent (or 400, configurable) |
|
||||
| Speed | ms | seconds per sentence (CPU). Short docs fine; very long docs hit the time budget → `incompleteResults` + partial matches (a LanguageTool-native concept) |
|
||||
| Rules/levels | fine-grained rules, levels `default…picky` | **level supported**: `default` (base prompt, desktop parity) / `picky` (base + style addendum); `disabledRules` forward to a real LT box when configured, ignored by the LLM |
|
||||
| Where it runs | same box | **local** node-llama-cpp, or **remote** OpenAI-compatible endpoint (`GRAMMAR_LLM_BACKEND=openai`), or a real-LT-first cascade (`GRAMMAR_LLM_LANGUAGETOOL_URL`) — pick your compute strategy per deployment |
|
||||
| Dictionary | rule-based + user dicts | statistical (model) |
|
||||
| Memory | ~1–2 GB | model ~2.6 GB (Q4) + context ≈ **give the container ≥ 4 GB RAM** |
|
||||
| GPU | n/a | auto-detected by node-llama-cpp (build/run with a CUDA build + `--gpus all` for best performance) |
|
||||
| Telemetry/premium endpoints | has some | not needed by Overleaf; others → 404 |
|
||||
|
||||
The engine output is byte-identical to the grammar-llm desktop app for a given
|
||||
model: same system prompt, stop sequences, sampling, sentence pipeline
|
||||
(vendored `core/` + mirrored `llamaService` semantics).
|
||||
|
||||
## Development
|
||||
|
||||
**Build the Docker image** — no local Node installation required; the
|
||||
compile (`npm ci` + esbuild) happens inside the `node:24-bookworm-slim`
|
||||
base image's own Node:
|
||||
|
||||
```sh
|
||||
make all # docker build -t grammar-llm:latest .
|
||||
```
|
||||
|
||||
**Local development** (optional; needs Node ≥ 20 + npm to run the bundle
|
||||
and unit tests directly on the host):
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run build # esbuild → dist/*.mjs (self-contained; node-llama-cpp external)
|
||||
npm test # build + node --test (LLM-free: fake engine + unit tests)
|
||||
npm run smoke # end-to-end curl against a running local server (needs model)
|
||||
```
|
||||
|
||||
`Makefile` exposes the same targets plus Docker ops — `make help`:
|
||||
|
||||
```make
|
||||
make all # build the Docker image (default target; no local Node needed)
|
||||
make docker-test # run the unit tests inside a node base-image container (no local Node needed)
|
||||
make docker-build # docker build -t grammar-llm:latest .
|
||||
make docker-smoke # build + container on :8010 + e2e smoke
|
||||
make docker-run # start the container (model volume kept)
|
||||
make docker-stop # stop + rm (volume kept) / make docker-clobber (also deletes volume)
|
||||
make logs # tail container logs
|
||||
```
|
||||
|
||||
### Updating the vendored core
|
||||
|
||||
The functional core is vendored under `core/` (snapshot of
|
||||
`grammar-llm/src/electron/core/*.ts` — provenance in `core/.sync-info`):
|
||||
|
||||
```sh
|
||||
scripts/sync-core.sh # from /data_1/compare/grammar-llm (default)
|
||||
scripts/sync-core.sh /path/to/grammar-llm
|
||||
npm run build && npm test
|
||||
```
|
||||
|
||||
`src/llama.ts` mirrors `grammar-llm/src/electron/llamaService.ts` — when the
|
||||
desktop changes prompts/sampling, keep both in sync.
|
||||
|
||||
## Docker build layout
|
||||
|
||||
- **builder**: `node:24-bookworm-slim`, `npm ci` (installs
|
||||
`node-llama-cpp` + platform prebuilds + `esbuild`), bundles `dist/`.
|
||||
- **runtime**: `node:24-bookworm-slim`, `npm ci --omit=dev` (native libs for
|
||||
the target platform), non-root user, `VOLUME /models`, `HEALTHCHECK` against
|
||||
`/v2/health` (1800 s start-period to cover first-run model download).
|
||||
|
||||
## License & credits
|
||||
|
||||
See [CREDITS.md](CREDITS.md). The functional core is vendored from
|
||||
[whiteh4cker-tr/grammar-llm](https://github.com/whiteh4cker-tr/grammar-llm)
|
||||
(MIT, © 2025 whiteh4cker-tr) — provenance and per-file hashes in
|
||||
`core/.sync-info`. The external protocol is a compatibility implementation of
|
||||
the [LanguageTool](https://languagetool.org) v2 REST API (LGPL; API-level
|
||||
only, no LT code included).
|
||||
@@ -1,25 +0,0 @@
|
||||
// Build: bundle the TypeScript server + vendored core into self-contained
|
||||
// ES modules under dist/. node-llama-cpp stays external (native, platform
|
||||
// packages installed by npm at install time).
|
||||
|
||||
import { build } from 'esbuild';
|
||||
|
||||
const quiet = process.argv.includes('--quiet');
|
||||
|
||||
await build({
|
||||
entryPoints: ['src/server.ts', 'src/download-model.ts', 'src/languagetool.ts', 'src/config.ts'],
|
||||
outdir: 'dist',
|
||||
outExtension: { '.js': '.mjs' },
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
// Native module: keep as a runtime import; npm installs the right
|
||||
// @node-llama-cpp/* prebuilt for the target platform.
|
||||
external: ['node-llama-cpp'],
|
||||
sourcemap: false,
|
||||
minify: false,
|
||||
logLevel: quiet ? 'warning' : 'info',
|
||||
});
|
||||
|
||||
if (!quiet) console.log('✓ build complete → dist/ (server.mjs, download-model.mjs, languagetool.mjs, config.mjs)');
|
||||
@@ -1,34 +0,0 @@
|
||||
services:
|
||||
grammar-llm:
|
||||
image: grammar-llm:latest
|
||||
container_name: languagetool
|
||||
hostname: languagetool
|
||||
restart: unless-stopped
|
||||
|
||||
# Optional environment (uncomment and set as a mapping — compose rejects
|
||||
# an environment key with no entries):
|
||||
# environment:
|
||||
# GRAMMAR_LLM_MODEL_FILE: GRMR-V3-G4B-Q8_0.gguf # optional: higher-quality (slower) quant
|
||||
# GRAMMAR_LLM_MODEL_URL: "..." # optional: custom model source
|
||||
# GRAMMAR_LLM_DOWNLOAD_AT_START: "true" # default: download model on first start
|
||||
# GRAMMAR_LLM_REQUEST_TIMEOUT_MS: "55000" # per-request time budget
|
||||
# GRAMMAR_LLM_PRELOAD: "true" # load model at boot
|
||||
volumes:
|
||||
- ./models:/models # the model downloaded here (make download-model → ./models/GRMR-V3-G4B-Q4_K_M.gguf); survives container recreation
|
||||
# For NVIDIA GPU inference (build with the linux-x64-cuda prebuilt and
|
||||
# uncomment; node-llama-cpp auto-detects CUDA when available):
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: 1
|
||||
# capabilities: [gpu]
|
||||
|
||||
networks:
|
||||
- overleaf-network
|
||||
|
||||
networks:
|
||||
overleaf-network:
|
||||
external: true
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
2026-10-28T01:38:51Z
|
||||
source: /data_1/compare/grammar-llm/src/electron/core
|
||||
ceff65a
|
||||
57303d960bf50d2084f71c80149f94e593f1927f4cc6eb1019ce2bd8b2961f73 /data_1/compare/grammar-llm/src/electron/core/apply.ts
|
||||
7c851d7a0c9fd3118a37119d9850ef149792381f82687cee70b58800dfda402d /data_1/compare/grammar-llm/src/electron/core/clean.ts
|
||||
57d585afe1ac6e232d05f03e46c33a2b3ae2c65a18478c798ad3d6f9bd68892f /data_1/compare/grammar-llm/src/electron/core/correction.ts
|
||||
d97ad1c71100da76c3ce0b356727aa51a5f66c43afaf4f73a75d0b125072c594 /data_1/compare/grammar-llm/src/electron/core/diff.ts
|
||||
f750127beb34b35fb1de3dab02ab402d2a29c2e52d852079f49af22a4e923e8a /data_1/compare/grammar-llm/src/electron/core/reconstruct.ts
|
||||
5588044015fde745805ad194d582a8604b3db2636b59c1c36aeda8ecec376593 /data_1/compare/grammar-llm/src/electron/core/sentences.ts
|
||||
133f88bc76531a8727f7c0346f1ae2a4be7ec519532d16409b8c84648fa48ebc /data_1/compare/grammar-llm/src/electron/core/types.ts
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { Suggestion } from './types.js';
|
||||
|
||||
export function findAllOccurrences(haystack: string, needle: string): Array<[number, number]> {
|
||||
if (!needle || !haystack) return [];
|
||||
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(escaped, 'g');
|
||||
const matches: Array<[number, number]> = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(haystack)) !== null) {
|
||||
matches.push([match.index, match.index + needle.length]);
|
||||
if (match.index === re.lastIndex) re.lastIndex++;
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function nearestOccurrence(occurrences: Array<[number, number]>, approxStart: number): [number, number] {
|
||||
return occurrences.reduce((best, occ) =>
|
||||
Math.abs(occ[0] - approxStart) < Math.abs(best[0] - approxStart) ? occ : best,
|
||||
);
|
||||
}
|
||||
|
||||
export function applySuggestion(text: string, suggestionIndex: number, suggestions: Suggestion[]): string {
|
||||
if (suggestionIndex < 0 || suggestionIndex >= suggestions.length) {
|
||||
throw new RangeError('Invalid suggestion index');
|
||||
}
|
||||
const suggestion = suggestions[suggestionIndex];
|
||||
const { start_index: start, end_index: end } = suggestion;
|
||||
|
||||
if (start >= 0 && start <= end && end <= text.length && text.slice(start, end) === suggestion.original) {
|
||||
return text.slice(0, start) + suggestion.corrected + text.slice(end);
|
||||
}
|
||||
|
||||
const occurrences = findAllOccurrences(text, suggestion.original);
|
||||
if (occurrences.length > 0) {
|
||||
const [tStart, tEnd] = nearestOccurrence(occurrences, start);
|
||||
return text.slice(0, tStart) + suggestion.corrected + text.slice(tEnd);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export function applySuggestionsBulk(text: string, suggestions: Suggestion[]): string {
|
||||
if (suggestions.length === 0) return text;
|
||||
|
||||
const sorted = suggestions
|
||||
.filter((s) => s.start_index >= 0 && s.end_index >= 0 && s.start_index <= s.end_index)
|
||||
.sort((a, b) => b.start_index - a.start_index);
|
||||
|
||||
const appliedIntervals: Array<[number, number]> = [];
|
||||
let result = text;
|
||||
|
||||
for (const s of sorted) {
|
||||
const { start_index: start, end_index: end } = s;
|
||||
let candidate: [number, number] | null = null;
|
||||
|
||||
if (end <= result.length && result.slice(start, end) === s.original) {
|
||||
candidate = [start, end];
|
||||
} else {
|
||||
const occurrences = findAllOccurrences(result, s.original);
|
||||
if (occurrences.length > 0) {
|
||||
candidate = nearestOccurrence(occurrences, start);
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidate) continue;
|
||||
|
||||
const [cStart, cEnd] = candidate;
|
||||
const overlaps = appliedIntervals.some(([aStart, aEnd]) => !(cEnd <= aStart || cStart >= aEnd));
|
||||
if (overlaps) continue;
|
||||
|
||||
result = result.slice(0, cStart) + s.corrected + result.slice(cEnd);
|
||||
appliedIntervals.push([cStart, cEnd]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
const TEMPLATE_TAG_RE = /<\|.*?\|>/g;
|
||||
const QUOTE_PERIOD_RE = /([.!?])(["'])\s*\./g;
|
||||
const QUOTE_DUP_RE = /([.!?])(["'])\s*\1/g;
|
||||
|
||||
// Mojibake: the UTF-8 bytes of smart quotes (e.g. U+201C = E2 80 9C) can arrive
|
||||
// as Latin-1/CP1252 characters (\u00e2\u20ac\u0153 = "“", or \u00e2\u0080\u009c
|
||||
// when the C1 control bytes survive). Models fine-tuned on scraped data often
|
||||
// reproduce these instead of proper quotes. Replace longer patterns first.
|
||||
const MOJIBAKE_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/\u00e2\u20ac\u0153/g, '\u201C'], // “
|
||||
[/\u00e2\u0080\u009c/g, '\u201C'], // “ (raw control bytes)
|
||||
[/\u00e2\u20ac\u009d/g, '\u201D'], // ”
|
||||
[/\u00e2\u0080\u009d/g, '\u201D'], // ” (raw control bytes)
|
||||
[/\u00e2\u20ac\u02dc/g, '\u2018'], // ‘
|
||||
[/\u00e2\u0080\u0098/g, '\u2018'], // ‘ (raw control bytes)
|
||||
[/\u00e2\u20ac\u2122/g, '\u2019'], // ’
|
||||
[/\u00e2\u0080\u0099/g, '\u2019'], // ’ (raw control bytes)
|
||||
[/\u00e2\u20ac\u00a6/g, '\u2026'], // …
|
||||
[/\u00e2\u0080\u00a6/g, '\u2026'], // … (raw control bytes)
|
||||
];
|
||||
|
||||
/** Normalize mojibake smart quotes back to proper Unicode characters. */
|
||||
export function fixMojibakeQuotes(s: string): string {
|
||||
let result = s;
|
||||
for (const [pattern, replacement] of MOJIBAKE_PATTERNS) {
|
||||
result = result.replace(pattern, replacement);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const INSTRUCTION_PREFIXES = [
|
||||
'correct the grammar and spelling of this sentence:',
|
||||
'here is the corrected sentence:',
|
||||
'corrected sentence:',
|
||||
'the corrected version is:',
|
||||
'grammar correction:',
|
||||
'corrected:',
|
||||
];
|
||||
|
||||
export function cleanCorrectedText(corrected: string, original: string): string {
|
||||
if (!corrected) return original;
|
||||
|
||||
let result = fixMojibakeQuotes(corrected).replace(TEMPLATE_TAG_RE, '').trim();
|
||||
|
||||
for (const prefix of INSTRUCTION_PREFIXES) {
|
||||
if (result.toLowerCase().startsWith(prefix)) {
|
||||
result = result.slice(prefix.length).trim();
|
||||
result = result.replace(/^[:]\s*/, '');
|
||||
}
|
||||
}
|
||||
|
||||
const words = result.split(/\s+/);
|
||||
if (words.length > 10) {
|
||||
for (let i = 0; i < words.length - 5; i++) {
|
||||
const segment = words.slice(i, i + 5).join(' ');
|
||||
if (words.slice(i + 5).join(' ').includes(segment)) {
|
||||
result = words.slice(0, i + 5).join(' ');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (original && /^[A-Z]/.test(original) && result && /^[a-z]/.test(result)) {
|
||||
result = result[0].toUpperCase() + result.slice(1);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const before = result;
|
||||
result = result.replace(QUOTE_PERIOD_RE, '$1$2');
|
||||
result = result.replace(QUOTE_DUP_RE, '$1$2');
|
||||
if (result === before) break;
|
||||
}
|
||||
|
||||
if (original && /[.!?]$/.test(original) && result && !/[.!?]$/.test(result)) {
|
||||
result += original[original.length - 1];
|
||||
}
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
export function isOnlyQuoteChange(original: string, corrected: string): boolean {
|
||||
if (original === corrected) return false;
|
||||
const normalize = (s: string): string => s.replace(/[\u2018\u2019]/g, "'").replace(/[\u201C\u201D]/g, '"');
|
||||
if (normalize(original) === normalize(corrected)) return true;
|
||||
if (normalize(original.trim()) === normalize(corrected.trim())) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { splitIntoSentences } from './sentences.js';
|
||||
import { cleanCorrectedText, isOnlyQuoteChange } from './clean.js';
|
||||
import { highlightWordDifferences, extractWordFixes } from './diff.js';
|
||||
import { reconstructTextFromSentences } from './reconstruct.js';
|
||||
import type { CorrectionResponse, SentenceCorrector } from './types.js';
|
||||
|
||||
export function cleanQuotePunctuation(s: string): string {
|
||||
let result = s;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const before = result;
|
||||
result = result.replace(/([.!?])(["'])\s*\./g, '$1$2');
|
||||
result = result.replace(/([.!?])(["'])\s*\1/g, '$1$2');
|
||||
if (result === before) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function correctText(text: string, corrector: SentenceCorrector): Promise<CorrectionResponse> {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return { suggestions: [], correctedText: '' };
|
||||
|
||||
const sentenceData = splitIntoSentences(text);
|
||||
const suggestions: CorrectionResponse['suggestions'] = [];
|
||||
const correctedSentences: string[] = [];
|
||||
|
||||
for (let i = 0; i < sentenceData.length; i++) {
|
||||
const sent = sentenceData[i];
|
||||
const sentence = sent.text;
|
||||
|
||||
if (sentence.length < 2) {
|
||||
correctedSentences.push(sentence);
|
||||
continue;
|
||||
}
|
||||
|
||||
let corrected = await corrector.correct(sentence);
|
||||
corrected = cleanCorrectedText(corrected, sentence);
|
||||
|
||||
if (isOnlyQuoteChange(sentence, corrected)) {
|
||||
corrected = sentence;
|
||||
}
|
||||
if (corrected.length > sentence.length * 2) {
|
||||
corrected = sentence;
|
||||
}
|
||||
|
||||
corrected = cleanQuotePunctuation(corrected);
|
||||
|
||||
// Diff once: restore the original quote style (curly vs straight) so
|
||||
// quote changes are never applied to the user's text.
|
||||
const diff = highlightWordDifferences(sentence, corrected);
|
||||
corrected = diff.preservedCorrected;
|
||||
correctedSentences.push(corrected);
|
||||
|
||||
if (
|
||||
corrected.toLowerCase().trim() !== sentence.toLowerCase().trim() &&
|
||||
corrected.trim() !== sentence.trim() &&
|
||||
corrected.length <= sentence.length * 1.5 &&
|
||||
!isOnlyQuoteChange(sentence, corrected)
|
||||
) {
|
||||
suggestions.push({
|
||||
original: sentence,
|
||||
corrected,
|
||||
sentence: `Sentence ${i + 1}`,
|
||||
start_index: sent.start,
|
||||
end_index: sent.end,
|
||||
original_highlighted: diff.originalHighlighted,
|
||||
corrected_highlighted: diff.correctedHighlighted,
|
||||
wordFixes: extractWordFixes(sentence, corrected, sent.start),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const correctedText = cleanQuotePunctuation(
|
||||
reconstructTextFromSentences(text, sentenceData, correctedSentences),
|
||||
);
|
||||
return { suggestions, correctedText };
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import { diffArrays } from 'diff';
|
||||
import type { WordFix } from './types.js';
|
||||
|
||||
export function tokenize(text: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = '';
|
||||
for (const char of text) {
|
||||
if (/\s/.test(char)) {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
tokens.push(char);
|
||||
} else if ('.,!?;:"()[]{}\u201C\u201D'.includes(char)) {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
tokens.push(char);
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current) tokens.push(current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// Curly quotes compare equal to their straight counterparts so that
|
||||
// quote-style changes alone never trigger word highlighting.
|
||||
function normalizeToken(token: string): string {
|
||||
return token
|
||||
.replace(/[\u201C\u201D]/g, '"')
|
||||
.replace(/[\u2018\u2019]/g, "'");
|
||||
}
|
||||
|
||||
export function highlightWordDifferences(
|
||||
original: string,
|
||||
corrected: string,
|
||||
): {
|
||||
originalHighlighted: string;
|
||||
correctedHighlighted: string;
|
||||
/** Corrected text with the original's quote characters restored. */
|
||||
preservedCorrected: string;
|
||||
} {
|
||||
const originalTokens = tokenize(original);
|
||||
const correctedTokens = tokenize(corrected);
|
||||
const parts = diffArrays(originalTokens.map(normalizeToken), correctedTokens.map(normalizeToken));
|
||||
|
||||
let originalHighlighted = '';
|
||||
let correctedHighlighted = '';
|
||||
let preservedCorrected = '';
|
||||
let originalIndex = 0;
|
||||
let correctedIndex = 0;
|
||||
|
||||
for (const part of parts) {
|
||||
const value = Array.isArray(part.value) ? part.value : [part.value];
|
||||
const length = value.length;
|
||||
|
||||
if (part.added) {
|
||||
for (let k = 0; k < length; k++) {
|
||||
const token = correctedTokens[correctedIndex + k];
|
||||
correctedHighlighted += token.trim()
|
||||
? `<span class="corrected-word">${token}</span>`
|
||||
: token;
|
||||
preservedCorrected += token;
|
||||
}
|
||||
correctedIndex += length;
|
||||
} else if (part.removed) {
|
||||
for (let k = 0; k < length; k++) {
|
||||
const token = originalTokens[originalIndex + k];
|
||||
originalHighlighted += token.trim()
|
||||
? `<span class="error-word">${token}</span>`
|
||||
: token;
|
||||
}
|
||||
originalIndex += length;
|
||||
} else {
|
||||
// Equal after quote normalization: emit the ORIGINAL characters on both
|
||||
// sides so quote style stays untouched and words aren't highlighted.
|
||||
const originalSlice = originalTokens.slice(originalIndex, originalIndex + length).join('');
|
||||
originalHighlighted += originalSlice;
|
||||
correctedHighlighted += originalSlice;
|
||||
preservedCorrected += originalSlice;
|
||||
originalIndex += length;
|
||||
correctedIndex += length;
|
||||
}
|
||||
}
|
||||
|
||||
return { originalHighlighted, correctedHighlighted, preservedCorrected };
|
||||
}
|
||||
|
||||
export function extractWordFixes(
|
||||
original: string,
|
||||
corrected: string,
|
||||
baseOffset: number,
|
||||
): WordFix[] {
|
||||
const originalTokens = tokenize(original);
|
||||
const correctedTokens = tokenize(corrected);
|
||||
const parts = diffArrays(originalTokens.map(normalizeToken), correctedTokens.map(normalizeToken));
|
||||
|
||||
// Sentence-relative start offset of each original token. Tokens are
|
||||
// contiguous slices of the original, so cumulative lengths give offsets.
|
||||
const originalStarts: number[] = [];
|
||||
let pos = 0;
|
||||
for (const token of originalTokens) {
|
||||
originalStarts.push(pos);
|
||||
pos += token.length;
|
||||
}
|
||||
|
||||
const fixes: WordFix[] = [];
|
||||
let originalIndex = 0;
|
||||
let correctedIndex = 0;
|
||||
let pendingRemoved: Array<{ token: string; start: number }> = [];
|
||||
|
||||
const emitDeletion = (removed: Array<{ token: string; start: number }>) => {
|
||||
const originalText = removed.map((r) => r.token).join('');
|
||||
if (originalText.trim() === '') return; // whitespace-only — nothing visible to click
|
||||
const last = removed[removed.length - 1];
|
||||
fixes.push({
|
||||
original: originalText,
|
||||
corrected: '',
|
||||
start: baseOffset + removed[0].start,
|
||||
end: baseOffset + last.start + last.token.length,
|
||||
});
|
||||
};
|
||||
|
||||
for (const part of parts) {
|
||||
const value = Array.isArray(part.value) ? part.value : [part.value];
|
||||
if (part.removed) {
|
||||
for (let k = 0; k < value.length; k++) {
|
||||
const idx = originalIndex + k;
|
||||
pendingRemoved.push({ token: originalTokens[idx], start: originalStarts[idx] });
|
||||
}
|
||||
originalIndex += value.length;
|
||||
} else if (part.added) {
|
||||
const addedTokens = correctedTokens.slice(correctedIndex, correctedIndex + value.length);
|
||||
if (pendingRemoved.length > 0 && addedTokens.length > 0) {
|
||||
if (pendingRemoved.length === addedTokens.length) {
|
||||
// 1:1 pairing.
|
||||
for (let k = 0; k < pendingRemoved.length; k++) {
|
||||
const removed = pendingRemoved[k];
|
||||
const added = addedTokens[k];
|
||||
if (removed.token !== added && removed.token.trim() !== '' && added.trim() !== '') {
|
||||
fixes.push({
|
||||
original: removed.token,
|
||||
corrected: added,
|
||||
start: baseOffset + removed.start,
|
||||
end: baseOffset + removed.start + removed.token.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unequal run. Pair as many leading tokens as possible 1:1; if the
|
||||
// leftovers are punctuation/whitespace only (e.g. 'slow' ->
|
||||
// 'slowly,'), emit them as a separate insertion so the comma shows
|
||||
// as its own marker in the editor. Otherwise keep the whole-run
|
||||
// joined fix (e.g. 'is are' -> 'was' must stay a single unit).
|
||||
const pairCount = Math.min(pendingRemoved.length, addedTokens.length);
|
||||
const leftoverRemoved = pendingRemoved.slice(pairCount);
|
||||
const leftoverAdded = addedTokens.slice(pairCount);
|
||||
// eslint-disable-next-line no-useless-escape -- the [ ] escapes are required here; unescaped [] matches nothing
|
||||
const punctOnly = (tokens: string[]) => tokens.every((t) => /^[\s.,!?;:'"()\[\]{}\u201C\u201D\u2018\u2019]*$/.test(t));
|
||||
if (punctOnly(leftoverRemoved.map((r) => r.token)) && punctOnly(leftoverAdded)) {
|
||||
for (let k = 0; k < pairCount; k++) {
|
||||
const removed = pendingRemoved[k];
|
||||
const added = addedTokens[k];
|
||||
if (removed.token !== added && removed.token.trim() !== '' && added.trim() !== '') {
|
||||
fixes.push({
|
||||
original: removed.token,
|
||||
corrected: added,
|
||||
start: baseOffset + removed.start,
|
||||
end: baseOffset + removed.start + removed.token.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
const pairEnd = pendingRemoved[pairCount - 1].start + pendingRemoved[pairCount - 1].token.length;
|
||||
const addedText = leftoverAdded.join('');
|
||||
if (addedText.trim() !== '') {
|
||||
fixes.push({ original: '', corrected: addedText, start: baseOffset + pairEnd, end: baseOffset + pairEnd });
|
||||
}
|
||||
// leftoverRemoved is whitespace/punctuation only — nothing
|
||||
// visible to click, so no deletion fix is emitted.
|
||||
} else {
|
||||
const originalText = pendingRemoved.map((r) => r.token).join('');
|
||||
const addedText = addedTokens.join('');
|
||||
if (originalText.trim() !== '' && addedText.trim() !== '') {
|
||||
const last = pendingRemoved[pendingRemoved.length - 1];
|
||||
fixes.push({
|
||||
original: originalText,
|
||||
corrected: addedText,
|
||||
start: baseOffset + pendingRemoved[0].start,
|
||||
end: baseOffset + last.start + last.token.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (pendingRemoved.length === 0 && addedTokens.length > 0) {
|
||||
// Pure insertion: mark the point before the next original token.
|
||||
const addedText = addedTokens.join('');
|
||||
if (addedText.trim() !== '') {
|
||||
const insertAt = originalIndex < originalTokens.length
|
||||
? originalStarts[originalIndex]
|
||||
: original.length;
|
||||
fixes.push({ original: '', corrected: addedText, start: baseOffset + insertAt, end: baseOffset + insertAt });
|
||||
}
|
||||
}
|
||||
pendingRemoved = [];
|
||||
correctedIndex += value.length;
|
||||
} else {
|
||||
// Equal part closes any pending change group; a removed run with no
|
||||
// added counterpart is a pure deletion.
|
||||
if (pendingRemoved.length > 0) emitDeletion(pendingRemoved);
|
||||
pendingRemoved = [];
|
||||
originalIndex += value.length;
|
||||
correctedIndex += value.length;
|
||||
}
|
||||
}
|
||||
|
||||
// A removed run at the very end of the diff is also a pure deletion.
|
||||
if (pendingRemoved.length > 0) emitDeletion(pendingRemoved);
|
||||
|
||||
return fixes;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { SentenceData } from './types.js';
|
||||
|
||||
export function reconstructTextFromSentences(
|
||||
originalText: string,
|
||||
sentenceData: SentenceData[],
|
||||
correctedSentences: string[],
|
||||
): string {
|
||||
if (sentenceData.length !== correctedSentences.length) return originalText;
|
||||
|
||||
const resultParts: string[] = [];
|
||||
let lastSpanEnd = 0;
|
||||
|
||||
for (let i = 0; i < sentenceData.length; i++) {
|
||||
const sent = sentenceData[i];
|
||||
const corrected = correctedSentences[i];
|
||||
const { start, end } = sent;
|
||||
const spanEnd = sent.spanEnd ?? end;
|
||||
const gapBeforeStart = sent.gapBeforeStart ?? lastSpanEnd;
|
||||
|
||||
if (gapBeforeStart > lastSpanEnd) {
|
||||
resultParts.push(originalText.slice(lastSpanEnd, gapBeforeStart));
|
||||
} else if (start > lastSpanEnd) {
|
||||
resultParts.push(originalText.slice(lastSpanEnd, start));
|
||||
}
|
||||
|
||||
resultParts.push(corrected);
|
||||
|
||||
if (spanEnd > end) {
|
||||
resultParts.push(originalText.slice(end, spanEnd));
|
||||
} else if (i < sentenceData.length - 1) {
|
||||
if (end < originalText.length) {
|
||||
let whitespaceEnd = end;
|
||||
while (whitespaceEnd < originalText.length && /\s/.test(originalText[whitespaceEnd])) {
|
||||
whitespaceEnd++;
|
||||
}
|
||||
if (whitespaceEnd > end) {
|
||||
resultParts.push(originalText.slice(end, whitespaceEnd));
|
||||
} else if (corrected && /[.!?]$/.test(corrected) && whitespaceEnd < originalText.length) {
|
||||
const nextChar = originalText[whitespaceEnd];
|
||||
// Python: next_char.isalpha() and next_char.isupper()
|
||||
if (nextChar && /[A-Z]/.test(nextChar)) {
|
||||
resultParts.push(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastSpanEnd = spanEnd;
|
||||
}
|
||||
|
||||
if (lastSpanEnd < originalText.length) {
|
||||
resultParts.push(originalText.slice(lastSpanEnd));
|
||||
}
|
||||
|
||||
return resultParts.join('');
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { SentenceData } from './types.js';
|
||||
|
||||
const ABBREVIATIONS = new Set([
|
||||
'etc', 'eg', 'e.g', 'ie', 'i.e', 'vs', 'viz', 'cf', 'ca', 'approx',
|
||||
'no', 'vol', 'fig', 'p', 'pp', 'ch', 'sec', 'ex', 'al', 'et', 'seq',
|
||||
'etc.', 'e.g.', 'i.e.', 'vs.', 'viz.', 'cf.', 'ca.', 'approx.',
|
||||
'no.', 'vol.', 'fig.', 'p.', 'pp.', 'ch.', 'sec.', 'ex.', 'et al.', 'seq.',
|
||||
'mr', 'mrs', 'ms', 'dr', 'prof', 'rev', 'sr', 'jr', 'st',
|
||||
]);
|
||||
|
||||
// Python: (?<=[.!?])(?!\w)(?<!\d\.\d)(?<!\s[A-Za-z]\.)\s+(?=[A-Z"'])|(?<=[.!?])\s*$
|
||||
// (re.VERBOSE + re.IGNORECASE; \w is ASCII in JS — acceptable for English text)
|
||||
const SENTENCE_BOUNDARY_RE = /(?<=[.!?])(?!\w)(?<!\d\.\d)(?<!\s[A-Za-z]\.)\s+(?=[A-Z"'])|(?<=[.!?])\s*$/gi;
|
||||
|
||||
// A missing space after terminal punctuation — "…on its functionality.The team
|
||||
// were…". The original pattern needs \s+ after the punctuation, so such a run
|
||||
// was swallowed into one sentence. This boundary is zero-width, so it is found
|
||||
// separately, and only when:
|
||||
// * the punctuated word has at least two letters — keeps single-letter
|
||||
// initials glued ("U.S.Today") and decimals out ("3.5.Next"); abbreviations
|
||||
// such as "etc.They" are rejected later by ABBREVIATIONS; and
|
||||
// * the next word starts upper-case followed by a lower-case letter — keeps
|
||||
// "README.TXT" glued and skips "happy.enough".
|
||||
const MISSING_SPACE_BOUNDARY_RE = /(?<=[A-Za-z]{2}[.!?])(?=[A-Z][a-z])/g;
|
||||
|
||||
interface BoundaryMatch {
|
||||
index: number; // position immediately after the terminal punctuation
|
||||
length: number; // whitespace separating the two sentences (0 when missing)
|
||||
}
|
||||
|
||||
function findBoundaryMatches(text: string): BoundaryMatch[] {
|
||||
const matches: BoundaryMatch[] = [];
|
||||
for (const pattern of [SENTENCE_BOUNDARY_RE, MISSING_SPACE_BOUNDARY_RE]) {
|
||||
const re = new RegExp(pattern.source, pattern.flags);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
matches.push({ index: match.index, length: match[0].length });
|
||||
// JS exec() loops forever on zero-width matches; Python's finditer()
|
||||
// auto-advances. Guard like findAllOccurrences does.
|
||||
if (match.index === re.lastIndex) re.lastIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
matches.sort((a, b) => a.index - b.index || b.length - a.length);
|
||||
// The two patterns are mutually exclusive (one needs a non-word character
|
||||
// right after the punctuation, the other an upper-case letter), but never
|
||||
// emit the same split twice.
|
||||
return matches.filter((m, i) => i === 0 || m.index !== matches[i - 1].index);
|
||||
}
|
||||
|
||||
export function splitIntoSentences(text: string): SentenceData[] {
|
||||
const sentences: SentenceData[] = [];
|
||||
if (!text.trim()) return sentences;
|
||||
|
||||
let lastEnd = 0;
|
||||
const potentialSplits = findBoundaryMatches(text);
|
||||
|
||||
for (const { index: splitPos, length: gapLength } of potentialSplits) {
|
||||
const sentenceText = text.slice(lastEnd, splitPos).trim();
|
||||
if (!sentenceText) {
|
||||
lastEnd = splitPos + gapLength;
|
||||
continue;
|
||||
}
|
||||
|
||||
let isTrueBoundary = true;
|
||||
|
||||
const prevWords = sentenceText.toLowerCase().split(/\s+/);
|
||||
if (prevWords.length > 0) {
|
||||
const lastWord = prevWords[prevWords.length - 1].replace(/^[.,!?;:"']+|[.,!?;:"']+$/g, '');
|
||||
if (ABBREVIATIONS.has(lastWord)) {
|
||||
isTrueBoundary = false;
|
||||
} else if (/\d\.\d/.test(sentenceText.slice(-10))) {
|
||||
// Python quirk replicated: the single-letter-initial check was dead
|
||||
// code in main.py (misspelled variable), so initials ARE split here.
|
||||
isTrueBoundary = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Only meaningful for whitespace boundaries: it inspects the character
|
||||
// after the gap's first character to reject a lower-case continuation.
|
||||
// A space-less boundary is already constrained to an upper-case start.
|
||||
if (gapLength > 0 && splitPos + 2 < text.length) {
|
||||
const nextChars = text.slice(splitPos + 1, splitPos + 3);
|
||||
// Python: (next_chars and next_chars[0].islower()) or next_chars[0].isdigit()
|
||||
if (/[a-z\d]/.test(nextChars[0])) {
|
||||
isTrueBoundary = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isTrueBoundary) continue;
|
||||
|
||||
let startNoWs = lastEnd;
|
||||
while (startNoWs < splitPos && /\s/.test(text[startNoWs])) startNoWs++;
|
||||
|
||||
let spanEnd = splitPos;
|
||||
while (spanEnd < text.length && /\s/.test(text[spanEnd])) spanEnd++;
|
||||
// Nothing was consumed: only mark a one-character span when the boundary
|
||||
// sits at the very end of the text. Mid-text space-less boundaries must keep
|
||||
// spanEnd === end so reconstructTextFromSentences re-inserts the space.
|
||||
if (spanEnd === splitPos && splitPos >= text.length) spanEnd = splitPos + 1;
|
||||
|
||||
sentences.push({ text: sentenceText, start: startNoWs, end: splitPos, spanEnd, gapBeforeStart: lastEnd });
|
||||
lastEnd = spanEnd;
|
||||
}
|
||||
|
||||
if (lastEnd < text.length) {
|
||||
const remaining = text.slice(lastEnd).trim();
|
||||
if (remaining) {
|
||||
let startNoWs = lastEnd;
|
||||
while (startNoWs < text.length && /\s/.test(text[startNoWs])) startNoWs++;
|
||||
sentences.push({ text: remaining, start: startNoWs, end: text.length, spanEnd: text.length, gapBeforeStart: lastEnd });
|
||||
}
|
||||
}
|
||||
|
||||
if (sentences.length === 0) {
|
||||
const content = text.trim();
|
||||
if (content) {
|
||||
let startNoWs = 0;
|
||||
while (startNoWs < text.length && /\s/.test(text[startNoWs])) startNoWs++;
|
||||
sentences.push({ text: content, start: startNoWs, end: text.length, spanEnd: text.length, gapBeforeStart: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
return sentences;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
export interface SentenceData {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
spanEnd: number;
|
||||
gapBeforeStart: number;
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
original: string;
|
||||
corrected: string;
|
||||
sentence: string;
|
||||
start_index: number;
|
||||
end_index: number;
|
||||
original_highlighted: string;
|
||||
corrected_highlighted: string;
|
||||
wordFixes: WordFix[];
|
||||
}
|
||||
|
||||
export interface WordFix {
|
||||
original: string; // misspelled word as it appears in the text
|
||||
corrected: string; // replacement word
|
||||
start: number; // absolute offset in the full input text
|
||||
end: number; // absolute offset (exclusive)
|
||||
}
|
||||
|
||||
export interface CorrectionResponse {
|
||||
suggestions: Suggestion[];
|
||||
correctedText: string;
|
||||
}
|
||||
|
||||
export interface SentenceCorrector {
|
||||
correct(sentence: string): Promise<string>;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
# grammar-llm container — docker-compose (modeled on docker-languagetool's
|
||||
# compose so admins can swap containers with a one-line service change).
|
||||
#
|
||||
# Mirrors erikvl87/languagetool: same default port 8010, /v2/* API.
|
||||
# The model is kept in a named volume (auto-downloaded on first start from
|
||||
# Hugging Face unless you pre-place it), so it survives container recreation.
|
||||
|
||||
|
||||
services:
|
||||
grammar-llm:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: grammar-llm:latest
|
||||
container_name: grammar-llm
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 8010:8010 # same default port as docker-languagetool
|
||||
# Optional environment (uncomment and set as a mapping — compose rejects
|
||||
# an environment key with no entries):
|
||||
# environment:
|
||||
# GRAMMAR_LLM_MODEL_FILE: GRMR-V3-G4B-Q8_0.gguf # optional: higher-quality (slower) quant
|
||||
# GRAMMAR_LLM_MODEL_URL: "..." # optional: custom model source
|
||||
# GRAMMAR_LLM_DOWNLOAD_AT_START: "true" # default: download model on first start
|
||||
# GRAMMAR_LLM_REQUEST_TIMEOUT_MS: "55000" # per-request time budget
|
||||
# GRAMMAR_LLM_PRELOAD: "true" # load model at boot
|
||||
# # — (a) real LanguageTool first, LLM second (compute-saving cascade) —
|
||||
# GRAMMAR_LLM_LANGUAGETOOL_URL: "http://languagetool:8010" # optional; empty = LLM only
|
||||
# GRAMMAR_LLM_CASCADE_BUDGET_MS: "55000" # shared LT+LLM time budget (keep < LT-client cap; Overleaf: 60 s)
|
||||
# GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: "45000" # LT's window to win the race
|
||||
# GRAMMAR_LLM_LLM_GRACE_MS: "400" # ms before the LLM starts in parallel (0 = pure parallel)
|
||||
# # — (b) route the LLM step to a remote OpenAI-compatible API —
|
||||
# GRAMMAR_LLM_BACKEND: "openai" # local (default) | openai
|
||||
# GRAMMAR_LLM_OPENAI_BASE_URL: "https://api.openai.com/v1"
|
||||
# GRAMMAR_LLM_OPENAI_MODEL: "gpt-4o-mini" # required when backend=openai
|
||||
# GRAMMAR_LLM_OPENAI_API_KEY: "sk-..." # only if the endpoint needs one
|
||||
# # — (c) level used when the client sends none —
|
||||
# GRAMMAR_LLM_DEFAULT_LEVEL: "default" # default | picky
|
||||
volumes:
|
||||
- grammar-llm-models:/models # persistent model storage (~2.5 GB)
|
||||
# For NVIDIA GPU inference (build with the linux-x64-cuda prebuilt and
|
||||
# uncomment; node-llama-cpp auto-detects CUDA when available):
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: 1
|
||||
# capabilities: [gpu]
|
||||
|
||||
volumes:
|
||||
grammar-llm-models:
|
||||
-2333
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "grammar-llm-container",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "LanguageTool-compatible (/v2/* API) HTTP grammar checker — drop-in replacement for docker-languagetool — powered by the grammar-llm engine (GRMR-V3-G4B via node-llama-cpp).",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"start": "node dist/server.mjs",
|
||||
"download-model": "node dist/download-model.mjs",
|
||||
"test": "node build.mjs --quiet && node --test test/*.test.mjs",
|
||||
"smoke": "bash test/smoke.sh",
|
||||
"sync-core": "bash scripts/sync-core.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-llama-cpp": "3.19.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"diff": "^9.0.0",
|
||||
"esbuild": "^0.25.0"
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# sync-core.sh — refresh the vendored grammar-llm core in ./core/ from the
|
||||
# grammar-llm source tree.
|
||||
#
|
||||
# The container's functional core lives in the grammar-llm project
|
||||
# (/data_1/compare/grammar-llm on this machine). This project vendors a
|
||||
# snapshot of src/electron/core/*.ts (the pure, framework-free engine) so the
|
||||
# Docker build stays self-contained. Run this script after updating
|
||||
# grammar-llm to pick up engine changes.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/sync-core.sh [path-to-grammar-llm-repo]
|
||||
#
|
||||
# The default location is the sibling repo at <compare-root>/grammar-llm.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# Default: /data_1/compare/grammar-llm (project is at
|
||||
# /data_1/compare/overleaf-cep/tools/grammar-llm-container)
|
||||
DEFAULT_SRC="$(cd "$PROJECT_ROOT/../../.." && pwd)/grammar-llm"
|
||||
SRC="${1:-$DEFAULT_SRC}"
|
||||
|
||||
if [ ! -d "$SRC/src/electron/core" ]; then
|
||||
echo "ERROR: $SRC/src/electron/core not found." >&2
|
||||
echo "Usage: $0 [path-to-grammar-llm-repo]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DEST="$PROJECT_ROOT/core"
|
||||
mkdir -p "$DEST"
|
||||
|
||||
# Copy the non-test core modules (apply.ts is UI-side app logic, kept for
|
||||
# completeness; the server uses sentences/clean/correction/diff/reconstruct/types).
|
||||
shopt -s nullglob
|
||||
for f in "$SRC"/src/electron/core/*.ts; do
|
||||
b="$(basename "$f")"
|
||||
case "$b" in
|
||||
*.test.ts) ;;
|
||||
*) cp -f "$f" "$DEST/$b" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Record provenance
|
||||
{
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ
|
||||
echo "source: $SRC"
|
||||
if git -C "$SRC" rev-parse --short HEAD >/dev/null 2>&1; then
|
||||
echo "commit: $(git -C "$SRC" rev-parse --short HEAD)"
|
||||
echo "status: $(git -C "$SRC" status --porcelain -- src/electron/core | head -1)"
|
||||
fi
|
||||
sha256sum "$SRC"/src/electron/core/*.ts | grep -v test || true
|
||||
} > "$DEST/.sync-info"
|
||||
|
||||
echo "Synced $(ls "$DEST"/*.ts | wc -l) core module(s) from $SRC"
|
||||
echo "Provenance written to $DEST/.sync-info"
|
||||
@@ -1,211 +0,0 @@
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_MODEL_URL =
|
||||
'https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/GRMR-V3-G4B-Q4_K_M.gguf';
|
||||
|
||||
export interface Sampling {
|
||||
temperature: number;
|
||||
topK: number;
|
||||
topP: number;
|
||||
minP: number;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
host: string;
|
||||
port: number;
|
||||
allowOrigin: string;
|
||||
|
||||
/** Reported as the LanguageTool software version in response envelopes. */
|
||||
reportedVersion: string;
|
||||
|
||||
modelDir: string;
|
||||
modelFile: string;
|
||||
modelUrl: string;
|
||||
modelPath: string;
|
||||
contextSize: number;
|
||||
concurrency: number;
|
||||
preload: boolean;
|
||||
|
||||
maxTextLength: number;
|
||||
maxRuns: number; // 0 = unlimited checkable text runs per request
|
||||
requestTimeoutMs: number;
|
||||
sentenceTimeoutMs: number;
|
||||
maxBodyBytes: number;
|
||||
maxInFlight: number;
|
||||
|
||||
/** Language codes accepted by /v2/check (lower-cased, includes 'auto'). */
|
||||
supportedLanguages: Set<string>;
|
||||
/** What to do for a non-English language code: 'empty' (silent) or 'error' (HTTP 400). */
|
||||
unsupportedLanguageMode: 'empty' | 'error';
|
||||
|
||||
/** LLM backend: 'local' (node-llama-cpp) or 'openai' (OpenAI-compatible API). */
|
||||
backend: 'local' | 'openai';
|
||||
/**
|
||||
* Wire style for the 'openai' backend: 'openai' = standard /chat/completions
|
||||
* (OpenAI, vLLM, llama-server, …); 'ollama' = Ollama's native /api/chat
|
||||
* (needed for models like Qwen3 where the OpenAI-compat layer ignores
|
||||
* think:false and burns the token budget on hidden reasoning).
|
||||
*/
|
||||
openaiStyle: 'openai' | 'ollama';
|
||||
/**
|
||||
* Optional custom base system prompt for the 'openai' backend (e.g. a
|
||||
* fine-tuned model's own trained prompt, such as
|
||||
* ifioravanti/mistral-grammar-checker's). Empty = the built-in grammar-llm
|
||||
* prompt. The 'picky' level still appends its addendum on top.
|
||||
*/
|
||||
openaiSystemPrompt: string;
|
||||
/** OpenAI-compatible base URL (e.g. https://api.openai.com/v1, a llama-server, vLLM, Ollama /v1, …). */
|
||||
openaiBaseUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiModel: string;
|
||||
/** Timeout for a single remote LLM call (ms). */
|
||||
openaiTimeoutMs: number;
|
||||
/**
|
||||
* Completion-token headroom added to the sentence length (per-sentence
|
||||
* max_tokens / num_predict). Some models (hidden reasoning, retokenization)
|
||||
* need more than the visible output — raise when a model returns empty
|
||||
* answers (e.g. muse-glimmer needs ~75 completion tokens per sentence).
|
||||
*/
|
||||
openaiMaxTokenPad: number;
|
||||
|
||||
/**
|
||||
* Optional real LanguageTool container to query FIRST (cascade).
|
||||
* LT runs with the requested level (picky→picky), and its matches win;
|
||||
* when LT finds nothing, the LLM runs second. null = LLM only.
|
||||
*/
|
||||
gtUrl: string | null;
|
||||
/** Timeout for the real-LanguageTool call (ms) — the LT's window to win the race. */
|
||||
gtTimeoutMs: number;
|
||||
/**
|
||||
* Total wall-clock budget (ms) for a cascaded check (LT attempt + LLM
|
||||
* fallback). Kept under LT-client caps (Overleaf's controller times out at
|
||||
* 60 s and then shows nothing) so the response always arrives in time.
|
||||
*/
|
||||
cascadeBudgetMs: number;
|
||||
/**
|
||||
* How long (ms) to let a fast real-LT win before starting the LLM in the
|
||||
* cascade. Keeps the "compute-saving" benefit when LT answers quickly, but
|
||||
* the LLM is otherwise running in parallel so the fallback is not blocked.
|
||||
* 0 = start the LLM immediately (pure parallel).
|
||||
*/
|
||||
llmGraceMs: number;
|
||||
|
||||
/** Level used when the client does not send one ('default' or 'picky'). */
|
||||
defaultLevel: 'default' | 'picky';
|
||||
|
||||
sampling: Sampling;
|
||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||
}
|
||||
|
||||
function envStr(env: NodeJS.ProcessEnv, key: string, def: string): string {
|
||||
const v = env[key];
|
||||
return v !== undefined && v !== '' ? v : def;
|
||||
}
|
||||
function envInt(env: NodeJS.ProcessEnv, key: string, def: number): number {
|
||||
const v = env[key];
|
||||
if (v === undefined || v === '') return def;
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) throw new Error(`env ${key} must be an integer, got ${v}`);
|
||||
return Math.trunc(n);
|
||||
}
|
||||
function envBool(env: NodeJS.ProcessEnv, key: string, def: boolean): boolean {
|
||||
const v = env[key];
|
||||
if (v === undefined || v === '') return def;
|
||||
const s = String(v).toLowerCase();
|
||||
return s === '1' || s === 'true' || s === 'yes' || s === 'on';
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
const contextSize = Math.min(131_072, Math.max(256, envInt(env, 'GRAMMAR_LLM_CONTEXT_SIZE', 8192)));
|
||||
const modelDir = envStr(env, 'GRAMMAR_LLM_MODEL_DIR', '/models');
|
||||
const modelFile = envStr(env, 'GRAMMAR_LLM_MODEL_FILE', 'GRMR-V3-G4B-Q4_K_M.gguf');
|
||||
const logLevel = (envStr(env, 'GRAMMAR_LLM_LOG_LEVEL', 'info').toLowerCase()) as Config['logLevel'];
|
||||
if (!['debug', 'info', 'warn', 'error'].includes(logLevel)) {
|
||||
throw new Error(`GRAMMAR_LLM_LOG_LEVEL must be one of debug|info|warn|error, got '${env.GRAMMAR_LLM_LOG_LEVEL}'`);
|
||||
}
|
||||
const unsupportedLanguageMode = envStr(env, 'GRAMMAR_LLM_UNSUPPORTED_LANGUAGE', 'empty').toLowerCase();
|
||||
if (unsupportedLanguageMode !== 'empty' && unsupportedLanguageMode !== 'error') {
|
||||
throw new Error(`GRAMMAR_LLM_UNSUPPORTED_LANGUAGE must be 'empty' or 'error', got '${env.GRAMMAR_LLM_UNSUPPORTED_LANGUAGE}'`);
|
||||
}
|
||||
|
||||
const backend = envStr(env, 'GRAMMAR_LLM_BACKEND', 'local').toLowerCase();
|
||||
if (backend !== 'local' && backend !== 'openai') {
|
||||
throw new Error(`GRAMMAR_LLM_BACKEND must be 'local' or 'openai', got '${env.GRAMMAR_LLM_BACKEND}'`);
|
||||
}
|
||||
const openaiStyleRaw = envStr(env, 'GRAMMAR_LLM_OPENAI_STYLE', 'openai').toLowerCase();
|
||||
if (openaiStyleRaw !== 'openai' && openaiStyleRaw !== 'ollama') {
|
||||
throw new Error(`GRAMMAR_LLM_OPENAI_STYLE must be 'openai' or 'ollama', got '${env.GRAMMAR_LLM_OPENAI_STYLE}'`);
|
||||
}
|
||||
const openaiModel = envStr(env, 'GRAMMAR_LLM_OPENAI_MODEL', '');
|
||||
if (backend === 'openai' && !openaiModel) {
|
||||
throw new Error('GRAMMAR_LLM_BACKEND=openai requires GRAMMAR_LLM_OPENAI_MODEL (e.g. gpt-4o-mini, or your GRMR model id on the server)');
|
||||
}
|
||||
const defaultLevel = envStr(env, 'GRAMMAR_LLM_DEFAULT_LEVEL', 'default').toLowerCase();
|
||||
if (defaultLevel !== 'default' && defaultLevel !== 'picky') {
|
||||
throw new Error(`GRAMMAR_LLM_DEFAULT_LEVEL must be 'default' or 'picky', got '${env.GRAMMAR_LLM_DEFAULT_LEVEL}'`);
|
||||
}
|
||||
const gtUrlRaw = envStr(env, 'GRAMMAR_LLM_LANGUAGETOOL_URL', '');
|
||||
if (gtUrlRaw && !/^https?:\/\//.test(gtUrlRaw)) {
|
||||
throw new Error(`GRAMMAR_LLM_LANGUAGETOOL_URL must be an http(s) URL, got '${gtUrlRaw}'`);
|
||||
}
|
||||
|
||||
return {
|
||||
host: envStr(env, 'HOST', '0.0.0.0'),
|
||||
port: Math.max(1, Math.min(65535, envInt(env, 'PORT', 8010))),
|
||||
allowOrigin: envStr(env, 'GRAMMAR_LLM_ALLOW_ORIGIN', '*'),
|
||||
reportedVersion: envStr(env, 'GRAMMAR_LLM_REPORTED_LT_VERSION', '6.8'),
|
||||
|
||||
modelDir,
|
||||
modelFile,
|
||||
modelUrl: envStr(env, 'GRAMMAR_LLM_MODEL_URL', DEFAULT_MODEL_URL),
|
||||
modelPath: path.join(modelDir, modelFile),
|
||||
contextSize,
|
||||
concurrency: Math.max(1, envInt(env, 'GRAMMAR_LLM_CONCURRENCY', 1)),
|
||||
preload: envBool(env, 'GRAMMAR_LLM_PRELOAD', true),
|
||||
|
||||
maxTextLength: Math.max(1000, envInt(env, 'GRAMMAR_LLM_MAX_TEXT_LENGTH', 100_000)),
|
||||
maxRuns: Math.max(0, envInt(env, 'GRAMMAR_LLM_MAX_RUNS', 0)),
|
||||
requestTimeoutMs: Math.max(1000, envInt(env, 'GRAMMAR_LLM_REQUEST_TIMEOUT_MS', 55_000)),
|
||||
sentenceTimeoutMs: Math.max(1000, envInt(env, 'GRAMMAR_LLM_SENTENCE_TIMEOUT_MS', 20_000)),
|
||||
maxBodyBytes: Math.max(10 * 1024, envInt(env, 'GRAMMAR_LLM_MAX_BODY_BYTES', 2 * 1024 * 1024)),
|
||||
maxInFlight: Math.max(1, envInt(env, 'GRAMMAR_LLM_MAX_IN_FLIGHT', 4)),
|
||||
cascadeBudgetMs: Math.max(2000, envInt(env, 'GRAMMAR_LLM_CASCADE_BUDGET_MS', 55_000)),
|
||||
llmGraceMs: Math.max(0, envInt(env, 'GRAMMAR_LLM_LLM_GRACE_MS', 400)),
|
||||
|
||||
supportedLanguages: new Set(
|
||||
envStr(env, 'GRAMMAR_LLM_LANGUAGES', 'auto,en,en-us,en-gb,en-au,en-ca,en-ie,en-nz,en-za,en-in')
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
),
|
||||
unsupportedLanguageMode,
|
||||
|
||||
backend,
|
||||
openaiStyle: openaiStyleRaw as 'openai' | 'ollama',
|
||||
openaiSystemPrompt: envStr(env, 'GRAMMAR_LLM_OPENAI_SYSTEM_PROMPT', ''),
|
||||
openaiBaseUrl: envStr(env, 'GRAMMAR_LLM_OPENAI_BASE_URL', 'https://api.openai.com/v1').replace(/\/$/, ''),
|
||||
openaiApiKey: envStr(env, 'GRAMMAR_LLM_OPENAI_API_KEY', ''),
|
||||
openaiModel,
|
||||
openaiTimeoutMs: Math.max(1000, envInt(env, 'GRAMMAR_LLM_OPENAI_TIMEOUT_MS', 60_000)),
|
||||
openaiMaxTokenPad: Math.max(0, envInt(env, 'GRAMMAR_LLM_OPENAI_MAX_TOKEN_PAD', 40)),
|
||||
|
||||
gtUrl: gtUrlRaw ? gtUrlRaw.replace(/\/$/, '') : null,
|
||||
gtTimeoutMs: Math.max(2000, envInt(env, 'GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS', 45_000)),
|
||||
|
||||
defaultLevel: defaultLevel === 'picky' ? 'picky' : 'default',
|
||||
|
||||
sampling: {
|
||||
temperature: Number(envStr(env, 'GRAMMAR_LLM_TEMPERATURE', '0.7')),
|
||||
topK: Math.max(1, envInt(env, 'GRAMMAR_LLM_TOP_K', 40)),
|
||||
topP: Number(envStr(env, 'GRAMMAR_LLM_TOP_P', '0.95')),
|
||||
minP: Number(envStr(env, 'GRAMMAR_LLM_MIN_P', '0.01')),
|
||||
},
|
||||
logLevel,
|
||||
};
|
||||
}
|
||||
|
||||
export function isLanguageSupported(cfg: Config, code: string | null | undefined): boolean {
|
||||
if (code === undefined || code === null || String(code).trim() === '') return true; // LT default
|
||||
const c = String(code).trim().toLowerCase();
|
||||
return cfg.supportedLanguages.has(c);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
// One-shot model downloader (start.sh calls this before the server boots).
|
||||
// Uses node-llama-cpp's createModelDownloader (same API the desktop app's
|
||||
// modelManager.ts adapter uses against v3.19:
|
||||
// await createModelDownloader({ modelUri, dirPath, fileName, onProgress })
|
||||
// → { download(): Promise, cancel(): Promise }
|
||||
// onProgress(status: { downloadedSize, totalSize, ... })
|
||||
//
|
||||
// Usage:
|
||||
// node dist/download-model.mjs --url <url> --out /models/GRMR-V3-G4B-Q4_K_M.gguf
|
||||
// node dist/download-model.mjs --url <url> --dir /models --file name.gguf
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function arg(name: string): string | undefined {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
if (i === -1) return undefined;
|
||||
return process.argv[i + 1];
|
||||
}
|
||||
|
||||
function die(msg: string): never {
|
||||
console.error(`ERROR: ${msg}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const url = arg('url');
|
||||
const out = arg('out');
|
||||
const dir = arg('dir');
|
||||
const file = arg('file');
|
||||
|
||||
if (!url) die('--url is required');
|
||||
if (!out && !(dir && file)) die('either --out or both --dir and --file are required');
|
||||
|
||||
const outPath = out ?? path.join(dir as string, file as string);
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
|
||||
const existing = fs.existsSync(outPath) ? fs.statSync(outPath).size : 0;
|
||||
if (existing > 0) {
|
||||
console.log(`Model already present (${(existing / 1024 / 1024).toFixed(0)} MB): ${outPath}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Downloading model…`);
|
||||
console.log(` url: ${url}`);
|
||||
console.log(` target: ${outPath}`);
|
||||
|
||||
const { createModelDownloader } = await import('node-llama-cpp');
|
||||
|
||||
let lastLog = 0;
|
||||
let lastPct = -1;
|
||||
const downloader = await createModelDownloader({
|
||||
modelUri: url,
|
||||
dirPath: path.dirname(outPath),
|
||||
fileName: path.basename(outPath),
|
||||
onProgress: (status: { downloadedSize: number; totalSize: number }) => {
|
||||
const now = Date.now();
|
||||
const pct = status.totalSize > 0 ? Math.floor((status.downloadedSize / status.totalSize) * 100) : 0;
|
||||
if (now - lastLog > 1000 || pct !== lastPct) {
|
||||
lastLog = now;
|
||||
lastPct = pct;
|
||||
const mb = (status.downloadedSize / 1024 / 1024).toFixed(1);
|
||||
const tot = status.totalSize > 0 ? ` / ${(status.totalSize / 1024 / 1024).toFixed(0)} MB` : '';
|
||||
process.stdout.write(`\r ${pct}% ${mb} MB${tot} `);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await downloader.download();
|
||||
console.log('');
|
||||
const size = fs.statSync(outPath).size;
|
||||
console.log(`Model downloaded: ${outPath} (${(size / 1024 / 1024).toFixed(0)} MB)`);
|
||||
} catch (err) {
|
||||
console.error('');
|
||||
console.error(`Model download failed: ${String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
// Check engine: runs the grammar-llm core pipeline (sentence splitting, LLM
|
||||
// correction, cleaning, diff/word-fix extraction, reconstruction) over the
|
||||
// plain-text runs of a document, with a per-request time budget.
|
||||
//
|
||||
// The per-sentence guard pipeline mirrors grammar-llm's core/correction.ts
|
||||
// (correctText) exactly — same order of guards, same suggestion shape — so
|
||||
// container results stay identical to the desktop app for the same model.
|
||||
|
||||
import { cleanCorrectedText, isOnlyQuoteChange } from '../core/clean.js';
|
||||
import { cleanQuotePunctuation } from '../core/correction.js';
|
||||
import { extractWordFixes, highlightWordDifferences } from '../core/diff.js';
|
||||
import { reconstructTextFromSentences } from '../core/reconstruct.js';
|
||||
import { splitIntoSentences } from '../core/sentences.js';
|
||||
import type { CorrectionResponse, Suggestion } from '../core/types.js';
|
||||
import { LlamaService, type CheckLevel } from './llama.js';
|
||||
import { OpenAIService } from './openai.js';
|
||||
import type { Config } from './config.js';
|
||||
import type { Logger } from './log.js';
|
||||
import type { TextRun } from './languagetool.js';
|
||||
|
||||
/** Common contract of the two backends (local node-llama-cpp / OpenAI-compatible). */
|
||||
interface SentenceBackend {
|
||||
isReady: boolean;
|
||||
ensureLoaded(): Promise<void>;
|
||||
correct(sentence: string, level: CheckLevel): Promise<string>;
|
||||
loadErrorText: string | null;
|
||||
describe(): string;
|
||||
}
|
||||
|
||||
function describeLocal(svc: LlamaService): string {
|
||||
return `local model=${svc.modelPath}`;
|
||||
}
|
||||
|
||||
export type EngineState = 'stopped' | 'loading' | 'ready' | 'error';
|
||||
|
||||
export interface EngineStatus {
|
||||
state: EngineState;
|
||||
errorMessage: string | null;
|
||||
modelPath: string;
|
||||
checksCompleted: number;
|
||||
checksFailed: number;
|
||||
}
|
||||
|
||||
export interface RunCheckResult extends CorrectionResponse {
|
||||
/** true when the request time budget cut the check short. */
|
||||
incomplete: boolean;
|
||||
}
|
||||
|
||||
export class CheckEngine {
|
||||
private backend: SentenceBackend;
|
||||
private localLlama: LlamaService | null = null;
|
||||
private state: EngineState = 'stopped';
|
||||
private loadError: string | null = null;
|
||||
private loading: Promise<void> | null = null;
|
||||
/**
|
||||
* Serialization chain: the LLM backend keeps ONE shared chat context
|
||||
* (resetChatHistory() per sentence), so concurrent checks must not interleave.
|
||||
* Checks queue here; each keeps its own internal deadline semantics.
|
||||
*/
|
||||
private checkChain: Promise<unknown> = Promise.resolve();
|
||||
checksCompleted = 0;
|
||||
checksFailed = 0;
|
||||
|
||||
constructor(private cfg: Config, private log: Logger) {
|
||||
if (cfg.backend === 'openai') {
|
||||
this.backend = new OpenAIService({
|
||||
baseUrl: cfg.openaiBaseUrl,
|
||||
apiKey: cfg.openaiApiKey,
|
||||
model: cfg.openaiModel,
|
||||
sampling: cfg.sampling,
|
||||
timeoutMs: cfg.openaiTimeoutMs,
|
||||
tokenPad: cfg.openaiMaxTokenPad,
|
||||
logger: log,
|
||||
style: cfg.openaiStyle,
|
||||
systemPrompt: cfg.openaiSystemPrompt || undefined,
|
||||
});
|
||||
} else {
|
||||
const svc = new LlamaService(
|
||||
{
|
||||
modelPath: cfg.modelPath,
|
||||
contextSize: cfg.contextSize,
|
||||
sampling: cfg.sampling,
|
||||
logger: log,
|
||||
},
|
||||
log,
|
||||
);
|
||||
this.localLlama = svc;
|
||||
this.backend = {
|
||||
isReady: svc.isReady,
|
||||
ensureLoaded: () => svc.ensureLoaded(),
|
||||
correct: (s, l) => svc.correct(s, l),
|
||||
get loadErrorText() {
|
||||
return svc.loadErrorText;
|
||||
},
|
||||
describe: () => describeLocal(svc),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
status(): EngineStatus {
|
||||
return {
|
||||
state: this.state,
|
||||
errorMessage: this.loadError ?? this.backend.loadErrorText,
|
||||
modelPath: this.cfg.backend === 'openai' ? `(remote: ${this.cfg.openaiModel})` : this.cfg.modelPath,
|
||||
checksCompleted: this.checksCompleted,
|
||||
checksFailed: this.checksFailed,
|
||||
};
|
||||
}
|
||||
|
||||
async ensureReady(): Promise<void> {
|
||||
if (this.state === 'ready') return;
|
||||
if (this.loading) await this.loading;
|
||||
if (this.state === 'ready') return;
|
||||
this.state = 'loading';
|
||||
this.loading = this.backend
|
||||
.ensureLoaded()
|
||||
.then(() => {
|
||||
this.state = 'ready';
|
||||
this.loadError = null;
|
||||
})
|
||||
.catch((err) => {
|
||||
this.state = 'error';
|
||||
this.loadError = String(err);
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = null;
|
||||
});
|
||||
await this.loading;
|
||||
}
|
||||
|
||||
/** Check a single plain-text run. Never throws for per-sentence failures. */
|
||||
async correctRun(run: TextRun, deadline: number, level: CheckLevel = 'default'): Promise<RunCheckResult> {
|
||||
const text = run.text;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return { suggestions: [], correctedText: '', incomplete: false };
|
||||
|
||||
const sentenceData = splitIntoSentences(text);
|
||||
const n = sentenceData.length;
|
||||
const deadlineExceeded = () => Date.now() > deadline;
|
||||
|
||||
// Process sentences one at a time (single shared LLM session), stopping
|
||||
// at the request deadline; unprocessed sentences pass through unchanged.
|
||||
const suggestions: Suggestion[] = [];
|
||||
const correctedSentences: string[] = [];
|
||||
let incomplete = false;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const sent = sentenceData[i];
|
||||
const sentence = sent.text;
|
||||
if (sentence.length < 2) {
|
||||
correctedSentences.push(sentence);
|
||||
continue;
|
||||
}
|
||||
if (deadlineExceeded()) {
|
||||
incomplete = true;
|
||||
correctedSentences.push(sentence);
|
||||
continue;
|
||||
}
|
||||
let corrected = await this.backend.correct(sentence, level);
|
||||
corrected = cleanCorrectedText(corrected, sentence);
|
||||
if (isOnlyQuoteChange(sentence, corrected)) corrected = sentence;
|
||||
if (corrected.length > sentence.length * 2) corrected = sentence;
|
||||
corrected = cleanQuotePunctuation(corrected);
|
||||
|
||||
// Diff once: restore the original quote style (curly vs straight) so
|
||||
// quote changes are never applied to the user's text.
|
||||
const diff = highlightWordDifferences(sentence, corrected);
|
||||
corrected = diff.preservedCorrected;
|
||||
correctedSentences.push(corrected);
|
||||
|
||||
if (
|
||||
corrected.toLowerCase().trim() !== sentence.toLowerCase().trim() &&
|
||||
corrected.trim() !== sentence.trim() &&
|
||||
corrected.length <= sentence.length * 1.5 &&
|
||||
!isOnlyQuoteChange(sentence, corrected)
|
||||
) {
|
||||
suggestions.push({
|
||||
original: sentence,
|
||||
corrected,
|
||||
sentence: `Sentence ${i + 1}`,
|
||||
start_index: sent.start,
|
||||
end_index: sent.end,
|
||||
original_highlighted: diff.originalHighlighted,
|
||||
corrected_highlighted: diff.correctedHighlighted,
|
||||
wordFixes: extractWordFixes(sentence, corrected, sent.start),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const correctedText = cleanQuotePunctuation(reconstructTextFromSentences(text, sentenceData, correctedSentences));
|
||||
return { suggestions, correctedText, incomplete };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all runs of a resolved input (sequentially; each run is bounded by
|
||||
* the shared deadline). Returns per-run results in order.
|
||||
* Concurrent callers are serialized on the shared LLM context.
|
||||
*/
|
||||
check(
|
||||
runs: TextRun[],
|
||||
opts: { maxRuns?: number; requestTimeoutMs?: number; level?: CheckLevel },
|
||||
): Promise<{ results: RunCheckResult[]; incomplete: boolean }> {
|
||||
const task = this.checkChain.then(() => this.runCheck(runs, opts));
|
||||
// Keep the chain alive without swallowing task errors for the caller.
|
||||
this.checkChain = task.catch(() => { /* errors reach the caller via `task` */ });
|
||||
return task;
|
||||
}
|
||||
|
||||
private async runCheck(
|
||||
runs: TextRun[],
|
||||
opts: { maxRuns?: number; requestTimeoutMs?: number; level?: CheckLevel },
|
||||
): Promise<{ results: RunCheckResult[]; incomplete: boolean }> {
|
||||
await this.ensureReady();
|
||||
const start = Date.now();
|
||||
const budget = opts.requestTimeoutMs ?? this.cfg.requestTimeoutMs;
|
||||
const deadline = start + budget;
|
||||
const maxRuns = opts.maxRuns ?? this.cfg.maxRuns;
|
||||
const level = opts.level ?? this.cfg.defaultLevel;
|
||||
|
||||
const results: RunCheckResult[] = [];
|
||||
let incomplete = false;
|
||||
const considered = maxRuns > 0 ? runs.slice(0, maxRuns) : runs;
|
||||
if (maxRuns > 0 && runs.length > maxRuns) incomplete = true;
|
||||
|
||||
for (let i = 0; i < considered.length; i++) {
|
||||
const run = considered[i];
|
||||
if (Date.now() > deadline) {
|
||||
incomplete = true;
|
||||
this.log.warn('check time budget exhausted; remaining sentences passed through unchanged', {
|
||||
budgetMs: budget,
|
||||
ranMs: Date.now() - start,
|
||||
run: i,
|
||||
});
|
||||
results.push({ suggestions: [], correctedText: run.text, incomplete: true });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const r = await this.correctRun(run, deadline, level);
|
||||
results.push(r);
|
||||
if (r.incomplete) incomplete = true;
|
||||
} catch (err) {
|
||||
this.checksFailed++;
|
||||
this.log.error('check run failed', { err: String(err) });
|
||||
results.push({ suggestions: [], correctedText: run.text, incomplete: true });
|
||||
incomplete = true;
|
||||
}
|
||||
}
|
||||
this.checksCompleted++;
|
||||
this.log.info('check completed', {
|
||||
backend: this.backend.describe(),
|
||||
level,
|
||||
runs: results.length,
|
||||
suggestions: results.reduce((a, r) => a + r.suggestions.length, 0),
|
||||
ranMs: Date.now() - start,
|
||||
incomplete,
|
||||
});
|
||||
return { results, incomplete };
|
||||
}
|
||||
}
|
||||
|
||||
export interface SentenceCorrectorShim {
|
||||
correct(sentence: string): Promise<string>;
|
||||
}
|
||||
@@ -1,439 +0,0 @@
|
||||
// LanguageTool response builder + request resolution.
|
||||
//
|
||||
// Converts grammar-llm core output (suggestions/wordFixes) into the
|
||||
// LanguageTool HTTP v2 "check" JSON shape so Overleaf — and any other client
|
||||
// speaking the LanguageTool protocol — works unchanged.
|
||||
//
|
||||
// Key invariant (from Overleaf's LanguageTool module):
|
||||
// annotations.map(a => a.text ?? a.markup).join('') === editor source
|
||||
// so match offsets must be offsets into the EDITOR SOURCE document.
|
||||
//
|
||||
// Strategy:
|
||||
// - Reconstruct the full document text (text + markup segments in order).
|
||||
// - Identify maximal plain-text runs (language: markup = "ignore this text").
|
||||
// - Run the grammar-llm core pipeline on each run (the LLM never sees LaTeX
|
||||
// markup, and no match can ever span it).
|
||||
// - Rebase suggestion offsets by the run's document offset — matches end up
|
||||
// in editor-source coordinates, as Overleaf expects.
|
||||
//
|
||||
// Response schema reference
|
||||
// (languagetool: org.languagetool.tools.RuleMatchesAsJsonSerializer):
|
||||
// top-level : { software, warnings, language, matches[], sentenceRanges }
|
||||
// match : { message, shortMessage?, replacements[], offset, length,
|
||||
// context, type, rule, ignoreForIncompleteSentence, contextForSureMatch }
|
||||
// replacement: { value, suffix?, type? }
|
||||
// rule : { id, description, issueType, category, tags? }
|
||||
// category : { id, name }
|
||||
//
|
||||
// Overleaf consumes: matches[].offset/.length, matches[].message,
|
||||
// matches[].replacements[] (first 5 values), matches[].rule.category.id
|
||||
// (filters id === 'TYPOS' while Hunspell is active). We use
|
||||
// category.id = 'GRAMMAR' so our results survive that filter.
|
||||
|
||||
import type { Suggestion, WordFix } from '../core/types.js';
|
||||
|
||||
export interface LtReplacement {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface LtRule {
|
||||
id: string;
|
||||
description: string;
|
||||
issueType: string;
|
||||
category: { id: string; name: string };
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface LtMatch {
|
||||
message: string;
|
||||
shortMessage?: string;
|
||||
replacements: LtReplacement[];
|
||||
offset: number;
|
||||
length: number;
|
||||
context: { text: string; offset: number; length: number };
|
||||
type: { typeName: string };
|
||||
rule: LtRule;
|
||||
ignoreForIncompleteSentence: boolean;
|
||||
contextForSureMatch: number;
|
||||
}
|
||||
|
||||
export interface LtLanguage {
|
||||
name: string;
|
||||
code: string;
|
||||
longCode: string;
|
||||
}
|
||||
|
||||
export interface LtCheckResponse {
|
||||
software: {
|
||||
name: string;
|
||||
version: string;
|
||||
buildDate: string;
|
||||
apiVersion: number;
|
||||
premium: boolean;
|
||||
status: string;
|
||||
};
|
||||
warnings: { incompleteResults: boolean; incompleteResultsReason?: string };
|
||||
language: {
|
||||
name: string;
|
||||
code: string;
|
||||
detectedLanguage: { name: string; code: string; confidence: number; source: string };
|
||||
};
|
||||
matches: LtMatch[];
|
||||
sentenceRanges: Array<[number, number]>;
|
||||
}
|
||||
|
||||
/** Static rule object reported on every match. */
|
||||
const RULE: LtRule = {
|
||||
id: 'GRAMMAR_LLM',
|
||||
description: 'Grammar, spelling, or style issue detected by grammar-llm (GRMR-V3-G4B)',
|
||||
issueType: 'Grammar',
|
||||
category: { id: 'GRAMMAR', name: 'Grammar' },
|
||||
tags: ['LLM', 'GRMR-V3-G4B'],
|
||||
};
|
||||
|
||||
/** Languages we advertise (GRMR-V3-G4B is English-only). */
|
||||
const LANGUAGES: LtLanguage[] = [
|
||||
{ name: 'English (US)', code: 'en-US', longCode: 'en-US' },
|
||||
{ name: 'English (UK)', code: 'en-GB', longCode: 'en-GB' },
|
||||
{ name: 'English (Australia)', code: 'en-AU', longCode: 'en-AU' },
|
||||
{ name: 'English (Canada)', code: 'en-CA', longCode: 'en-CA' },
|
||||
{ name: 'English (Ireland)', code: 'en-IE', longCode: 'en-IE' },
|
||||
{ name: 'English (New Zealand)', code: 'en-NZ', longCode: 'en-NZ' },
|
||||
{ name: 'English (India)', code: 'en-IN', longCode: 'en-IN' },
|
||||
{ name: 'English (South Africa)', code: 'en-ZA', longCode: 'en-ZA' },
|
||||
{ name: 'English', code: 'en', longCode: 'en' },
|
||||
];
|
||||
|
||||
export function getLanguages(): LtLanguage[] {
|
||||
return LANGUAGES;
|
||||
}
|
||||
|
||||
export function normalizeLanguage(code: string | null | undefined): LtLanguage | null {
|
||||
if (code === null || code === undefined || String(code).trim() === '' || String(code).trim().toLowerCase() === 'auto') {
|
||||
return LANGUAGES[0]; // default: English (US)
|
||||
}
|
||||
const c = String(code).trim().toLowerCase();
|
||||
return LANGUAGES.find((l) => l.code.toLowerCase() === c) ?? null;
|
||||
}
|
||||
|
||||
/** A plain-text segment with its absolute offset into the full document. */
|
||||
export interface TextRun {
|
||||
text: string;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface ResolvedCheckInput {
|
||||
/** Full document text (markup included) — offsets/limits are against this. */
|
||||
text: string;
|
||||
/** Maximal checkable runs (plain text), offsets absolute into `text`. */
|
||||
runs: TextRun[];
|
||||
}
|
||||
|
||||
export type CheckInput = ResolvedCheckInput | { error: string };
|
||||
|
||||
/**
|
||||
* Resolve the text to check from a request body (LanguageTool `text`/`data`
|
||||
* parameters; `data` may be a JSON object {text, annotation:[{text|markup}]}
|
||||
* — Overleaf sends annotation when it has a Lezer syntax tree).
|
||||
*/
|
||||
export function resolveCheckInput(body: Record<string, unknown>): CheckInput {
|
||||
const rawText = body['text'];
|
||||
const rawData = body['data'];
|
||||
|
||||
if (rawText === undefined && rawData === undefined) {
|
||||
return { error: "Missing 'text' or 'data' parameter" };
|
||||
}
|
||||
|
||||
// 1) plain `text` parameter
|
||||
if (typeof rawText === 'string') {
|
||||
return { text: rawText, runs: [{ text: rawText, offset: 0 }] };
|
||||
}
|
||||
|
||||
// 2) `data` parameter (string JSON or object)
|
||||
let data: unknown = rawData;
|
||||
if (typeof rawData === 'string') {
|
||||
try {
|
||||
data = JSON.parse(rawData);
|
||||
} catch {
|
||||
// Not JSON — treat the raw string as plain text.
|
||||
return { text: rawData, runs: [{ text: rawData, offset: 0 }] };
|
||||
}
|
||||
}
|
||||
if (data && typeof data === 'object') {
|
||||
const d = data as Record<string, unknown>;
|
||||
if (typeof d['text'] === 'string' && d['annotation'] === undefined) {
|
||||
return { text: d['text'], runs: [{ text: d['text'], offset: 0 }] };
|
||||
}
|
||||
if (Array.isArray(d['annotation'])) {
|
||||
// Reconstruct the full document and collect maximal plain-text runs
|
||||
// with offsets absolute into it (markup segments keep alignment).
|
||||
let text = '';
|
||||
let pendingStart = -1;
|
||||
const runs: TextRun[] = [];
|
||||
const flush = () => {
|
||||
if (pendingStart >= 0) {
|
||||
runs.push({ text: text.slice(pendingStart), offset: pendingStart });
|
||||
pendingStart = -1;
|
||||
}
|
||||
};
|
||||
for (const seg of d['annotation']) {
|
||||
if (!seg || typeof seg !== 'object') continue;
|
||||
const s = seg as Record<string, unknown>;
|
||||
if (typeof s['text'] === 'string') {
|
||||
if (pendingStart < 0) pendingStart = text.length;
|
||||
text += s['text'];
|
||||
} else if (typeof s['markup'] === 'string') {
|
||||
flush();
|
||||
text += s['markup'];
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return { text, runs: runs.filter((r) => r.text.trim().length > 0) };
|
||||
}
|
||||
}
|
||||
|
||||
return { error: "Missing 'text' or 'data' parameter" };
|
||||
}
|
||||
|
||||
function buildContext(fullText: string, offset: number, length: number, radius = 25): { text: string; offset: number; length: number } {
|
||||
const from = Math.max(0, offset - radius);
|
||||
const to = Math.min(fullText.length, offset + length + radius);
|
||||
return {
|
||||
text: fullText.slice(from, to),
|
||||
offset: offset - from,
|
||||
length: Math.max(0, length),
|
||||
};
|
||||
}
|
||||
|
||||
interface BaseMatchOpts {
|
||||
message: string;
|
||||
shortMessage?: string;
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
function makeMatch(fullText: string, offset: number, length: number, opts: BaseMatchOpts): LtMatch {
|
||||
return {
|
||||
message: opts.message,
|
||||
...(opts.shortMessage !== undefined ? { shortMessage: opts.shortMessage } : {}),
|
||||
replacements: [{ value: opts.replacement }],
|
||||
offset,
|
||||
length,
|
||||
context: buildContext(fullText, offset, length),
|
||||
type: { typeName: 'grammar' },
|
||||
rule: RULE,
|
||||
ignoreForIncompleteSentence: false,
|
||||
contextForSureMatch: -1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor a zero-width (pure insertion) fix to the nearest non-whitespace
|
||||
* token, expressed as a LanguageTool replacement match:
|
||||
* - insertion before token T (at or before T): match T, replace with added+T
|
||||
* - insertion at run end: match the last token, replace with T+added
|
||||
*/
|
||||
function anchorInsertion(
|
||||
fullText: string,
|
||||
run: TextRun,
|
||||
pos: number,
|
||||
addedText: string,
|
||||
): LtMatch | null {
|
||||
const added = addedText.trim();
|
||||
if (!added) return null;
|
||||
const base = run.offset;
|
||||
const isPunct = /^\s*[.,!?;:'"()\[\]{}]+\s*$/.test(added);
|
||||
|
||||
// Next non-whitespace token at/after pos (within the run).
|
||||
const rest = run.text.slice(pos);
|
||||
const mNext = rest.match(/^\s*(\S+)/);
|
||||
if (mNext) {
|
||||
const wsLen = mNext[0].length - mNext[1].length;
|
||||
const tok = mNext[1];
|
||||
const tokStart = pos + wsLen;
|
||||
const tokEnd = tokStart + tok.length;
|
||||
if (tokEnd <= run.text.length) {
|
||||
const gap = run.text.slice(pos, tokStart);
|
||||
// Punctuation attaches to the preceding word (no leading gap); a word
|
||||
// keeps the preceding gap and gets exactly one space before the token.
|
||||
const replacement = (isPunct ? added + (gap ? ' ' : '') + tok : gap + added + ' ' + tok) as string;
|
||||
const collapsed = replacement.replace(/\s{2,}/g, ' ');
|
||||
return makeMatch(fullText, base + pos, tokEnd - pos, {
|
||||
message: isPunct ? `Add '${added}' after the previous word` : `Add '${added}' before '${tok}'`,
|
||||
shortMessage: `add "${added}"`,
|
||||
replacement: collapsed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// No token after the insertion point → anchor on the last non-whitespace
|
||||
// token of the run (insertion at the run's end).
|
||||
const before = run.text.slice(0, pos);
|
||||
const mLast = before.match(/(\S+)\s*$/);
|
||||
if (mLast && mLast.index !== undefined) {
|
||||
const tok = mLast[1];
|
||||
const tokStart = mLast.index;
|
||||
const replacement = (tok + (isPunct ? added : ` ${added}`)).replace(/\s{2,}/g, ' ');
|
||||
return makeMatch(fullText, base + tokStart, tok.length, {
|
||||
message: `Add '${added}' at the end of the sentence`,
|
||||
shortMessage: `add "${added}"`,
|
||||
replacement,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one core suggestion (offsets are absolute into the original full input
|
||||
* text passed to correctText) to LanguageTool matches, rebased onto the
|
||||
* editor document via `run`.
|
||||
*/
|
||||
export function suggestionToMatches(fullText: string, run: TextRun, suggestion: Suggestion): LtMatch[] {
|
||||
const out: LtMatch[] = [];
|
||||
const fixes = suggestion.wordFixes ?? [];
|
||||
let usedWordFixes = false;
|
||||
|
||||
for (const fix of fixes) {
|
||||
const m = wordFixToMatch(fullText, run, fix);
|
||||
if (m) {
|
||||
out.push(m);
|
||||
usedWordFixes = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Full rewrite with no word-level fixes: one sentence-level match over the
|
||||
// changed middle span, replacing it with the corrected middle.
|
||||
if (!usedWordFixes) {
|
||||
const span = sentenceChangeSpan(run, suggestion);
|
||||
if (span) {
|
||||
out.push(
|
||||
makeMatch(fullText, span.offset, span.length, {
|
||||
message: 'Suggested grammar/style correction (rewrite by grammar-llm)',
|
||||
shortMessage: 'rewrite',
|
||||
replacement: span.replacement,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* GRMR models sometimes emit the corrected word wrapped in quotes:
|
||||
* 'goed' -> '"good"', 'cat' -> '"Cat"'.
|
||||
* The desktop app shows that verbatim in the review UI, but a LanguageTool-style
|
||||
* client applies replacements[] verbatim, which would inject literal quote
|
||||
* characters into the document (bad in LaTeX). The adapter layer therefore
|
||||
* unwraps one pair of matching surrounding quotes from a word fix.
|
||||
*/
|
||||
function unwrapQuotedCorrection(corrected: string): string {
|
||||
if (!corrected || corrected.length < 3) return corrected;
|
||||
const pairs: Array<[string, string]> = [
|
||||
['\u201C', '\u201D'], // “ ”
|
||||
['"', '"'], // “ ”
|
||||
['\u2018', '\u2019'], // ‘ ’
|
||||
["'", "'"], // ' '
|
||||
];
|
||||
for (const [open, close] of pairs) {
|
||||
if (corrected.startsWith(open) && corrected.endsWith(close) && corrected.length >= open.length + close.length + 1) {
|
||||
const inner = corrected.slice(open.length, corrected.length - close.length);
|
||||
if (inner.trim().length > 0 && !inner.includes('\n') && (inner.match(/\s/g) ?? []).length <= 1) {
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
}
|
||||
return corrected;
|
||||
}
|
||||
|
||||
function wordFixToMatch(fullText: string, run: TextRun, fix: WordFix): LtMatch | null {
|
||||
// WordFix offsets are absolute in the text passed to correctText (the run's
|
||||
// text, 0-based) — rebase via run.offset.
|
||||
const relStart = fix.start - 0; // fix.start is already run-relative
|
||||
const relEnd = fix.end;
|
||||
if (relStart < 0 || relStart > run.text.length) return null;
|
||||
const base = run.offset;
|
||||
|
||||
if (relEnd > relStart) {
|
||||
const length = Math.min(relEnd - relStart, run.text.length - relStart);
|
||||
if (length <= 0) return null;
|
||||
const replacement = unwrapQuotedCorrection(fix.corrected ?? '');
|
||||
const message = replacement === '' ? `Remove '${fix.original}'` : `Shouldn't this be '${replacement}'?`;
|
||||
return makeMatch(fullText, base + relStart, length, {
|
||||
message,
|
||||
shortMessage: replacement === '' ? undefined : replacement,
|
||||
replacement,
|
||||
});
|
||||
}
|
||||
|
||||
// Zero width → pure insertion; anchor on a neighbouring token.
|
||||
return anchorInsertion(fullText, run, relEnd, fix.corrected ?? '');
|
||||
}
|
||||
|
||||
interface SpanResult {
|
||||
offset: number;
|
||||
length: number;
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
/** Changed middle span of a sentence-level rewrite (run-relative). */
|
||||
function sentenceChangeSpan(run: TextRun, s: Suggestion): SpanResult | null {
|
||||
const original = s.original;
|
||||
const corrected = s.corrected ?? '';
|
||||
if (!original || !corrected || original === corrected) return null;
|
||||
if (s.start_index < 0 || s.end_index <= s.start_index) return null;
|
||||
|
||||
const a = original;
|
||||
const b = corrected;
|
||||
let p = 0;
|
||||
const max = Math.min(a.length, b.length);
|
||||
while (p < max && a[p] === b[p]) p++;
|
||||
let suffix = 0;
|
||||
while (suffix < a.length - p && suffix < b.length - p && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]) suffix++;
|
||||
|
||||
const relOffset = s.start_index + p;
|
||||
const relLength = a.length - p - suffix;
|
||||
if (relOffset < 0 || relLength <= 0 || relOffset >= run.text.length) return null;
|
||||
const length = Math.min(relLength, run.text.length - relOffset);
|
||||
if (length <= 0) return null;
|
||||
return {
|
||||
offset: run.offset + relOffset,
|
||||
length,
|
||||
replacement: b.slice(p, b.length - suffix),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full LanguageTool v2 "check" JSON body.
|
||||
*/
|
||||
export function buildCheckResponse(opts: {
|
||||
fullText: string;
|
||||
matches: LtMatch[];
|
||||
sentenceRanges: Array<[number, number]>;
|
||||
languageCode?: string;
|
||||
incomplete?: string;
|
||||
reportedVersion?: string;
|
||||
}): LtCheckResponse {
|
||||
const lang = normalizeLanguage(opts.languageCode) ?? LANGUAGES[0];
|
||||
const matches = [...opts.matches].sort((x, y) => x.offset - y.offset || x.length - y.length);
|
||||
|
||||
return {
|
||||
software: {
|
||||
name: 'LanguageTool',
|
||||
version: opts.reportedVersion ?? '6.8',
|
||||
buildDate: new Date().toISOString().slice(0, 10),
|
||||
apiVersion: 1,
|
||||
premium: false,
|
||||
status: '',
|
||||
},
|
||||
warnings: {
|
||||
incompleteResults: Boolean(opts.incomplete),
|
||||
...(opts.incomplete ? { incompleteResultsReason: opts.incomplete } : {}),
|
||||
},
|
||||
language: {
|
||||
name: lang.name,
|
||||
code: lang.code,
|
||||
detectedLanguage: { name: lang.name, code: lang.code, confidence: 1, source: 'given' },
|
||||
},
|
||||
matches,
|
||||
sentenceRanges: opts.sentenceRanges ?? [],
|
||||
};
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
// LLM inference wrapper. Mirrors grammar-llm's src/electron/llamaService.ts
|
||||
// (SYSTEM_PROMPT, STOP_SEQUENCES, sampling params, per-sentence semantics) so
|
||||
// the container produces the same corrections as the desktop app.
|
||||
//
|
||||
// node-llama-cpp v3.19 API (verified against the desktop's usage, which is
|
||||
// itself "API verified against installed node-llama-cpp v3.19 types"):
|
||||
// - getLlama() auto-detects CUDA / Metal / Vulkan / CPU
|
||||
// - llama.loadModel({ modelPath }) -> model
|
||||
// - contextSize belongs on model.createContext(), not loadModel()
|
||||
// - sequences are created via context.getSequence()
|
||||
// - Sampling params (temperature/topK/topP/minP/maxTokens/repeatPenalty/
|
||||
// customStopTriggers) are per-prompt options for session.prompt(), NOT
|
||||
// session options.
|
||||
// - session.resetChatHistory() is called before each sentence.
|
||||
// - node-llama-cpp is ESM with top-level await, so it is loaded via dynamic
|
||||
// import() (see desktop modelManager.ts notes).
|
||||
|
||||
import type { Logger } from './log.js';
|
||||
|
||||
export interface Sampling {
|
||||
temperature: number;
|
||||
topK: number;
|
||||
topP: number;
|
||||
minP: number;
|
||||
}
|
||||
|
||||
export interface LlamaServiceOptions {
|
||||
modelPath: string;
|
||||
contextSize: number;
|
||||
sampling: Sampling;
|
||||
pickyExtraPrompt?: string;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check level (LanguageTool's `level` parameter).
|
||||
* - 'default': base correction prompt — byte-identical to the desktop app.
|
||||
* - 'picky': base prompt + strict picky addendum (style, word choice,
|
||||
* phrasing, punctuation — like LanguageTool's picky level
|
||||
* adding rule families on top of the default set).
|
||||
*/
|
||||
export type CheckLevel = 'default' | 'picky';
|
||||
|
||||
export const SYSTEM_PROMPT =
|
||||
'You are a grammar correction assistant. Correct the grammar, spelling, and punctuation of the given text. Return only the corrected text without any additional explanations or prefixes.';
|
||||
|
||||
export const DEFAULT_PICKY_EXTRA =
|
||||
'Additionally, be highly picky, like a careful editor: also fix word choice, awkward or redundant phrasing, weak punctuation, and style issues. Prefer the most precise and idiomatic English. ';
|
||||
|
||||
export const STOP_SEQUENCES = ['<|endoftext|>', '<|corrected_end|>', '\n\n', 'Corrected:', 'Here is'];
|
||||
|
||||
interface SessionLike {
|
||||
resetChatHistory(): void;
|
||||
prompt(sentence: string, options: Record<string, unknown>): Promise<unknown>;
|
||||
}
|
||||
|
||||
export class LlamaService {
|
||||
private baseSession: SessionLike | null = null;
|
||||
private pickySession: SessionLike | null = null;
|
||||
private loading: Promise<void> | null = null;
|
||||
private loadError: unknown = null;
|
||||
|
||||
readonly modelPath: string;
|
||||
readonly contextSize: number;
|
||||
readonly sampling: Sampling;
|
||||
readonly pickyExtraPrompt: string;
|
||||
private log: Logger;
|
||||
|
||||
constructor(opts: LlamaServiceOptions, log?: Logger) {
|
||||
this.modelPath = opts.modelPath;
|
||||
this.contextSize = opts.contextSize;
|
||||
this.sampling = opts.sampling;
|
||||
this.pickyExtraPrompt = opts.pickyExtraPrompt ?? DEFAULT_PICKY_EXTRA;
|
||||
this.log = log ?? defaultLogger;
|
||||
}
|
||||
|
||||
get loadErrorText(): string | null {
|
||||
return this.loadError ? String(this.loadError) : null;
|
||||
}
|
||||
|
||||
get isReady(): boolean {
|
||||
return this.baseSession !== null;
|
||||
}
|
||||
|
||||
async ensureLoaded(): Promise<void> {
|
||||
if (this.baseSession) return;
|
||||
if (this.loading) return this.loading;
|
||||
|
||||
this.loading = (async () => {
|
||||
if (!this.modelPath) throw new Error('No model path configured');
|
||||
// Dynamic import: node-llama-cpp is ESM with top-level await.
|
||||
const { getLlama, LlamaChatSession } = await import('node-llama-cpp');
|
||||
const llama = await getLlama(); // auto-detects CUDA / Metal / Vulkan / CPU
|
||||
const model = await llama.loadModel({ modelPath: this.modelPath });
|
||||
// sequences: 2 → one chat session per level (shared KV cache context).
|
||||
const context = await model.createContext({ contextSize: this.contextSize, sequences: 2 });
|
||||
// Two chat sessions on the shared KV context (separate sequences):
|
||||
// - base: the desktop system prompt (default level = desktop parity)
|
||||
// - picky: base + picky addendum in the SYSTEM slot. (A GRMR fine-tune
|
||||
// echoes instructions placed in the user message, so the
|
||||
// addendum must not go there.)
|
||||
this.baseSession = new LlamaChatSession({
|
||||
contextSequence: context.getSequence(),
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
}) as unknown as SessionLike;
|
||||
this.pickySession = new LlamaChatSession({
|
||||
contextSequence: context.getSequence(),
|
||||
systemPrompt: SYSTEM_PROMPT + ' ' + this.pickyExtraPrompt,
|
||||
}) as unknown as SessionLike;
|
||||
this.log.info('model loaded and ready', {
|
||||
modelPath: this.modelPath,
|
||||
contextSize: this.contextSize,
|
||||
levels: ['default(base)', 'picky(base+editor addendum)'],
|
||||
});
|
||||
})();
|
||||
|
||||
try {
|
||||
await this.loading;
|
||||
} catch (err) {
|
||||
this.loadError = err;
|
||||
this.log.error('failed to load model', { err: String(err) });
|
||||
throw err;
|
||||
} finally {
|
||||
this.loading = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct a single sentence — raw sentence in the user slot (desktop
|
||||
* parity; GRMR fine-tunes echo user-slot instructions, so never put
|
||||
* instructions there). Per-level selection happens via the session's
|
||||
* system prompt (base vs base+picky addendum).
|
||||
* Mirrors the desktop corrector: reset chat history, per-prompt sampling,
|
||||
* trim, fall back to the original sentence on error so the pipeline keeps
|
||||
* going.
|
||||
*/
|
||||
async correct(sentence: string, level: CheckLevel = 'default'): Promise<string> {
|
||||
await this.ensureLoaded();
|
||||
const session = level === 'picky' ? this.pickySession : this.baseSession;
|
||||
if (!session) throw new Error('session not ready');
|
||||
try {
|
||||
session.resetChatHistory();
|
||||
const response = (await session.prompt(sentence, {
|
||||
maxTokens: sentence.length + 20,
|
||||
temperature: this.sampling.temperature,
|
||||
topK: this.sampling.topK,
|
||||
topP: this.sampling.topP,
|
||||
minP: this.sampling.minP,
|
||||
repeatPenalty: { penalty: 1 }, // penalty 1 = disabled (desktop parity)
|
||||
customStopTriggers: STOP_SEQUENCES,
|
||||
})) as unknown as string;
|
||||
return response.trim();
|
||||
} catch (error) {
|
||||
this.log.error(`Error correcting sentence '${sentence}':`, { err: String(error) });
|
||||
return sentence;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultLogger: Logger = {
|
||||
debug: () => undefined,
|
||||
info: (m) => console.log(m),
|
||||
warn: (m) => console.warn(m),
|
||||
error: (m, x) => console.error(m, x ? String(x) : ''),
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
/* Minimal leveled logger (no dependencies). Writes ISO-timestamped lines. */
|
||||
type Level = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
const RANK: Record<Level, number> = { debug: 10, info: 20, warn: 30, error: 40 };
|
||||
|
||||
export interface Logger {
|
||||
debug: (msg: string, extra?: Record<string, unknown>) => void;
|
||||
info: (msg: string, extra?: Record<string, unknown>) => void;
|
||||
warn: (msg: string, extra?: Record<string, unknown>) => void;
|
||||
error: (msg: string, extra?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function createLogger(minLevel: Level = 'info'): Logger {
|
||||
const threshold = RANK[minLevel];
|
||||
const emit = (level: Level, msg: string, extra?: Record<string, unknown>) => {
|
||||
if (RANK[level] < threshold) return;
|
||||
const line = extra && Object.keys(extra).length > 0 ? `${msg} ${JSON.stringify(extra)}` : msg;
|
||||
const ts = new Date().toISOString();
|
||||
if (level === 'error') console.error(`[${ts}] ${level.toUpperCase()} ${line}`);
|
||||
else if (level === 'warn') console.warn(`[${ts}] ${level.toUpperCase()} ${line}`);
|
||||
else console.log(`[${ts}] ${level.toUpperCase()} ${line}`);
|
||||
};
|
||||
return {
|
||||
debug: (m, x) => emit('debug', m, x),
|
||||
info: (m, x) => emit('info', m, x),
|
||||
warn: (m, x) => emit('warn', m, x),
|
||||
error: (m, x) => emit('error', m, x),
|
||||
};
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
// OpenAI-compatible (chat/completions) backend — same contract as LlamaService
|
||||
// so an Overleaf server without local inference capacity can redirect the LLM
|
||||
// step to a hosted model (OpenAI, OpenRouter, vLLM, llama.cpp llama-server,
|
||||
// Ollama /v1, a self-hosted GRMR-V3 LoRA endpoint, …).
|
||||
//
|
||||
// Prompt/stop/sampling semantics match src/llama.ts (and therefore the
|
||||
// grammar-llm desktop app): same system prompt, same level addendum, same
|
||||
// stop sequences, same sampling, same "fall back to the original sentence on
|
||||
// error" behavior so the pipeline keeps going.
|
||||
|
||||
import type { Logger } from './log.js';
|
||||
import type { Sampling, CheckLevel } from './llama.js';
|
||||
import { SYSTEM_PROMPT, DEFAULT_PICKY_EXTRA, STOP_SEQUENCES } from './llama.js';
|
||||
|
||||
export interface OpenAIServiceOptions {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
sampling: Sampling;
|
||||
timeoutMs: number;
|
||||
/** completion-token headroom over sentence length (num_predict / max_tokens margin). */
|
||||
tokenPad?: number;
|
||||
pickyExtraPrompt?: string;
|
||||
/** Custom base system prompt (e.g. a fine-tune's trained prompt). Undefined = built-in. */
|
||||
systemPrompt?: string;
|
||||
logger?: Logger;
|
||||
/** 'openai' = /chat/completions (default); 'ollama' = native /api/chat with think:false. */
|
||||
style?: 'openai' | 'ollama';
|
||||
}
|
||||
|
||||
export class OpenAIService {
|
||||
readonly model: string;
|
||||
private baseUrl: string;
|
||||
private apiKey: string;
|
||||
private sampling: Sampling;
|
||||
private timeoutMs: number;
|
||||
private tokenPad: number;
|
||||
private pickyExtraPrompt: string;
|
||||
private sysPrompt: string;
|
||||
private log: Logger;
|
||||
private style: 'openai' | 'ollama';
|
||||
private pingDone = false;
|
||||
|
||||
constructor(opts: OpenAIServiceOptions) {
|
||||
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
|
||||
if (opts.style === 'ollama') this.baseUrl = this.baseUrl.replace(/\/v1$/, '');
|
||||
this.apiKey = opts.apiKey;
|
||||
this.model = opts.model;
|
||||
this.sampling = opts.sampling;
|
||||
this.timeoutMs = opts.timeoutMs;
|
||||
this.tokenPad = Math.max(0, opts.tokenPad ?? 40);
|
||||
this.pickyExtraPrompt = opts.pickyExtraPrompt ?? DEFAULT_PICKY_EXTRA;
|
||||
this.sysPrompt = (opts.systemPrompt ?? '').trim();
|
||||
this.log = opts.logger ?? defaultLogger;
|
||||
this.style = opts.style ?? 'openai';
|
||||
}
|
||||
|
||||
get isReady(): boolean {
|
||||
return true; // remote backend — readiness is a connectivity question
|
||||
}
|
||||
|
||||
get loadErrorText(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** No local model to load. Best-effort availability ping (never fatal). */
|
||||
async ensureLoaded(): Promise<void> {
|
||||
if (this.pingDone) return;
|
||||
this.pingDone = true;
|
||||
this.log.info('openai-compatible backend', { baseUrl: this.baseUrl, model: this.model, style: this.style });
|
||||
try {
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), 5000);
|
||||
const r = await fetch(`${this.baseUrl}/${this.style === 'ollama' ? 'api/tags' : 'models'}`, {
|
||||
headers: this.authHeaders(),
|
||||
signal: ctl.signal,
|
||||
});
|
||||
clearTimeout(t);
|
||||
if (!r.ok) this.log.warn('model list endpoint not reachable/ok (will still attempt completions)', { status: r.status });
|
||||
} catch (err) {
|
||||
this.log.warn('model list endpoint unreachable (will still attempt completions)', { err: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
describe(): string {
|
||||
return `openai:${this.baseUrl} model=${this.model}`;
|
||||
}
|
||||
|
||||
private authHeaders(): Record<string, string> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.apiKey) h['Authorization'] = `Bearer ${this.apiKey}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* One chat call per sentence. `level === 'picky'` uses the system prompt +
|
||||
* picky addendum; 'default' is the base prompt (identical to the desktop
|
||||
* app). Returns the original sentence on any error.
|
||||
*/
|
||||
async correct(sentence: string, level: CheckLevel = 'default'): Promise<string> {
|
||||
const base = this.sysPrompt || SYSTEM_PROMPT;
|
||||
const system = level === 'picky' ? `${base} ${this.pickyExtraPrompt}` : base;
|
||||
if (this.style === 'ollama') return this.correctOllama(sentence, system);
|
||||
const body = {
|
||||
model: this.model,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: sentence },
|
||||
],
|
||||
temperature: this.sampling.temperature,
|
||||
top_p: this.sampling.topP,
|
||||
top_k: this.sampling.topK,
|
||||
max_tokens: sentence.length + this.tokenPad,
|
||||
stop: STOP_SEQUENCES,
|
||||
};
|
||||
try {
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), this.timeoutMs);
|
||||
const r = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: this.authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal: ctl.signal,
|
||||
});
|
||||
clearTimeout(t);
|
||||
if (!r.ok) {
|
||||
const text = await r.text().catch(() => '');
|
||||
this.log.error('openai backend error', { status: r.status, body: text.slice(0, 300) });
|
||||
return sentence;
|
||||
}
|
||||
const json: { choices?: Array<{ message?: { content?: string } }> } = await r.json();
|
||||
const content = json.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string' || content.length === 0) {
|
||||
this.log.error('openai backend returned no usable content', {});
|
||||
return sentence;
|
||||
}
|
||||
return content.trim();
|
||||
} catch (err) {
|
||||
this.log.error(`Error correcting sentence '${sentence}' via openai backend:`, { err: String(err) });
|
||||
return sentence;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ollama native /api/chat path. Used for Qwen3-family and similar thinking
|
||||
* models: the OpenAI-compat layer of this Ollama build ignores the
|
||||
* top-level think flag and ~100× burns the completion budget on hidden
|
||||
* reasoning (measured: 111–400 completion tokens for ~15-token answers,
|
||||
* occasionally hitting max_tokens with an EMPTY answer). Native think:false
|
||||
* suppresses reasoning entirely (measured: ~15 completion tokens, same
|
||||
* answer) — same contract, deterministic budget.
|
||||
*/
|
||||
private async correctOllama(sentence: string, system: string): Promise<string> {
|
||||
const body = {
|
||||
model: this.model,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: sentence },
|
||||
],
|
||||
stream: false,
|
||||
think: false,
|
||||
options: {
|
||||
temperature: this.sampling.temperature,
|
||||
top_k: this.sampling.topK,
|
||||
top_p: this.sampling.topP,
|
||||
stop: STOP_SEQUENCES,
|
||||
num_predict: Math.max(sentence.length + this.tokenPad, 128),
|
||||
},
|
||||
};
|
||||
try {
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), this.timeoutMs);
|
||||
const r = await fetch(`${this.baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: this.authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal: ctl.signal,
|
||||
});
|
||||
clearTimeout(t);
|
||||
if (!r.ok) {
|
||||
const text = await r.text().catch(() => '');
|
||||
this.log.error('ollama backend error', { status: r.status, body: text.slice(0, 300) });
|
||||
return sentence;
|
||||
}
|
||||
const json: { message?: { content?: string } } = await r.json();
|
||||
const content = json.message?.content;
|
||||
if (typeof content !== 'string' || content.length === 0) {
|
||||
this.log.error('ollama backend returned no usable content', {});
|
||||
return sentence;
|
||||
}
|
||||
return content.trim();
|
||||
} catch (err) {
|
||||
this.log.error(`Error correcting sentence '${sentence}' via ollama backend:`, { err: String(err) });
|
||||
return sentence;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultLogger: Logger = {
|
||||
debug: () => undefined,
|
||||
info: (m) => console.log(m),
|
||||
warn: (m) => console.warn(m),
|
||||
error: (m, x) => console.error(m, x ? String(x) : ''),
|
||||
};
|
||||
@@ -1,647 +0,0 @@
|
||||
// grammar-llm server — LanguageTool-compatible HTTP API (v2).
|
||||
//
|
||||
// Endpoints:
|
||||
// GET /v2/languages LanguageTool language list
|
||||
// POST /v2/check grammar check (form-urlencoded or JSON body)
|
||||
// GET /v2/version version info
|
||||
// GET /v2/health engine/model status (container HEALTHCHECK)
|
||||
// GET / short info payload
|
||||
//
|
||||
// Drop-in for erikvl87/languagetool (docker-languagetool): same default port
|
||||
// (8010), same wire API, `GET /v2/languages` + `POST /v2/check` semantics.
|
||||
|
||||
import http from 'node:http';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { isLanguageSupported, loadConfig, type Config } from './config.js';
|
||||
import { CheckEngine, type CheckEngine } from './engine.js';
|
||||
import { createLogger, type Logger } from './log.js';
|
||||
import {
|
||||
buildCheckResponse,
|
||||
getLanguages,
|
||||
resolveCheckInput,
|
||||
suggestionToMatches,
|
||||
type LtMatch,
|
||||
type TextRun,
|
||||
} from './languagetool.js';
|
||||
import { splitIntoSentences } from '../core/sentences.js';
|
||||
import type { Suggestion } from '../core/types.js';
|
||||
|
||||
// Re-export for consumers/tests (e.g. building the real engine with the
|
||||
// openai backend directly).
|
||||
export { CheckEngine };
|
||||
|
||||
/** LLM half of a check (cascade race participant). */
|
||||
interface LlmOutcome {
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
/** Real-LanguageTool half of the cascade race: null = nothing found / down. */
|
||||
interface LtOutcome {
|
||||
kind: 'lt';
|
||||
value: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface EngineLike {
|
||||
status(): {
|
||||
state: string;
|
||||
errorMessage: string | null;
|
||||
modelPath: string;
|
||||
checksCompleted: number;
|
||||
checksFailed: number;
|
||||
};
|
||||
ensureReady(): Promise<void>;
|
||||
check(
|
||||
runs: TextRun[],
|
||||
opts: { maxRuns?: number; requestTimeoutMs?: number; level?: 'default' | 'picky' },
|
||||
): Promise<{ results: Array<{ suggestions: Suggestion[]; correctedText: string; incomplete: boolean }>; incomplete: boolean }>;
|
||||
}
|
||||
|
||||
export interface ServerDeps {
|
||||
cfg: Config;
|
||||
engine: EngineLike;
|
||||
log?: Logger;
|
||||
}
|
||||
|
||||
interface RequestBody {
|
||||
params: Record<string, string | undefined>;
|
||||
json: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function readBody(req: http.IncomingMessage, maxBytes: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
let done = false;
|
||||
req.on('data', (c: Buffer) => {
|
||||
if (done) return;
|
||||
size += c.length;
|
||||
if (size > maxBytes) {
|
||||
done = true;
|
||||
req.removeAllListeners('data');
|
||||
req.resume();
|
||||
reject(Object.assign(new Error('body too large'), { statusCode: 413 }));
|
||||
return;
|
||||
}
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', (err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
function parseRequestBody(body: string, req: http.IncomingMessage): RequestBody {
|
||||
const ctype = String(req.headers['content-type'] ?? '').toLowerCase();
|
||||
const params: Record<string, string | undefined> = {};
|
||||
let json: Record<string, unknown> | null = null;
|
||||
|
||||
if (ctype.includes('application/json')) {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
json = parsed;
|
||||
// Also expose scalar params (some clients mix both encodings).
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (typeof v === 'string') params[k] = v;
|
||||
}
|
||||
} catch {
|
||||
// fall through to urlencoded
|
||||
}
|
||||
}
|
||||
if (!json && (ctype.includes('application/x-www-form-urlencoded') || ctype === '' || ctype.includes('text/plain'))) {
|
||||
const sp = new URLSearchParams(body);
|
||||
for (const [k, v] of sp.entries()) {
|
||||
params[k] = v;
|
||||
}
|
||||
// If it was actually JSON (no content-type), be helpful.
|
||||
if (params['text'] === undefined && params['data'] === undefined && body.trim().startsWith('{')) {
|
||||
try {
|
||||
json = JSON.parse(body) as Record<string, unknown>;
|
||||
for (const [k, v] of Object.entries(json)) {
|
||||
if (typeof v === 'string') params[k] = v;
|
||||
}
|
||||
} catch {
|
||||
/* not JSON after all */
|
||||
}
|
||||
}
|
||||
}
|
||||
return { params, json };
|
||||
}
|
||||
|
||||
function jsonOut(res: http.ServerResponse, status: number, obj: unknown, allowOrigin: string): void {
|
||||
const payload = JSON.stringify(obj);
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': allowOrigin,
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
function errorOut(res: http.ServerResponse, status: number, message: string, allowOrigin: string): void {
|
||||
jsonOut(res, status, { message }, allowOrigin);
|
||||
}
|
||||
|
||||
export class LtServer {
|
||||
private inFlight = 0;
|
||||
private readonly log: Logger;
|
||||
|
||||
constructor(private deps: ServerDeps) {
|
||||
this.log = deps.log ?? createLogger(deps.cfg.logLevel);
|
||||
this.httpServer = http.createServer((req, res) => {
|
||||
this.log.debug('request', { method: req.method, url: req.url ?? '' });
|
||||
void this.handle(req, res).catch((err) => {
|
||||
this.log.error('unhandled error', { err: String(err) });
|
||||
if (!res.headersSent) errorOut(res, 500, 'internal error', deps.cfg.allowOrigin);
|
||||
else res.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
readonly httpServer: http.Server;
|
||||
|
||||
listen(port: number, host: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.httpServer.once('error', reject);
|
||||
this.httpServer.listen(port, host, () => {
|
||||
this.log.info(`listening on http://${host}:${port} (LanguageTool-compatible API)`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
return new Promise((resolve) => this.httpServer.close(() => resolve()));
|
||||
}
|
||||
|
||||
private async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const { cfg } = this.deps;
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
||||
const path = url.pathname;
|
||||
const method = (req.method ?? 'GET').toUpperCase();
|
||||
|
||||
if (method === 'OPTIONS') {
|
||||
res.writeHead(204, {
|
||||
'Access-Control-Allow-Origin': cfg.allowOrigin,
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && (path === '/v2/languages' || path === '/languages')) {
|
||||
jsonOut(res, 200, getLanguages(), cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/v2/version') {
|
||||
jsonOut(res, 200, this.versionObject(), cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && (path === '/v2/health' || path === '/healthz' || path === '/health')) {
|
||||
this.handleHealth(res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && (path === '/' || path === '/index.html')) {
|
||||
jsonOut(res, 200, { ...this.versionObject(), endpoints: ['/v2/languages', 'POST /v2/check', '/v2/version', '/v2/health'] }, cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'POST' && (path === '/v2/check' || path === '/check')) {
|
||||
await this.handleCheck(req, res, url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.startsWith('/v2/')) {
|
||||
errorOut(res, 404, `unknown v2 endpoint: ${path}`, cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
errorOut(res, 404, `not found: ${path}`, cfg.allowOrigin);
|
||||
}
|
||||
|
||||
private versionObject(): Record<string, unknown> {
|
||||
const { cfg } = this.deps;
|
||||
const st = this.deps.engine.status();
|
||||
return {
|
||||
name: 'LanguageTool',
|
||||
version: cfg.reportedVersion,
|
||||
apiVersion: 1,
|
||||
engine: {
|
||||
name: 'grammar-llm',
|
||||
backend: cfg.backend,
|
||||
...(cfg.backend === 'openai' ? { model: cfg.openaiModel } : { model: cfg.modelFile }),
|
||||
state: st.state,
|
||||
},
|
||||
languageToolProxy: cfg.gtUrl ?? null,
|
||||
defaultLevel: cfg.defaultLevel,
|
||||
};
|
||||
}
|
||||
|
||||
private handleHealth(res: http.ServerResponse): void {
|
||||
const { cfg } = this.deps;
|
||||
const st = this.deps.engine.status();
|
||||
const ok = st.state === 'ready';
|
||||
jsonOut(res, ok ? 200 : 503, {
|
||||
status: ok ? 'ok' : 'degraded',
|
||||
state: st.state,
|
||||
...(st.errorMessage ? { error: st.errorMessage } : {}),
|
||||
model: {
|
||||
file: cfg.modelFile,
|
||||
path: cfg.modelPath,
|
||||
},
|
||||
checks: { completed: st.checksCompleted, failed: st.checksFailed },
|
||||
backend: cfg.backend,
|
||||
languageToolProxy: cfg.gtUrl ?? null,
|
||||
defaultLevel: cfg.defaultLevel,
|
||||
inFlight: this.inFlight,
|
||||
version: cfg.reportedVersion,
|
||||
uptimeSec: Math.round(process.uptime()),
|
||||
}, cfg.allowOrigin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward the request to a real LanguageTool container (v2 API), exactly
|
||||
* like LanguageTool itself would see it:
|
||||
* - original `text` or `data` (annotation) payload — LT handles Overleaf's
|
||||
* LaTeX annotations natively and returns offsets in editor-document space
|
||||
* - `level` always present ('picky'→'picky', otherwise 'default')
|
||||
* - `disabledRules` / `language` passed through
|
||||
* Returns the LT response when it produced ≥1 match, otherwise null (the
|
||||
* caller then runs the LLM). Never throws — LT failure means "try the LLM".
|
||||
*/
|
||||
private async forwardToLanguagetool(args: {
|
||||
cfg: Config;
|
||||
body: Buffer;
|
||||
params: Record<string, string | undefined>;
|
||||
bodyObj: Record<string, unknown>;
|
||||
language: string;
|
||||
level: 'default' | 'picky';
|
||||
deadline: number;
|
||||
}): Promise<Record<string, unknown> | null> {
|
||||
const { cfg, body, params, bodyObj, language, level, deadline } = args;
|
||||
const gtUrl = cfg.gtUrl;
|
||||
if (!gtUrl) return null;
|
||||
|
||||
// Rebuild a clean form body from the merged params (LT v2 is form-based).
|
||||
const text = typeof bodyObj['text'] === 'string' ? bodyObj['text'] : params['text'];
|
||||
const data = typeof bodyObj['data'] === 'string' ? bodyObj['data'] : bodyObj['data'] ? JSON.stringify(bodyObj['data']) : params['data'];
|
||||
if (!text && !data) return null; // nothing to forward (caller 400s)
|
||||
|
||||
const form = new URLSearchParams();
|
||||
form.set('language', language);
|
||||
form.set('level', level); // ← the picky flag, always explicit
|
||||
if (typeof text === 'string') form.set('text', text);
|
||||
if (typeof data === 'string') form.set('data', data);
|
||||
const disabledRules = params['disabledRules'];
|
||||
if (disabledRules) form.set('disabledRules', disabledRules);
|
||||
|
||||
const remaining = Math.max(500, Math.min(cfg.gtTimeoutMs, deadline - Date.now()));
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), remaining);
|
||||
try {
|
||||
const res = await fetch(`${gtUrl}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: form.toString(),
|
||||
signal: ctl.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.log.warn('real LanguageTool returned an error — falling back to grammar-llm', { status: res.status });
|
||||
return null;
|
||||
}
|
||||
const json = (await res.json()) as Record<string, unknown>;
|
||||
const matches = Array.isArray(json['matches']) ? (json['matches'] as unknown[]) : [];
|
||||
if (matches.length === 0) return null; // nothing found → LLM runs second
|
||||
// Pass the genuine LT response through (add missing envelope bits if
|
||||
// the upstream LT is an unusual build).
|
||||
if (json['software'] === undefined) json['software'] = { name: 'LanguageTool', version: cfg.reportedVersion, apiVersion: 1 };
|
||||
if (json['warnings'] === undefined) json['warnings'] = { incompleteResults: false };
|
||||
return json;
|
||||
} catch (err) {
|
||||
this.log.warn('real LanguageTool unreachable — falling back to grammar-llm', { err: String(err), gtUrl });
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCheck(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void> {
|
||||
const { cfg, engine } = this.deps;
|
||||
|
||||
// In-flight guard (protect the single LLM session from pileups).
|
||||
if (this.inFlight >= cfg.maxInFlight) {
|
||||
errorOut(res, 503, 'server busy — too many checks in flight, retry shortly', cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
this.inFlight++;
|
||||
try {
|
||||
const body = await readBody(req, cfg.maxBodyBytes).catch((err: unknown) => {
|
||||
if ((err as { statusCode?: number })?.statusCode === 413) {
|
||||
errorOut(res, 413, 'request body too large', cfg.allowOrigin);
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
if (body === null) return;
|
||||
|
||||
// Query-string params may also carry the request (LT allows both); merged into bodyObj below.
|
||||
|
||||
const parsed = parseRequestBody(body.toString('utf8'), req);
|
||||
const bodyObj: Record<string, unknown> = parsed.json ?? {};
|
||||
// LanguageTool accepts text/data/level/language as query or body params;
|
||||
// merge so both styles work.
|
||||
if (bodyObj['text'] === undefined && parsed.params['text'] !== undefined) bodyObj['text'] = parsed.params['text'];
|
||||
if (bodyObj['data'] === undefined && parsed.params['data'] !== undefined) bodyObj['data'] = parsed.params['data'];
|
||||
const params: Record<string, string | undefined> = {
|
||||
...Object.fromEntries(url.searchParams.entries()),
|
||||
...Object.fromEntries(Object.entries(parsed.params)),
|
||||
};
|
||||
|
||||
const language = params['language'] ?? 'auto';
|
||||
// LanguageTool's `level`: 'picky' or 'default'. The level is ALWAYS forwarded
|
||||
// to the real LanguageTool container (picky→picky) and drives the LLM mode.
|
||||
const levelRaw = String(params['level'] ?? bodyObj['level'] ?? '').trim().toLowerCase();
|
||||
const level: 'default' | 'picky' = levelRaw === 'picky' ? 'picky' : levelRaw === 'default' ? 'default' : cfg.defaultLevel;
|
||||
if (levelRaw) this.log.debug('level requested', { levelRaw, resolved: level });
|
||||
const disabledRules = params['disabledRules'] ?? (parsed.json?.['disabledRules'] as string | undefined);
|
||||
if (disabledRules) this.log.debug('disabledRules (forwarded to real LT when configured; ignored by grammar-llm)', { disabledRules });
|
||||
|
||||
// 2) resolve text/runs (also validates text length)
|
||||
const input = resolveCheckInput(bodyObj);
|
||||
if ('error' in input) {
|
||||
errorOut(res, 400, input.error, cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
const fullText = input.text;
|
||||
if (fullText.trim().length === 0) {
|
||||
jsonOut(res, 200, buildCheckResponse({ fullText, matches: [], sentenceRanges: [], languageCode: language, reportedVersion: cfg.reportedVersion }), cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
if (fullText.length > cfg.maxTextLength) {
|
||||
errorOut(res, 413, `text too long (${fullText.length} > ${cfg.maxTextLength})`, cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) LANGUAGE GATE for the LLM (English model only). The real LT (when
|
||||
// configured as a cascade front) can still serve other languages below.
|
||||
// (Applied early so an unsupported language never spends LLM compute.)
|
||||
if (!isLanguageSupported(cfg, language)) {
|
||||
if (cfg.unsupportedLanguageMode === 'error') {
|
||||
errorOut(res, 400, `language not supported (this deployment runs the English grammar-llm model; got '${language}')`, cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
this.log.info('unsupported language requested — returning empty matches', { language });
|
||||
jsonOut(res, 200, buildCheckResponse({ fullText: '', matches: [], sentenceRanges: [], languageCode: language, reportedVersion: cfg.reportedVersion }), cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4) CASCADE (when a real LanguageTool is configured): run the real LT
|
||||
// and the LLM on ONE shared wall-clock budget (cascadeBudgetMs, kept
|
||||
// under Overleaf's 60 s controller timeout — over that it silently shows
|
||||
// nothing). Rules:
|
||||
// - real LT with ≥1 match wins the moment it resolves (fast path,
|
||||
// compute-saving: the LLM start is cancelled if it has not begun);
|
||||
// - the LLM starts after llmGraceMs so a warm LT can win for free, but
|
||||
// it then runs in PARALLEL — a slow/empty LT no longer blocks it;
|
||||
// - whichever resolves first with a 200 result is served; 400/503 LLM
|
||||
// outcomes only surface when the LT had nothing either.
|
||||
if (cfg.gtUrl) {
|
||||
const cascadeDeadline = Date.now() + cfg.cascadeBudgetMs;
|
||||
const t0 = Date.now();
|
||||
const ltPromise = this.forwardToLanguagetool({
|
||||
cfg, body, params, bodyObj, language, level,
|
||||
deadline: cascadeDeadline,
|
||||
}).then((value) => ({ kind: 'lt' as const, value }));
|
||||
|
||||
const gate = { cancelled: false };
|
||||
let cancelGate = (): void => { /* default: grace is 0 */ };
|
||||
const gatePromise = new Promise<void>((resolve) => {
|
||||
if (cfg.llmGraceMs === 0) { resolve(); return; }
|
||||
const t = setTimeout(resolve, cfg.llmGraceMs);
|
||||
cancelGate = () => { clearTimeout(t); gate.cancelled = true; resolve(); };
|
||||
});
|
||||
const llmPromise = (async () => {
|
||||
await gatePromise;
|
||||
if (gate.cancelled) {
|
||||
// LT already won with matches: never start the LLM (zero waste).
|
||||
return new Promise<LlmOutcome>(() => { /* suppressed on purpose */ });
|
||||
}
|
||||
return this.runLlmCheck({ cfg, language, level, fullText, runs: input.runs, cascadeDeadline });
|
||||
})();
|
||||
|
||||
const outcome = await this.resolveCascade(ltPromise, llmPromise, () => cancelGate());
|
||||
if (outcome.ltWon) {
|
||||
this.log.info('real LanguageTool produced matches — LLM skipped', { gtUrl: cfg.gtUrl, level, ms: Date.now() - t0, matches: (outcome.body as { matches?: unknown[] })?.matches?.length });
|
||||
} else {
|
||||
this.log.info('real LT found no matches (or was unreachable) — serving LLM result', { gtUrl: cfg.gtUrl, level });
|
||||
}
|
||||
jsonOut(res, outcome.status, outcome.body, cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5) LLM-ONLY path (no real LT configured).
|
||||
const outcome = await this.runLlmCheck({ cfg, language, level, fullText, runs: input.runs, cascadeDeadline: null });
|
||||
if (outcome.status !== 200) {
|
||||
errorOut(res, outcome.status as 400 | 500 | 503, String((outcome.body as { message?: string })?.message ?? 'check failed'), cfg.allowOrigin);
|
||||
return;
|
||||
}
|
||||
jsonOut(res, 200, outcome.body, cfg.allowOrigin);
|
||||
} finally {
|
||||
this.inFlight--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The LLM half of a check (language gate → engine → LT JSON). Resolves to a
|
||||
* full HTTP outcome, never throws (internal errors become 500 outcomes).
|
||||
*/
|
||||
private async runLlmCheck(args: {
|
||||
cfg: Config;
|
||||
language: string;
|
||||
level: 'default' | 'picky';
|
||||
fullText: string;
|
||||
runs: TextRun[];
|
||||
/** shared cascade deadline (Date.now()+budget), or null for LLM-only. */
|
||||
cascadeDeadline: number | null;
|
||||
}): Promise<LlmOutcome> {
|
||||
const { cfg, language, level, fullText, runs, cascadeDeadline } = args;
|
||||
const { engine } = this.deps;
|
||||
|
||||
try {
|
||||
await engine.ensureReady();
|
||||
} catch { /* surfaced via status() */ }
|
||||
const st = engine.status();
|
||||
if (st.state !== 'ready') {
|
||||
const hint = st.state === 'error'
|
||||
? ` engine error: ${st.errorMessage ?? 'unknown'}`
|
||||
: ' (model still loading or downloading)';
|
||||
return { status: 503, body: { message: `grammar-llm engine not ready${hint}` } };
|
||||
}
|
||||
|
||||
try {
|
||||
// The LLM gets the budget that is LEFT of the shared cascade window
|
||||
// (or its own default in LLM-only mode), so the total response time
|
||||
// stays under LT-client caps like Overleaf's 60 s.
|
||||
const budgetMs = cascadeDeadline
|
||||
? Math.min(cfg.requestTimeoutMs, Math.max(1500, cascadeDeadline - Date.now()))
|
||||
: cfg.requestTimeoutMs;
|
||||
const { results, incomplete } = await engine.check(runs, {
|
||||
maxRuns: cfg.maxRuns,
|
||||
requestTimeoutMs: budgetMs,
|
||||
level,
|
||||
});
|
||||
|
||||
const matches: LtMatch[] = [];
|
||||
const sentenceRanges: Array<[number, number]> = [];
|
||||
results.forEach((r, i) => {
|
||||
for (const s of r.suggestions) {
|
||||
matches.push(...suggestionToMatches(fullText, runs[i], s));
|
||||
}
|
||||
for (const s of splitIntoSentences(runs[i].text)) {
|
||||
sentenceRanges.push([runs[i].offset + s.start, runs[i].offset + s.end]);
|
||||
}
|
||||
});
|
||||
sentenceRanges.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
this.log.info('LLM check completed', {
|
||||
language, level, runs: results.length,
|
||||
matches: matches.length, incomplete,
|
||||
budgetMs,
|
||||
});
|
||||
|
||||
const incompleteReason = incomplete
|
||||
? 'Check stopped early: per-request time budget exhausted. Only a subset of the document was checked.'
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: buildCheckResponse({
|
||||
fullText,
|
||||
matches,
|
||||
sentenceRanges,
|
||||
languageCode: language,
|
||||
incomplete: incompleteReason,
|
||||
reportedVersion: cfg.reportedVersion,
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
this.log.error('LLM check failed', { err: String(err) });
|
||||
return { status: 500, body: { message: 'grammar-llm engine failed during check' } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Race between the real-LT outcome and the LLM outcome with LT preference:
|
||||
* - LT resolves with matches → served immediately (LLM cancelled/ignored);
|
||||
* - LLM resolves with 200 → served immediately (LT is a lost cause then);
|
||||
* - a 400/503 LLM outcome is held back while the LT is still running;
|
||||
* it is served only if the LT also had nothing.
|
||||
*/
|
||||
private async resolveCascade(
|
||||
ltPromise: Promise<LtOutcome>,
|
||||
llmPromise: Promise<LlmOutcome>,
|
||||
onLltWin: () => void,
|
||||
/**
|
||||
* When the LLM is ready but the LT is still running, give the LT this many
|
||||
* extra ms (capped) to land a match before serving the LLM. Keeps "real LT
|
||||
* first" without waiting out the LT's full budget.
|
||||
*/
|
||||
ltExtraWaitMs = 1200,
|
||||
): Promise<{ status: number; body: unknown; ltWon: boolean }> {
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
// Tag each side so we can identify which promise settled (identity is not
|
||||
// preserved through Promise.race). The ids are unique, one per side.
|
||||
const ltTagged = ltPromise.then((outcome) => ({ id: 'lt' as const, outcome }));
|
||||
const llmTagged = llmPromise.then((outcome) => ({ id: 'llm' as const, outcome }));
|
||||
const pending: Array<Promise<{ id: 'lt' | 'llm'; outcome: LtOutcome | LlmOutcome }>> = [ltTagged, llmTagged];
|
||||
let staleLlm: { status: number; body: unknown } | undefined;
|
||||
|
||||
while (pending.length > 0) {
|
||||
const { id, outcome } = await Promise.race(pending);
|
||||
// Drop whichever entry settled, by its unique tag (not by object identity).
|
||||
const settledWrapper = id === 'lt' ? ltTagged : llmTagged;
|
||||
const at = pending.indexOf(settledWrapper);
|
||||
if (at >= 0) pending.splice(at, 1);
|
||||
|
||||
if (id === 'lt') {
|
||||
if (outcome.value) {
|
||||
onLltWin(); // cancel the LLM start if it has not begun yet
|
||||
return { status: 200, body: outcome.value, ltWon: true };
|
||||
}
|
||||
if (pending.length === 0) {
|
||||
return staleLlm
|
||||
? { status: staleLlm.status, body: staleLlm.body, ltWon: false }
|
||||
: { status: 200, body: { software: { name: 'LanguageTool', version: '6.8', apiVersion: 1 }, warnings: {}, matches: [] }, ltWon: false };
|
||||
}
|
||||
continue; // wait for the LLM
|
||||
}
|
||||
|
||||
// LLM outcome:
|
||||
if (outcome.status === 200) {
|
||||
if (pending.length > 0) {
|
||||
// The LT is still running: give it a short capped chance to win.
|
||||
const maybeLt = await Promise.race([
|
||||
pending[0].then((o) => o),
|
||||
sleep(Math.max(0, Math.min(ltExtraWaitMs, 1200))).then(() => null),
|
||||
]);
|
||||
if (maybeLt && maybeLt.id === 'lt' && maybeLt.outcome.value) {
|
||||
onLltWin();
|
||||
return { status: 200, body: maybeLt.outcome.value, ltWon: true };
|
||||
}
|
||||
}
|
||||
return { status: 200, body: outcome.body, ltWon: false };
|
||||
}
|
||||
staleLlm = { status: outcome.status, body: outcome.body };
|
||||
if (pending.length === 0) return { status: outcome.status, body: outcome.body, ltWon: false };
|
||||
}
|
||||
throw new Error('cascade resolved without an outcome');
|
||||
}
|
||||
}
|
||||
|
||||
export async function main(): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
const log = createLogger(cfg.logLevel);
|
||||
const engine = new CheckEngine(cfg, log);
|
||||
const lt = new LtServer({ cfg, engine, log });
|
||||
|
||||
if (cfg.preload) {
|
||||
if (cfg.backend === 'openai') {
|
||||
log.info('openai-compatible backend — no local model to preload', { baseUrl: cfg.openaiBaseUrl, model: cfg.openaiModel });
|
||||
} else {
|
||||
log.info('preloading model (first check waits for this to finish)…', { modelPath: cfg.modelPath });
|
||||
}
|
||||
engine.ensureReady().catch(() => {
|
||||
// surfaced via /v2/health and as 503s on /v2/check
|
||||
});
|
||||
}
|
||||
|
||||
await lt.listen(cfg.port, cfg.host);
|
||||
const shutdown = (sig: string) => {
|
||||
log.info(`${sig} received, shutting down`);
|
||||
void lt.close().then(() => process.exit(0));
|
||||
setTimeout(() => process.exit(1), 10_000).unref();
|
||||
};
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
}
|
||||
|
||||
// Auto-start when executed directly (node dist/server.mjs); no-op when imported by tests.
|
||||
const isMain = (() => {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) return false;
|
||||
try {
|
||||
return import.meta.url === pathToFileURL(entry).href;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (isMain) {
|
||||
main().catch((err) => {
|
||||
console.error('fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# grammar-llm container entrypoint.
|
||||
#
|
||||
# 1. Warn about LanguageTool-container env vars that don't apply here
|
||||
# (drop-in sanity: admins often copy the old container's env verbatim).
|
||||
# 2. Ensure the model file is present (download on first start by default).
|
||||
# 3. Exec the HTTP server.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# --- drop-in compatibility warnings ---------------------------------------
|
||||
while IFS= read -r name; do
|
||||
[ -n "$name" ] || continue
|
||||
echo "WARN: env '$name' is a LanguageTool (Java) setting; the grammar-llm container ignores it." >&2
|
||||
done < <(env | grep -Eo '^langtool_[A-Za-z0-9_]*' | sort -u)
|
||||
for var in Java_Xms Java_Xmx; do
|
||||
case "$var" in Java_Xms) val="${Java_Xms:-}" ;; Java_Xmx) val="${Java_Xmx:-}" ;; *) val="" ;; esac
|
||||
if [ -n "$val" ]; then
|
||||
echo "WARN: env '$var' is a LanguageTool (Java) setting; the grammar-llm container ignores it." >&2
|
||||
fi
|
||||
done
|
||||
|
||||
# --- configuration (defaults match docker-languagetool expectations) -------
|
||||
BACKEND="${GRAMMAR_LLM_BACKEND:-local}"
|
||||
MODEL_DIR="${GRAMMAR_LLM_MODEL_DIR:-/models}"
|
||||
MODEL_FILE="${GRAMMAR_LLM_MODEL_FILE:-GRMR-V3-G4B-Q4_K_M.gguf}"
|
||||
MODEL_URL="${GRAMMAR_LLM_MODEL_URL:-https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/GRMR-V3-G4B-Q4_K_M.gguf}"
|
||||
MODEL_PATH="$MODEL_DIR/$MODEL_FILE"
|
||||
DOWNLOAD_AT_START="${GRAMMAR_LLM_DOWNLOAD_AT_START:-true}"
|
||||
|
||||
export GRAMMAR_LLM_MODEL_DIR="$MODEL_DIR"
|
||||
export GRAMMAR_LLM_MODEL_FILE="$MODEL_FILE"
|
||||
export GRAMMAR_LLM_MODEL_URL="$MODEL_URL"
|
||||
|
||||
mkdir -p "$MODEL_DIR"
|
||||
|
||||
if [ "${BACKEND}" != "openai" ]; then
|
||||
if [ ! -s "$MODEL_PATH" ]; then
|
||||
if [ "$DOWNLOAD_AT_START" = "true" ]; then
|
||||
echo "INFO: model not found at $MODEL_PATH — downloading $(basename "$MODEL_PATH") ..."
|
||||
node "$APP_DIR/dist/download-model.mjs" --url "$MODEL_URL" --out "$MODEL_PATH"
|
||||
else
|
||||
echo "ERROR: model not found at $MODEL_PATH and GRAMMAR_LLM_DOWNLOAD_AT_START=false" >&2
|
||||
echo " Mount the model file into $MODEL_DIR or set GRAMMAR_LLM_DOWNLOAD_AT_START=true." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ ! -s "$MODEL_PATH" ]; then
|
||||
echo "ERROR: model still missing at $MODEL_PATH after download step" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "INFO: using model $MODEL_PATH (local backend)"
|
||||
else
|
||||
echo "INFO: backend=openai (model=${GRAMMAR_LLM_OPENAI_MODEL:-<unset>} base=${GRAMMAR_LLM_OPENAI_BASE_URL:-https://api.openai.com/v1}) — no local model needed"
|
||||
fi
|
||||
echo "INFO: starting grammar-llm server (LanguageTool-compatible API) on port ${PORT:-8010}"
|
||||
exec node "$APP_DIR/dist/server.mjs"
|
||||
@@ -1,303 +0,0 @@
|
||||
// Unit tests for the LanguageTool mapper & request resolution.
|
||||
// Runs against the esbuild bundle (dist/languagetool.mjs) with the vendored
|
||||
// core — no LLM, no network.
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildCheckResponse,
|
||||
getLanguages,
|
||||
normalizeLanguage,
|
||||
resolveCheckInput,
|
||||
suggestionToMatches,
|
||||
} from '../dist/languagetool.mjs';
|
||||
|
||||
const RUN = { text: 'He said teh thing.', offset: 0 };
|
||||
|
||||
function suggestionWith(fixes, original = 'He said teh thing.', corrected = 'He said the thing.') {
|
||||
return {
|
||||
original,
|
||||
corrected,
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: original.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: fixes,
|
||||
};
|
||||
}
|
||||
|
||||
test('replacement word fix → LT match with absolute offset', () => {
|
||||
const matches = suggestionToMatches(RUN.text, RUN, suggestionWith([{ original: 'teh', corrected: 'the', start: 8, end: 11 }]));
|
||||
assert.equal(matches.length, 1);
|
||||
const m = matches[0];
|
||||
assert.equal(m.offset, 8);
|
||||
assert.equal(m.length, 3);
|
||||
assert.deepEqual(m.replacements, [{ value: 'the' }]);
|
||||
assert.match(m.message, /'the'/);
|
||||
assert.equal(m.rule.category.id, 'GRAMMAR'); // must not be 'TYPOS' (Overleaf filter)
|
||||
assert.equal(m.context.text.slice(m.context.offset, m.context.offset + m.length), 'teh');
|
||||
});
|
||||
|
||||
test('deletion word fix → empty replacement', () => {
|
||||
const text = 'He said very very thing.';
|
||||
const run = { text, offset: 0 };
|
||||
const matches = suggestionToMatches(text, run, {
|
||||
original: text,
|
||||
corrected: 'He said very thing.',
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: text.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: 'very', corrected: '', start: 13, end: 17 }],
|
||||
});
|
||||
assert.equal(matches.length, 1);
|
||||
assert.equal(matches[0].offset, 13);
|
||||
assert.equal(matches[0].length, 4);
|
||||
assert.deepEqual(matches[0].replacements, [{ value: '' }]);
|
||||
assert.match(matches[0].message, /Remove/);
|
||||
});
|
||||
|
||||
test('punctuation insertion after a word attaches to the previous word (core ground truth)', () => {
|
||||
// core emit for 'slow and happy' -> 'slowly, and happy':
|
||||
// slow->slowly [0,4) + pure insertion ',' at pos 4
|
||||
const text = 'slow and happy';
|
||||
const run = { text, offset: 0 };
|
||||
const matches = suggestionToMatches(text, run, {
|
||||
original: text,
|
||||
corrected: 'slowly, and happy',
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: text.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: 'slow', corrected: 'slowly', start: 0, end: 4 }, { original: '', corrected: ',', start: 4, end: 4 }],
|
||||
});
|
||||
const m = matches[1];
|
||||
assert.equal(m.offset, 4);
|
||||
assert.equal(m.length, 4); // covers ' and' (gap + token)
|
||||
assert.equal(m.replacements[0].value, ', and');
|
||||
// Apply both matches (reverse offset order) to the original doc.
|
||||
let doc = text;
|
||||
for (const m2 of [...matches].sort((a, b) => b.offset - a.offset)) {
|
||||
doc = doc.slice(0, m2.offset) + m2.replacements[0].value + doc.slice(m2.offset + m2.length);
|
||||
}
|
||||
assert.equal(doc, 'slowly, and happy');
|
||||
});
|
||||
|
||||
test('word insertion before a token keeps spacing (core ground truth)', () => {
|
||||
// core emit for 'quick and happy' -> 'quick and very happy':
|
||||
// pure insertion 'very ' at pos 10 (before 'happy')
|
||||
const text = 'quick and happy';
|
||||
const run = { text, offset: 0 };
|
||||
const matches = suggestionToMatches(text, run, {
|
||||
original: text,
|
||||
corrected: 'quick and very happy',
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: text.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: '', corrected: 'very ', start: 10, end: 10 }],
|
||||
});
|
||||
const m = matches[0];
|
||||
assert.equal(m.offset, 10);
|
||||
assert.equal(m.length, 5); // token 'happy'
|
||||
assert.equal(m.replacements[0].value, 'very happy');
|
||||
const result = text.slice(0, m.offset) + m.replacements[0].value + text.slice(m.offset + m.length);
|
||||
assert.equal(result, 'quick and very happy');
|
||||
});
|
||||
|
||||
test('punctuation insertion right before a token (no gap)', () => {
|
||||
const text = 'He ate andbutter fast';
|
||||
const run = { text, offset: 0 };
|
||||
const matches = suggestionToMatches(text, run, {
|
||||
original: text,
|
||||
corrected: 'He ate and,butter fast',
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: text.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: '', corrected: ',', start: 10, end: 10 }],
|
||||
});
|
||||
const m = matches[0];
|
||||
assert.equal(m.offset, 10);
|
||||
assert.equal(m.length, 6); // token 'butter'
|
||||
assert.equal(m.replacements[0].value, ',butter');
|
||||
const result = text.slice(0, m.offset) + m.replacements[0].value + text.slice(m.offset + m.length);
|
||||
assert.equal(result, 'He ate and,butter fast');
|
||||
});
|
||||
|
||||
test('insertion at run end anchors on last token', () => {
|
||||
const text = 'He ran quick';
|
||||
const run = { text, offset: 0 };
|
||||
const matches = suggestionToMatches(text, run, {
|
||||
original: text,
|
||||
corrected: 'He ran quick.',
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: text.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: '', corrected: '.', start: 12, end: 12 }],
|
||||
});
|
||||
const m = matches[0];
|
||||
const result = text.slice(0, m.offset) + m.replacements[0].value + text.slice(m.offset + m.length);
|
||||
assert.equal(result, 'He ran quick.');
|
||||
});
|
||||
|
||||
test('model quote artifacts are unwrapped from replacements', () => {
|
||||
// GRMR sometimes wraps the corrected word in quotes: 'goed' -> '"good"'
|
||||
const text = 'The cat is goed.';
|
||||
const run = { text, offset: 0 };
|
||||
for (const corrected of ['"good"', '\u201Cgood\u201D', "'good'"]) {
|
||||
const matches = suggestionToMatches(text, run, {
|
||||
original: text,
|
||||
corrected: 'The cat is good.',
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: text.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: 'goed', corrected, start: 11, end: 15 }],
|
||||
});
|
||||
assert.equal(matches.length, 1, 'one match for ' + corrected);
|
||||
assert.equal(matches[0].replacements[0].value, 'good');
|
||||
}
|
||||
// …same for a different word — a grammar model quoting its replacement is a
|
||||
// display artifact, not a request to add quotes to the document
|
||||
const m2 = suggestionToMatches(text, run, {
|
||||
original: text,
|
||||
corrected: 'The cat is wonderful.',
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: text.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: 'goed', corrected: '"wonderful"', start: 11, end: 15 }],
|
||||
});
|
||||
assert.equal(m2[0].replacements[0].value, 'wonderful');
|
||||
});
|
||||
|
||||
test('sentence-level rewrite (no word fixes) → partial-span replacement, offset-rebased', () => {
|
||||
const text = 'The committee met every day';
|
||||
const run = { text, offset: 100 };
|
||||
const s = suggestionWith([], 'The committee met every day', 'The committee has met daily');
|
||||
const matches = suggestionToMatches(text, run, s);
|
||||
assert.equal(matches.length, 1);
|
||||
const m = matches[0];
|
||||
const result = text.slice(0, m.offset - 100) + m.replacements[0].value + text.slice(m.offset - 100 + m.length);
|
||||
assert.equal(result, 'The committee has met daily');
|
||||
assert.ok(m.offset >= 100); // rebased onto the document
|
||||
});
|
||||
|
||||
test('matches outside the run text are dropped', () => {
|
||||
const run = { text: 'short', offset: 0 };
|
||||
const matches = suggestionToMatches('short', run, suggestionWith([{ original: 'x', corrected: 'y', start: 99, end: 100 }]));
|
||||
assert.equal(matches.length, 0);
|
||||
});
|
||||
|
||||
test('resolveCheckInput: plain text param', () => {
|
||||
const r = resolveCheckInput({ text: 'Hello world' });
|
||||
assert.ok(!('error' in r));
|
||||
assert.equal(r.text, 'Hello world');
|
||||
assert.deepEqual(r.runs, [{ text: 'Hello world', offset: 0 }]);
|
||||
});
|
||||
|
||||
test('resolveCheckInput: annotation (Overleaf LaTeX payload) keeps offset alignment', () => {
|
||||
const annotation = [
|
||||
{ text: 'The dog ' },
|
||||
{ markup: '{\\textbf{runs}} ' },
|
||||
{ text: 'fast.' },
|
||||
];
|
||||
const r = resolveCheckInput({ data: JSON.stringify({ annotation }) });
|
||||
assert.ok(!('error' in r));
|
||||
assert.equal(r.text, 'The dog {\\textbf{runs}} fast.');
|
||||
assert.deepEqual(r.runs, [
|
||||
{ text: 'The dog ', offset: 0 },
|
||||
{ text: 'fast.', offset: 24 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveCheckInput: { text } data object', () => {
|
||||
const r = resolveCheckInput({ data: { text: 'abc' } });
|
||||
assert.equal(r.text, 'abc');
|
||||
assert.deepEqual(r.runs, [{ text: 'abc', offset: 0 }]);
|
||||
});
|
||||
|
||||
test('resolveCheckInput: missing text and data → error', () => {
|
||||
const r = resolveCheckInput({});
|
||||
assert.ok('error' in r);
|
||||
assert.match(r.error, /Missing/);
|
||||
});
|
||||
|
||||
test('resolveCheckInput: non-JSON string data is treated as text', () => {
|
||||
const r = resolveCheckInput({ data: 'plain string data' });
|
||||
assert.equal(r.text, 'plain string data');
|
||||
});
|
||||
|
||||
test('normalizeLanguage: auto + en variants accepted, de rejected', () => {
|
||||
assert.equal(normalizeLanguage(null).code, 'en-US');
|
||||
assert.equal(normalizeLanguage('auto').code, 'en-US');
|
||||
assert.equal(normalizeLanguage('en-GB').code, 'en-GB');
|
||||
assert.equal(normalizeLanguage('EN').code, 'en');
|
||||
assert.equal(normalizeLanguage('de'), null);
|
||||
});
|
||||
|
||||
test('getLanguages: LanguageTool list shape (name/code/longCode)', () => {
|
||||
const langs = getLanguages();
|
||||
assert.ok(Array.isArray(langs));
|
||||
for (const l of langs) {
|
||||
assert.ok(typeof l.name === 'string');
|
||||
assert.ok(typeof l.code === 'string');
|
||||
assert.ok(typeof l.longCode === 'string');
|
||||
}
|
||||
assert.ok(langs.some((l) => l.code === 'en-US'));
|
||||
});
|
||||
|
||||
test('buildCheckResponse: LT envelope shape + sorted matches + warnings', () => {
|
||||
const body = buildCheckResponse({
|
||||
fullText: 'He said teh thing.',
|
||||
matches: [
|
||||
{
|
||||
message: 'm2',
|
||||
replacements: [{ value: 'b' }],
|
||||
offset: 10,
|
||||
length: 1,
|
||||
context: { text: 'x', offset: 0, length: 1 },
|
||||
type: { typeName: 'grammar' },
|
||||
rule: { id: 'GRAMMAR_LLM', description: 'd', issueType: 'Grammar', category: { id: 'GRAMMAR', name: 'Grammar' } },
|
||||
ignoreForIncompleteSentence: false,
|
||||
contextForSureMatch: -1,
|
||||
},
|
||||
{
|
||||
message: 'm1',
|
||||
replacements: [{ value: 'a' }],
|
||||
offset: 3,
|
||||
length: 1,
|
||||
context: { text: 'x', offset: 0, length: 1 },
|
||||
type: { typeName: 'grammar' },
|
||||
rule: { id: 'GRAMMAR_LLM', description: 'd', issueType: 'Grammar', category: { id: 'GRAMMAR', name: 'Grammar' } },
|
||||
ignoreForIncompleteSentence: false,
|
||||
contextForSureMatch: -1,
|
||||
},
|
||||
],
|
||||
sentenceRanges: [
|
||||
[0, 18],
|
||||
],
|
||||
languageCode: 'en-GB',
|
||||
incomplete: 'stopped early',
|
||||
});
|
||||
assert.equal(body.software.name, 'LanguageTool');
|
||||
assert.equal(body.software.apiVersion, 1);
|
||||
assert.equal(body.software.premium, false);
|
||||
assert.equal(body.language.code, 'en-GB');
|
||||
assert.equal(body.language.detectedLanguage.source, 'given');
|
||||
assert.deepEqual(body.matches.map((m) => m.offset), [3, 10]);
|
||||
assert.equal(body.warnings.incompleteResults, true);
|
||||
assert.equal(body.warnings.incompleteResultsReason, 'stopped early');
|
||||
assert.deepEqual(body.sentenceRanges, [[0, 18]]);
|
||||
});
|
||||
@@ -1,737 +0,0 @@
|
||||
// HTTP API tests with a deterministic fake engine (no LLM, no network).
|
||||
// Mirrors how Overleaf's LanguageToolController talks to a LT-compatible box:
|
||||
// GET /v2/languages
|
||||
// POST /v2/check (x-www-form-urlencoded: language, level, data/annotation,
|
||||
// disabledRules — see LanguageToolController.mjs)
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { LtServer } from '../dist/server.mjs';
|
||||
import { CheckEngine } from '../dist/server.mjs';
|
||||
import { loadConfig } from '../dist/config.mjs';
|
||||
import http from 'node:http';
|
||||
|
||||
function makeConfig(over = {}) {
|
||||
const env = {
|
||||
PORT: '8010',
|
||||
HOST: '127.0.0.1',
|
||||
GRAMMAR_LLM_MODEL_DIR: '/tmp',
|
||||
GRAMMAR_LLM_PRELOAD: 'false',
|
||||
GRAMMAR_LLM_LOG_LEVEL: 'warn',
|
||||
...over,
|
||||
};
|
||||
return loadConfig(env);
|
||||
}
|
||||
|
||||
/** Deterministic "engine": corrects the literal word 'teh' → 'the' wherever it
|
||||
* appears, and inserts a comma after 'quick'. */
|
||||
class FakeEngine {
|
||||
state = 'ready';
|
||||
statusError = null;
|
||||
checkCalls = 0;
|
||||
lastLevel = null;
|
||||
constructor() {}
|
||||
|
||||
status() {
|
||||
return {
|
||||
state: this.state,
|
||||
errorMessage: this.statusError,
|
||||
modelPath: '/tmp/fake.gguf',
|
||||
checksCompleted: 0,
|
||||
checksFailed: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async ensureReady() {
|
||||
if (this.state !== 'ready') throw new Error('model not ready');
|
||||
}
|
||||
|
||||
async check(runs, opts = {}) {
|
||||
if (this.state !== 'ready') throw new Error('not ready');
|
||||
this.checkCalls++;
|
||||
this.lastLevel = opts.level ?? null;
|
||||
const results = [];
|
||||
for (const run of runs) {
|
||||
const out = { suggestions: [], correctedText: run.text, incomplete: false };
|
||||
const t = run.text;
|
||||
const idx = t.indexOf('teh');
|
||||
if (idx >= 0) {
|
||||
const corrected = t.slice(0, idx) + 'the' + t.slice(idx + 3);
|
||||
out.correctedText = corrected;
|
||||
out.suggestions = [
|
||||
{
|
||||
original: t,
|
||||
corrected,
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: t.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: 'teh', corrected: 'the', start: idx, end: idx + 3 }],
|
||||
},
|
||||
];
|
||||
}
|
||||
const kidx = t.indexOf('quick');
|
||||
if (kidx >= 0 && out.suggestions.length === 0) {
|
||||
// insertion after 'quick' (pos = kidx+5)
|
||||
const pos = kidx + 5;
|
||||
const corrected = t.slice(0, pos) + ',' + t.slice(pos);
|
||||
out.correctedText = corrected;
|
||||
out.suggestions = [
|
||||
{
|
||||
original: t,
|
||||
corrected,
|
||||
sentence: 'Sentence 1',
|
||||
start_index: 0,
|
||||
end_index: t.length,
|
||||
original_highlighted: '',
|
||||
corrected_highlighted: '',
|
||||
wordFixes: [{ original: '', corrected: ',', start: pos, end: pos }],
|
||||
},
|
||||
];
|
||||
}
|
||||
results.push(out);
|
||||
}
|
||||
return { results, incomplete: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function boot(_over = {}, engineOverride, cfgOver = {}) {
|
||||
const cfg = makeConfig(cfgOver);
|
||||
const engine = engineOverride ?? new FakeEngine();
|
||||
const server = new LtServer({ cfg, engine });
|
||||
await server.listen(0, '127.0.0.1');
|
||||
const port = server.httpServer.address().port;
|
||||
return { cfg, engine, server, base: `http://127.0.0.1:${port}` };
|
||||
}
|
||||
|
||||
async function close(server) {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
test('GET /v2/languages: LT shape (Overleaf reads name+code)', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/languages`);
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json());
|
||||
assert.ok(Array.isArray(body));
|
||||
assert.ok(body.some((l) => l.code === 'en-US'));
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /v2/check (Overleaf style: form + annotation data)', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const source = 'He wrote \\textbf{report} about teh project.';
|
||||
const annotation = [
|
||||
{ text: 'He wrote ' },
|
||||
{ markup: '\\textbf{report} ' },
|
||||
{ text: 'about teh project.' },
|
||||
];
|
||||
const params = new URLSearchParams();
|
||||
params.set('language', 'auto');
|
||||
params.set('level', 'picky');
|
||||
params.set('data', JSON.stringify({ annotation }));
|
||||
params.set('disabledRules', 'WHITESPACE_RULE,CONSECUTIVE_SPACES');
|
||||
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||
body: params.toString(),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json());
|
||||
|
||||
assert.equal(body.software.name, 'LanguageTool');
|
||||
assert.equal(body.software.apiVersion, 1);
|
||||
assert.equal(body.warnings.incompleteResults, false);
|
||||
assert.equal(body.language.code, 'en-US');
|
||||
|
||||
// 'teh' is at absolute offset: len('He wrote ') + len('\\textbf{report} ')
|
||||
const baseOffset = 'He wrote '.length + '\\textbf{report} '.length;
|
||||
assert.equal(body.matches.length, 1);
|
||||
const m = body.matches[0];
|
||||
assert.equal(m.offset, baseOffset + 'about '.length);
|
||||
assert.equal(m.length, 3);
|
||||
assert.deepEqual(m.replacements, [{ value: 'the' }]);
|
||||
assert.equal(m.rule.category.id, 'GRAMMAR');
|
||||
assert.ok(m.context.text.includes('teh'));
|
||||
// overleaf invariant: offset within doc
|
||||
assert.ok(m.offset + m.length <= source.length);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /v2/check with JSON body', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ language: 'en-GB', level: 'default', text: 'I has teh book.' }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json());
|
||||
assert.equal(body.language.code, 'en-GB');
|
||||
assert.equal(body.matches.length, 1);
|
||||
assert.equal(body.matches[0].offset, 6); // 'has ' before 'teh' → 'I has teh' → t@6
|
||||
assert.deepEqual(body.matches[0].replacements, [{ value: 'the' }]);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /v2/check: no text and no data → 400', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en',
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
const body = (await res.json());
|
||||
assert.match(body.message, /Missing/);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /v2/check: too-long text → 413', async (t) => {
|
||||
const { server, base } = await boot({}, undefined, { GRAMMAR_LLM_MAX_TEXT_LENGTH: '3000' });
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&text=' + encodeURIComponent('a'.repeat(3500)),
|
||||
});
|
||||
assert.equal(res.status, 413);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /v2/check: unsupported language → empty matches (default mode)', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=de&text=' + encodeURIComponent('Ich habe teh Buch.'),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json());
|
||||
assert.deepEqual(body.matches, []);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /v2/check: unsupported language → 400 (error mode)', async (t) => {
|
||||
const { server, base } = await boot({}, undefined, { GRAMMAR_LLM_UNSUPPORTED_LANGUAGE: 'error' });
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=de&text=' + encodeURIComponent('Ich habe teh Buch.'),
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /v2/check: engine not ready → 503 with message', async (t) => {
|
||||
const engine = new FakeEngine();
|
||||
engine.state = 'loading';
|
||||
const { server, base } = await boot({}, engine);
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&text=' + encodeURIComponent('I has teh book.'),
|
||||
});
|
||||
assert.equal(res.status, 503);
|
||||
const body = (await res.json());
|
||||
assert.match(body.message, /not ready/);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('GET /v2/health reflects engine state', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/health`);
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json());
|
||||
assert.equal(body.status, 'ok');
|
||||
assert.equal(body.state, 'ready');
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('GET /v2/health: not ready → 503', async (t) => {
|
||||
const engine = new FakeEngine();
|
||||
engine.state = 'error';
|
||||
engine.statusError = 'boom';
|
||||
const { server, base } = await boot({}, engine);
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/health`);
|
||||
assert.equal(res.status, 503);
|
||||
const body = (await res.json());
|
||||
assert.equal(body.state, 'error');
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('GET /v2/version and / info payloads', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const v = (await (await fetch(`${base}/v2/version`)).json());
|
||||
assert.equal(v.name, 'LanguageTool');
|
||||
assert.ok(v.engine);
|
||||
|
||||
const i = (await (await fetch(`${base}/`)).json());
|
||||
assert.ok(Array.isArray(i.endpoints));
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown /v2 endpoint → 404 JSON', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/nope`);
|
||||
assert.equal(res.status, 404);
|
||||
const body = (await res.json());
|
||||
assert.match(body.message, /unknown v2 endpoint/);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('OPTIONS preflight → CORS headers', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, { method: 'OPTIONS' });
|
||||
assert.ok([200, 204].includes(res.status));
|
||||
assert.ok((res.headers.get('access-control-allow-origin') ?? '').includes('*'));
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('responses carry CORS + no-store headers (browser client compat)', async (t) => {
|
||||
const { server, base } = await boot();
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/languages`);
|
||||
assert.ok((res.headers.get('access-control-allow-origin') ?? '').includes('*'));
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store');
|
||||
assert.match(res.headers.get('content-type') ?? '', /application\/json/);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// New: LT-first cascade, LLM level modes, OpenAI-compatible remote backend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** In-process fake LanguageTool (v2 /check). Optional {delayMs} to simulate a slow LT. */
|
||||
function fakeLt(handler, { delayMs = 0 } = {}) {
|
||||
const seen = { bodies: [] };
|
||||
const server = http.createServer((req, res) => {
|
||||
let raw = '';
|
||||
req.on('data', (c) => (raw += c));
|
||||
req.on('end', () => {
|
||||
const body = new URLSearchParams(raw);
|
||||
seen.bodies.push(Object.fromEntries(body.entries()));
|
||||
const done = () => {
|
||||
const out = handler(body, seen.bodies.length - 1) ?? { matches: [] };
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(out));
|
||||
};
|
||||
if (delayMs > 0) setTimeout(done, delayMs);
|
||||
else done();
|
||||
});
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = server.address().port;
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
seen,
|
||||
close: () => new Promise((r) => server.close(() => r())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** In-process fake OpenAI-compatible chat/completions endpoint. */
|
||||
function fakeOpenAi(corrections = {}) {
|
||||
const seen = { requests: [] };
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === 'GET' && req.url?.endsWith('/models')) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end('{"data":[{"id":"fake"}]}');
|
||||
return;
|
||||
}
|
||||
let raw = '';
|
||||
req.on('data', (c) => (raw += c));
|
||||
req.on('end', async () => {
|
||||
const json = JSON.parse(raw || '{}');
|
||||
seen.requests.push(json);
|
||||
const user = json.messages?.[1]?.content ?? '';
|
||||
const corrected = corrections[user] ?? user;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
choices: [{ message: { content: corrected } }],
|
||||
}));
|
||||
});
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = server.address().port;
|
||||
resolve({
|
||||
baseUrl: `http://127.0.0.1:${port}/v1`,
|
||||
seen,
|
||||
close: () => new Promise((r) => server.close(() => r())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('cascade: real LT produces matches → LT wins, LLM skipped, level forwarded', async () => {
|
||||
const lt = await fakeLt(() => ({
|
||||
software: { name: 'LanguageTool', version: '6.4' },
|
||||
warnings: { incompleteResults: false },
|
||||
matches: [
|
||||
{
|
||||
message: 'FROM-REAL-LT',
|
||||
shortMessage: 'LT-match',
|
||||
replacements: [{ value: 'the' }],
|
||||
offset: 2,
|
||||
length: 3,
|
||||
context: { text: 'I teh has', offset: 0, length: 9 },
|
||||
type: { typeName: 'Typographical' },
|
||||
rule: { id: 'RULE_LT', description: 'x', issueType: 'Typo', category: { id: 'TYPOS', name: 'Typos' } },
|
||||
},
|
||||
],
|
||||
}));
|
||||
try {
|
||||
const engine = new FakeEngine();
|
||||
const { server, base } = await boot(
|
||||
{},
|
||||
engine,
|
||||
{ GRAMMAR_LLM_LANGUAGETOOL_URL: lt.url, GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '2000' },
|
||||
);
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&level=picky&text=' + encodeURIComponent('I teh has cats'),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.matches.length, 1);
|
||||
assert.equal(body.matches[0].message, 'FROM-REAL-LT');
|
||||
assert.equal(body.software.version, '6.4');
|
||||
assert.equal(engine.checkCalls, 0, 'LLM must be skipped when LT has matches');
|
||||
// the picky flag must reach the real LT verbatim:
|
||||
assert.equal(lt.seen.bodies[0].level, 'picky');
|
||||
assert.equal(lt.seen.bodies[0].language, 'en');
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
} finally {
|
||||
await lt.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('cascade: real LT finds nothing → LLM runs second (fallback)', async () => {
|
||||
const lt = await fakeLt(() => ({ matches: [] }));
|
||||
try {
|
||||
const engine = new FakeEngine();
|
||||
const { server, base } = await boot(
|
||||
{},
|
||||
engine,
|
||||
{ GRAMMAR_LLM_LANGUAGETOOL_URL: lt.url, GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '2000' },
|
||||
);
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&level=default&text=' + encodeURIComponent('I teh has cats'),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.matches.length, 1, 'fallback LLM match expected');
|
||||
assert.match(body.matches[0].message, /the/);
|
||||
assert.equal(engine.checkCalls, 1, 'LLM must run exactly once as fallback');
|
||||
assert.equal(lt.seen.bodies[0].level, 'default');
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
} finally {
|
||||
await lt.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('cascade: real LT down → LLM fallback still returns 200', async () => {
|
||||
const engine = new FakeEngine();
|
||||
const { server, base } = await boot(
|
||||
{},
|
||||
engine,
|
||||
{
|
||||
GRAMMAR_LLM_LANGUAGETOOL_URL: 'http://127.0.0.1:9', // closed port
|
||||
GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '800',
|
||||
},
|
||||
);
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&text=' + encodeURIComponent('I teh has cats'),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.matches.length, 1);
|
||||
assert.equal(engine.checkCalls, 1);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
|
||||
test('cascade race: slow empty LT does NOT block the LLM (the 71 s bug)', async () => {
|
||||
// The real-world bug: LT took (or was given) a long time and answered "0
|
||||
// matches"; the LLM then ran AFTER it, pushing the total past Overleaf's
|
||||
// 60 s controller timeout → the UI silently showed nothing.
|
||||
// With the shared-budget concurrent cascade the LLM must be served as soon
|
||||
// as IT is ready, even while the LT is still running.
|
||||
const lt = await fakeLt(() => ({ matches: [] }), { delayMs: 2000 });
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const engine = new FakeEngine();
|
||||
const origCheck = engine.check.bind(engine);
|
||||
// make the LLM take ~300 ms so it settles well before the 2 s LT
|
||||
engine.check = async (runs, opts = {}) => {
|
||||
await sleep(300);
|
||||
return origCheck(runs, opts);
|
||||
};
|
||||
const { server, base } = await boot(
|
||||
{},
|
||||
engine,
|
||||
{
|
||||
GRAMMAR_LLM_LANGUAGETOOL_URL: lt.url,
|
||||
GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '4000',
|
||||
GRAMMAR_LLM_CASCADE_BUDGET_MS: '6000',
|
||||
GRAMMAR_LLM_LLM_GRACE_MS: '50', // LLM starts quickly, as in production
|
||||
},
|
||||
);
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&text=' + encodeURIComponent('I teh has cats'),
|
||||
});
|
||||
const elapsed = Date.now() - t0;
|
||||
const body = await res.json();
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(body.matches.length >= 1, 'LLM fallback match must be present');
|
||||
assert.ok(
|
||||
elapsed < 2000,
|
||||
`LLM result must be served before the slow empty LT settles (elapsed ${elapsed} ms; LT was 2000 ms)`,
|
||||
);
|
||||
assert.equal(engine.checkCalls, 1, 'the LLM ran');
|
||||
} finally {
|
||||
await close(server);
|
||||
await lt.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('cascade race: LT with matches still wins even when slower than the LLM grace', async () => {
|
||||
const lt = await fakeLt(() => ({
|
||||
software: { name: 'LanguageTool', version: '6.4' },
|
||||
warnings: { incompleteResults: false },
|
||||
matches: [
|
||||
{
|
||||
message: 'FROM-REAL-LT',
|
||||
replacements: [{ value: 'the' }],
|
||||
offset: 2,
|
||||
length: 3,
|
||||
context: { text: 'I teh has', offset: 0, length: 9 },
|
||||
type: { typeName: 'Typographical' },
|
||||
rule: { id: 'RULE_LT', category: { id: 'TYPOS', name: 'Typos' } },
|
||||
},
|
||||
],
|
||||
}), { delayMs: 700 });
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const engine = new FakeEngine();
|
||||
const origCheck = engine.check.bind(engine);
|
||||
engine.check = async (runs, opts = {}) => {
|
||||
await sleep(200); // LLM would "finish" first — LT (700 ms) must still win
|
||||
return origCheck(runs, opts);
|
||||
};
|
||||
const { server, base } = await boot(
|
||||
{},
|
||||
engine,
|
||||
{
|
||||
GRAMMAR_LLM_LANGUAGETOOL_URL: lt.url,
|
||||
GRAMMAR_LLM_LANGUAGETOOL_TIMEOUT_MS: '4000',
|
||||
GRAMMAR_LLM_CASCADE_BUDGET_MS: '6000',
|
||||
GRAMMAR_LLM_LLM_GRACE_MS: '50',
|
||||
},
|
||||
);
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&level=picky&text=' + encodeURIComponent('I teh has cats'),
|
||||
});
|
||||
const body = await res.json();
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(body.matches.length, 1);
|
||||
assert.equal(body.matches[0].message, 'FROM-REAL-LT', 'the LT match must win');
|
||||
} finally {
|
||||
await close(server);
|
||||
await lt.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('level: absent/invalid → cfg.defaultLevel; explicit respected (LLM receives it)', async () => {
|
||||
const mk = (env) => {
|
||||
const engine = new FakeEngine();
|
||||
return { engine, env: { ...env } };
|
||||
};
|
||||
// absent level
|
||||
{
|
||||
const { engine } = mk({});
|
||||
const { server, base } = await boot({}, engine, { GRAMMAR_LLM_DEFAULT_LEVEL: 'picky' });
|
||||
await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&text=' + encodeURIComponent('I teh has'),
|
||||
});
|
||||
assert.equal(engine.lastLevel, 'picky');
|
||||
await close(server);
|
||||
}
|
||||
// invalid level → falls back to defaultLevel
|
||||
{
|
||||
const { engine } = mk({});
|
||||
const { server, base } = await boot({}, engine, { GRAMMAR_LLM_DEFAULT_LEVEL: 'picky' });
|
||||
await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&level=bogus&text=' + encodeURIComponent('I teh has'),
|
||||
});
|
||||
assert.equal(engine.lastLevel, 'picky');
|
||||
await close(server);
|
||||
}
|
||||
// explicit picky
|
||||
{
|
||||
const { engine } = mk({});
|
||||
const { server, base } = await boot({}, engine, {});
|
||||
await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&level=picky&text=' + encodeURIComponent('I teh has'),
|
||||
});
|
||||
assert.equal(engine.lastLevel, 'picky');
|
||||
await close(server);
|
||||
}
|
||||
// explicit default
|
||||
{
|
||||
const { engine } = mk({});
|
||||
const { server, base } = await boot({}, engine, {});
|
||||
await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&level=default&text=' + encodeURIComponent('I teh has'),
|
||||
});
|
||||
assert.equal(engine.lastLevel, 'default');
|
||||
await close(server);
|
||||
}
|
||||
void mk;
|
||||
});
|
||||
|
||||
test('openai backend: real engine pipeline against a fake OpenAI endpoint (default level prompt)', async () => {
|
||||
const fake = await fakeOpenAi({ 'He run fast to school.': 'He ran fast to school.' });
|
||||
try {
|
||||
const cfg = makeConfig({
|
||||
GRAMMAR_LLM_BACKEND: 'openai',
|
||||
GRAMMAR_LLM_OPENAI_MODEL: 'fake-model-123',
|
||||
GRAMMAR_LLM_OPENAI_BASE_URL: fake.baseUrl,
|
||||
GRAMMAR_LLM_OPENAI_TIMEOUT_MS: '5000',
|
||||
GRAMMAR_LLM_DEFAULT_LEVEL: 'default',
|
||||
});
|
||||
const log = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
|
||||
const engine = new CheckEngine(cfg, log);
|
||||
const server = new LtServer({ cfg, engine, log });
|
||||
await server.listen(0, '127.0.0.1');
|
||||
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
try {
|
||||
const res = await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&text=' + encodeURIComponent('He run fast to school.'),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.matches.length, 1, 'core diff must produce the ran-fix');
|
||||
assert.equal(body.matches[0].replacements[0].value, 'ran');
|
||||
// request shape: model + system prompt
|
||||
const req = fake.seen.requests[0];
|
||||
assert.equal(req.model, 'fake-model-123');
|
||||
assert.equal(req.messages[0].role, 'system');
|
||||
assert.match(req.messages[0].content, /grammar correction assistant/);
|
||||
assert.doesNotMatch(req.messages[0].content, /highly picky/);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('openai backend: picky level gets the stricter prompt; default does not', async () => {
|
||||
const fake = await fakeOpenAi({ 'He run fast to school.': 'He ran fast to school.' });
|
||||
try {
|
||||
const mk = (defaultLevel) => {
|
||||
const cfg = makeConfig({
|
||||
GRAMMAR_LLM_BACKEND: 'openai',
|
||||
GRAMMAR_LLM_OPENAI_MODEL: 'fake-model-123',
|
||||
GRAMMAR_LLM_OPENAI_BASE_URL: fake.baseUrl,
|
||||
GRAMMAR_LLM_OPENAI_TIMEOUT_MS: '5000',
|
||||
GRAMMAR_LLM_DEFAULT_LEVEL: defaultLevel,
|
||||
});
|
||||
const log = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
|
||||
const engine = new CheckEngine(cfg, log);
|
||||
return new LtServer({ cfg, engine, log });
|
||||
};
|
||||
// explicit picky
|
||||
{
|
||||
const server = mk('default');
|
||||
await server.listen(0, '127.0.0.1');
|
||||
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
await fetch(`${base}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'language=en&level=picky&text=' + encodeURIComponent('He run fast to school.'),
|
||||
});
|
||||
await server.close();
|
||||
}
|
||||
const pickyReq = fake.seen.requests[fake.seen.requests.length - 1];
|
||||
assert.match(pickyReq.messages[0].content, /highly picky/);
|
||||
assert.match(pickyReq.messages[0].content, /grammar correction assistant/);
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# smoke.sh — end-to-end check against a RUNNING grammar-llm server.
|
||||
#
|
||||
# This does NOT start the server (it would need the model). Run a server
|
||||
# first, e.g.:
|
||||
#
|
||||
# node dist/download-model.mjs --out /tmp/models/GRMR-V3-G4B-Q4_K_M.gguf \
|
||||
# --url https://huggingface.co/icecubetr/GRMR-V3-G4B-GGUF/resolve/main/GRMR-V3-G4B-Q4_K_M.gguf
|
||||
# GRAMMAR_LLM_MODEL_DIR=/tmp/models PORT=8090 node dist/server.mjs &
|
||||
#
|
||||
# then:
|
||||
#
|
||||
# BASE=http://127.0.0.1:8090 npm run smoke
|
||||
#
|
||||
# or against the docker container:
|
||||
#
|
||||
# BASE=http://127.0.0.1:8010 npm run smoke
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${BASE:-http://127.0.0.1:8010}"
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
say() { printf '\n\033[1m== %s ==\033[0m\n' "$*"; }
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; pass=$((pass+1)); }
|
||||
bad() { printf ' \033[31m✗\033[0m %s\n' "$*"; fail=$((fail+1)); }
|
||||
|
||||
say "GET $BASE/v2/languages"
|
||||
body="$(curl -fsS "$BASE/v2/languages")"
|
||||
if echo "$body" | jq -e '.[0].name and .[0].code' >/dev/null 2>&1; then
|
||||
ok "languages list has name+code"
|
||||
echo "$body" | jq -c '.[0]'
|
||||
else
|
||||
bad "languages list unexpected: $body"
|
||||
fi
|
||||
|
||||
say "GET $BASE/v2/health"
|
||||
body="$(curl -fsS "$BASE/v2/health" || true)"
|
||||
state="$(echo "$body" | jq -r '.state // "missing"' 2>/dev/null || echo missing)"
|
||||
echo " state: $state"
|
||||
[ "$state" = "ready" ] && ok "model ready" || { echo " (model not ready — /v2/check will 503; continuing for shape checks)"; }
|
||||
|
||||
say "POST $BASE/v2/check (text=…, Overleaf style)"
|
||||
code="$(curl -s -o /tmp/glm-smoke-check.json -w '%{http_code}' -X POST "$BASE/v2/check" \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'language=auto' \
|
||||
--data-urlencode 'level=picky' \
|
||||
--data-urlencode 'disabledRules=WHITESPACE_RULE,CONSECUTIVE_SPACES' \
|
||||
--data-urlencode 'text=I has two cats. They is very goed cat.')"
|
||||
resp="$(cat /tmp/glm-smoke-check.json)"
|
||||
|
||||
echo "$resp" | head -c 400; echo
|
||||
if [ "$code" = "200" ]; then
|
||||
n="$(echo "$resp" | jq '.matches | length' 2>/dev/null || echo '?')"
|
||||
echo " matches: $n"
|
||||
echo "$resp" | jq -c '.matches[]? | {offset, length, replacements: [ .replacements[].value ], message}' 2>/dev/null | head -5 || true
|
||||
if [ "$n" != "?" ] && [ "$n" -ge 1 ]; then ok "produced matches"; else bad "expected >=1 match (model may have found none)"; fi
|
||||
if echo "$resp" | jq -e '.software // .software' >/dev/null 2>&1; then ok "has software block"; else bad "no software block"; fi
|
||||
else
|
||||
# 503 (model not ready) is acceptable for a shape test, but report status
|
||||
echo " status: $code"
|
||||
[ "$code" = "503" ] && { echo " (model still loading — rerun smoke once /v2/health shows ready)"; } || bad "unexpected status $code"
|
||||
fi
|
||||
|
||||
say "POST /v2/check with annotation (LaTeX markup must be skipped)"
|
||||
resp="$(curl -s -X POST "$BASE/v2/check" \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'language=en-US' \
|
||||
--data-urlencode 'data={"annotation":[{"text":"The "},{"markup":"{\\textbf{bold}} "},{"text":"cat is goed."}]}' )"
|
||||
echo "$resp" | head -c 400; echo
|
||||
# No match may fall inside the markup span [4,16)
|
||||
if echo "$resp" | jq -e '.matches[] | select(.offset >= 4 and .offset < 16)' >/dev/null 2>&1; then
|
||||
bad "a match overlaps the LaTeX markup span (offsets misaligned?)"
|
||||
else
|
||||
ok "no match crosses the markup span"
|
||||
fi
|
||||
|
||||
say "negative: missing text/data → 400"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/v2/check" -H 'Content-Type: application/x-www-form-urlencoded' --data 'language=en')"
|
||||
[ "$code" = "400" ] && ok "400 as expected" || bad "expected 400, got $code"
|
||||
|
||||
say "CORS preflight"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' -X OPTIONS "$BASE/v2/check")"
|
||||
[ "$code" = "204" ] || [ "$code" = "200" ] && ok "preflight $code" || bad "preflight $code"
|
||||
|
||||
say "summary"
|
||||
echo " pass=$pass fail=$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
@@ -1 +0,0 @@
|
||||
.env
|
||||
@@ -1,155 +0,0 @@
|
||||
# LanguageTool server (Docker Compose)
|
||||
|
||||
Standalone [LanguageTool](https://languagetool.org) grammar-check server used by
|
||||
the Overleaf integration in `services/web/modules/languagetool`
|
||||
(Overleaf CE fork repo, see the module's `README.md`).
|
||||
|
||||
- **Image:** `erikvl87/languagetool:latest` (community server fork of LanguageTool;
|
||||
supports `level=default` and `level=picky` — other levels return HTTP 400)
|
||||
- **Container name:** `languagetool` (overleaf reaches it by this hostname on the shared network)
|
||||
- **Host port:** `127.0.0.1:32768` → container `8010` (pinned, stable across restarts)
|
||||
- **Compose project name:** `languagetools_server_compose` (directory name)
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
tools/languagetools_server_compose/
|
||||
├── compose.yaml # top-level: `extends` the service in languagetool/compose.yaml
|
||||
├── up.sh # docker compose up -d
|
||||
├── down.sh # docker compose down
|
||||
├── up_languagetool.sh # docker compose up -d languagetool
|
||||
├── down_languagetool.sh # docker compose down languagetool
|
||||
├── logs_languagetool.sh # docker compose logs -f languagetool
|
||||
├── autoupdate_languagetool.sh # pull latest image, recreate container only if changed
|
||||
├── lt-test.tex # test document: sentences with verified expected detections
|
||||
└── languagetool/
|
||||
├── compose.yaml # actual service definition
|
||||
├── download_ngrams.sh # main n-gram set (de en es fr nl) → ngrams/
|
||||
├── download_ngrams_untested.sh # untested set (he it ru zh) → ngrams/untested/
|
||||
└── ngrams/ # n-gram data, mounted read-only into the container (gitignored)
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd tools/languagetools_server_compose
|
||||
|
||||
# 1. Populate n-gram data (one-time, ~45 GB disk; resumable, idempotent)
|
||||
./languagetool/download_ngrams.sh
|
||||
./languagetool/download_ngrams_untested.sh # optional: he/it/ru/zh
|
||||
|
||||
# 2. Start
|
||||
./up.sh
|
||||
|
||||
# 3. Verify (container must be healthy)
|
||||
docker ps --filter name=languagetool
|
||||
curl -s http://127.0.0.1:32768/v2/languages | head -c 200
|
||||
```
|
||||
|
||||
## N-gram data
|
||||
|
||||
LanguageTool loads per-language n-gram models from the directory given in
|
||||
`langtool_languageModel` (here: `/ngrams` ← `languagetool/ngrams/`).
|
||||
Every subdirectory named after a language code (e.g. `en/`, `de/`) is picked
|
||||
up — currently present: `de en es fr nl` (main) and `he it ru zh` (untested).
|
||||
|
||||
| Set | Languages | Size (extracted) | Zip sources |
|
||||
|---|---|---|---|
|
||||
| main | de en es fr nl | ~24 GB | `ngrams-<lang>-YYYYMMDD.zip` (~1.7–9 GB each) |
|
||||
| untested | he it ru zh | ~14 GB | `ngram-<lang>-YYYYMMDD.zip` (~0.2–2.3 GB each) |
|
||||
|
||||
- Downloads come from `https://languagetool.org/download/ngram-data/`
|
||||
(untested set: `.../ngram-data/untested/`).
|
||||
- Both scripts are idempotent: existing zips are skip-validated (`unzip -t`),
|
||||
downloads are resumable (`curl -C -`). Re-run safely after an interrupted transfer.
|
||||
- The downloaded zip archives stay in `ngrams/` (~21 GB total).
|
||||
Safe to `rm ngrams/*.zip` once extraction succeeded, to reclaim that space.
|
||||
- `an` (Aragonese) is **not** available on the server; it is kept commented
|
||||
out in `download_ngrams_untested.sh` in case it appears later.
|
||||
|
||||
> **Pitfall (cost us a debugging session):** if `ngrams/` does not exist on
|
||||
> the host, Docker creates an *empty* directory for the bind mount and the
|
||||
> server runs **without any n-gram models** — spelling detection
|
||||
> (`recieve`, `realy`, …) silently degrades while the API still answers.
|
||||
> Verify with `ls languagetool/ngrams/en` and a check request that
|
||||
> `MORFOLOGIK_RULE_EN_US` still fires.
|
||||
|
||||
## Service configuration (`languagetool/compose.yaml`)
|
||||
|
||||
| Setting | Value | Notes |
|
||||
|---|---|---|
|
||||
| `image` | `erikvl87/languagetool:latest` | community fork, HTTP REST API on 8010 |
|
||||
| `Java_Xms` / `Java_Xmx` | `512m` / `2g` | JVM heap |
|
||||
| `langtool_languageModel` | `/ngrams` | n-gram model directory (read-only bind mount) |
|
||||
| `ports` | `127.0.0.1:32768:8010` | host port pinned — do not reintroduce the ephemeral `127.0.0.1::8010` form |
|
||||
| `healthcheck` | `wget --spider http://localhost:8010/v2/languages` | 30s interval, 60s start period |
|
||||
| `restart` | `unless-stopped` | |
|
||||
|
||||
## Connecting Overleaf
|
||||
|
||||
The matching Overleaf side (CE fork v6.3.1) exposes the feature through
|
||||
`services/web/modules/languagetool/` — enabled via overleaf service env vars.
|
||||
In this deployment they live in `/data_1/docker/compose/overleaf/compose.yaml`:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
LANGUAGE_TOOL_ENABLED: "true"
|
||||
LANGUAGE_TOOL_HOST: languagetool # resolves on the shared docker network
|
||||
LANGUAGE_TOOL_PORT: "8010"
|
||||
```
|
||||
|
||||
- The overleaf container reaches LT through the shared network
|
||||
(`languagetools_server_compose_default`); from inside overleaf the host is
|
||||
`languagetool:8010`, from the host it is `127.0.0.1:32768`.
|
||||
- Overleaf checks `/v2/check` (POST, form-encoded `text`, `language`, `level`,
|
||||
plus `disabledRules`); client-side defaults: **picky level on**,
|
||||
spellcheck language = Overleaf default (`en-US`), and the checks are
|
||||
stored as **project settings** (shared with collaborators).
|
||||
- If `LANGUAGE_TOOL_ENABLED` is false or the proxy can't reach LT, Overleaf
|
||||
simply does not offer the feature (no client errors).
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# basic
|
||||
curl -s -X POST http://127.0.0.1:32768/v2/check \
|
||||
-d 'language=en-US' -d 'text=You can recieve a bonus.' -d 'level=picky'
|
||||
|
||||
# picky vs default (passive voice is picky-only on this build)
|
||||
curl -s -X POST http://127.0.0.1:32768/v2/check \
|
||||
--data-urlencode 'language=en-US' --data-urlencode 'text=The error was detected by the test suite.' \
|
||||
-d 'level=default' # → no passive-voice match
|
||||
curl -s -X POST http://127.0.0.1:32768/v2/check \
|
||||
--data-urlencode 'language=en-US' --data-urlencode 'text=The error was detected by the test suite.' \
|
||||
-d 'level=picky' # → PASSIVE_VOICE_SIMPLE
|
||||
```
|
||||
|
||||
Editor test document: **`lt-test.tex`** in this directory.
|
||||
Verified expectations (2026-10-26, with the exact `disabledRules` the proxy sends):
|
||||
|
||||
| Picky mode | Underlines |
|
||||
|---|---|
|
||||
| **on** (Overleaf default) | **27** — section A (2 passive-voice) + section B (23) |
|
||||
| **off** | **25** — section A disappears, section B stays |
|
||||
|
||||
See the file header for the per-sentence rule annotations.
|
||||
|
||||
## Update / rollback
|
||||
|
||||
```bash
|
||||
./autoupdate_languagetool.sh # pull erikvl87/languagetool:latest, recreate only if image changed
|
||||
docker compose down && docker compose up -d # manual restart
|
||||
docker compose logs -f languagetool # tail logs
|
||||
```
|
||||
|
||||
Troubleshooting:
|
||||
|
||||
- **Overleaf can't reach LT** — from the overleaf container:
|
||||
`wget -O- http://languagetool:8010/v2/languages`. If DNS fails, the overleaf
|
||||
service is not attached to the `languagetools_server_compose_default` network
|
||||
(see the two `networks` blocks above; both must carry the same name).
|
||||
- **Port changed after a recreate** — always use the pinned form
|
||||
`"127.0.0.1:32768:8010"`; the old `"127.0.0.1::8010"` form picks a random
|
||||
host port per container creation.
|
||||
- **No spelling detections** — n-gram mount empty, see the pitfall above.
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE_NAME="languagetool"
|
||||
IMAGE_NAME="erikvl87/languagetool:latest"
|
||||
YAML_DIRECTORY_NAME="${COMPOSE_DIRECTORY:-$(pwd)}"
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
@@ -1,7 +0,0 @@
|
||||
services:
|
||||
languagetool:
|
||||
extends:
|
||||
file: ./languagetool/compose.yaml
|
||||
service: languagetool
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
docker compose down
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
docker compose down languagetool
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ngrams
|
||||
@@ -1,21 +0,0 @@
|
||||
services:
|
||||
languagetool:
|
||||
image: erikvl87/languagetool:latest
|
||||
container_name: languagetool
|
||||
hostname: languagetool
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
Java_Xms: 512m
|
||||
Java_Xmx: 2g
|
||||
langtool_languageModel: /ngrams
|
||||
ports:
|
||||
- "127.0.0.1:32768:8010"
|
||||
volumes:
|
||||
- ./ngrams:/ngrams:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --quiet --spider --timeout=5 http://localhost:8010/v2/languages 2>&1 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download & extract LanguageTool n-gram data (main set: de en es fr nl)
|
||||
# into ngrams/ next to this script.
|
||||
#
|
||||
# Sources: https://languagetool.org/download/ngram-data/
|
||||
# ngrams-de-20150819.zip (~1.7 GB)
|
||||
# ngrams-en-20150817.zip (~9.0 GB)
|
||||
# ngrams-es-20150915.zip (~1.7 GB)
|
||||
# ngrams-fr-20150913.zip (~1.8 GB)
|
||||
# ngrams-nl-20181229.zip (~1.2 GB)
|
||||
#
|
||||
# Usage: ./download_ngrams.sh
|
||||
# Re-running is safe: already-downloaded & valid zips are skipped (download is
|
||||
# resumable via curl -C).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NGRAMS_DIR="$BASE/ngrams"
|
||||
BASE_URL="https://languagetool.org/download/ngram-data"
|
||||
|
||||
# lang -> zip filename (verified against server directory listing)
|
||||
declare -a FILES=(
|
||||
"ngrams-de-20150819.zip"
|
||||
"ngrams-en-20150817.zip"
|
||||
"ngrams-es-20150915.zip"
|
||||
"ngrams-fr-20150913.zip"
|
||||
"ngrams-nl-20181229.zip"
|
||||
)
|
||||
|
||||
command -v curl >/dev/null || { echo "ERROR: curl not found" >&2; exit 1; }
|
||||
command -v unzip >/dev/null || { echo "ERROR: unzip not found" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$NGRAMS_DIR"
|
||||
echo "Target dir: $NGRAMS_DIR"
|
||||
|
||||
failed=0
|
||||
for zip in "${FILES[@]}"; do
|
||||
url="$BASE_URL/$zip"
|
||||
dest="$NGRAMS_DIR/$zip"
|
||||
|
||||
echo "=================================================================="
|
||||
echo "$zip"
|
||||
|
||||
if [ -f "$dest" ] && unzip -t "$dest" >/dev/null 2>&1; then
|
||||
echo " [skip] already downloaded and valid: $zip"
|
||||
else
|
||||
echo " [dl] $url"
|
||||
if ! curl --fail --location --retry 3 --retry-delay 5 -C - \
|
||||
--connect-timeout 30 --progress-bar -o "$dest" "$url"; then
|
||||
echo " [FAIL] download failed for $url (partial file kept for resume: $dest)" >&2
|
||||
rm -f "$dest" # 404/error body would corrupt the name for future runs
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
echo " [extract] -> $NGRAMS_DIR"
|
||||
if ! unzip -o -q "$dest" -d "$NGRAMS_DIR"; then
|
||||
echo " [FAIL] extraction failed for $zip" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "=================================================================="
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo "Finished with ERRORS — re-run to resume failed downloads." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "All done. Contents:"
|
||||
ls -lh "$NGRAMS_DIR"
|
||||
@@ -1,75 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download & extract LanguageTool n-gram data (UNTESTED set) into ngrams/untested/
|
||||
# next to this script.
|
||||
#
|
||||
# Sources: https://languagetool.org/download/ngram-data/untested/
|
||||
# ngram-he-20150916.zip (~225 MB)
|
||||
# ngram-it-20150915.zip (~1.1 GB)
|
||||
# ngram-ru-20150914.zip (~2.3 GB)
|
||||
# ngram-zh-20150916.zip (~1.7 GB)
|
||||
#
|
||||
# NOTE: "an" (Aragonese) was requested but does NOT exist on the server
|
||||
# (checked ngram-data/, untested/, temp/, archive/ — only he/it/ru/zh are
|
||||
# offered). Kept commented out so it can be enabled if it appears later.
|
||||
#
|
||||
# Usage: ./download_ngrams_untested.sh
|
||||
# Re-running is safe: already-downloaded & valid zips are skipped (download is
|
||||
# resumable via curl -C).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NGRAMS_DIR="$BASE/ngrams"
|
||||
UNTESTED_DIR="$NGRAMS_DIR"
|
||||
BASE_URL="https://languagetool.org/download/ngram-data/untested"
|
||||
|
||||
# zip filenames (verified against server directory listing)
|
||||
declare -a FILES=(
|
||||
"ngram-he-20150916.zip"
|
||||
"ngram-it-20150915.zip"
|
||||
"ngram-ru-20150914.zip"
|
||||
"ngram-zh-20150916.zip"
|
||||
# "ngram-an-*.zip (404 — not available on the server as of 2025/2026)"
|
||||
)
|
||||
|
||||
command -v curl >/dev/null || { echo "ERROR: curl not found" >&2; exit 1; }
|
||||
command -v unzip >/dev/null || { echo "ERROR: unzip not found" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$UNTESTED_DIR"
|
||||
echo "Target dir: $UNTESTED_DIR"
|
||||
|
||||
failed=0
|
||||
for zip in "${FILES[@]}"; do
|
||||
url="$BASE_URL/$zip"
|
||||
dest="$UNTESTED_DIR/$zip"
|
||||
|
||||
echo "=================================================================="
|
||||
echo "$zip"
|
||||
|
||||
if [ -f "$dest" ] && unzip -t "$dest" >/dev/null 2>&1; then
|
||||
echo " [skip] already downloaded and valid: $zip"
|
||||
else
|
||||
echo " [dl] $url"
|
||||
if ! curl --fail --location --retry 3 --retry-delay 5 -C - \
|
||||
--connect-timeout 30 --progress-bar -o "$dest" "$url"; then
|
||||
echo " [FAIL] download failed for $url (partial file kept for resume: $dest)" >&2
|
||||
rm -f "$dest" # 404/error body would corrupt the name for future runs
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
echo " [extract] -> $UNTESTED_DIR"
|
||||
if ! unzip -o -q "$dest" -d "$UNTESTED_DIR"; then
|
||||
echo " [FAIL] extraction failed for $zip" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "=================================================================="
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo "Finished with ERRORS — re-run to resume failed downloads." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "All done. Contents:"
|
||||
ls -lh "$UNTESTED_DIR"
|
||||
@@ -1,2 +0,0 @@
|
||||
docker compose logs -f languagetool
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
% LanguageTool level test — ext-ce-v6.3.1 + erikvl87/languagetool
|
||||
%
|
||||
% Section A: expected to be flagged ONLY in Picky mode (level=picky).
|
||||
% Section B: expected to be flagged in BOTH modes (default and picky).
|
||||
%
|
||||
% All sentences below were verified live against the running server
|
||||
% (2026-10-26), with the exact disabledRules the Overleaf proxy sends.
|
||||
%
|
||||
% Usage: open in Overleaf, wait ~2 s after the last edit,
|
||||
% hover a wavy underline for the message + one-click replacements.
|
||||
% Toggle Settings → Spelling and Language → LanguageTool → "Picky mode"
|
||||
% off/on and compare: section A marks should appear/disappear.
|
||||
|
||||
\documentclass{article}
|
||||
\begin{document}
|
||||
|
||||
\section*{A: Picky-only (missed at default level)}
|
||||
|
||||
% PASSIVE_VOICE_SIMPLE — passive voice
|
||||
The experiment was conducted by our group.
|
||||
|
||||
% PASSIVE_VOICE_SIMPLE — passive voice
|
||||
The error was detected by the test suite.
|
||||
|
||||
\section*{B: Both modes (default and picky)}
|
||||
|
||||
% EN_A_VS_AN — wrong article
|
||||
An sequence off independent variables.
|
||||
|
||||
% MORFOLOGIK_RULE_EN_US — spelling mistake
|
||||
You can recieve a bonus for this.
|
||||
|
||||
% MORFOLOGIK_RULE_EN_US — spelling mistake
|
||||
This is a realy interesting result.
|
||||
|
||||
% MORFOLOGIK_RULE_EN_US — spelling mistake
|
||||
He shoulda told us about the crash.
|
||||
|
||||
% AGREEMENT_SENT_START — agreement error
|
||||
The results shows a significant difference.
|
||||
|
||||
% BEEN_PART_AGREEMENT — possible agreement error
|
||||
A new paper was publish last week.
|
||||
|
||||
% THERE_S_MANY — possible agreement error
|
||||
There is three reasons for this choice.
|
||||
|
||||
% IT_IS — possible typo (missing apostrophe)
|
||||
Its time to finish the manuscript.
|
||||
|
||||
% AFFECT_EFFECT — commonly confused word
|
||||
This should have an affect on the outcome.
|
||||
|
||||
% LOOSE_LOSE — possible typo
|
||||
You will loose the key if you leave it here.
|
||||
|
||||
% MODAL_OF — possible typo
|
||||
You should of checked the configuration.
|
||||
|
||||
% MODAL_OF — possible typo
|
||||
It might of happened last night.
|
||||
|
||||
% MODAL_OF — possible typo
|
||||
We could of fixed the bug yesterday.
|
||||
|
||||
% INFORMATIONS — nonstandard word
|
||||
We need more informations about this topic.
|
||||
|
||||
% EN_A_VS_AN — wrong article (uncountable)
|
||||
He gave me a advice yesterday morning.
|
||||
|
||||
% ENGLISH_WORD_REPEAT_RULE — word repetition
|
||||
The the method was tested twice.
|
||||
|
||||
% ADMIT_ENJOY_VB — use 'ing'
|
||||
I look forward to hear from you again.
|
||||
|
||||
% TOO_TO — possible typo
|
||||
I want to too participate in this.
|
||||
|
||||
% AGREEMENT_SENT_START — agreement error
|
||||
Each of the students have passed the exam.
|
||||
|
||||
% AGREEMENT_SENT_START — agreement error
|
||||
The dog which barks is friendly.
|
||||
|
||||
% RB_RB_COMMA — intensifier comma
|
||||
We have a very very good result here.
|
||||
|
||||
% FREE_GIFT — redundant phrase
|
||||
This is a free gift for all of you.
|
||||
|
||||
% ADMIT_ENJOY_VB — use 'ing'
|
||||
The manual recommends to restart the server.
|
||||
|
||||
% ALLOW_TO — use a gerund
|
||||
The tool allows to configure the output.
|
||||
|
||||
% ALLOW_TO — use a gerund
|
||||
The server requires to restart after the change.
|
||||
|
||||
\end{document}
|
||||
@@ -1,2 +0,0 @@
|
||||
docker compose up -d
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
docker compose up -d languagetool
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js'
|
||||
import { promiseMapWithLimit } from '@overleaf/promise-utils'
|
||||
import { db } from './lib/mongodb.mjs'
|
||||
|
||||
const tags = ['server-ce', 'server-pro']
|
||||
|
||||
const WRITE_CONCURRENCY = parseInt(process.env.WRITE_CONCURRENCY, 10) || 10
|
||||
|
||||
// The Zotero module used to store linkedFileData with its own key names.
|
||||
// "Zotero: fix for 6.3.0" switched to the names accepted by the
|
||||
// overleaf-editor-core schema, but files imported before that still carry the
|
||||
// old keys, which document-updater rejects (e.g. when cloning the project).
|
||||
const LEGACY_KEYS = [
|
||||
'zoteroGroupId',
|
||||
'importedByUserId',
|
||||
'importedByName',
|
||||
'bibFormat',
|
||||
]
|
||||
|
||||
function hasLegacyKeys(data) {
|
||||
return (
|
||||
data?.provider === 'zotero' && LEGACY_KEYS.some(key => key in data)
|
||||
)
|
||||
}
|
||||
|
||||
function convert(data) {
|
||||
const importerId = data.importer_id ?? data.importedByUserId
|
||||
const format = data.format ?? data.bibFormat
|
||||
return {
|
||||
provider: 'zotero',
|
||||
group_id: data.group_id ?? data.zoteroGroupId ?? null,
|
||||
...(data.importedAt && { importedAt: data.importedAt }),
|
||||
...(importerId && { importer_id: importerId.toString() }),
|
||||
importer_name: data.importer_name ?? data.importedByName ?? 'Unknown',
|
||||
format: format === 'biblatex' ? 'biblatex' : 'bibtex',
|
||||
}
|
||||
}
|
||||
|
||||
function collectUpdates(folder, mongoPath, updates) {
|
||||
if (!folder) return
|
||||
;(folder.fileRefs || []).forEach((file, i) => {
|
||||
if (hasLegacyKeys(file?.linkedFileData)) {
|
||||
updates[`${mongoPath}.fileRefs.${i}.linkedFileData`] = convert(
|
||||
file.linkedFileData
|
||||
)
|
||||
}
|
||||
})
|
||||
;(folder.folders || []).forEach((subfolder, i) => {
|
||||
collectUpdates(subfolder, `${mongoPath}.folders.${i}`, updates)
|
||||
})
|
||||
}
|
||||
|
||||
const migrate = async () => {
|
||||
await batchedUpdate(
|
||||
db.projects,
|
||||
{},
|
||||
async nextBatch => {
|
||||
await promiseMapWithLimit(WRITE_CONCURRENCY, nextBatch, async project => {
|
||||
const updates = {}
|
||||
;(project.rootFolder || []).forEach((folder, i) => {
|
||||
collectUpdates(folder, `rootFolder.${i}`, updates)
|
||||
})
|
||||
if (Object.keys(updates).length === 0) return
|
||||
await db.projects.updateOne({ _id: project._id }, { $set: updates })
|
||||
})
|
||||
},
|
||||
{ _id: 1, rootFolder: 1 }
|
||||
)
|
||||
}
|
||||
|
||||
const rollback = async () => {}
|
||||
|
||||
export default {
|
||||
tags,
|
||||
migrate,
|
||||
rollback,
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
.env
|
||||
backup_seaweedfs_online
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user