Compare commits

..
Author SHA1 Message Date
Florian Porrmann 565b793055 GitLab Synchronization: Fixed v6.3.0 rebase related errors 2026-10-09 16:50:10 +02:00
Florian Porrmann fcb3d06f24 GitLab Synchronization: Replaced tabs with spaces to keep the style consistent 2026-09-28 00:34:31 +02:00
Florian Porrmann d037520105 GitLab Synchronization: Changed getUserToken to just return the token instead of the entire object, making the code more compatible with the Github integration 2026-09-28 00:34:31 +02:00
Florian Porrmann cb59625058 GitLab Synchronization: Fixed more merging related problems 2026-09-28 00:34:31 +02:00
yu-i-i 9c041338b3 Changes by @fporrmann
Added missing return in clean sync flow
Fixed incorrect initialization order
Rewrote commit comparison for merge state evaluation
Fixed a problem with the getBlobContent function
Removed old log lines
Fixed merging when there were changes in Gitlab and Overleaf
Removed additional log line
2026-09-28 00:34:31 +02:00
yu-i-i 3bfeb32bef GitLab Synchronization: add gitlabUrl to ExpressLocals; use settings.gitlabSync instead of env vars 2026-09-28 00:34:31 +02:00
Florian Porrmann d458e51918 GitLab Synchronization: Initial commit 2026-09-28 00:34:26 +02:00
135 changed files with 4485 additions and 8952 deletions
+1 -1
View File
@@ -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
-7
View File
@@ -1,7 +0,0 @@
FROM pandoc/core:3.11-alpine
ENTRYPOINT []
RUN apk add --no-cache zip
+3 -25
View File
@@ -11,26 +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)
all: build-base build-community build-pandoc
all: build-base build-community
clean:
-docker rmi --force $(OVERLEAF_BASE_TAG) $(OVERLEAF_TAG) $(OVERLEAF_PANDOC_TAG)
-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
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
build-base:
cp .dockerignore $(MONOREPO_ROOT)
@@ -60,27 +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)
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)
SHELLCHECK_OPTS = \
--shell=bash \
@@ -102,5 +80,5 @@ shellcheck_fix:
done
.PHONY: all \
build-base build-community build-pandoc\
build-base build-community \
shellcheck shellcheck_fix
+1 -19
View File
@@ -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
-54
View File
@@ -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"]
-89
View File
@@ -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
@@ -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,
@@ -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,
},
}
@@ -424,6 +424,8 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
cioSiteId: Settings.analytics?.cio?.siteId,
linkedInInsightsPartnerId: Settings.analytics?.linkedIn?.partnerId,
githubSyncEnabled: !!Settings.githubSync?.clientID && !!Settings.githubSync?.clientSecret,
gitlabSyncEnabled: !!Settings.gitlabSync,
gitlabUrl: Settings.gitlabSync?.url,
zoteroEnabled: !!Settings.zotero?.clientKey && !!Settings.zotero?.clientSecret,
enablePandocConversions: Settings.enablePandocConversions,
mixpanelLabsToken:
@@ -16,6 +16,7 @@ const trackChangesModuleAvailable =
* @property {Object | undefined} apis.linkedUrlProxy
* @property {string | undefined} apis.linkedUrlProxy.url
* @property {boolean | undefined} enableGithubSync
* @property {boolean | undefined} enableGitLabSync
* @property {boolean | undefined} enableGitBridge
* @property {boolean | undefined} enableHomepage
* @property {boolean | undefined} enableSaml
@@ -61,6 +62,8 @@ const Features = {
return Boolean(Settings.disableLinkSharing) === false
case 'github-sync':
return Boolean(Settings.enableGithubSync)
case 'gitlab-sync':
return Boolean(Settings.enableGitLabSync)
case 'git-bridge':
return Boolean(Settings.enableGitBridge)
case 'oauth':
@@ -6,6 +6,7 @@
const thirdPartyIcons = [
{ file: 'git.svg', label: 'Git' },
{ file: 'github.svg', label: 'GitHub' },
{ file: 'gitlab.svg', label: 'GitLab' },
{ file: 'dropbox-transparent.svg', label: 'Dropbox' },
{ file: 'zotero_64x64x32.png', label: 'Zotero' },
{ file: 'mendeley.svg', label: 'Mendeley' },
@@ -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
+35 -37
View File
@@ -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(
+21 -4
View File
@@ -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,
@@ -1078,6 +1076,10 @@ module.exports = {
__dirname,
'../modules/github-sync/frontend/js/components/github-sync-widget.tsx'
),
Path.resolve(
__dirname,
'../modules/gitlab-sync/frontend/js/components/gitlab-sync-widget.tsx'
),
],
referenceLinkingWidgets: [
Path.resolve(
@@ -1097,6 +1099,18 @@ module.exports = {
'../modules/github-sync/frontend/js/components/import-from-github-menu.tsx'
),
],
importProjectFromGitLabModalWrapper: [
Path.resolve(
__dirname,
'../modules/gitlab-sync/frontend/js/components/import-from-gitlab-modal-wrapper.tsx'
),
],
importProjectFromGitLabMenu: [
Path.resolve(
__dirname,
'../modules/gitlab-sync/frontend/js/components/import-from-gitlab-menu.tsx'
),
],
editorLeftMenuSync: [],
editorLeftMenuManageTemplate: [],
menubarExtraComponents: [
@@ -1164,6 +1178,10 @@ module.exports = {
__dirname,
'../modules/github-sync/frontend/js/components/github-integration-card.tsx'
),
Path.resolve(
__dirname,
'../modules/gitlab-sync/frontend/js/components/gitlab-integration-card.tsx'
),
Path.resolve(
__dirname,
'../modules/git-bridge/frontend/js/card/components/git-integration-card.tsx'
@@ -1206,6 +1224,7 @@ module.exports = {
'template-gallery',
'git-bridge',
'github-sync',
'gitlab-sync',
'zotero',
],
viewIncludes: {},
@@ -1234,8 +1253,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') && {
@@ -9,6 +9,7 @@ function Origin({ origin }: Pick<LoadedUpdate['meta'], 'origin'>) {
if (origin?.kind === 'upload') result = t('history_entry_origin_upload')
if (origin?.kind === 'git-bridge') result = t('history_entry_origin_git')
if (origin?.kind === 'github') result = t('history_entry_origin_github')
if (origin?.kind === 'gitlab') result = t('history_entry_origin_gitlab')
if (result) {
return <span className="history-version-origin">({result})</span>
@@ -20,6 +20,7 @@ export interface Meta {
| 'upload'
| 'git-bridge'
| 'github'
| 'gitlab'
| 'history-resync'
| 'history-migration'
}
@@ -71,7 +71,7 @@ export const RailLayout = () => {
const { features } = useProjectContext()
const { isRestrictedTokenMember } = useEditorContext()
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
const { isOverleaf, githubSyncEnabled, zoteroEnabled } = getMeta('ol-ExposedSettings')
const { isOverleaf, githubSyncEnabled, gitlabSyncEnabled, zoteroEnabled } = getMeta('ol-ExposedSettings')
const { view, setSettingsShown, focusMode } = useLayoutContext()
@@ -119,7 +119,7 @@ export const RailLayout = () => {
icon: 'integration_instructions',
title: t('integrations'),
component: <IntegrationsPanel />,
hide: !isOverleaf && !gitBridgeEnabled && !githubSyncEnabled && !zoteroEnabled,
hide: !isOverleaf && !gitBridgeEnabled && !githubSyncEnabled && !gitlabSyncEnabled && !zoteroEnabled,
},
{
key: 'review-panel',
@@ -149,6 +149,7 @@ export const RailLayout = () => {
isOverleaf,
gitBridgeEnabled,
githubSyncEnabled,
gitlabSyncEnabled,
zoteroEnabled,
]
)
@@ -251,7 +251,7 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
}
if (
update.meta.type === 'external' &&
(update.meta.source === 'git-bridge' || update.meta.source === 'github')
(update.meta.source === 'git-bridge' || update.meta.source === 'github' || update.meta.source === 'gitlab')
) {
return
}
@@ -12,6 +12,7 @@ const integrationPanelComponents = allIntegrationPanelComponents.filter(
({ path }) =>
(getMeta('ol-gitBridgeEnabled') || !path.includes('git-bridge')) &&
(getMeta('ol-ExposedSettings').githubSyncEnabled || !path.includes('github-sync')) &&
(getMeta('ol-ExposedSettings').gitlabSyncEnabled || !path.includes('gitlab-sync')) &&
(getMeta('ol-ExposedSettings').zoteroEnabled || !path.includes('zotero'))
)
@@ -58,7 +58,7 @@ function NewProjectButton({
align = 'start',
}: NewProjectButtonProps) {
const { t } = useTranslation()
const { templateLinks, githubSyncEnabled } = getMeta('ol-ExposedSettings')
const { templateLinks, githubSyncEnabled, gitlabSyncEnabled } = getMeta('ol-ExposedSettings')
const [modal, setModal] =
useState<Nullable<NewProjectButtonModalVariant>>(null)
const portalTemplates = getMeta('ol-portalTemplates') || []
@@ -167,11 +167,19 @@ function NewProjectButton({
const [importProjectFromGithubMenu] = !githubSyncEnabled ? [] : importOverleafModules(
'importProjectFromGithubMenu'
)
const [importProjectFromGitLabMenu] = !gitlabSyncEnabled ? [] : importOverleafModules(
'importProjectFromGitLabMenu'
)
const ImportProjectFromGithubMenu: JSXElementConstructor<{
onClick: (e: React.MouseEvent) => void
}> = importProjectFromGithubMenu?.import.default
const ImportProjectFromGitLabMenu: JSXElementConstructor<{
onClick: (e: React.MouseEvent) => void
}> = importProjectFromGitLabMenu?.import.default
return (
<>
<OLDropdown
@@ -259,6 +267,18 @@ function NewProjectButton({
/>
</li>
)}
{ImportProjectFromGitLabMenu && (
<li role="none">
<ImportProjectFromGitLabMenu
onClick={e =>
handleModalMenuClick(e, {
modalVariant: 'import_from_gitlab',
dropdownMenuEvent: 'import-from-gitlab',
})
}
/>
</li>
)}
<OLDropdownDivider />
<OLDropdownHeader aria-hidden="true">
{t('templates')}
@@ -15,6 +15,7 @@ export type NewProjectButtonModalVariant =
| 'example_project'
| 'upload_project'
| 'import_from_github'
| 'import_from_gitlab'
| 'import_docx'
| 'import_markdown'
@@ -36,6 +37,14 @@ function NewProjectButtonModal({
onHide: () => void
}> = importProjectFromGithubModalWrapper?.import.default
const [importProjectFromGitLabModalWrapper] = importOverleafModules(
'importProjectFromGitLabModalWrapper'
)
const ImportProjectFromGitLabModalWrapper: JSXElementConstructor<{
onHide: () => void
}> = importProjectFromGitLabModalWrapper?.import.default
const location = useLocation()
const openProject = useCallback(
@@ -82,6 +91,8 @@ function NewProjectButtonModal({
)
case 'import_from_github':
return <ImportProjectFromGithubModalWrapper onHide={onHide} />
case 'import_from_gitlab':
return <ImportProjectFromGitLabModalWrapper onHide={onHide} />
default:
return null
}
@@ -67,7 +67,7 @@ function WelcomeMessageCreateNewProjectDropdown({
useFeatureFlag('import-markdown') &&
getMeta('ol-ExposedSettings').enablePandocConversions
const { isOverleaf, githubSyncEnabled } = getMeta('ol-ExposedSettings')
const { isOverleaf, githubSyncEnabled, gitlabSyncEnabled } = getMeta('ol-ExposedSettings')
const handleDropdownItemClick = useCallback(
(
@@ -184,6 +184,23 @@ function WelcomeMessageCreateNewProjectDropdown({
</OLDropdownItem>
</li>
)}
{(isOverleaf || gitlabSyncEnabled) && (
<li role="none">
<DropdownItem
as="button"
onClick={e =>
handleDropdownItemClick(
e,
'import_from_gitlab',
'import-from-gitlab'
)
}
tabIndex={-1}
>
{t('import_from_gitlab')}
</DropdownItem>
</li>
)}
{(portalTemplates?.length ?? 0) > 0 ? (
<>
<OLDropdownDivider />
@@ -19,11 +19,12 @@ const availableLangFeedbackLinkingWidgets = importOverleafModules(
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
const githubSyncEnabled = getMeta('ol-ExposedSettings').githubSyncEnabled
const gitlabSyncEnabled = getMeta('ol-ExposedSettings').gitlabSyncEnabled
const zoteroEnabled = getMeta('ol-ExposedSettings').zoteroEnabled
const availableIntegrationLinkingWidgets = allAvailableIntegrationLinkingWidgets.filter(
({ path }) =>
(githubSyncEnabled || !path.includes('github-sync'))
(githubSyncEnabled || !path.includes('github-sync')) && (gitlabSyncEnabled || !path.includes('gitlab-sync'))
)
function LinkingSection() {
@@ -55,7 +56,7 @@ function LinkingSection() {
}[]
const renderSyncSection =
getMeta('ol-isSaas') || gitBridgeEnabled || githubSyncEnabled
getMeta('ol-isSaas') || gitBridgeEnabled || githubSyncEnabled || gitlabSyncEnabled
const allIntegrationLinkingWidgets = integrationLinkingWidgets.concat(
oauth2ServerComponents
@@ -0,0 +1,18 @@
function GitLabLogoBlack({ size = 20 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 380 380"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M282.83,170.73l-.27-.69-26.14-68.22a6.81,6.81,0,0,0-2.69-3.24,7,7,0,0,0-8,.43,7,7,0,0,0-2.32,3.52l-17.65,54H154.29l-17.65-54A6.86,6.86,0,0,0,134.32,99a7,7,0,0,0-8-.43,6.87,6.87,0,0,0-2.69,3.24L97.44,170l-.26.69a48.54,48.54,0,0,0,16.1,56.1l.09.07.24.17,39.82,29.82,19.7,14.91,12,9.06a8.07,8.07,0,0,0,9.76,0l12-9.06,19.7-14.91,40.06-30,.1-.08A48.56,48.56,0,0,0,282.83,170.73Z"
fill="currentColor"
/>
</svg>
)
}
export default GitLabLogoBlack
@@ -0,0 +1,32 @@
function GitLabLogo({ size = 40 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 200 200"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g transform="translate(-90 -90)">
<path
d="m265.26416 174.37243-.2134-.55822-21.19899-55.30908c-.4236-1.08359-1.18542-1.99642-2.17699-2.62689-.98837-.63373-2.14749-.93253-3.32305-.87014-1.1689.0624-2.29195.48925-3.20809 1.21821-.90957.73554-1.56629 1.73047-1.87493 2.85346l-14.31327 43.80662h-57.90965l-14.31327-43.80662c-.30864-1.12299-.96536-2.11791-1.87493-2.85346-.91614-.72895-2.03911-1.15582-3.20809-1.21821-1.17548-.0624-2.33468.23641-3.32297.87014-.99166.63047-1.75348 1.5433-2.17707 2.62689l-21.19891 55.31237-.21348.55493c-6.28158 16.38521-.92929 34.90803 13.05891 45.48782.0262.0164.0492.0361.0755.0558l.18719.14119 32.29094 24.17392 15.97151 12.09024 9.71951 7.34871c2.34117 1.77316 5.57877 1.77316 7.92002 0l9.71943-7.34871 15.96822-12.09024 32.48142-24.31511c.0296-.023.0559-.0427.0854-.0657 13.97834-10.57977 19.32735-29.09604 13.04905-45.47796z"
fill="#E24329"
/>
<path
d="m265.26416 174.37243-.2134-.55822c-10.5174 2.16062-20.20405 6.6099-28.49844 12.81593-.1346.0985-25.20497 19.05805-46.55171 35.19699 15.84998 11.98517 29.6477 22.40405 29.6477 22.40405l32.48142-24.31511c.0296-.023.0559-.0427.0854-.0657 13.97834-10.57977 19.32735-29.09604 13.04905-45.47796z"
fill="#FC6D26"
/>
<path
d="m160.34962 244.23117 15.97151 12.09024 9.71951 7.34871c2.34117 1.77316 5.57877 1.77316 7.92002 0l9.71943-7.34871 15.96822-12.09024s-13.79772-10.41888-29.6477-22.40405c-15.85327 11.98517-29.65099 22.40405-29.65099 22.40405z"
fill="#FCA326"
/>
<path
d="m143.44561 186.63014c-8.29111-6.20274-17.97446-10.65531-28.49507-12.81264l-.21348.55493c-6.28158 16.38521-.92929 34.90803 13.05891 45.48782.0262.0164.0492.0361.0755.0558l.18719.14119 32.29094 24.17392s13.79772-10.41888 29.65099-22.40405c-21.34673-16.13894-46.42031-35.09848-46.55499-35.19699z"
fill="#FC6D26"
/>
</g>
</svg>
)
}
export default GitLabLogo
@@ -16,4 +16,5 @@
@import '../../../modules/admin-tools/frontend/styles/manage-users-page';
@import '../../../modules/admin-tools/frontend/styles/pagination-dark';
@import '../../../modules/github-sync/frontend/styles/github-sync';
@import '../../../modules/gitlab-sync/frontend/styles/gitlab-sync';
@import '../../../modules/reference-picker/frontend/styles/reference-picker';
@@ -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);
+73
View File
@@ -259,6 +259,11 @@
"ask_proj_owner_to_upgrade_for_full_history": "Please ask the project owner to upgrade to access this project’s full history.",
"ask_repo_owner_to_reconnect": "Ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to reconnect the project.",
"ask_repo_owner_to_renew_overleaf_subscription": "Ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to renew their __appName__ subscription and reconnect the project.",
"ask_proj_owner_to_add_you_as_gitlab_collaborator": "You don’t have permission to push to the linked repository <0>__repoFullName__</0>. Ask the owner of the project (<1>__projectOwnerEmail__</1>) to add you as a collaborator to this repository.",
"ask_proj_owner_to_export_to_gitlab": "Ask the owner of the project (<0>__projectOwnerEmail__</0>) to export the project to a Git repository and add you as a collaborator.",
"ask_proj_owner_to_unlink_from_current_gitlab": "Ask the owner of the project (<0>__projectOwnerEmail__</0>) to unlink the project from the current git repository and create a connection to a different repository.",
"ask_gitlab_repo_owner_to_reconnect": "Ask the GitLab repository owner (<0>__repoOwnerEmail__</0>) to reconnect the project.",
"ask_gitlab_repo_owner_to_renew_overleaf_subscription": "Ask the GitLab repository owner (<0>__repoOwnerEmail__</0>) to renew their __appName__ subscription and reconnect the project.",
"assistant_pointer_ask_ai": "Ask the AI assistant",
"assistant_pointer_hint_environment": "Ask the AI assistant to change this __name__",
"assistant_pointer_hint_package": "Ask the AI assistant to explain this package",
@@ -407,6 +412,7 @@
"checking": "Checking",
"checking_dropbox_status": "Checking Dropbox status",
"checking_project_github_status": "Checking project status in GitHub",
"checking_project_gitlab_status": "Checking project status in GitLab",
"checking_your_student_status": "We’re checking your student status. We’ll email you if your document upload needs review.",
"choose_a_custom_color": "Choose a custom color",
"choose_a_new_password": "Choose a new password",
@@ -540,6 +546,7 @@
"contacting_the_sales_team": "Contacting the Sales team",
"continue": "Continue",
"continue_github_merge": "I have manually merged. Continue",
"continue_gitlab_merge": "I have manually merged. Continue",
"continue_to": "Continue to __appName__",
"continue_to_checkout": "Continue to checkout",
"continue_using_free_features": "Continue using our free features",
@@ -575,6 +582,7 @@
"create_new_tag": "Create new tag",
"create_password": "Create password",
"create_project_in_github": "Create a GitHub repository",
"create_project_in_gitlab": "Create a GitLab repository",
"create_your_account": "Create your account",
"created": "Created",
"created_at": "Created at",
@@ -924,6 +932,7 @@
"export_as_markdown": "Export as Markdown (.md)",
"export_csv": "Export CSV",
"export_project_to_github": "Export Project to GitHub",
"export_project_to_gitlab": "Export Project to GitLab",
"failed": "Failed",
"failed_to_consent_to_workbench_terms": "Failed to consent to workbench terms. Please try again later.",
"failed_to_publish_as_a_template": "Failed to publish as a template.",
@@ -1033,6 +1042,7 @@
"from_external_url": "From external URL",
"from_filename": "From <0>__filename__</0>",
"from_github": "From GitHub",
"from_gitlab": "From GitLab",
"from_project_files": "From project files",
"from_provider": "From __provider__",
"from_url": "From URL",
@@ -1134,6 +1144,41 @@
"github_workflow_files_delete_github_repo": "The repository has been created on GitHub but linking was unsuccessful. You will have to delete GitHub repository or choose a new name.",
"github_workflow_files_error": "The __appName__ GitHub sync service couldn’t sync GitHub Workflow files (in .github/workflows/). Please authorize __appName__ to edit your GitHub workflow files and try again.",
"github_workflow_files_error_non_owner": "The __appName__ GitHub sync service couldn’t sync GitHub Workflow files (in .github/workflows/). Please ensure the project owner has authorized __appName__ to edit this project’s GitHub workflow files and try again.",
"gitlab": "GitLab",
"gitlab_commit_message_placeholder": "Commit message for changes made in __appName__...",
"gitlab_credentials_expired": "Your GitLab authorization credentials have expired",
"gitlab_empty_repository_error": "It looks like your GitLab repository is empty or not yet available. Create a new file on GitLab.com then try again.",
"gitlab_file_name_error": "This repository cannot be imported, because it contains file(s) with an invalid filename:",
"gitlab_file_sync_error": "We are unable to sync the following files:",
"gitlab_git_and_dropbox_integrations": "<0>GitLab</0>, <0>Git</0> and <0>Dropbox</0> integrations",
"gitlab_git_and_reference_manager_integrations": "GitLab, Git and reference manager integrations",
"gitlab_git_folder_error": "This project contains a .git folder at the top level, indicating that it is already a git repository. The Overleaf GitLab sync service cannot sync git histories. Please remove the .git folder and try again.",
"gitlab_integration": "GitLab integration",
"gitlab_integration_lowercase": "Git and GitLab integration",
"gitlab_is_no_longer_connected": "GitLab is no longer connected to this project.",
"gitlab_is_premium": "GitLab Sync is a premium feature",
"gitlab_large_files_error": "Merge failed: your GitLab repository contains files over the 50mb file size limit ",
"gitlab_manual_merge_user_prompt": "Please manually resolve the merge request associated with the branch <0>__sharelatex_branch__</0>. Click below to continue, after you have manually merged.",
"gitlab_merge_failed_error": "Your changes in __appName__ and GitLab could not be automatically merged.",
"gitlab_no_master_branch_error": "This repository cannot be imported as it is missing a default branch. Please make sure the project has a default branch",
"gitlab_private_description": "You choose who can see and commit to this repository.",
"gitlab_public_description": "Anyone can see this repository. You choose who can commit.",
"gitlab_repository_diverged": "The default branch of the linked repository has been force-pushed. The list of recent commits below may be incomplete due to rewritten history since the last sync.",
"gitlab_successfully_linked_description": "Thanks, we’ve successfully linked your GitLab account to __appName__. You can now export your __appName__ projects to GitLab, or import projects from your GitLab repositories.",
"gitlab_symlink_error": "Your GitLab repository contains symbolic link files, which are not currently supported by Overleaf. Please remove these and try again.",
"gitlab_sync_description": "With GitLab Sync you can link your __appName__ projects to GitLab repositories, create new commits from __appName__, and merge commits from GitLab.",
"gitlab_sync_error": "Sorry, there was a problem checking our GitLab service. Please try again in a few moments.",
"gitlab_sync_repository_not_found_description": "The linked repository has either been removed, or you no longer have access to it. You can set up sync with a new repository by cloning the project and using the ‘GitLab’ menu item. You can also unlink the repository from this project.",
"gitlab_timeout_error": "Syncing your Overleaf project with GitLab has timed out. This may be due to the overall size of your project, or the number of files/changes to sync, being too large.",
"gitlab_too_many_files_error": "This repository cannot be imported as it exceeds the maximum number of files allowed",
"gitlab_validation_check": "Please check that the repository name is valid, and that you have permission to create the repository.",
"gitlab_validation_check_auth": "Please check that you have permission to create the repository.",
"gitlab_validation_name_exists": "Repository with this name already exists.",
"gitlab_validation_name_invalid": "Repository name is invalid.",
"gitlab_workflow_authorize": "Authorize GitLab Workflow files",
"gitlab_workflow_files_delete_gitlab_repo": "The repository has been created on GitLab but linking was unsuccessful. You will have to delete GitLab repository or choose a new name.",
"gitlab_workflow_files_error": "The __appName__ GitLab sync service couldn’t sync GitLab Workflow files (in .gitlab/workflows/). Please authorize __appName__ to edit your GitLab workflow files and try again.",
"gitlab_workflow_files_error_non_owner": "The __appName__ GitLab sync service couldn’t sync GitLab Workflow files (in .gitlab/workflows/). Please ensure the project owner has authorized __appName__ to edit this project’s GitLab workflow files and try again.",
"give-ai-consent": "Give AI consent",
"give_feedback": "Give feedback",
"global": "global",
@@ -1215,6 +1260,7 @@
"history_entry_origin_dropbox": "via Dropbox",
"history_entry_origin_git": "via Git",
"history_entry_origin_github": "via GitHub",
"history_entry_origin_gitlab": "via GitLab",
"history_entry_origin_upload": "upload",
"history_label_created_by": "Created by",
"history_label_project_current_state": "Current state",
@@ -1352,7 +1398,9 @@
"import_a_bibtex_file_from_your_provider_account": "Import a BibTeX file from your __provider__ account",
"import_document_description": "Content will be imported from the selected document. Formatting may not be reproduced exactly.",
"import_existing_projects_from_github": "Import existing projects from GitHub",
"import_existing_projects_from_gitlab": "Import existing projects from GitLab",
"import_from_github": "Import from GitHub",
"import_from_gitlab": "Import from GitLab",
"import_idp_metadata": "Import IdP metadata",
"import_markdown_file": "Import Markdown file",
"import_to_sharelatex": "Import to __appName__",
@@ -1367,6 +1415,7 @@
"imported_from_zotero_at_date_by": "Imported from Zotero at __formattedDate__ __relativeDate__ by __importedByName__",
"importing": "Importing",
"importing_and_merging_changes_in_github": "Importing and merging changes in GitHub",
"importing_and_merging_changes_in_gitlab": "Importing and merging changes in GitLab",
"in_editor_chat": "In-editor chat",
"in_editor_chat_managed_by_overleaf_support": "In-editor chat is managed by Overleaf support.",
"in_editor_chat_section_description": "Lets collaborators leave and reply to comments directly within a project.",
@@ -1428,6 +1477,7 @@
"integrations": "Integrations",
"integrations_include": "Integrations include:",
"integrations_like_github": "Integrations like GitHub Sync",
"integrations_like_gitlab": "Integrations like GitLab Sync",
"interested_in_cheaper_personal_plan": "Would you be interested in the cheaper <0>__price__</0> Personal plan?",
"invalid_certificate": "Invalid certificate. Please check the certificate and try again.",
"invalid_confirmation_code": "That didn’t work. Please check the code and try again.",
@@ -1617,6 +1667,8 @@
"link_sharing_is_on": "Link sharing is on",
"link_to_github": "Link to your GitHub account",
"link_to_github_description": "You need to authorise __appName__ to access your GitHub account to allow us to sync your projects.",
"link_to_gitlab": "Link to your GitLab account",
"link_to_gitlab_description": "You need to authorise __appName__ to access your GitLab account to allow us to sync your projects.",
"link_to_mendeley": "Link to Mendeley",
"link_to_readcube": "Link to ReadCube",
"link_to_zotero": "Link to Zotero",
@@ -1630,9 +1682,11 @@
"loading": "Loading",
"loading_content": "Creating Project",
"loading_github_repositories": "Loading your GitHub repositories",
"loading_gitlab_repositories": "Loading your GitLab repositories",
"loading_prices": "loading prices",
"loading_python_runtime": "Loading Python runtime...",
"loading_recent_github_commits": "Loading recent commits",
"loading_recent_gitlab_commits": "Loading recent commits",
"loading_references": "Loading references",
"local_account": "Local account",
"log_entry_description": "Log entry with level: __level__",
@@ -1858,6 +1912,7 @@
"no_members": "No members",
"no_messages_yet": "No messages yet",
"no_new_commits_in_github": "No new commits in GitHub since last merge.",
"no_new_commits_in_gitlab": "No new commits in GitLab since last merge.",
"no_one_has_commented_or_left_any_suggestions_yet": "No one has commented or left any suggestions yet.",
"no_one_invited_yet": "No one invited yet",
"no_one_sharing_link_is_disabled": "No one—sharing link is disabled",
@@ -1943,6 +1998,7 @@
"only_importer_can_refresh": "Only the person who originally imported this __provider__ file can refresh it.",
"only_invited_people": "Only invited people",
"only_project_owner_can_link_github": "Only the project owner can link this project to a GitHub repository. Please contact the project owner if you would like to set this up.",
"only_project_owner_can_link_gitlab": "Only the project owner can link this project to a GitLab repository. Please contact the project owner if you would like to set this up.",
"open_action_menu": "Open __name__ action menu",
"open_advanced_reference_search": "Open advanced reference search",
"open_as_template": "Open as Template",
@@ -2193,6 +2249,7 @@
"project_linked_to": "This project is linked to",
"project_name": "Project name",
"project_not_linked_to_github": "This project is not linked to a GitHub repository. You can create a repository for it in GitHub:",
"project_not_linked_to_gitlab": "This project is not linked to a GitLab repository. You can create a repository for it in GitLab:",
"project_notifications": "Project notifications",
"project_notifications_muted_description": "You are not receiving any notifications, as you have disabled all project notifications.",
"project_ownership_transfer_confirmation_1": "Are you sure you want to make <0>__user__</0> the owner of <1>__project__</1>?",
@@ -2234,9 +2291,12 @@
"publisher_account": "Publisher Account",
"publishing": "Publishing",
"pull_github_changes_into_sharelatex": "Pull GitHub changes into __appName__",
"pull_gitlab_changes_into_sharelatex": "Pull GitLab changes into __appName__",
"purge": "Purge",
"push_sharelatex_changes_to_github": "Push __appName__ changes to GitHub",
"push_sharelatex_changes_to_gitlab": "Push __appName__ changes to GitLab",
"push_to_github_pull_to_overleaf": "Push to GitHub, pull to __appName__",
"push_to_gitlab_pull_to_overleaf": "Push to GitLab, pull to __appName__",
"quoted_text": "Quoted text",
"raw_logs": "Raw logs",
"raw_logs_description": "Raw logs from the LaTeX compiler",
@@ -2273,9 +2333,11 @@
"real_time_track_changes": "Real-time track-changes",
"realtime_track_changes": "Real-time track changes",
"reauthorize_github_account": "Reauthorize your GitHub Account",
"reauthorize_gitlab_account": "Reauthorize your GitLab Account",
"recaptcha_conditions": "The site is protected by reCAPTCHA.",
"recent": "Recent",
"recent_commits_in_github": "Recent commits in GitHub",
"recent_commits_in_gitlab": "Recent commits in GitLab",
"recommended": "Recommended",
"recompile": "Recompile",
"recompile_from_scratch": "Recompile from scratch",
@@ -2557,6 +2619,7 @@
"select_from_source_files": "select from source files",
"select_from_your_computer": "select from your computer",
"select_github_repository": "Select a GitHub repository to import into __appName__.",
"select_gitlab_repository": "Select a GitLab repository to import into __appName__.",
"select_image_from_project_files": "Select image from project files",
"select_project": "Select __project__",
"select_projects": "Select Projects",
@@ -2848,12 +2911,16 @@
"sync_failed": "Sync failed. Please try again.",
"sync_now": "Sync now",
"sync_project_to_github_explanation": "Any changes you have made in __appName__ will be committed and merged with any updates in GitHub.",
"sync_project_to_gitlab_explanation": "Any changes you have made in __appName__ will be committed and merged with any updates in GitLab.",
"sync_settings": "Sync settings",
"sync_to_dropbox": "Sync to Dropbox",
"sync_to_github": "Sync to GitHub",
"sync_to_gitlab": "Sync to GitLab",
"sync_with_a_github_repository": "Sync with a GitHub repository.",
"sync_with_a_gitlab_repository": "Sync with a GitLab repository.",
"sync_with_dropbox": "Sync with Dropbox",
"sync_with_github": "Sync with GitHub",
"sync_with_gitlab": "Sync with GitLab",
"sync_with_zotero": "Sync with Zotero",
"syncing": "Syncing",
"synctex_error_recompile_and_try_again": "That didn’t work. Recompile and try again.",
@@ -3007,6 +3074,7 @@
"to_continue_using_upgrade_or_change_your_browser": "To continue using __appName__ without problems you need to upgrade or change to a <0>supported browser</0>.",
"to_fix_this_you_can": "To fix this, you can:",
"to_fix_this_you_can_ask_the_github_repository_owner": "To fix this, you can ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to renew their __appName__ subscription and reconnect the project.",
"to_fix_this_you_can_ask_the_gitlab_repository_owner": "To fix this, you can ask the GitLab repository owner (<0>__repoOwnerEmail__</0>) to renew their __appName__ subscription and reconnect the project.",
"to_insert_or_move_a_caption_make_sure_tabular_is_directly_within_table": "To insert or move a caption, make sure \\begin{tabular} is directly within a table environment",
"to_keep_edit_access": "To keep edit access, ask the project owner to upgrade their plan or reduce the number of people with edit access.",
"to_modify_your_subscription_go_to": "To modify your subscription go to",
@@ -3163,6 +3231,8 @@
"unlink_from_sso": "Unlink from SSO",
"unlink_github_repository": "Unlink GitHub repository",
"unlink_github_warning": "Syncing will pause until you relink your GitHub account. Your projects’ GitHub connections won’t be lost, and syncing will resume automatically once you relink. Are you sure you want to unlink your GitHub account?",
"unlink_gitlab_repository": "Unlink GitLab repository",
"unlink_gitlab_warning": "Syncing will pause until you relink your GitLab account. Your projects’ GitLab connections won’t be lost, and syncing will resume automatically once you relink. Are you sure you want to unlink your GitLab account?",
"unlink_linked_accounts": "Unlink any linked accounts (such as ORCID ID, IEEE). <0>Remove them in Account settings (under Linked Accounts).</0>",
"unlink_linked_google_account": "Unlink your Google account. <0>Remove it in Account settings (under Linked Accounts).</0>",
"unlink_provider_account_title": "Unlink __provider__ Account",
@@ -3170,6 +3240,7 @@
"unlink_reference": "Unlink __provider__ References Provider",
"unlink_sso": "Unlink SSO",
"unlink_the_project_from_the_current_github_repo": "Unlink the project from the current GitHub repository and create a connection to a repository you own.",
"unlink_the_project_from_the_current_gitlab_repo": "Unlink the project from the current GitLab repository and create a connection to a repository you own.",
"unlink_user_explanation": "You’re about to remove the SSO login option for <0>__email__</0>. This will force them to reauthenticate their Overleaf account with your IdP. They’ll receive an email asking them to do this.",
"unlink_users": "Unlink users",
"unlink_warning_reference": "Warning: When you unlink your account from __provider__ you will not be able to import references into your projects.",
@@ -3380,6 +3451,7 @@
"work_or_university_sso": "Work/university single sign-on",
"work_with_non_overleaf_users": "Work with non-Overleaf users",
"work_with_other_github_users": "Work with other GitHub users",
"work_with_other_gitlab_users": "Work with other GitLab users",
"writefull": "Writefull",
"x_changes_in": "__count__ change in",
"x_changes_in_plural": "__count__ changes in",
@@ -3453,6 +3525,7 @@
"youll_be_moved_to_the_free_plan": "You’ll be moved to the Free plan",
"youll_get_best_results_in_visual_but_can_be_used_in_source": "You’ll get the best results from using this tool in the <0>Visual Editor</0>, although you can still use it to insert tables in the <1>Code Editor</1>. Once you’ve selected the number of rows and columns you need, the table will appear in your document and you can double click in a cell to add contents to it.",
"youll_need_to_ask_the_github_repository_owner": "You’ll need to ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) to reconnect the project.",
"youll_need_to_ask_the_gitlab_repository_owner": "You’ll need to ask the GitLab repository owner (<0>__repoOwnerEmail__</0>) to reconnect the project.",
"youll_no_longer_need_to_remember_credentials": "You’ll no longer need to remember a separate email address and password. Instead, you will use single-sign on to login to Overleaf. <0>Read more about SSO</0>.",
"your_account_is_managed_by_admin_cant_join_additional_group": "Your __appName__ account is managed by your current group admin (__admin__). This means you can’t join additional group subscriptions. <0>Read more about Managed Users.</0>",
"your_account_is_managed_by_your_group_admin": "Your account is managed by your group admin. You can’t change or delete your email address.",
@@ -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(
@@ -0,0 +1,24 @@
import mongoose from "../../../../app/src/infrastructure/Mongoose.mjs"
const { Schema } = mongoose
const { ObjectId } = Schema
export const GitLabSyncProjectStatesSchema = new Schema(
{
projectId: { type: ObjectId, ref: 'Project', required: true, unique: true },
repoFullName: { type: String, required: true },
defaultBranchName: { type: String, default: null },
mergeStatus: { type: String, enum: ['clean', 'conflict', 'diverged'], default: 'clean' },
lastSyncCommit: { type: String, default: null },
lastSyncVersion: { type: Number, default: null },
unmergedBranchName: { type: String, default: null },
unmergedBranchHead: { type: String, default: null },
conflictVersion: { type: Number, default: null },
},
{ collection: 'gitlabSyncProjectStates', minimize: false }
)
export const GitLabSyncProjectStates = mongoose.model(
'GitLabSyncProjectStates',
GitLabSyncProjectStatesSchema,
)
@@ -0,0 +1,17 @@
import mongoose from "../../../../app/src/infrastructure/Mongoose.mjs"
const { Schema } = mongoose
const { ObjectId } = Schema
export const GitLabSyncUserCredentialsSchema = new Schema(
{
userId: { type: ObjectId, ref: 'User', required: true, unique: true },
gitlab: { type: String, required: true },
},
{ collection: 'gitlabSyncUserCredentials', minimize: false }
)
export const GitLabSyncUserCredentials = mongoose.model(
'GitLabSyncUserCredentials',
GitLabSyncUserCredentialsSchema,
)
@@ -0,0 +1,82 @@
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import fs from 'node:fs'
import crypto from 'node:crypto'
import Path from 'node:path'
import AccessTokenEncryptorClass from '@overleaf/access-token-encryptor'
const TOKEN_CIPHER_FILE = '/var/lib/overleaf/data/.token-cipher.json'
const TOKEN_CIPHER_LABEL = 'OL_CEP-v3'
let encryptorInstance = null
/**
* Get or create a stable encryptor data that persists across container
* recreations. Priority:
* 1. GITLAB_CIPHER_PASSWORD || TOKEN_CIPHER_PASSWORD env var (explicit user config)
* 2. JSON file with encryptor data in the persistent volume (/var/lib/overleaf/data/)
* — auto-generated on first use, survives container rebuilds
*/
function _getEncryptorData() {
const cipherPassword = process.env.GITLAB_TOKEN_CIPHER_PASSWORD || process.env.TOKEN_CIPHER_PASSWORD
const cipherLabel = process.env.GITLAB_TOKEN_CIPHER_LABEL ||
process.env.TOKEN_CIPHER_LABEL ||
TOKEN_CIPHER_LABEL
if (cipherPassword) {
return {
cipherLabel,
cipherPasswords: {
[cipherLabel]: cipherPassword
}
}
}
// cipherPassword is not set, use file
const cipherFile = process.env.GITLAB_TOKEN_CIPHER_FILE ||
process.env.TOKEN_CIPHER_FILE ||
TOKEN_CIPHER_FILE
try {
return JSON.parse(fs.readFileSync(cipherFile, 'utf8'))
} catch (err) {
if (err.code === 'ENOENT') {
logger.info({ cipherFile }, 'Creating new cipherFile')
} else {
logger.error({ err, cipherFile }, 'Bad cipherFile')
throw err
}
}
// File doesn't exist yet — generate one
try {
const encryptorData = {
cipherLabel,
cipherPasswords: {
[cipherLabel]: crypto.randomBytes(32).toString('base64')
},
}
const dir = Path.dirname(cipherFile)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(cipherFile, JSON.stringify(encryptorData, null, 2), { mode: 0o600 })
return encryptorData
} catch (err) {
logger.error({ err, cipherFile }, 'Cannot create cipherFile')
throw err
}
}
function _getEncryptor() {
if (!encryptorInstance) {
const encryptorData = _getEncryptorData()
encryptorInstance = new AccessTokenEncryptorClass(encryptorData)
}
return encryptorInstance
}
export const AccessTokenEncryptor = {
async encryptJson(json) {
return await _getEncryptor().promises.encryptJson(json)
},
async decryptToJson(encryptedJson) {
return await _getEncryptor().promises.decryptToJson(encryptedJson)
},
}
@@ -0,0 +1,665 @@
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import crypto from 'node:crypto'
import {
fetchJson,
fetchJsonWithResponse,
fetchNothing,
fetchStream,
RequestFailedError,
} from '@overleaf/fetch-utils'
import {
InvalidTokenError,
ExpiredTokenError,
NotFoundError,
GitConflictError,
RateLimitError,
ProviderRequestError,
PermissionDeniedError,
AlreadyExistsError,
} from './GitSyncErrors.mjs'
const GITLAB_URL = Settings.gitlabSync?.url
const GITLAB_API_BASE = `${GITLAB_URL}/api/v4`
const GITLAB_DEFAULT_BRANCH = Settings.gitlabSync?.defaultBranch
const MAX_PER_PAGE = 100
const REQUEST_TIMEOUT_MS = 60 * 1000
const REQUEST_LONG_TIMEOUT_MS = 600 * 1000
const MERGE_REQUEST_POLL_INTERVAL_MS = Settings.gitlabSync?.mergeRequestPollInterval
const MERGE_REQUEST_TIMEOUT_MS = Settings.gitlabSync?.mergeRequestTimeout
const mergeRequestPendingStates = new Set([
'unchecked',
'checking',
'preparing',
'approvals_syncing',
])
const mergeRequestBlockingStates = new Set([
'conflict',
'ci_must_pass',
'ci_still_running',
'commits_status',
'discussions_not_resolved',
'draft_status',
'jira_association_missing',
'merge_request_blocked',
'merge_time',
'need_rebase',
'not_approved',
'not_open',
'requested_changes',
'security_policy_pipeline_check',
'security_policy_violations',
'status_checks_must_pass',
'locked_paths',
'locked_lfs_files',
'title_regex',
])
const maxConcurrency = process.env.GITLAB_API_MAX_CONCURRENCY || 5
function buildHeaders(token) {
return {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': 'Overleaf-CEP-GitLab-Sync',
...(token ? { Authorization: `Bearer ${token}` } : {}),
}
}
function encodePath(path) {
return encodeURIComponent(path)
}
function projectPath(repoFullName) {
return encodeURIComponent(repoFullName)
}
function isRateLimitError(response) {
return response.headers.get('retry-after') != null
}
function isRepositoryAlreadyExistsError(err) {
if (err.response?.status !== 400) {
return false
}
const json_data = JSON.parse(err.body)
return Object.values(json_data.message).some(value => {
if (Array.isArray(value)) {
return value.includes("has already been taken");
}
return value === "has already been taken";
});
}
function normalizeGitLabError(err, operation) {
const body = err.body || ''
logger.error({ operation, err, body }, 'GitLab API request failed')
if (err.name === 'AbortError') {
throw new ProviderRequestError('GitLab request timed out', { status: 504 }, err)
}
if (!(err instanceof RequestFailedError)) {
throw new ProviderRequestError('Something wrong with GitLab request', { status: 500 }, err)
}
const status = err.response?.status || 500
if (status === 401) {
const json_body = JSON.parse(err.body) || {}
if (json_body && json_body.error && json_body.error.toLowerCase() === 'invalid_token') {
throw new ExpiredTokenError('Token expired', { status }, err)
}
throw new InvalidTokenError('Invalid token', { status }, err)
}
if (status === 404) {
throw new NotFoundError('Not found', { status }, err)
}
if (status === 409) {
throw new GitConflictError()
}
if (status === 403 || status === 429) {
if (isRateLimitError(err.response)) throw new RateLimitError('Rate limit exeeded', { status: 429 }, err)
throw new PermissionDeniedError('Permission denied', { status: 403 }, err)
}
if (status === 400) {
if (isRepositoryAlreadyExistsError(err)) {
throw new AlreadyExistsError('Repository already exists', { status }, err)
}
}
throw new ProviderRequestError('GitLab request failed', { status }, err)
}
// wrappers
function fetchGitLabJson(url, options, operation) {
return fetchJson(url, options).catch(err => {
normalizeGitLabError(err, operation)
})
}
function fetchGitLabJsonWithResponse(url, options, operation) {
return fetchJsonWithResponse(url, options).catch(err => {
normalizeGitLabError(err, operation)
})
}
function fetchGitLabNothing(url, options, operation) {
return fetchNothing(url, options).catch(err => {
normalizeGitLabError(err, operation)
})
}
function fetchGitLabStream(url, options, operation) {
return fetchStream(url, options).catch(err => {
normalizeGitLabError(err, operation)
})
}
async function fetchAllPages(url, options, operation) {
let page = 1
let all = []
while (true) {
const pageUrl = new URL(url.toString())
pageUrl.searchParams.set('page', page.toString())
const { json, response } = await fetchGitLabJsonWithResponse(pageUrl.toString(), options, operation)
all = all.concat(json)
const link = response.headers.get('link') || ''
if (!link.includes('rel="next"')) break
page++
}
return all
}
function getTokenRefreshTimestamp(token, safetyMarginInSec = 300) {
return token.created_at + token.expires_in - safetyMarginInSec;
}
async function compareCommitsFull(token, repoFullName, from, to) {
const url = new URL(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/compare`)
url.searchParams.set('from', from)
url.searchParams.set('to', to)
return await fetchGitLabJson(url.toString(), {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'compareCommitsFull')
}
// ---------------------- exports ------------------------------- //
// OAuth
function getOAuth2Url() {
const oAuthUrl = new URL(`${GITLAB_URL}/oauth/authorize`)
oAuthUrl.searchParams.append('client_id', Settings.gitlabSync.clientID)
oAuthUrl.searchParams.append('redirect_uri', Settings.gitlabSync.callbackURL)
oAuthUrl.searchParams.append('response_type', 'code')
oAuthUrl.searchParams.append('scope', 'api read_user read_repository write_repository')
return oAuthUrl
}
function exchangeCodeForToken(code) {
return fetchGitLabJson(`${GITLAB_URL}/oauth/token`, {
method: 'POST',
headers: buildHeaders(),
json: {
client_id: Settings.gitlabSync.clientID,
client_secret: Settings.gitlabSync.clientSecret,
code: code,
grant_type: 'authorization_code',
redirect_uri: Settings.gitlabSync.callbackURL,
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'exchangeCodeForToken').then(r => [r.access_token, r.refresh_token, getTokenRefreshTimestamp(r)] )
}
function refreshToken(refreshToken) {
return fetchGitLabJson(`${GITLAB_URL}/oauth/token`, {
method: 'POST',
headers: buildHeaders(),
json: {
client_id: Settings.gitlabSync.clientID,
client_secret: Settings.gitlabSync.clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
redirect_uri: Settings.gitlabSync.callbackURL,
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'refreshToken').then(r => [r.access_token, r.refresh_token, getTokenRefreshTimestamp(r)])
}
function revokeToken(token) {
const { clientID, clientSecret } = Settings.gitlabSync
return fetchGitLabNothing(`${GITLAB_URL}/oauth/revoke`, {
method: 'POST',
headers: buildHeaders(),
json: {
client_id: clientID,
client_secret: clientSecret,
token,
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}, 'revokeToken')
}
// user, orgs, permissions
function getUser(token) {
return fetchGitLabJson(`${GITLAB_API_BASE}/user`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}, 'getUser').then(({ id, username, name }) => ({ id, login: username, name }))
}
async function getUserAndOrgs(token) {
const user = await fetchGitLabJson(`${GITLAB_API_BASE}/user`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}, 'getUserAndOrgs:user')
const groupsUrl = new URL(`${GITLAB_API_BASE}/groups`)
groupsUrl.searchParams.set('all_available', 'true')
groupsUrl.searchParams.set('min_access_level', '30')
groupsUrl.searchParams.set('top_level_only', 'true')
groupsUrl.searchParams.set('per_page', MAX_PER_PAGE.toString())
groupsUrl.searchParams.set('simple', 'true')
const groups = await fetchAllPages(groupsUrl, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}, 'getUserAndOrgs:groups')
return {
user: user.username,
orgs: groups.map(group => group.full_path),
}
}
function getPushPermission(token, repoFullName) {
return fetchGitLabJson(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}, 'getPushPermission').then(project => {
const projectAccess = project.permissions?.project_access?.access_level || 0
const groupAccess = project.permissions?.group_access?.access_level || 0
return Math.max(projectAccess, groupAccess) >= 30
})
}
// repos
function resolveNamespaceId(token, org) {
if (org == null) return Promise.resolve(null)
if (/^\d+$/.test(org)) return Promise.resolve(Number(org))
return fetchGitLabJson(`${GITLAB_API_BASE}/groups/${encodePath(org)}`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}, 'resolveNamespaceId').then(group => group.id)
}
async function createRepo(token, { name, description, isPublic, org }) {
const namespaceId = await resolveNamespaceId(token, org)
const payload = {
name,
description,
visibility: isPublic ? 'public' : 'private',
initialize_with_readme: false,
default_branch: GITLAB_DEFAULT_BRANCH,
merge_method: 'merge', // Use merge here to allow for conflict resolution to work properly without the need for a rebase
}
if (namespaceId != null) {
payload.namespace_id = namespaceId
}
return fetchGitLabJson(`${GITLAB_API_BASE}/projects`, {
method: 'POST',
headers: buildHeaders(token),
json: payload,
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
}, 'createRepo')
}
async function listUserRepos(token) {
const url = new URL(`${GITLAB_API_BASE}/projects`)
url.searchParams.set('membership', 'true')
url.searchParams.set('per_page', MAX_PER_PAGE.toString())
url.searchParams.set('simple', 'true')
url.searchParams.set('order_by', 'last_activity_at')
url.searchParams.set('sort', 'desc')
url.searchParams.set('active', 'true') // Only list projects that are not archived and not marked for deletion
const repos = await fetchAllPages(url, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}, 'listUserRepos')
return repos.map(repo => ({
name: repo.name,
fullName: repo.path_with_namespace,
defaultBranchName: repo.default_branch,
}))
}
// Git (blobs / trees / commits)
function uploadBlob(token, repoFullName, buffer) {
void token
void repoFullName
if (Buffer.isBuffer(buffer)) {
return Promise.resolve(buffer.toString('base64'))
}
if (typeof buffer === 'string') {
return Promise.resolve(buffer)
}
return Promise.resolve(String(buffer))
}
function getBlobStream(token, repoFullName, ref, path) {
const encodedPath = encodePath(path)
return fetchGitLabStream(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/files/${encodedPath}/raw?ref=${encodeURIComponent(ref)}`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS),
}, 'getBlobStream')
}
function createTree(token, repoFullName, entries, baseTree) {
void token
void repoFullName
const existingPaths = new Set((baseTree || []).map(entry => entry.path))
const actions = []
for (const entry of entries) {
if (entry.sha == null) {
actions.push({
action: 'delete',
file_path: entry.path,
})
continue
}
actions.push({
action: existingPaths.has(entry.path) ? 'update' : 'create',
file_path: entry.path,
content: entry.content,
encoding: 'base64',
})
}
return Promise.resolve({
baseTree: baseTree || [],
entries: actions,
})
}
function createCommit(token, repoFullName, { tree, message, branch, start_sha, force = false }) {
const actions = Array.isArray(tree?.entries) ? tree.entries : []
let payload = {
branch: branch || GITLAB_DEFAULT_BRANCH,
commit_message: message,
actions,
}
if (branch && branch != GITLAB_DEFAULT_BRANCH) {
payload.start_sha = start_sha
}
return fetchGitLabJson(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/commits`, {
method: 'POST',
headers: buildHeaders(token),
json: payload,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'createCommit').then(r => r.id) // For GitLab id refers to the SHA of the created commit
}
function getCommitTree(token, repoFullName, commit) {
return listBlobsAtCommit(token, repoFullName, commit)
}
async function listBlobsAtCommit(token, repoFullName, commit) {
const url = new URL(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/tree`)
url.searchParams.set('ref', commit)
url.searchParams.set('recursive', 'true')
url.searchParams.set('per_page', MAX_PER_PAGE.toString())
const treeEntries = await fetchAllPages(url, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
}, 'listBlobsAtCommit')
return treeEntries
.filter(entry => entry.type === 'blob')
.map(entry => ({
sha: entry.id,
path: entry.path,
mode: entry.mode,
type: 'blob',
}))
}
async function getBlobContent(token, repoFullName, sha) {
if (!sha) return null
const url = `${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/blobs/${sha}`
return await fetchGitLabJson(url, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
}, 'getBlobContent').then(r => r.content || '')
}
async function listNewCommitsWithStatus(token, fullName, branchName, fromCommit) {
const forward = await compareCommitsFull(token, fullName, fromCommit, branchName);
const reverse = await compareCommitsFull(token, fullName, branchName, fromCommit);
const forwardHasCommits = forward.commits.length > 0;
const reverseHasCommits = reverse.commits.length > 0;
let diverged = false
if (forward.compare_same_ref) {
// Identical
}
else if (forwardHasCommits && !reverseHasCommits) {
// Ahead
}
else if (!forwardHasCommits && reverseHasCommits) {
// Behind (here also considered a diverged state)
diverged = true
}
else if (forwardHasCommits && reverseHasCommits) {
// Diverged
diverged = true
}
const commits = (forward.commits || []).map(c => ({
message: c.message || c.title || '',
author: {
name: c.author_name || '',
email: c.author_email || '',
date: c.created_at || c.committed_date || '',
},
sha: c.id,
}))
return { commits, diverged }
}
// branches
function getBranchHead(token, repoFullName, branchName) {
return fetchGitLabJson(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/branches/${encodePath(branchName)}`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'getBranchHead').then(r => r.commit.id)
}
function createBranch(token, repoFullName, branchName, sha) {
return fetchGitLabJson(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/branches`, {
method: 'POST',
headers: buildHeaders(token),
json: {
branch: branchName,
ref: sha,
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'createBranch')
}
function updateBranch(token, repoFullName, branchName, sha, force = false) {
// Update functionality does not exist in GitLab API as it automatically updates the branch when a commit is made to it
}
function deleteBranch(token, repoFullName, branchName) {
return fetchGitLabNothing(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/branches/${encodePath(branchName)}`, {
method: 'DELETE',
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'deleteBranch')
}
// merge / compare
async function createMergeRequest(token, repoFullName, sourceBranch, targetBranch, title) {
return fetchGitLabJson(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/merge_requests`, {
method: 'POST',
headers: buildHeaders(token),
json: {
source_branch: sourceBranch,
target_branch: targetBranch,
title,
remove_source_branch: true,
squash: false,
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}, 'createMergeRequest')
}
async function waitForMergeRequestToBeReady(token, repoFullName, mergeRequestIid) {
const startedAt = Date.now()
while (Date.now() - startedAt < MERGE_REQUEST_TIMEOUT_MS) {
const mergeRequest = await fetchGitLabJson(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/merge_requests/${mergeRequestIid}`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}, 'getMergeRequest')
const status = mergeRequest.detailed_merge_status
if (status === 'mergeable') {
return {
canMerge: true,
reason: status,
}
}
if (mergeRequestPendingStates.has(status)) {
await new Promise(resolve => setTimeout(resolve, MERGE_REQUEST_POLL_INTERVAL_MS))
continue
}
if (mergeRequestBlockingStates.has(status)) {
return {
canMerge: false,
reason: status,
}
}
// Unknown future GitLab status, treat as unmergeable to be safe
return {
canMerge: false,
reason: `unknown_status:${status}`,
}
}
throw new Error(`Timed out waiting for merge request ${mergeRequestIid} readiness`)
}
async function mergeOpenMergeRequest(token, repoFullName, mergeRequestIid) {
return fetchGitLabJson(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/merge_requests/${mergeRequestIid}/merge`, {
method: 'PUT',
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS),
}, 'mergeOpenMergeRequest')
}
function mergeBranch(token, repoFullName, base, head) {
return createMergeRequest(token, repoFullName, head, base, `Merge ${head} into ${base}`)
.then(async mergeRequest => {
const ret = await waitForMergeRequestToBeReady(token, repoFullName, mergeRequest.iid)
if (!ret.canMerge) {
throw new GitConflictError("Merge request cannot be merged due to conflicts, reason: " + ret.reason)
}
await mergeOpenMergeRequest(token, repoFullName, mergeRequest.iid)
return getBranchHead(token, repoFullName, base)
})
}
function compareCommits(token, repoFullName, from, to) {
const url = new URL(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/compare`)
url.searchParams.set('from', from)
url.searchParams.set('to', to)
url.searchParams.set('straight', 'true')
return fetchGitLabJson(url.toString(), {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}, 'compareCommits').then(r => r.diffs || [])
}
function getRepoZipball(token, repoFullName, sha) {
return fetchGitLabStream(`${GITLAB_API_BASE}/projects/${projectPath(repoFullName)}/repository/archive.zip?sha=${encodeURIComponent(sha)}`, {
headers: buildHeaders(token),
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS),
}, 'getRepoZipball')
}
export default {
maxConcurrency,
getOAuth2Url,
exchangeCodeForToken,
refreshToken,
revokeToken,
getUser,
getUserAndOrgs,
getPushPermission,
createRepo,
listUserRepos,
uploadBlob,
getBlobStream,
createTree,
createCommit,
getCommitTree,
listBlobsAtCommit,
getBlobContent,
listNewCommitsWithStatus,
getBranchHead,
createBranch,
updateBranch,
deleteBranch,
mergeBranch,
compareCommits,
getRepoZipball,
}
@@ -0,0 +1,242 @@
import { expressify } from '@overleaf/promise-utils'
import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.mjs'
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs'
import Csrf from '../../../../app/src/infrastructure/Csrf.mjs'
import GitLabSyncHandler from './GitLabSyncHandler.mjs'
import TokenManager from './TokenManager.mjs'
import api from './GitLabApiClient.mjs'
import { doGitMerge } from './GitMerge.mjs'
import { InvalidTokenError, GitNotLinkedError, AlreadyExistsError } from './GitSyncErrors.mjs'
async function getConnectionStatus(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
try {
const isConnected = await GitLabSyncHandler.getGitConnState(userId)
res.json(isConnected)
} catch (err) {
const info = OError.getFullInfo(err)
const errStatus = info?.status || 500
logger.error(OError.getFullStack(err))
logger.error({ info }, "failed to check user connection")
return res.status(errStatus).json({ message: err.message })
}
}
async function getProjectState(req, res) {
const { project_id: projectId } = req.params
const userId = SessionManager.getLoggedInUserId(req.session)
try {
const pss = await GitLabSyncHandler.getProjectState(userId, projectId)
return res.json(pss)
} catch (err) {
const info = OError.getFullInfo(err)
const errStatus = info?.status || 500
logger.error(OError.getFullStack(err))
logger.error({ info }, "failed get project sync state")
return res.status(errStatus).json({ message: err.message })
}
}
async function getUserAndOrgs(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const userAndOrgs = await GitLabSyncHandler.getUserAndOrgs(userId)
res.json(userAndOrgs)
}
async function listUserRepos(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
try {
const repos = await GitLabSyncHandler.listUserRepos(userId)
res.json({ repos })
} catch (err) {
if (err instanceof InvalidTokenError) {
return res.json(null)
}
const info = OError.getFullInfo(err)
const errStatus = info?.status || 500
logger.error(OError.getFullStack(err))
logger.error({ userId, info }, 'Failed to get repositories list')
return res.status(errStatus).json({ message: err.message })
}
}
async function getMergeOverview(req, res) {
const { project_id: projectId } = req.params
const userId = SessionManager.getLoggedInUserId(req.session)
try {
const commitsAndStatus = await GitLabSyncHandler.getMergeOverview(userId, projectId)
res.json(commitsAndStatus)
} catch (err) {
const info = OError.getFullInfo(err)
const errStatus = info?.status || 500
logger.error(OError.getFullStack(err))
logger.error({ info, projectId }, 'Error listing commits since last sync')
return res.status(errStatus).json({ message: err.message })
}
}
// Import a GitLab repository as a new project
async function importRepo(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const { name, fullName, defaultBranchName } = req.body
try {
const projectId = await GitLabSyncHandler.importRepo(userId, name, fullName, defaultBranchName)
res.json({ projectId })
} catch (error) {
logger.error({ error, userId }, 'Failed to import git repository from server')
res.status(error.status || 500).json({ message: error.message })
}
}
// Redirect user to Git server OAuth2 authorization URL
async function oauth2(req, res) {
const oauth2Url = api.getOAuth2Url()
oauth2Url.searchParams.append('state', req.csrfToken())
res.redirect(oauth2Url.toString())
}
// callback
async function oauth2Callback(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const { code, state } = req.query
try {
await Csrf.promises.validateToken(state, req.session)
} catch {
HttpErrorHandler.forbidden(req, res, 'Invalid CSRF token')
return
}
let token
let refresh_token
let refresh_timestamp
try {
[token, refresh_token, refresh_timestamp] = await api.exchangeCodeForToken(code)
if (!token || !refresh_token) {
HttpErrorHandler.badRequest(req, res, 'Failed to obtain access token from Git server')
return
}
} catch (err) {
const info = OError.getFullInfo(err)
const err_body = err.body
logger.error(OError.getFullStack(err))
logger.error({ info, err_body, userId }, 'Failed to obtain access token from Git server')
HttpErrorHandler.badRequest(req, res, err.message || 'Bad request')
return
}
try {
await TokenManager.saveUserToken(userId, {token, refresh_token, refresh_timestamp})
} catch (err) {
const info = OError.getFullInfo(err)
const errStatus = info?.status || 500
logger.error(OError.getFullStack(err))
logger.error({ info, userId }, 'Error saving user token')
HttpErrorHandler.handleErrorByStatusCode(req, res, err, errStatus)
return
}
// Save success message in session to display on redirect
req.session.projectSyncSuccessMessage =
req.i18n.translate('gitlab_successfully_linked_description')
res.redirect('/user/settings?oauth-complete=gitlab#project-sync')
}
// Unlink user's Git server account
async function unlink(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
try {
await TokenManager.removeUserToken(userId)
} catch (err) {
const info = OError.getFullInfo(err)
const errStatus = info?.status || 500
logger.error(OError.getFullStack(err))
logger.error({ info, projectId }, 'Error removing user credentials')
return res.status(errStatus).json({ message: err.message })
}
res.sendStatus(200)
}
// Export project to Git server
// Expected req.body:
// name (string): repository name
// description (string, optional): repository description
// isPublic (boolean, optional): if true, the repository is public
// org (string, optional): if provided, repository will be created under this organization
async function exportProject(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const { project_id: projectId } = req.params
try {
await GitLabSyncHandler.exportProject(userId, projectId, req.body)
} catch (err) {
const info = OError.getFullInfo(err)
const errStatus = info?.status || 500
logger.error(OError.getFullStack(err))
logger.error({ info, projectId }, 'Error exporting project')
let key = 'gitlab_validation_check'
if (err instanceof AlreadyExistsError) key = 'gitlab_validation_name_exists'
else if (errStatus === 401 || errStatus === 403) key = 'gitlab_validation_check_auth'
return res.status(errStatus).json({ key, message: err.message })
}
res.sendStatus(200)
}
async function gitMerge(req, res) {
const { project_id: projectId } = req.params
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const commitMessage = req.body?.message || 'Updates from Overleaf'
const claimConflictIsResolved = req.body.claimConflictIsResolved
const mergeResult = await doGitMerge(userId, projectId, commitMessage, claimConflictIsResolved)
res.json(mergeResult)
} catch (err) {
const info = OError.getFullInfo(err)
const errStatus = info?.status || 500
logger.error(OError.getFullStack(err))
logger.error({ info, projectId }, 'Error syncing project to GitLab')
return res.status(errStatus).json({ message: err.message })
}
}
// Unlink user's Git server account
async function unlinkRepo(req, res) {
const { project_id: projectId } = req.params
const userId = SessionManager.getLoggedInUserId(req.session)
try {
const ownerEmail = await GitLabSyncHandler.unlinkRepo(userId, projectId)
if (ownerEmail) return res.status(403).json({ ownerEmail })
} catch (err) {
const info = OError.getFullInfo(err)
const errStatus = info?.status || 500
logger.error(OError.getFullStack(err))
logger.error({ info, projectId }, 'Error unlinking repo from project')
return res.status(errStatus).json({ message: err.message })
}
res.sendStatus(200)
}
export default {
getConnectionStatus: expressify(getConnectionStatus),
getProjectState: expressify(getProjectState),
oauth2: expressify(oauth2),
unlink: expressify(unlink),
getUserAndOrgs: expressify(getUserAndOrgs),
oauth2Callback: expressify(oauth2Callback),
listUserRepos: expressify(listUserRepos),
importRepo: expressify(importRepo),
exportProject: expressify(exportProject),
getMergeOverview: expressify(getMergeOverview),
gitMerge: expressify(gitMerge),
unlinkRepo: expressify(unlinkRepo),
}
@@ -0,0 +1,249 @@
import Path from 'path'
import fs from 'fs'
import { pipeline } from 'node:stream/promises'
import crypto from 'crypto'
import pLimit from 'p-limit'
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs'
import ProjectUploadManager from '../../../../app/src/Features/Uploads/ProjectUploadManager.mjs'
import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs'
import DocumentUpdaterHandler from '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.mjs'
import api from './GitLabApiClient.mjs'
import SyncStateManager from './SyncStateManager.mjs'
import HistoryManager from './HistoryManager.mjs'
import TokenManager from './TokenManager.mjs'
import { InvalidTokenError, NotFoundError } from './GitSyncErrors.mjs'
async function getGitConnState(userId) {
try {
const token = await TokenManager.getUserToken(userId)
if (!token) return false;
await api.getUser(token)
return true
} catch (err) {
if (err instanceof InvalidTokenError) {
logger.debug( { err, userId }, 'token invalid, treating as not connected')
return false
}
throw OError.tag(err, 'failed to validate token', { userId })
}
}
async function getProjectState(userId, projectId) {
let pss = null
const projection = { _id: 0, mergeStatus: 1, repoFullName: 1, unmergedBranchName: 1 }
pss = await SyncStateManager.getProjectState(projectId, projection)
if (!pss) {
pss = { mergeStatus: 'need-export' }
try {
const { owner_ref } = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1 })
if (owner_ref.toString() !== userId) {
pss.ownerEmail = await UserGetter.promises.getUserEmail(owner_ref)
}
} catch (err) {
pss.ownerEmail = 'nobody@nowhere'
logger.error({ err }, "failed get user email")
}
return pss
}
let canPush
try {
const token = await TokenManager.getUserToken(userId)
if (!token) throw new InvalidTokenError('no user token', { userId, status: 400 });
canPush = await api.getPushPermission(token, pss.repoFullName)
} catch (err) {
if ((err instanceof NotFoundError) ||
(err instanceof PermissionDeniedError)
) canPush = false
else throw err
}
if (!canPush) {
pss.mergeStatus = 'need-permission'
// send owner's email to collaborator
// send nothing to the owner
try {
const { owner_ref } = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1 })
if (owner_ref.toString() !== userId) {
pss.ownerEmail = await UserGetter.promises.getUserEmail(owner_ref)
}
} catch (err) {
logger.error({ err, userId }, "failed get user email")
pss.ownerEmail = 'nobody@nowhere'
}
}
return pss
}
async function unlinkRepo(userId, projectId) {
const { owner_ref } = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1 })
if (owner_ref.toString() !== userId) {
let ownerEmail
try {
ownerEmail = await UserGetter.promises.getUserEmail(owner_ref)
} catch (err) {
logger.error({ err, userId }, "failed get user email")
ownerEmail = 'nobody@nowhere'
}
return ownerEmail
}
await SyncStateManager.removeProjectState(projectId)
return null
}
async function listUserRepos(userId) {
const token = await TokenManager.getUserToken(userId)
if (!token) throw new InvalidTokenError('no user token', { userId, status: 400 });
return await api.listUserRepos(token)
}
async function getUserAndOrgs(userId) {
const token = await TokenManager.getUserToken(userId)
if (!token) throw new InvalidTokenError('no user token', { userId, status: 400 });
return await api.getUserAndOrgs(token)
}
async function getMergeOverview(userId, projectId) {
const projectSyncState = await SyncStateManager.getProjectState(projectId)
if (!projectSyncState) throw new GitNotLinkedError(projectId)
const { repoFullName, defaultBranchName, lastSyncCommit, lastSyncVersion, mergeStatus } = projectSyncState
if (mergeStatus === 'conflict') return null
const token = await TokenManager.getUserToken(userId)
if (!token) throw new InvalidTokenError('no user token', { userId, status: 400 });
const currentVersion = await HistoryManager.latestVersion(projectId.toString())
const isProjectUpdated = currentVersion !== lastSyncVersion
try {
const commitsAndStatus = await api.listNewCommitsWithStatus(token, repoFullName, defaultBranchName, lastSyncCommit)
if(commitsAndStatus.diverged) {
await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'diverged' } )
}
return { ...commitsAndStatus, isProjectUpdated }
} catch (err) {
if (err instanceof NotFoundError) {
// what is the reason of NotFoundError? is it a missing commit or not?
let canPush
try {
canPush = await api.getPushPermission(token, repoFullName)
} catch (e) {
logger.error({ error: e }, "failed to check push permission")
canPush = false
}
if (!canPush) throw err
await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'diverged' } )
return { commits: [], diverged: true, isProjectUpdated }
} else {
throw err
}
}
}
async function importRepo(userId, projectName, repoFullName, defaultBranchName) {
const token = await TokenManager.getUserToken(userId)
if (!token) throw new InvalidTokenError('no user token', { userId, status: 400 });
const defaultBranchHead = await api.getBranchHead(token, repoFullName, defaultBranchName)
const fsPath = Path.join(Settings.path.dumpFolder, `gitlab_import_${crypto.randomUUID()}`)
let project_id
try {
const stream = await api.getRepoZipball(token, repoFullName, defaultBranchHead)
await pipeline(stream, fs.createWriteStream(fsPath))
const { project } = await ProjectUploadManager.promises.createProjectFromZipArchiveWithName(
userId,
projectName,
fsPath
)
project_id = project?._id
} catch (err) {
throw OError.tag(
err,
'failed importing git repo',
{ userId, projectName, repoFullName, defaultBranchName, fsPath }
)
} finally {
fs.promises.rm(fsPath, { force: true }).catch(() => {})
}
try {
const projectVersion = await HistoryManager.latestVersion(project_id.toString())
await SyncStateManager.createProjectState(project_id, {
repoFullName,
mergeStatus: 'clean',
lastSyncCommit: defaultBranchHead,
defaultBranchName,
lastSyncVersion: projectVersion
})
} catch (err) {
// don't throw up
logger.error(
{ err, userId, projectId: project_id.toString(), repoFullName },
'Failed to create state of imported repo'
)
}
return project_id
}
async function exportProject(userId, projectId, repoOptions) {
const isLinked = await SyncStateManager.getProjectState(projectId)
if (isLinked) {
throw new OError('Project is already linked to Git server', { projectId })
}
const token = await TokenManager.getUserToken(userId)
if (!token) throw new InvalidTokenError('no user token', { userId, status: 400 });
const { path_with_namespace: repoFullName, default_branch: defaultBranchName } =
await api.createRepo(token, repoOptions)
await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
const currentVersion = await HistoryManager.latestVersion(projectId)
const currentPaths = await HistoryManager.getPathsAtVersion(projectId, currentVersion)
const limit = pLimit(api.maxConcurrency)
const blobData = await Promise.all(
currentPaths.paths.map((path) =>
limit(async () => {
const buffer = await HistoryManager.getProjectFileBuffer(projectId, currentVersion, path)
const sha = "DON_T_DELETE" // sha needs to be present so that the file is not detected as a deletion
return { path, sha, content: buffer }
})
)
)
const tree = await api.createTree(token, repoFullName, blobData)
const initialCommit = await api.createCommit(token, repoFullName, {
tree,
message: 'Initial Overleaf import',
})
const force = true
return SyncStateManager.createProjectState(projectId, {
mergeStatus: 'clean',
defaultBranchName,
lastSyncCommit: initialCommit,
lastSyncVersion: currentVersion,
repoFullName,
})
}
export default {
exportProject,
getProjectState,
unlinkRepo,
importRepo,
getGitConnState,
listUserRepos,
getUserAndOrgs,
getMergeOverview,
}
@@ -0,0 +1,100 @@
import logger from '@overleaf/logger'
import GitLabSyncController from './GitLabSyncController.mjs'
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs'
import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs'
export default {
apply(webRouter) {
logger.debug({}, 'Init gitlab-sync router')
// start the GitLab OAuth flow by redirecting to GitLab authorization page
webRouter.get(
'/user/gitlab-sync/oauth2',
AuthenticationController.requireLogin(),
GitLabSyncController.oauth2
)
// callback for GitLab OAuth flow
webRouter.get(
'/user/gitlab-sync/oauth2/callback',
AuthenticationController.requireLogin(),
GitLabSyncController.oauth2Callback
)
// unlink GitLab account
webRouter.post(
'/user/gitlab-sync/unlink',
AuthenticationController.requireLogin(),
GitLabSyncController.unlink
)
// get user git connection status
webRouter.get(
'/user/gitlab-sync/status',
AuthenticationController.requireLogin(),
GitLabSyncController.getConnectionStatus
)
// get git user name and user's organizations
webRouter.get(
'/user/gitlab-sync/orgs',
AuthenticationController.requireLogin(),
GitLabSyncController.getUserAndOrgs
)
// list user's repos
webRouter.get(
'/user/gitlab-sync/repos',
AuthenticationController.requireLogin(),
GitLabSyncController.listUserRepos
)
// create a new project from git server repo
webRouter.post(
'/project/new/gitlab-sync',
AuthenticationController.requireLogin(),
GitLabSyncController.importRepo
)
// export project to git server
webRouter.post(
'/project/:project_id/gitlab-sync/export',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
GitLabSyncController.exportProject
)
// get project sync state
webRouter.get(
'/project/:project_id/gitlab-sync/state',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
GitLabSyncController.getProjectState
)
// get overview of the coming merge:
// merge state (clean or diverged), unmerged commits, is OL version changed?
webRouter.get(
'/project/:project_id/gitlab-sync/merge/overview',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
GitLabSyncController.getMergeOverview
)
// merge OL project with Git server repo
webRouter.post(
'/project/:project_id/gitlab-sync/merge',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
GitLabSyncController.gitMerge
)
// unlink Git repo from Git server
webRouter.delete(
'/project/:project_id/gitlab-sync',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
GitLabSyncController.unlinkRepo
)
},
}
@@ -0,0 +1,753 @@
import { normalize } from 'path/posix'
import pLimit from 'p-limit'
import { createHash } from 'crypto'
import logger from '@overleaf/logger'
import SyncStateManager from './SyncStateManager.mjs'
import api from './GitLabApiClient.mjs'
import HistoryManager from './HistoryManager.mjs'
import { GitConflictError } from './GitSyncErrors.mjs'
import TokenManager from './TokenManager.mjs'
import { ObjectId } from '../../../../app/src/infrastructure/mongodb.mjs'
import LockManager from '../../../../app/src/infrastructure/LockManager.mjs'
import ProjectEntityHandler from '../../../../app/src/Features/Project/ProjectEntityHandler.mjs'
import UpdateMerger from '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger.mjs'
import DocumentUpdaterHandler from '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.mjs'
const SyncLockManager = LockManager.withTimeout(300) // 5 min
export async function doGitMerge(userId, projectId, message, claimConflictIsResolved) {
return await SyncLockManager.promises.runWithLock(
'gitSync',
projectId,
() => doGitMergeWithoutLock(userId, projectId, message, claimConflictIsResolved)
)
}
async function doGitMergeWithoutLock(userId, projectId, message, claimConflictIsResolved) {
const projectSyncState = await SyncStateManager.getProjectState(projectId)
if (!projectSyncState) throw new GitNotLinkedError(projectId)
const {
repoFullName,
defaultBranchName,
mergeStatus,
unmergedBranchName
} = projectSyncState
// do nothing if conflict is not yet resolved and 'Sync' button is pushed
// do nothing if conflict is already resolved and 'I have manually merged' button is pushed
if (
!claimConflictIsResolved && mergeStatus === 'conflict' ||
claimConflictIsResolved && mergeStatus !== 'conflict'
) return { mergeStatus, repoFullName, unmergedBranchName }
const token = await TokenManager.getUserToken(userId)
if (!token) throw new InvalidTokenError('no user token', { userId, status: 400 });
await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
const currentVersion = await HistoryManager.latestVersion(projectId)
const defaultBranchHead = await api.getBranchHead(token, repoFullName, defaultBranchName)
let newUnmergedBranchName
switch (mergeStatus) {
case 'clean':
newUnmergedBranchName = await resolveCleanSyncState(
token,
userId,
projectSyncState,
currentVersion,
defaultBranchHead,
message,
)
break
case 'conflict':
newUnmergedBranchName = await resolveConflictSyncState(
token,
userId,
projectSyncState,
currentVersion,
defaultBranchHead
)
break
case 'diverged':
newUnmergedBranchName = await resolveDetachedSyncState(
token,
userId,
projectSyncState,
currentVersion,
defaultBranchHead,
message,
)
break
default:
throw new Error('Bad mergeStatus')
}
const newMergeStatus = newUnmergedBranchName ? 'conflict' : 'clean'
return { mergeStatus: newMergeStatus, repoFullName, unmergedBranchName: newUnmergedBranchName }
}
// ----------- clean flow
async function resolveCleanSyncState(
token,
userId,
projectSyncState,
currentVersion,
defaultBranchHead,
message,
) {
const { projectId, repoFullName, defaultBranchName } = projectSyncState
// No changes in OL
if (currentVersion === projectSyncState.lastSyncVersion) {
// No changes in GH too: everything is up to date
if (projectSyncState.lastSyncCommit === defaultBranchHead) return null
// Need to apply only GH changes
const lastSyncCommit = defaultBranchHead
const lastSyncVersion = await applyGitSnapshotToProject({
token,
userId,
projectId,
repoFullName,
lastSyncCommit,
})
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit })
return null
}
// Possible changes in OL
if (defaultBranchHead === projectSyncState.lastSyncCommit) {
try {
const olBranchHead =
await exportChangesToGit({
token,
projectId,
repoFullName,
lastSyncVersion: projectSyncState.lastSyncVersion,
currentVersion,
message,
baseCommit: projectSyncState.lastSyncCommit,
defaultBranchName,
})
if (!olBranchHead) { // no real canges in OL, but currentVersion is ahead
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion: currentVersion })
return null
}
const lastSyncCommit = olBranchHead
const lastSyncVersion = await applyGitSnapshotToProject({
token,
userId,
projectId,
repoFullName,
lastSyncCommit,
})
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit })
return null
} catch (err) {
logger.error({ err, projectId }, 'Error during clean sync state resolution, fallback to merge')
}
}
// Git head is ahead of last known sync commit, create a temporary branch with OL changes and perform a merge with the default branch
const tempBranchName = generateBranchName()
const olBranchHead =
await exportChangesToGit({
token,
projectId,
repoFullName,
lastSyncVersion: projectSyncState.lastSyncVersion,
currentVersion,
message,
baseCommit: projectSyncState.lastSyncCommit,
branch: tempBranchName,
})
// Merge the temporary branch into the default branch
const { mergeCommit, conflict } = await mergeWithTempBranch(
token,
repoFullName,
defaultBranchName,
tempBranchName
)
if (conflict) {
await SyncStateManager.updateProjectState(projectId, {
mergeStatus: 'conflict',
lastSyncCommit: defaultBranchHead,
unmergedBranchName: tempBranchName,
unmergedBranchHead: olBranchHead,
conflictVersion: currentVersion,
})
return tempBranchName
} else {
const lastSyncCommit = mergeCommit
const lastSyncVersion = await applyGitSnapshotToProject({
token,
userId,
projectId,
repoFullName,
lastSyncCommit,
})
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit })
return null
}
}
// ----------- conflict flow
async function resolveConflictSyncState(
token,
userId,
projectSyncState,
currentVersion,
defaultBranchHead
) {
const { projectId, repoFullName, defaultBranchName, conflictVersion } = projectSyncState
// No OL changes after manual conflict resolving: pull GH changes only
if (currentVersion === conflictVersion) {
const lastSyncCommit = defaultBranchHead
const lastSyncVersion = await applyGitSnapshotToProject({
token,
userId,
projectId,
repoFullName,
lastSyncCommit,
})
await SyncStateManager.updateProjectState(projectId, {
mergeStatus: 'clean',
lastSyncVersion,
lastSyncCommit,
conflictVersion: null,
unmergedBranchName: null,
unmergedBranchHead: null
})
return null
}
// OL was changed after manual conflict resolution: need to merge
const prevOlBranchHead = projectSyncState.unmergedBranchHead
// Create a temporary branch with OL changes after manual conflict resolution
const tempBranchName = generateBranchName()
const newOlBranchHead = await exportChangesToGit({
token,
projectId,
repoFullName,
lastSyncVersion: conflictVersion,
currentVersion,
message: '[Updates in Overleaf during conflict resolution]',
baseCommit: prevOlBranchHead,
branch: tempBranchName,
})
// Were there really any changes? (e.g.: file created, then removed: no changes)
if (!newOlBranchHead) {
// nothing to push to GH
const lastSyncCommit = defaultBranchHead
const lastSyncVersion = await applyGitSnapshotToProject({
token,
userId,
projectId,
repoFullName,
lastSyncCommit,
})
await SyncStateManager.updateProjectState(projectId, {
mergeStatus: 'clean',
lastSyncVersion,
lastSyncCommit,
conflictVersion: null,
unmergedBranchName: null,
unmergedBranchHead: null
})
return null
}
// merge
const { mergeCommit, conflict } = await mergeWithTempBranch(
token,
repoFullName,
defaultBranchName,
tempBranchName
)
if (conflict) {
// conflict must be resolved manually
await SyncStateManager.updateProjectState(projectId, {
mergeStatus: 'conflict',
lastSyncCommit: defaultBranchHead,
unmergedBranchName: tempBranchName,
unmergedBranchHead: newOlBranchHead,
conflictVersion: currentVersion,
})
return tempBranchName
} else {
// merge succeeded
const lastSyncCommit = mergeCommit
const lastSyncVersion = await applyGitSnapshotToProject({
token,
userId,
projectId,
repoFullName,
lastSyncCommit,
})
await SyncStateManager.updateProjectState(projectId, {
mergeStatus: 'clean',
lastSyncVersion,
lastSyncCommit,
conflictVersion: null,
unmergedBranchName: null,
unmergedBranchHead: null
})
return null
}
}
// ----------- detached flow
// GH repo was force pushed, lastSync commit possibly unavailable
async function resolveDetachedSyncState(
token,
userId,
projectSyncState,
currentVersion,
defaultBranchHead,
message,
) {
const { projectId, repoFullName, defaultBranchName } = projectSyncState
// No changes in OL: re-anchor
if (currentVersion === projectSyncState.lastSyncVersion) {
const lastSyncCommit = defaultBranchHead
const lastSyncVersion = await applyGitSnapshotToProject({
token,
userId,
projectId,
repoFullName,
lastSyncCommit,
})
await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'clean', lastSyncVersion, lastSyncCommit })
return null
}
// Need to compare lastSync OL version (base), current OL version (local) and GH version (remote)
const baseSnapshot = await HistoryManager.getProjectSnapshot(projectId, projectSyncState.lastSyncVersion)
const localSnapshot = await HistoryManager.getProjectSnapshot(projectId, currentVersion)
const remoteBlobMap = await getGitBlobMap(token, repoFullName, defaultBranchHead)
const {
cleanLocalEntries,
conflictRemoteEntries,
conflictLocalEntries,
conflictBaseEntries,
hasConflicts,
hasChanges
} = await buildDetachedSyncPlan({
token,
repoFullName,
projectId,
currentVersion,
baseSnapshot,
localSnapshot,
remoteBlobMap,
})
// were any changes introduced in GH?
if (!hasChanges) {
await SyncStateManager.updateProjectState(projectId, {
mergeStatus: 'clean',
lastSyncVersion: currentVersion,
lastSyncCommit: defaultBranchHead
})
return null
}
if (!hasConflicts) {
let lastSyncCommit = defaultBranchHead
if (cleanLocalEntries.length !== 0) {
lastSyncCommit = await createCommitFromEntries({
token,
repoFullName,
parentCommit: defaultBranchHead,
entries: cleanLocalEntries,
message,
})
}
const lastSyncVersion = await applyGitSnapshotToProject({
token,
userId,
projectId,
repoFullName,
lastSyncCommit,
})
await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'clean', lastSyncVersion, lastSyncCommit })
return null
}
// Resolve potential conflict.
// Build synthetic merge topology:
//
// GH
// /
// H --- B ---
// \
// OL
//
// merge(GH <- OL)
//
// B: conflicted files are restored to lastSyncVersion (merge base)
// GH: in conflicted files remote changes after lastSyncVersion are replayed
// OL: all changes done in Overleaf after lastSyncVersion are applied
// Blobs referenced in conflictBaseEntries in theory could be lost, but for GitHub,
// GitLab and Gitea, blobs from historical commits are normally retained for a
// very long time so the practical probability of failure is likely close to zero.
// TODO: anyway, if error is thrown by api.createTree, upload all blobs
// referenced in conflictBaseEntries and try again
// H --- B
const baseCommit = await createCommitFromEntries({
token,
repoFullName,
parentCommit: defaultBranchHead,
entries: conflictBaseEntries,
message: '[Overleaf GitSync conflict resolution: restore last sync]',
})
// default branch: --- H --- B --- GH
const remoteChangesCommit = await createCommitFromEntries({
token,
repoFullName,
parentCommit: baseCommit,
entries: conflictRemoteEntries,
message: '[Overleaf GitSync conflict resolution: replay repo changes]',
})
const tempBranchName = generateBranchName()
// temp branch: --- H --- B --- OL
const olChangesCommit = await createCommitFromEntries({
token,
repoFullName,
parentCommit: baseCommit,
entries: [...cleanLocalEntries, ...conflictLocalEntries],
message,
branch: tempBranchName,
})
// merge: default <- temp (GH <- OL)
const { mergeCommit, conflict } = await mergeWithTempBranch(
token,
repoFullName,
defaultBranchName,
tempBranchName
)
// auto-merge succeeded, conflicts in editable files were resolved?
if (!conflict) {
const lastSyncCommit = mergeCommit
const lastSyncVersion = await applyGitSnapshotToProject({
token,
userId,
projectId,
repoFullName,
lastSyncCommit,
})
await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'clean', lastSyncVersion, lastSyncCommit })
return null
}
// merge failed: must manually resolve conflicts
await SyncStateManager.updateProjectState(projectId, {
mergeStatus: 'conflict',
lastSyncCommit: remoteChangesCommit,
unmergedBranchName: tempBranchName,
unmergedBranchHead: olChangesCommit,
conflictVersion: currentVersion,
})
return tempBranchName
}
// ----------- Helpers
async function applyGitSnapshotToProject({ token, userId, projectId, repoFullName, lastSyncCommit }) {
const invalidFiles = []
const updates = []
const deletions = []
const currentVersion = await HistoryManager.latestVersion(projectId)
const historySnapshot = await HistoryManager.getProjectSnapshot(projectId, currentVersion)
const blobMap = await getGitBlobMap(token, repoFullName, lastSyncCommit)
const blobPaths = new Set()
for (const [path, sha] of Object.entries(blobMap)) {
const invalid = validateFileName(path)
if (invalid) {
invalidFiles.push(invalid.name)
continue
}
blobPaths.add(path)
const hstSha = historySnapshot[path]?.data?.hash
// File at path is not changed, do not touch it
if (hstSha === sha) continue
updates.push(path)
}
for (const path of Object.keys(historySnapshot)) {
if (blobPaths.has(path)) continue
deletions.push(path)
}
// TODO: do not silently ignore invalidFiles, inform user about them
if (invalidFiles.length > 0) {
logger.warn({ projectId, invalidFiles }, 'Invalid file paths detected at Git server')
}
const limit = pLimit(api.maxConcurrency)
await Promise.all(
updates.map(path => limit(async () => {
const stream = await api.getBlobStream(token, repoFullName, lastSyncCommit, path)
return UpdateMerger.promises.mergeUpdate(userId, projectId, '/' + path, stream, 'gitlab')
}))
)
const localLimit = pLimit(5)
await Promise.all(
deletions.map(path => localLimit(() => {
return UpdateMerger.promises.deleteUpdate(userId, projectId, '/' + path, 'gitlab')
}))
)
return await HistoryManager.latestVersion(projectId)
}
async function buildDetachedSyncPlan({
token,
repoFullName,
projectId,
currentVersion,
baseSnapshot,
localSnapshot,
remoteBlobMap
}) {
const cleanLocalEntries = []
const conflictRemoteEntries = []
const conflictLocalEntries = []
const conflictBaseEntries = []
let hasConflicts = false
let hasChanges = false
const allPaths = new Set([
...Object.keys(baseSnapshot || {}),
...Object.keys(localSnapshot || {}),
...Object.keys(remoteBlobMap || {}),
])
for (const path of allPaths) {
const baseHash = getEntryHash(baseSnapshot[path])
const localHash = getEntryHash(localSnapshot[path])
const remoteHash = remoteBlobMap[path] || null
// OL == GH, do nothing
if (localHash === remoteHash) continue
if (baseHash === remoteHash) {
// changed in OL
hasChanges = true
cleanLocalEntries.push({ path, sha: localHash, content: Buffer.from(localSnapshot[path]?.data?.content || '', 'utf8').toString('base64') })
continue
}
if (baseHash === localHash) {
// changed in GH
hasChanges = true
continue
}
// conflict: changed both in GH and OL
hasChanges = true
hasConflicts = true
conflictRemoteEntries.push({ path, sha: remoteHash, content: await api.getBlobContent(token, repoFullName, remoteHash) })
conflictLocalEntries.push({ path, sha: localHash, content: Buffer.from(localSnapshot[path]?.data?.content || '', 'utf8').toString('base64') })
conflictBaseEntries.push({ path, sha: baseHash, content: Buffer.from(baseSnapshot[path]?.data?.content || '', 'utf8').toString('base64') })
}
return {
cleanLocalEntries,
conflictRemoteEntries,
conflictLocalEntries,
conflictBaseEntries,
hasConflicts,
hasChanges,
}
}
async function createCommitFromEntries({
token,
repoFullName,
parentCommit,
entries,
message,
branch,
}) {
const baseTree = await api.getCommitTree(token, repoFullName, parentCommit)
const newTree = await api.createTree(token, repoFullName, entries, baseTree)
return api.createCommit(token, repoFullName, {
tree: newTree,
start_sha: parentCommit,
message,
branch,
})
}
async function mergeWithTempBranch(
token,
repoFullName,
branchName,
tempBranchName
) {
try {
const mergeCommit =
await api.mergeBranch(token, repoFullName, branchName, tempBranchName)
return { conflict: false, mergeCommit }
} catch (err) {
if (err instanceof GitConflictError) {
return { conflict: true }
}
throw err
}
}
async function exportChangesToGit({
token,
projectId,
repoFullName,
lastSyncVersion,
currentVersion,
message,
baseCommit,
branch,
}) {
const diff = await HistoryManager.getProjectFileTreeDiff(
projectId,
lastSyncVersion,
currentVersion
)
const { paths } = await HistoryManager.getPathsAtVersion(projectId, lastSyncVersion)
const existedAtLastSync = new Set(paths)
const deletePaths = new Set()
const upsertPaths = new Set()
for (const item of diff) {
const oldPath = item.pathname
if (item.operation === 'removed') {
// it can happen that removed file was created after last sync
if (existedAtLastSync.has(oldPath)) deletePaths.add(oldPath)
continue
}
if (item.operation === 'renamed') {
if (existedAtLastSync.has(oldPath)) deletePaths.add(oldPath)
upsertPaths.add(item.newPathname)
continue
}
if (item.operation === 'added' || item.operation === 'edited') {
upsertPaths.add(oldPath)
}
}
// if a file is renamed and a new file with the old name is created,
// then the name will be in both sets, need to exclude it from deletePaths
for (const path of upsertPaths) {
if (deletePaths.has(path)) deletePaths.delete(path)
}
// this can happen when a file is created and then removed
if (deletePaths.size === 0 && upsertPaths.size === 0) return null
const limit = pLimit(api.maxConcurrency)
const upsertEntries = await Promise.all(
[...upsertPaths].map(path =>
limit(async () => {
const buffer = await HistoryManager.getProjectFileBuffer(projectId, currentVersion, path)
const sha = "DON_T_DELETE" // sha needs to be present so that the file is not detected as a deletion
return { path, sha, content: buffer }
})
)
)
const deleteEntries = [...deletePaths].map(path => ({ path, sha: null, content: null }))
const entries = [...deleteEntries, ...upsertEntries]
const newCommit = await createCommitFromEntries({
token,
repoFullName,
parentCommit: baseCommit,
entries,
message,
branch,
})
return newCommit
}
function generateBranchName() {
const d = new Date()
const pad = n => `${n}`.padStart(2, '0')
return `overleaf-${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`
}
async function getGitBlobMap(token, repoFullName, commit) {
const blobEntries = await api.listBlobsAtCommit(token, repoFullName, commit)
return Object.fromEntries(
blobEntries.map(({ path, sha }) => [path, sha])
)
}
function getEntryHash(entry) {
if (!entry?.data) return null
return (
entry.data.hash ||
gitBlobShaFromString(entry.data.content)
)
}
function gitBlobShaFromString(str) {
if (!str) return null
const content = Buffer.from(str, 'utf8')
const header = Buffer.from(`blob ${content.length}\0`, 'utf8')
const store = Buffer.concat([header, content])
return createHash('sha1').update(store).digest('hex')
}
function validateFileName(name) {
if (
typeof name !== 'string' ||
name.length === 0 ||
name.includes('\0')
) return { name }
const normalized = normalize(name)
if (
normalized !== name ||
normalized.startsWith('/') ||
normalized.startsWith('../')
) return { name }
return null
}
@@ -0,0 +1,28 @@
import OError from '@overleaf/o-error'
class GitNotLinkedError extends OError {
constructor(projectId) {
super('git server is not linked to project', { projectId })
}
}
class AlreadyExistsError extends OError {}
class GitConflictError extends OError {}
class InvalidTokenError extends OError {}
class ExpiredTokenError extends OError {}
class NotFoundError extends OError {}
class PermissionDeniedError extends OError {}
class RateLimitError extends OError {}
class ProviderRequestError extends OError {}
export {
AlreadyExistsError,
GitNotLinkedError,
GitConflictError,
InvalidTokenError,
ExpiredTokenError,
NotFoundError,
PermissionDeniedError,
RateLimitError,
ProviderRequestError,
}
@@ -0,0 +1,97 @@
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
import { fetchJson, fetchStream } from '@overleaf/fetch-utils'
import HistoryManager from '../../../../app/src/Features/History/HistoryManager.mjs'
const urlBase = `${Settings.apis.project_history.url}/project`
async function latestVersion(projectId) {
const url = `${urlBase}/${projectId}/version`
try {
await HistoryManager.promises.flushProject(projectId)
const json = await fetchJson(url)
return json.version
} catch (err) {
throw OError.tag(err, 'Failed to get the latest project version from history', { projectId })
}
}
async function getProjectSnapshot(projectId, version) {
const url = `${urlBase}/${projectId}/version/${version}`
try {
const snapshot = await fetchJson(url)
return snapshot.files || {}
} catch (err) {
throw OError.tag(err, 'Failed to get the project snapshot from history', { projectId })
}
}
async function getPathsAtVersion(projectId, version) {
const url = `${urlBase}/${projectId}/paths/version/${version}`
try {
const json = await fetchJson(url)
return json
} catch (err) {
throw OError.tag(err, 'Failed to fetch file paths from history', { projectId, version })
}
}
// response field 'diff' is an array of objects:
// {
// pathname: string,
// operation: 'added' | 'removed' | 'edited'| 'renamed' | undefined,
// ...
// }
async function getProjectFileTreeDiff(projectId, fromV, toV) {
const url = `${urlBase}/${projectId}/filetree/diff?from=${fromV}&to=${toV}`
try {
const json = await fetchJson(url)
return json.diff || []
} catch (err) {
throw OError.tag(err, 'Failed to fetch filetree diff from history', { projectId, fromV, toV })
}
}
// return file from history as a base64 encoded string
async function getProjectFileBuffer(projectId, version, filePath) {
const url = `${urlBase}/${projectId}/version/${version}/${encodeURIComponent(filePath)}`
try {
const stream = await fetchStream(url)
return await _streamToBase64(stream)
} catch (err) {
throw OError.tag(err, 'Failed to fetch file from history', { projectId, version, filePath })
}
}
async function _streamToBase64(stream) {
const parts = []
let leftover = null
for await (const chunk of stream) {
const data = leftover ? Buffer.concat([leftover, chunk]) : chunk
const remainder = data.length % 3
const completeLength = data.length - remainder
if (completeLength > 0) {
parts.push(data.subarray(0, completeLength).toString('base64'))
}
leftover = remainder ? data.subarray(completeLength) : null
}
if (leftover) {
parts.push(leftover.toString('base64'))
}
return parts.join('')
}
export default {
latestVersion,
getPathsAtVersion,
getProjectSnapshot,
getProjectFileBuffer,
getProjectFileTreeDiff,
}
@@ -0,0 +1,32 @@
import { GitLabSyncProjectStates } from '../models/gitlabSyncProjectStates.mjs'
import Mongo from '../../../../app/src/Features/Helpers/Mongo.mjs'
const { normalizeQuery } = Mongo
function getProjectState(projectId, projection = {}) {
return GitLabSyncProjectStates.findOne(normalizeQuery({ projectId }), projection).lean()
}
function createProjectState(projectId, data) {
return GitLabSyncProjectStates.create({
projectId: normalizeQuery(projectId),
...data
})
}
function updateProjectState(projectId, data) {
return GitLabSyncProjectStates.updateOne(
normalizeQuery({ projectId }),
{ $set: data },
)
}
function removeProjectState(projectId) {
return GitLabSyncProjectStates.deleteMany(normalizeQuery({ projectId }))
}
export default {
getProjectState,
createProjectState,
updateProjectState,
removeProjectState,
}
@@ -0,0 +1,115 @@
import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
import Mongo from '../../../../app/src/Features/Helpers/Mongo.mjs'
import { GitLabSyncUserCredentials } from '../models/gitlabSyncUserCredentials.mjs'
import { AccessTokenEncryptor } from './AccessTokenEncryptorHelper.mjs'
import { InvalidTokenError } from './GitSyncErrors.mjs'
import api from './GitLabApiClient.mjs'
const { normalizeQuery } = Mongo
async function encryptAccessToken(accessToken) {
try {
return await AccessTokenEncryptor.encryptJson(accessToken)
} catch (err) {
throw OError.tag('failed to encrypt token', err)
}
}
async function decryptAccessToken(tokenEncrypted) {
try {
return await AccessTokenEncryptor.decryptToJson(tokenEncrypted)
} catch (err) {
throw new InvalidTokenError('failed to decrypt token', { status: 401 }, err)
}
}
async function refreshToken(userId, decData) {
logger.info({ userId }, "refreshing GitLab token")
if (!decData.refresh_token) {
logger.error("refresh_token invalid")
return false
}
let token
let refresh_token
let refresh_timestamp
try {
[token, refresh_token, refresh_timestamp] = await api.refreshToken(decData.refresh_token)
if (!token || !refresh_token) {
HttpErrorHandler.badRequest(req, res, 'Failed to refresh access token')
return false
}
} catch (err) {
const info = OError.getFullInfo(err)
logger.error(OError.getFullStack(err))
logger.error({ info, userId }, 'Failed to refresh access token')
HttpErrorHandler.badRequest(req, res, err.message || 'Bad request')
return false
}
try {
await saveUserToken(userId, { token, refresh_token, refresh_timestamp })
} catch (err) {
const info = OError.getFullInfo(err)
const errStatus = info?.status || 500
logger.error(OError.getFullStack(err))
logger.error({ info, userId }, 'Error saving user token')
HttpErrorHandler.handleErrorByStatusCode(req, res, err, errStatus)
return false
}
return true
}
// ------------------------- exports -------------------------- //
async function getUserToken(userId) {
const credentials = await GitLabSyncUserCredentials.findOne(normalizeQuery({ userId }))
if (!credentials) throw new InvalidTokenError('no user token', { userId, status: 400 })
let decData = await decryptAccessToken(credentials.gitlab)
const now = Math.floor(Date.now() / 1000);
if (!decData.refresh_timestamp || decData.refresh_timestamp < now) {
const refreshed = await refreshToken(userId, decData)
if (refreshed) {
return await getUserToken(userId)
}
}
return decData.token
}
async function saveUserToken(userId, accessToken) {
const tokenEncrypted = await encryptAccessToken(accessToken)
await GitLabSyncUserCredentials.findOneAndUpdate(
normalizeQuery({ userId }),
{ $set: { gitlab: tokenEncrypted } },
{ upsert: true }
)
}
// Try to revoke user's token, then remove it
async function removeUserToken(userId) {
let token
try {
token = await getUserToken(userId)
} catch (err) {
logger.warn({ err, userId }, 'failed to get user token')
}
// fire-and-forget, but still handle errors
if (token) {
api.revokeToken(token).catch(err => {
logger.warn({ err, userId }, 'failed to revoke user token')
})
}
await GitLabSyncUserCredentials.deleteOne(normalizeQuery({ userId }))
return
}
export default {
saveUserToken,
getUserToken,
removeUserToken
}
@@ -0,0 +1,41 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '@/shared/context/project-context'
import GitLabLogo from '@/shared/svgs/gitlab-logo'
import IntegrationCard from '@/features/integrations-panel/integration-card'
import GitSyncModal from './modals/git-sync-modal'
import { GitSyncModalStatus } from '../types/git-sync-types'
const GitLabSyncCard = () => {
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
const { projectId, name } = useProjectContext()
const [modalStatus, setModalStatus] = useState<GitSyncModalStatus>('loading')
return (
<>
<IntegrationCard
title={t('gitlab')}
description={t('sync_with_a_gitlab_repository')}
icon={<GitLabLogo size={32} />}
showPaywallBadge={false}
onClick={() => setShowModal(true)}
>
</IntegrationCard>
<GitSyncModal
show={showModal}
modalStatus={modalStatus}
setModalStatus={setModalStatus}
handleHide={() => {
setShowModal(false)
setModalStatus('loading')
}}
projectId={projectId}
projectName={name}
/>
</>
)
}
export default GitLabSyncCard
@@ -0,0 +1,174 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import { getJSON, postJSON } from '@/infrastructure/fetch-json'
import useAsync from '@/shared/hooks/use-async'
import { debugConsole } from '@/utils/debugging'
import OLButton from '@/shared/components/ol/ol-button'
import {
OLModal,
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import Notification from '@/shared/components/notification'
import GitLabLogo from '@/shared/svgs/gitlab-logo'
export const GitLabSyncWidget = function GitLabSyncWidget() {
const { t } = useTranslation()
const { appName } = getMeta('ol-ExposedSettings')
const {
isLoading: isCheckingConn,
isError: isErrorConnCheck,
runAsync: runAsyncConnCheck,
data: isConnected,
setData: setConnState,
} = useAsync<boolean>()
const {
isLoading: isUnlinking,
isError: isErrorUnlink,
runAsync: runAsyncUnlink,
} = useAsync<void>()
const [showUnlinkModal, setShowUnlinkModal] = useState(false)
const handleConnCheck = useCallback(() => {
runAsyncConnCheck(getJSON('/user/gitlab-sync/status')).catch(err =>
debugConsole.error(err?.data?.message || err?.message || err),
)
}, [runAsyncConnCheck])
useEffect(() => {
handleConnCheck()
}, [handleConnCheck])
const handleUnlink = useCallback(() => {
runAsyncUnlink(postJSON('/user/gitlab-sync/unlink'))
.then(() => setConnState(false))
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
.finally(() => setShowUnlinkModal(false))
}, [runAsyncUnlink])
if (isCheckingConn) {
return (
<div className="settings-widget-container">
<div>
<GitLabLogo />
</div>
<div className="description-container">
<div className="title-row">
<h4>GitLab</h4>
</div>
<p className="small">
<span>{t('loading')}…</span>
</p>
</div>
</div>
)
}
return (
<>
<div className="settings-widget-container">
<div>
<GitLabLogo size={40} />
</div>
<div className="description-container">
<div className="title-row">
<h4 id="gitlab-sync">GitLab</h4>
</div>
<p className="small">
{t('gitlab_sync_description', { appName })}
</p>
{isErrorConnCheck && (
<Notification
type="error"
content={t('gitlab_sync_error')}
/>
)}
{isErrorUnlink && (
<Notification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
</div>
<div>
{isConnected ? (
<OLButton
variant="danger-ghost"
onClick={() => setShowUnlinkModal(true)}
disabled={isUnlinking}
>
{isUnlinking ? t('unlinking') : t('unlink')}
</OLButton>
) : isErrorConnCheck ? (
<OLButton
variant="secondary"
onClick={handleConnCheck}
>
{t('reconnect')}
</OLButton>
) : (
<OLButton
variant="secondary"
href="/user/gitlab-sync/oauth2"
>
{t('link')}
</OLButton>
)}
</div>
</div>
<OLModal
id="git-sync-modal"
show={showUnlinkModal}
onHide={() => setShowUnlinkModal(false)}
backdrop="static"
>
<OLModalHeader>
<OLModalTitle>
{t('unlink_provider_account_title', {
provider: 'GitLab',
})}
</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>
{t('unlink_gitlab_warning', {
provider: 'GitLab',
})}
</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={() => setShowUnlinkModal(false)}
>
{t('cancel')}
</OLButton>
<OLButton
variant="danger-ghost"
onClick={handleUnlink}
disabled={isUnlinking}
>
{isUnlinking ? t('unlinking') : t('unlink')}
</OLButton>
</OLModalFooter>
</OLModal>
</>
)
}
@@ -0,0 +1,12 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { OLDropdownItem } from '@/shared/components/ol/ol-dropdown-menu'
export default function ImportFromGitLabMenu({ onClick }: { onClick: (e: React.MouseEvent) => void }) {
const { t } = useTranslation()
return (
<OLDropdownItem onClick={onClick}>
{t('import_from_gitlab')}
</OLDropdownItem>
)
}
@@ -0,0 +1,221 @@
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import useAsync from '@/shared/hooks/use-async'
import { debugConsole } from '@/utils/debugging'
import {
postJSON,
getJSON,
} from '@/infrastructure/fetch-json'
import getMeta from '@/utils/meta'
import {
OLModal,
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import OLSpinner from '@/shared/components/ol/ol-spinner'
import Notification from '@/shared/components/notification'
type GitSyncRepo = {
name: string
fullName: string
defaultBranchName: string
}
type GitSyncReposResponse = {
repos?: GitSyncRepo[]
}
function ImportFromGitLabModalContent({ handleHide }: { handleHide: () => void }) {
const { t } = useTranslation()
const { appName } = getMeta('ol-ExposedSettings')
const {
isLoading,
isSuccess,
isError,
data,
runAsync,
} = useAsync<GitSyncReposResponse>()
useEffect(() => {
runAsync(getJSON('/user/gitlab-sync/repos'))
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
}, [runAsync])
const reposExist = data?.repos != null
const repos = reposExist ? data.repos : []
const {
isLoading: isImporting,
isSuccess: isImported,
isError: isErrorImport,
runAsync: runAsyncImport,
} = useAsync<{ projectId: string }>()
useEffect(() => {
if (isImported) {
handleHide()
}
}, [handleHide, isImported])
let gitlabUrl = getMeta('ol-ExposedSettings').gitlabUrl || ''
gitlabUrl = gitlabUrl.endsWith('/') ? gitlabUrl.slice(0, -1) : gitlabUrl
const showLinkToGitLab = !isImporting && isSuccess && !reposExist
const showRepos = !isImporting && isSuccess && reposExist
const handleImport = (repo: GitSyncRepo) => {
runAsyncImport(
postJSON('/project/new/gitlab-sync', {
body: repo
})
)
.then(data => {
window.location.href = `/project/${data.projectId}`
})
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
}
return (
<>
<OLModalHeader onClose={handleHide}>
<OLModalTitle>{t('import_from_gitlab')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{isLoading && (
<span>
<OLSpinner size="sm" className="me-2"/>
{t('loading_gitlab_repositories')}
</span>
)}
{isImporting && (
<span>
<OLSpinner size="sm" className="me-2"/>
{t('importing')}
</span>
)}
{isError && (
<div className="notification-list">
<Notification
type="error"
content={t('something_went_wrong_server')}
/>
</div>
)}
{showLinkToGitLab && (
<div className="text-center">
<p>
{t('link_to_gitlab_description', { appName })}
</p>
<OLButton
variant="secondary"
href="/user/gitlab-sync/oauth2"
>
{t('link_to_gitlab')}
</OLButton>
</div>
)}
{showRepos && (
<div>
{repos.length === 0 ? (
<p className="text-center">
{t('you_dont_have_any_repositories')}
</p>
) : (
<>
<p className="text-center">
{t('select_gitlab_repository', { appName })}
</p>
<div className="table-container table-container-bordered">
<table className="table table-striped table-hover">
<tbody>
{repos.map(repo => (
<tr key={repo.fullName}>
<td>
{repo.name}
<div className="small">
<a
href={`${gitlabUrl}/${repo.fullName}`}
target="_blank"
rel="noopener noreferrer"
>
{repo.fullName}
</a>
</div>
</td>
<td className="text-end">
<OLButton
variant="primary"
onClick={() => handleImport(repo)}
>
{t('import_to_sharelatex', { appName: 'Overleaf' })}
</OLButton>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
)}
{isErrorImport && (
<div className="notification-list">
<Notification
type="error"
content={t('something_went_wrong_server')}
/>
</div>
)}
</OLModalBody>
<OLModalFooter>
{!isImporting && (
<span className="me-auto">
<a
href="https://docs.gitlab.com/administration/settings/account_and_limit_settings/"
target="_blank"
rel="noopener noreferrer"
>
{t('cant_see_what_youre_looking_for_question')}
</a>
</span>
)}
<OLButton
variant="secondary"
onClick={handleHide}
disabled={isImporting}
>
{t('cancel')}
</OLButton>
</OLModalFooter>
</>
)
}
export default function ImportFromGitLabModal({ onHide }: { onHide: () => void }) {
return (
<OLModal
id="git-import-modal"
show
animation
size="lg"
onHide={onHide}
backdrop="static"
>
<ImportFromGitLabModalContent handleHide={onHide} />
</OLModal>
)
}
@@ -0,0 +1,47 @@
import { useTranslation, Trans } from 'react-i18next'
import Notification from '@/shared/components/notification'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import { ProjectSyncState } from '../../types/git-sync-types'
type GitSyncCannotExportModalProps = {
projectSyncState: ProjectSyncState
handleHide: () => void
}
const GitSyncCannotExportModal = ({ projectSyncState, handleHide }: GitSyncCannotExportModalProps) => {
const { t } = useTranslation()
return (
<>
<OLModalBody>
<Notification
type="warning"
content={(
<Trans
i18nKey="ask_proj_owner_to_export_to_gitlab"
values={{
projectOwnerEmail: projectSyncState.ownerEmail ?? '?',
}}
components={[
projectSyncState.ownerEmail ? <a href={`mailto:${projectSyncState.ownerEmail}`} /> : <></>
]}
/>
)}
/>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncCannotExportModal
@@ -0,0 +1,105 @@
import { useTranslation, Trans } from 'react-i18next'
import useAsync from '@/shared/hooks/use-async'
import { deleteJSON } from '@/infrastructure/fetch-json'
import { OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import Notification from '@/shared/components/notification'
import { debugConsole } from '@/utils/debugging'
import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types'
import getMeta from '@/utils/meta'
type GitSyncUnlinkModalProps = {
handleHide: () => void
setModalStatus: (modalStatus: GitSyncModalStatus) => void
projectSyncState: ProjectSyncState
projectId: string
}
const GitSyncUnlinkModal = ({
handleHide,
setModalStatus,
projectSyncState,
projectId,
}: GitSyncUnlinkModalProps) => {
const { t } = useTranslation()
const {
error,
isLoading,
runAsync,
} = useAsync<void>()
const handleUnlink = () => {
runAsync(deleteJSON(`/project/${projectId}/gitlab-sync`))
.then(() => setModalStatus('need-export'))
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
}
let gitlabUrl = getMeta('ol-ExposedSettings').gitlabUrl || ''
gitlabUrl = gitlabUrl.endsWith('/') ? gitlabUrl.slice(0, -1) : gitlabUrl
return (
<>
<OLModalBody>
<p>
{t('project_linked_to')}:&nbsp;
<a
href={`${gitlabUrl}/${projectSyncState.repoFullName}`}
target="_blank"
rel="noreferrer noopener"
>
{projectSyncState.repoFullName}
</a>
</p>
<hr />
{!error ? (
<Notification
type="warning"
content={t('unlink_the_project_from_the_current_gitlab_repo')}
/>
) : (
<Notification
type="error"
content={
error.info?.statusCode === 403 ? (
<Trans
i18nKey="ask_proj_owner_to_unlink_from_current_gitlab"
values={{ projectOwnerEmail: error?.data?.ownerEmail ?? '?' }}
components={[
error?.data?.ownerEmail ? <a href={`mailto:${error.data.ownerEmail}`} /> : <></>
]}
/>
) : (
t('something_went_wrong_server')
)
}
/>
)}
</OLModalBody>
<OLModalFooter>
<div className="d-flex gap-2">
<OLButton
variant="danger"
onClick={handleUnlink}
disabled={isLoading || !!error}
>
{t('confirm')}
</OLButton>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('cancel')}
</OLButton>
</div>
</OLModalFooter>
</>
)
}
export default GitSyncUnlinkModal
@@ -0,0 +1,57 @@
import { useTranslation, Trans } from 'react-i18next'
import getMeta from '@/utils/meta'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import Notification from '@/shared/components/notification'
import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types'
type GitSyncConflictModalProps = {
projectSyncState: ProjectSyncState
handleHide: () => void
setModalStatus: (modalStatus: GitSyncModalStatus) => void
}
const GitSyncConflictModal = ({ projectSyncState, handleHide, setModalStatus }: GitSyncConflictModalProps) => {
const { t } = useTranslation()
const { appName } = getMeta('ol-ExposedSettings')
return (
<>
<OLModalBody>
<Notification
type="warning"
content={t('gitlab_merge_failed_error', { appName })}
/>
<p className="mt-2">
<Trans
i18nKey="gitlab_manual_merge_user_prompt"
values={{sharelatex_branch: projectSyncState.unmergedBranchName }}
components={[<b />]}
/>
</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
<OLButton
variant="primary"
onClick={
() => setModalStatus('run-merge-resolved')
}
>
{t('continue_gitlab_merge')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncConflictModal
@@ -0,0 +1,229 @@
import { useTranslation } from 'react-i18next'
import { useState, useEffect } from 'react'
import useAsync from '@/shared/hooks/use-async'
import { debugConsole } from '@/utils/debugging'
import {
getJSON,
postJSON
} from '@/infrastructure/fetch-json'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import Notification from '@/shared/components/notification'
import OLButton from '@/shared/components/ol/ol-button'
import OLForm from '@/shared/components/ol/ol-form'
import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import OLFormLabel from '@/shared/components/ol/ol-form-label'
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
import OLFormSelect from '@/shared/components/ol/ol-form-select'
import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import { GitSyncModalStatus } from '../../types/git-sync-types'
type OrgsResponse = {
user: string
orgs: string[]
}
type GitSyncExportModalProps = {
projectId: string
projectName: string
handleHide: () => void
setModalStatus: (modalStatus: GitSyncModalStatus) => void
}
const GitSyncExportModal = ({
projectId,
projectName,
handleHide,
setModalStatus
}: GitSyncExportModalProps) => {
const { t } = useTranslation()
const [selectedOwner, setSelectedOwner] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const [repoName, setRepoName] = useState(projectName)
const [description, setDescription] = useState('')
const [visibility, setVisibility] = useState<'public' | 'private'>('private')
const {
runAsync: runAsyncUserAndOrgs,
data: userAndOrgs,
error: errorUserAndOrgs
} = useAsync<OrgsResponse>()
useEffect(() => {
runAsyncUserAndOrgs(getJSON('/user/gitlab-sync/orgs'))
.then(userAndOrgs => setSelectedOwner(userAndOrgs?.user))
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
}, [])
const { isLoading, error, setError, runAsync } = useAsync<void>()
const createRepo = () => {
const isPublic = visibility === 'public'
const org = selectedOwner === userAndOrgs?.user ? undefined : selectedOwner
runAsync(postJSON(`/project/${projectId}/gitlab-sync/export`, {
body: {
name: repoName,
description,
isPublic,
org,
},
}))
.then(() => setModalStatus('loading'))
.catch(err => {
debugConsole.error(err?.data?.message || err?.message || err)
if (!err?.data?.key) setError(t('something_went_wrong_server'))
else setError(t(err.data.key))
})
}
return (
<>
<OLModalBody>
<h4>{t('export_project_to_gitlab')}</h4>
<p>{t('project_not_linked_to_gitlab')}</p>
{error && (
<Notification
type="error"
content={error}
/>
)}
{errorUserAndOrgs && (
<Notification
type="error"
content={t('something_went_wrong_server')}
/>
)}
<OLForm onSubmit={createRepo}>
<OLRow>
<OLCol xs={4}>
<OLFormGroup>
<OLFormLabel htmlFor="gitlab-sync-owner">
{selectedOwner === userAndOrgs?.user
? t('owner')
: t('organization')}
</OLFormLabel>
<OLFormSelect
id="gitlab-sync-owner"
name="org"
value={selectedOwner}
onChange={e => setSelectedOwner(e.target.value)}
>
<option key={userAndOrgs?.user} value={userAndOrgs?.user}>
{userAndOrgs?.user}
</option>
{userAndOrgs?.orgs.map(org => (
<option key={org} value={org}>
{org}
</option>
))}
</OLFormSelect>
</OLFormGroup>
</OLCol>
<OLCol xs={5}>
<OLFormGroup>
<OLFormLabel htmlFor="gitlab-sync-name">
{t('repository_name')}
</OLFormLabel>
<OLFormControl
id="gitlab-sync-name"
name="name"
type="text"
value={repoName}
onChange={e => setRepoName(e.target.value)}
/>
</OLFormGroup>
</OLCol>
</OLRow>
<OLRow>
<OLCol xs={12}>
<OLFormGroup>
<OLFormLabel htmlFor="gitlab-sync-description">
{t('description')} ({t('optional')})
</OLFormLabel>
<OLFormControl
id="gitlab-sync-description"
name="description"
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
/>
</OLFormGroup>
</OLCol>
</OLRow>
<hr />
<fieldset>
<legend className="visually-hidden">
{t('repository_visibility')}
</legend>
<OLFormGroup>
<OLRow>
<OLCol xs={12}>
<OLFormCheckbox
type="radio"
id="public"
name="repository"
value="public"
checked={visibility === 'public'}
onChange={() => setVisibility('public')}
label={t('public', { defaultValue: 'Public' })}
description={t('gitlab_public_description')}
/>
</OLCol>
</OLRow>
<OLRow>
<OLCol xs={12}>
<OLFormCheckbox
className="mr-1"
type="radio"
id="private"
name="repository"
value="private"
checked={visibility === 'private'}
onChange={() => setVisibility('private')}
label={t('private', { defaultValue: 'Private' })}
description={t('gitlab_private_description')}
/>
</OLCol>
</OLRow>
</OLFormGroup>
</fieldset>
</OLForm>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
onClick={createRepo}
disabled={!repoName.trim() || isLoading}
isLoading={isLoading}
>
{t('create_project_in_gitlab')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncExportModal
@@ -0,0 +1,54 @@
import { useTranslation } from 'react-i18next'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import Notification from '@/shared/components/notification'
import { GitSyncModalStatus } from '../../types/git-sync-types'
type GitSyncLoadingModalProps = {
handleHide: () => void
setModalStatus: (modalStatus: GitSyncModalStatus) => void
errorMessage: string | null
}
const GitSyncLoadingModal = ({ handleHide, setModalStatus, errorMessage }: GitSyncLoadingModalProps) => {
const { t } = useTranslation()
return (
<>
<OLModalBody>
<div role="status" className="loading align-items-start">
<div aria-hidden="true" data-testid="ol-spinner" className="spinner-border spinner-border-sm"></div>
{t('checking_project_gitlab_status')}
</div>
</OLModalBody>
{errorMessage && (
<Notification
type="error"
content={errorMessage}
/>
)}
<OLModalFooter>
{errorMessage && (
<OLButton
variant="danger-ghost"
onClick={() => setModalStatus('confirm-unlink')}
>
{t('unlink')}
</OLButton>
)}
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('cancel')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncLoadingModal
@@ -0,0 +1,129 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { debugConsole } from '@/utils/debugging'
import { postJSON } from '@/infrastructure/fetch-json'
import useAsync from '@/shared/hooks/use-async'
import clientId from '@/utils/client-id'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import Notification from '@/shared/components/notification'
import { GitSyncModalStatus, ProjectSyncState } from '../../types/git-sync-types'
import { useReferencesContext } from '@/features/ide-react/context/references-context'
type GitSyncMergeModalProps = {
handleHide: () => void
modalStatus: GitSyncModalStatus
setModalStatus: (modalStatus: GitSyncModalStatus) => void
setProjectSyncState: (projectSyncState: ProjectSyncState) => void
commitMessage: string
setCommitMessage: (message: string) => void
projectId: string
}
const GitSyncMergeModal = ({
handleHide,
modalStatus,
setModalStatus,
setProjectSyncState,
commitMessage,
setCommitMessage,
projectId
}: GitSyncMergeModalProps) => {
const { t } = useTranslation()
const { error, isError, isLoading, setError, runAsync } = useAsync<ProjectSyncState>()
const { indexAllReferences } = useReferencesContext()
useEffect(() => {
if (modalStatus !== 'run-merge' &&
modalStatus !== 'run-merge-resolved'
) return
const claimConflictIsResolved = (modalStatus === 'run-merge-resolved')
runAsync(
postJSON(`/project/${projectId}/gitlab-sync/merge`, {
body: { message: commitMessage, claimConflictIsResolved },
})
)
.then(async data => {
switch (data.mergeStatus) {
case 'clean':
setModalStatus('merge-overview')
await postJSON(`/project/${projectId}/flush`)
.catch(e => debugConsole.error(e))
indexAllReferences(true)
break
case 'conflict':
setModalStatus('show-conflict')
break
default:
const message = `Unexpected merge status received: ${data.mergeStatus}`
const msError = new Error(message)
setError(msError)
throw msError
}
setProjectSyncState(data)
setCommitMessage('')
})
.catch(err => {
debugConsole.error(err?.data?.message || err?.message || err)
if (err?.info?.statusCode === 403 || err?.info?.statusCode === 404) setModalStatus('loading')
})
}, [
modalStatus,
projectId,
commitMessage,
runAsync,
])
if (!isLoading && !isError) return
return (
<>
<OLModalBody>
<div
role="status"
className="loading align-items-start"
>
<div
aria-hidden="true"
data-testid="ol-spinner"
className="spinner-border spinner-border-sm"
></div>
{t('importing_and_merging_changes_in_gitlab')}
</div>
</OLModalBody>
{error && (
<Notification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncMergeModal
@@ -0,0 +1,242 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import useAsync from '@/shared/hooks/use-async'
import { getJSON } from '@/infrastructure/fetch-json'
import { OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal'
import OLForm from '@/shared/components/ol/ol-form'
import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import OLButton from '@/shared/components/ol/ol-button'
import OLIconButton from '@/shared/components/ol/ol-icon-button'
import Notification from '@/shared/components/notification'
import { debugConsole } from '@/utils/debugging'
import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types'
import getMeta from '@/utils/meta'
type GitSyncMergeOverviewModalProps = {
handleHide: () => void
setModalStatus: (modalStatus: GitSyncModalStatus) => void
projectSyncState: ProjectSyncState
projectId: string
commitMessage: string
setCommitMessage: (message: string) => void
}
type GitCommit = {
message: string
author: {
name: string
email: string
date: string
}
sha: string
}
type UnmergedCommitsResponse = {
commits: GitCommit[]
diverged: boolean
isProjectUpdated: boolean
}
const GitSyncMergeOverviewModal = ({
handleHide,
setModalStatus,
projectSyncState,
projectId,
commitMessage,
setCommitMessage
}: GitSyncMergeOverviewModalProps) => {
const { t } = useTranslation()
const appName = 'Overleaf'
const {
data,
setData,
error,
isSuccess,
isLoading,
runAsync,
} = useAsync<UnmergedCommitsResponse | null>()
const loadUnmergedCommits = () => {
setData({})
runAsync(getJSON(`/project/${projectId}/gitlab-sync/merge/overview`))
.then(data => { if (!data) setModalStatus('loading') })
.catch(err => {
debugConsole.error(err?.data?.message || err?.message || err)
if (err?.info?.statusCode === 403 ||
err?.info?.statusCode === 404 ||
err?.info?.statusCode === 401
) setModalStatus('loading')
})
}
useEffect(() => {
loadUnmergedCommits()
}, [projectId, runAsync])
let gitlabUrl = getMeta('ol-ExposedSettings').gitlabUrl || ''
gitlabUrl = gitlabUrl.endsWith('/') ? gitlabUrl.slice(0, -1) : gitlabUrl
return (
<>
<OLModalBody>
<p>
{t('project_linked_to')}:&nbsp;
<a
href={`${gitlabUrl}/${projectSyncState.repoFullName}`}
target="_blank"
rel="noreferrer noopener"
>
{projectSyncState.repoFullName}
</a>
</p>
{isLoading && (
<>
<div role="status" className="loading align-items-start">
<div
aria-hidden="true"
data-testid="ol-spinner"
className="spinner-border spinner-border-sm"
></div>
{t('loading')}
</div>
</>
)}
{isSuccess && (
<>
<hr />
{data.diverged && (
<Notification
type="warning"
content={t('gitlab_repository_diverged')}
/>
)}
<h3 className="gitlab-sync-commits-heading">
{t('recent_commits_in_gitlab')}
<OLIconButton
icon="refresh"
size="sm"
variant="ghost"
onClick={loadUnmergedCommits}
accessibilityLabel={t('refresh')}
/>
</h3>
{data.commits?.length === 0 && (
<div>
<p className="small">{t('no_new_commits_in_gitlab')}</p>
</div>
)}
{data.commits?.length > 0 && (
<div style={{ maxHeight: '200px', overflow: 'auto' }}>
{data.commits.map((commit: GitCommit) => (
<div key={commit.sha}>
<span className="small float-end">
<a
href={`${gitlabUrl}/${projectSyncState.repoFullName}/-/commit/${commit.sha}`}
target="_blank"
rel="noreferrer noopener"
>
{commit.sha.substring(0, 7)}
</a>
</span>
<a
href={`${gitlabUrl}/${projectSyncState.repoFullName}/-/commit/${commit.sha}`}
target="_blank"
className="commit-message"
rel="noreferrer noopener"
>
{commit.message}
</a>
<div className="small">
by {commit.author.name} &lt;{commit.author.email}&gt;
</div>
</div>
))}
</div>
)}
{data.isProjectUpdated && (
<>
<hr />
<OLForm>
<p>{t('sync_project_to_gitlab_explanation', { appName })}</p>
<OLRow>
<OLCol xs={12}>
<OLFormGroup>
<OLFormControl
as="textarea"
rows={1}
value={commitMessage}
placeholder={t('gitlab_commit_message_placeholder', { appName })}
onChange={(e) => setCommitMessage(e.target.value)}
/>
</OLFormGroup>
</OLCol>
</OLRow>
</OLForm>
</>
)}
</>
)}
{error && (
<Notification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
</OLModalBody>
<OLModalFooter>
<div className="d-flex justify-content-between w-100">
<div className="d-flex gap-2">
<OLButton
variant="danger-ghost"
onClick={() => setModalStatus('confirm-unlink')}
disabled={!isSuccess && error?.info?.statusCode !== 404}
>
{t('unlink')}
</OLButton>
</div>
<div className="d-flex gap-2">
<OLButton
variant="primary"
leadingIcon="sync"
onClick={() => setModalStatus('run-merge')}
disabled={!isSuccess}
>
{t('sync')}
</OLButton>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</div>
</div>
</OLModalFooter>
</>
)
}
export default GitSyncMergeOverviewModal
@@ -0,0 +1,226 @@
import { useTranslation } from 'react-i18next'
import { useState, useEffect } from 'react'
import {
OLModalHeader,
OLModalTitle,
OLModal,
} from '@/shared/components/ol/ol-modal'
import { getJSON } from '@/infrastructure/fetch-json'
import useAsync from '@/shared/hooks/use-async'
import { debugConsole } from '@/utils/debugging'
import GitSyncLoadingModal from './git-sync-loading-modal'
import GitSyncMergeOverviewModal from './git-sync-merge-overview-modal'
import GitSyncMergeModal from './git-sync-merge-modal'
import GitSyncConflictModal from './git-sync-conflict-modal'
import GitSyncExportModal from './git-sync-export-modal'
import GitSyncNeedAuthModal from './git-sync-need-auth-modal'
import GitSyncNeedPermissionModal from './git-sync-need-permission-modal'
import GitSyncConfirmUnlinkModal from './git-sync-confirm-unlink-modal'
import GitSyncUnlinkUnavailableModal from './git-sync-unlink-unavailable-modal'
import GitSyncCannotExportModal from './git-sync-cannot-export-modal'
import { GitSyncModalStatus, ProjectSyncState } from '../../types/git-sync-types'
type GitSyncModalProps = {
show: boolean
handleHide: () => void
projectId: string
projectName: string
modalStatus: GitSyncModalStatus
setModalStatus: (modalStatus: GitSyncModalStatus) => void
}
function GitSyncModal({
show,
handleHide,
projectId,
projectName,
modalStatus,
setModalStatus,
}: GitSyncModalProps) {
const { t } = useTranslation()
const [commitMessage, setCommitMessage] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const {
runAsync: runAsyncConn,
error: errorConn,
setError: setErrorConn,
data: dataConn,
} = useAsync<boolean>()
const {
runAsync,
data: projectSyncState,
setData: setProjectSyncState,
error,
setError,
} = useAsync<ProjectSyncState>()
useEffect(() => {
if (!show) return
if (modalStatus !== 'loading') return
const fetchProjectState = () => {
setError(null)
setErrorMessage('')
runAsync(getJSON(`/project/${projectId}/gitlab-sync/state`))
.then(pss => {
switch (pss.mergeStatus) {
// pss.ownerEmail is set to the email address of the project owner
// or null if the user is the project owner
// only owner can export project to git server
case 'need-export':
if (!pss.ownerEmail) setModalStatus('need-export')
else setModalStatus('cannot-export')
break
case 'clean':
case 'diverged':
setModalStatus('merge-overview')
break
case 'conflict':
setModalStatus('show-conflict')
break
// user does not have push permission to repo
// pss.ownerEmail is set to the email address of the owner
// or null if the user is the project owner
// if the user is owner, it seems that repo is not accessible
case 'need-permission': {
if (!pss.ownerEmail) setModalStatus('unlink-unavailable')
else setModalStatus('need-permission')
break
}
}
})
.catch(err => {
debugConsole.error(err?.data?.message || err?.message || err)
setErrorMessage(t('generic_something_went_wrong'))
})
}
const fetchConnectionStatus = () => {
setErrorConn(null)
setErrorMessage('')
runAsyncConn(getJSON(`/user/gitlab-sync/status`))
.then(isConnected => {
if (!isConnected) {
setModalStatus('need-auth')
return
}
fetchProjectState()
})
.catch(err => {
debugConsole.error(err?.data?.message || err?.message || err)
setErrorMessage(t('generic_something_went_wrong'))
})
}
fetchConnectionStatus()
}, [show, modalStatus, projectId, runAsyncConn, runAsync])
return (
<OLModal show={show} onHide={handleHide} backdrop="static">
<OLModalHeader closeButton>
<OLModalTitle>{t('sync_with_gitlab')}</OLModalTitle>
</OLModalHeader>
{modalStatus === 'loading' && (
<GitSyncLoadingModal
handleHide={handleHide}
setModalStatus={setModalStatus}
errorMessage={errorMessage}
/>
)}
{modalStatus === 'need-export' && (
<GitSyncExportModal
projectId={projectId}
projectName={projectName}
handleHide={handleHide}
setModalStatus={setModalStatus}
/>
)}
{modalStatus === 'cannot-export' && (
<GitSyncCannotExportModal
projectSyncState={projectSyncState}
handleHide={handleHide}
/>
)}
{modalStatus === 'need-auth' && (
<GitSyncNeedAuthModal
handleHide={handleHide}
/>
)}
{modalStatus === 'need-permission' && (
<GitSyncNeedPermissionModal
handleHide={handleHide}
projectSyncState={projectSyncState}
/>
)}
{modalStatus === 'merge-overview' && (
<GitSyncMergeOverviewModal
projectId={projectId}
projectSyncState={projectSyncState}
setModalStatus={setModalStatus}
handleHide={handleHide}
commitMessage={commitMessage}
setCommitMessage={setCommitMessage}
/>
)}
{modalStatus === 'confirm-unlink' && (
<GitSyncConfirmUnlinkModal
projectId={projectId}
projectSyncState={projectSyncState}
setModalStatus={setModalStatus}
handleHide={handleHide}
/>
)}
{modalStatus === 'unlink-unavailable' && (
<GitSyncUnlinkUnavailableModal
projectId={projectId}
setModalStatus={setModalStatus}
handleHide={handleHide}
/>
)}
{(modalStatus === 'run-merge' ||
modalStatus === 'run-merge-resolved') && (
<GitSyncMergeModal
handleHide={handleHide}
modalStatus={modalStatus}
setModalStatus={setModalStatus}
commitMessage={commitMessage}
setCommitMessage={setCommitMessage}
projectId={projectId}
setProjectSyncState={setProjectSyncState}
/>
)}
{modalStatus === 'show-conflict' && (
<GitSyncConflictModal
projectSyncState={projectSyncState}
handleHide={handleHide}
setModalStatus={setModalStatus}
/>
)}
</OLModal>
)
}
export default GitSyncModal
@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
const GitSyncNeedAuthModal = ({ handleHide }: { handleHide: () => void }) => {
const { t } = useTranslation()
const { appName } = getMeta('ol-ExposedSettings')
return (
<>
<OLModalBody>
<p>{t('link_to_gitlab_description', { appName })}</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
<OLButton
variant="primary"
onClick={() => {
handleHide()
window.open(
'/user/gitlab-sync/oauth2',
'gitlabAuth',
'width=600,height=700'
)
}}
>
{t('link_to_gitlab')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncNeedAuthModal
@@ -0,0 +1,60 @@
import { useTranslation, Trans } from 'react-i18next'
import Notification from '@/shared/components/notification'
import {
OLModalBody,
OLModalFooter,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import { ProjectSyncState } from '../../types/git-sync-types'
import getMeta from '@/utils/meta'
type GitSyncNeedPermissionModalProps = {
projectSyncState: ProjectSyncState
handleHide: () => void
}
const GitSyncNeedPermissionModal = ({ projectSyncState, handleHide }: GitSyncNeedPermissionModalProps) => {
const { t } = useTranslation()
let gitlabUrl = getMeta('ol-ExposedSettings').gitlabUrl || ''
gitlabUrl = gitlabUrl.endsWith('/') ? gitlabUrl.slice(0, -1) : gitlabUrl
return (
<>
<OLModalBody>
<Notification
type="warning"
content={(
<Trans
i18nKey="ask_proj_owner_to_add_you_as_gitlab_collaborator"
values={{
repoFullName: projectSyncState.repoFullName ?? '?',
projectOwnerEmail: projectSyncState.ownerEmail ?? '?',
}}
components={[
projectSyncState.repoFullName ? (
<a
href={`${gitlabUrl}/${projectSyncState.repoFullName}`}
target="_blank"
rel="noreferrer noopener"
/>
) : (
<></>
),
projectSyncState.ownerEmail ? <a href={`mailto:${projectSyncState.ownerEmail}`} /> : <></>
]}
/>
)}
/>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncNeedPermissionModal
@@ -0,0 +1,85 @@
import { useTranslation, Trans } from 'react-i18next'
import useAsync from '@/shared/hooks/use-async'
import { deleteJSON } from '@/infrastructure/fetch-json'
import { OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import Notification from '@/shared/components/notification'
import { debugConsole } from '@/utils/debugging'
import { GitSyncModalStatus } from '../../types/git-sync-types'
type GitSyncUnlinkUnavailableModalProps = {
handleHide: () => void
setModalStatus: (modalStatus: GitSyncModalStatus) => void
projectId: string
}
const GitSyncUnlinkUnavailableModal = ({
handleHide,
setModalStatus,
projectId,
}: GitSyncUnlinkUnavailableModalProps) => {
const { t } = useTranslation()
const {
error,
isLoading,
runAsync,
} = useAsync<void>()
const handleUnlink = () => {
runAsync(deleteJSON(`/project/${projectId}/gitlab-sync`))
.then(() => setModalStatus('need-export'))
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
}
return (
<>
<OLModalBody>
<Notification
type="error"
content={t('gitlab_sync_repository_not_found_description')}
/>
{error && (
<Notification
type="error"
content={
error.info?.statusCode === 403 ? (
<Trans
i18nKey="ask_proj_owner_to_unlink_from_current_gitlab"
values={{ projectOwnerEmail: error?.data?.ownerEmail ?? '?' }}
components={[
error?.data?.ownerEmail ? <a href={`mailto:${error.data.ownerEmail}`} /> : <></>
]}
/>
) : (
t('something_went_wrong_server')
)
}
/>
)}
</OLModalBody>
<OLModalFooter>
<div className="d-flex gap-2">
<OLButton
variant="primary"
onClick={handleUnlink}
disabled={isLoading}
>
{t('unlink_gitlab_repository')}
</OLButton>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('cancel')}
</OLButton>
</div>
</OLModalFooter>
</>
)
}
export default GitSyncUnlinkUnavailableModal
@@ -0,0 +1,19 @@
export type GitSyncModalStatus =
'loading'
| 'need-export'
| 'cannot-export'
| 'need-auth'
| 'need-permission'
| 'merge-overview'
| 'confirm-unlink'
| 'unlink-unavailable'
| 'run-merge'
| 'run-merge-resolved'
| 'show-conflict'
export type ProjectSyncState = {
mergeStatus: 'clean' | 'conflict' | 'diverged' | 'need-export' | 'need-permission'
repoFullName: string
unmergedBranchName: string | null
ownerEmail?: string
}
@@ -0,0 +1,8 @@
.gitlab-sync-commits-heading {
align-items: center;
display: flex;
font-size: var(--font-size-03);
font-weight: 600;
justify-content: space-between;
line-height: var(--line-height-03);
}
@@ -0,0 +1,59 @@
import Settings from '@overleaf/settings'
import Modules from '../../app/src/infrastructure/Modules.mjs'
import logger from '@overleaf/logger'
let GitLabSyncModule = {}
if (process.env.GITLAB_SYNC_ENABLED?.toLowerCase() === 'true') {
logger.debug({}, 'Enabling GitLab Sync module')
// The GITLAB_URL variable has to be set for this module to work, otherwise the module will not be enabled
if (!process.env.GITLAB_SYNC_URL || !process.env.GITLAB_SYNC_CLIENT_ID || !process.env.GITLAB_SYNC_CLIENT_SECRET) {
logger.warn({}, 'GitLab Sync module is enabled but not all mandatory envirionment variables are set, stopping module initialization')
} else {
const siteUrl = Settings.siteUrl.replace(/\/+$/, '') || 'http://localhost'
Settings.gitlabSync = {
url: process.env.GITLAB_SYNC_URL.replace(/\/+$/, ''),
clientID: process.env.GITLAB_SYNC_CLIENT_ID,
clientSecret: process.env.GITLAB_SYNC_CLIENT_SECRET,
callbackURL: `${siteUrl}/user/gitlab-sync/oauth2/callback`,
defaultBranch: process.env.GITLAB_DEFAULT_BRANCH || 'main',
mergeRequestPollInterval: process.env.GITLAB_MERGE_REQUEST_POLL_INTERVAL_MS || 1000,
mergeRequestTimeout: process.env.GITLAB_MERGE_REQUEST_TIMEOUT_MS || 60_000,
}
const [{ default: GitLabSyncRouter },
{ default: SyncStateManager },
{ default: TokenManager }
] =
await Promise.all([
import('./app/src/GitLabSyncRouter.mjs'),
import('./app/src/SyncStateManager.mjs'),
import('./app/src/TokenManager.mjs'),
])
// Delete project sync state from mongo (hook 'projectExpired')
Modules.hooks.attach('projectExpired', async projectId => {
try {
await SyncStateManager.removeProjectState(projectId)
logger.debug({ projectId }, 'on project expire: removed Git sync state')
} catch (err) {
logger.warn({ projectId, err }, 'on project expire: failed to remove Git sync state')
}
})
// Delete user gitlab token from mongo (hook 'expireDeletedUser')
Modules.hooks.attach('expireDeletedUser', async userId => {
try {
await TokenManager.removeUserToken(userId)
} catch (err) {
logger.warn({ userId, err }, 'on user expire: failed removing user token')
}
})
GitLabSyncModule = {
router: GitLabSyncRouter,
}
}
}
export default GitLabSyncModule
@@ -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
View File
@@ -78,7 +78,6 @@
"last 1 year",
"safari >= 15"
],
"dependencies": {
"@ai-sdk/google-vertex": "^4.0.113",
"@ai-sdk/mcp": "patch:@ai-sdk/mcp@npm%3A1.0.37#~/.yarn/patches/@ai-sdk-mcp-npm-1.0.37-8cd89b8972.patch",
-112
View File
@@ -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)
@@ -55,6 +55,7 @@ describe('Features', function () {
expect(ctx.Features.hasFeature('analytics')).to.be.false
expect(ctx.Features.hasFeature('git-bridge')).to.be.false
expect(ctx.Features.hasFeature('github-sync')).to.be.false
expect(ctx.Features.hasFeature('gitlab-sync')).to.be.false
expect(ctx.Features.hasFeature('homepage')).to.be.false
expect(ctx.Features.hasFeature('link-url')).to.be.false
expect(ctx.Features.hasFeature('oauth')).to.be.false
@@ -78,6 +79,7 @@ describe('Features', function () {
expect(ctx.Features.hasFeature('analytics')).to.be.false
expect(ctx.Features.hasFeature('git-bridge')).to.be.false
expect(ctx.Features.hasFeature('github-sync')).to.be.false
expect(ctx.Features.hasFeature('gitlab-sync')).to.be.false
expect(ctx.Features.hasFeature('homepage')).to.be.false
expect(ctx.Features.hasFeature('link-url')).to.be.false
expect(ctx.Features.hasFeature('oauth')).to.be.false
@@ -108,6 +110,7 @@ describe('Features', function () {
expect(ctx.Features.hasFeature('link-url')).to.be.false
expect(ctx.Features.hasFeature('git-bridge')).to.be.false
expect(ctx.Features.hasFeature('github-sync')).to.be.false
expect(ctx.Features.hasFeature('gitlab-sync')).to.be.false
expect(ctx.Features.hasFeature('homepage')).to.be.false
expect(ctx.Features.hasFeature('oauth')).to.be.false
expect(ctx.Features.hasFeature('saml')).to.be.false
@@ -118,6 +121,7 @@ describe('Features', function () {
ctx.settings.enableHomepage = true
ctx.settings.enableGitBridge = true
ctx.settings.enableGithubSync = true
ctx.settings.enableGitLabSync = true
ctx.settings.enableSaml = true
ctx.settings.oauth = true
ctx.settings.enabledLinkedFileTypes = ['url', 'project_file']
@@ -127,6 +131,7 @@ describe('Features', function () {
expect(ctx.Features.hasFeature('affiliations')).to.be.true
expect(ctx.Features.hasFeature('analytics')).to.be.true
expect(ctx.Features.hasFeature('github-sync')).to.be.true
expect(ctx.Features.hasFeature('gitlab-sync')).to.be.true
expect(ctx.Features.hasFeature('git-bridge')).to.be.true
expect(ctx.Features.hasFeature('homepage')).to.be.true
expect(ctx.Features.hasFeature('link-url')).to.be.true
+2
View File
@@ -55,6 +55,8 @@ export type ExposedSettings = {
templatesEnabled?: boolean
linkedInInsightsPartnerId?: string
githubSyncEnabled: boolean
gitlabSyncEnabled: boolean
zoteroEnabled: boolean
enablePandocConversions: boolean
gitlabUrl: string
}
-22
View File
@@ -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
-2
View File
@@ -1,2 +0,0 @@
export OLD_VERSION="sharelatex/sharelatex:6.3.0"
export NEW_VERSION="sharelatex/sharelatex:6.3.1"
-101
View File
@@ -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.
-72
View File
@@ -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"]
-133
View File
@@ -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)"
-373
View File
@@ -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).
-25
View File
@@ -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
-76
View File
@@ -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;
}
-87
View File
@@ -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 };
}
-222
View File
@@ -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;
}
-34
View File
@@ -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:
File diff suppressed because it is too large Load Diff
-25
View File
@@ -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"
-211
View File
@@ -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);
}
-264
View File
@@ -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 ?? [],
};
}
-165
View File
@@ -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) : ''),
};
-29
View File
@@ -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),
};
}
-204
View File
@@ -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) : ''),
};
-647
View File
@@ -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);
});
}
-59
View File
@@ -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();
}
});

Some files were not shown because too many files have changed in this diff Show More