26 Commits

Author SHA1 Message Date
jmescuderowritefull
2f5e629d65 Include Fake Door Test infrastructure in Writefull with text-to-speech test (#28602)
GitOrigin-RevId: 5e52f6c0d3ea5001855408a13be4813023d5fdb7
2025-09-25 08:05:58 +00:00
Rebeka Dekany
cc49eeacbd Update Storybook controls for shared component stories and add Figma links for preview (#28634)
* Rename ui to shared

* Delete unused Switch component

* Update stories with Figma links

* Update Tag story naming for clarity

* Update Toggle button story naming for clarity

* Move shared components to the shared folder

* Remove file as part of TS migration

* Migrate jsx to tsx

* Remove file as part of TS migration

* Migrate jsx to tsx

* Include necessary controls only

* Auto SF

GitOrigin-RevId: d2458eeffa7a6b67ce522c3ccb6b4f71e5e76d62
2025-09-25 08:05:53 +00:00
Rebeka Dekany
a8734e191e Update learn wiki e2e tests for current navigation structure (#28685)
GitOrigin-RevId: d7d87bf712e0ed399c5fe3a7108db5c47d641ce8
2025-09-25 08:05:49 +00:00
Jimmy Domagala-Tang
bfd00a8151 Allow for different content in opted in/ out experiments (#28530)
* feat: allowing for different messages in experiment when user is opted in

GitOrigin-RevId: 7b4254be6cf8147399010053d5a2a4cf9bb8f342
2025-09-25 08:05:44 +00:00
Jimmy Domagala-Tang
07166bff73 feat: add in-editor notification when rolling image has updated (#28529)
GitOrigin-RevId: 771773ba1914ef609b6ac84799bdda2d7ae4affa
2025-09-25 08:05:39 +00:00
Andrew Rumble
0200ad7515 [monorepo] format ts files (#28678)
* Add ts files to format package script

* Apply changes to service package.json files

* [monorepo] only support plain .ts files in all the services

---------

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>
GitOrigin-RevId: 882cf274da86a0dbe1fff395441f6f2458c1405c
2025-09-25 08:05:30 +00:00
Eric Mc Sween
98069966ba Merge pull request #28672 from overleaf/em-reapply-restore-optimization
Reapply file restore optimization

GitOrigin-RevId: c4479141d890e3e0af746f406a0186bf21f3d918
2025-09-25 08:05:25 +00:00
Brian Gough
d7cd65d20c Merge pull request #28628 from overleaf/bg-cache-history-size-on-project
Implement project size checks on file uploads

GitOrigin-RevId: 4dffe237e6992b859f07964cffa82ff1c13e91c9
2025-09-24 08:07:37 +00:00
Jessica Lawshe
daa4bd9062 Merge pull request #28618 from overleaf/jel-commons-enable-while-migrating-to-group
[web] Support Commons migration to a group with domain capture

GitOrigin-RevId: 9262a44fc09d6d19a9c17895ef90bcef877e4a42
2025-09-24 08:07:29 +00:00
Andrew Rumble
aef9639405 Add acceptance test to notifications
GitOrigin-RevId: de2d0ed8cab15c4347ddecf227073a6d4e06120f
2025-09-24 08:06:52 +00:00
Andrew Rumble
92792828bf Convert HealthCheckController to TypeScript
GitOrigin-RevId: f527e8cecd8599af0d083d168003d325771d4279
2025-09-24 08:06:48 +00:00
Jakob Ackermann
9b2fcbe960 [monorepo] optimize Jenkins build triggers for services (#28477)
Co-authored-by: Andrew Rumble <andrew.rumble@overleaf.com>
Co-authored-by: Mathias Jakobsen <mathias.jakobsen@overleaf.com>
GitOrigin-RevId: ea7d6c435d4a81a0f42ea9b608a0cc79087cdffd
2025-09-24 08:06:43 +00:00
ilkin-overleaf
53bba1807b Merge pull request #28522 from overleaf/ii-domain-capture-create-user-notification
[web] Success message when linking to group with domain capture

GitOrigin-RevId: 2abf29b486da5043c9f207b8f1f741a3c3ee54ab
2025-09-24 08:06:29 +00:00
Andrew Rumble
139d7acd4f Run buildscript update script
GitOrigin-RevId: c6d4929dc59361359066961df6781e8764274c83
2025-09-24 08:06:18 +00:00
Andrew Rumble
ff9be88970 Remove unneeded tsconfig configuration
GitOrigin-RevId: 51373fbc32f46788906b7ad27afe2bb06c3c9854
2025-09-24 08:06:14 +00:00
Domagoj Kriskovic
e9b1c63ed7 Revert "File restore optimization - using snapshot timestamp and file paths (#28546)"
This reverts commit 376a53e1f927cb56544e6782b47d80345655038c.

GitOrigin-RevId: 8e4ab1d4042fec8df1b62ab1c5418d873dc6f5eb
2025-09-24 08:06:06 +00:00
Domagoj Kriskovic
005eba7502 File restore optimization - using snapshot timestamp and file paths (#28546)
* Use snapshot timestamp, remove request for paths at version

* Add timestamp to core Snapshot object

* Avoid mutating function argument

* Explain assumption about editable files

* snapshot.toRaw() in getContentAtVersion

* fix project-history acceptance test

* fix history_v1 test

* fix web tests

* Include the snapshot timestamp in stored chunks

---------

Co-authored-by: Eric Mc Sween <5454374+emcsween@users.noreply.github.com>
GitOrigin-RevId: 376a53e1f927cb56544e6782b47d80345655038c
2025-09-24 08:06:02 +00:00
Antoine Clausse
46715191e3 Merge pull request #28544 from overleaf/ac-some-web-esm-migration-4
[web] Convert some Features files to ES modules (part 4)

GitOrigin-RevId: cf11a7584e39c4d4de08e2f924240e488a4066c4
2025-09-24 08:05:58 +00:00
Andrew Rumble
c0c065e477 Fix HealthCheckController
GitOrigin-RevId: ac5dee607a67c43a9e49f8faf292ef83e9d6d906
2025-09-24 08:05:54 +00:00
Andrew Rumble
087224395c Convert NotificationsController test to TypeScript
GitOrigin-RevId: c220743a4da11e9221e8adc6bb6b4668fa15583e
2025-09-24 08:05:50 +00:00
Andrew Rumble
b15bd48376 Handle Zod errors
GitOrigin-RevId: 0501602e8d9a014987b3cbc89cffd86186995e06
2025-09-24 08:05:45 +00:00
Andrew Rumble
09d9612a71 Convert NotificationsController to TypeScript
GitOrigin-RevId: df1c25e3494fb12c2af74be30a85d8b0d59e1789
2025-09-24 08:05:41 +00:00
Andrew Rumble
52c9f2b512 Rename js->ts
GitOrigin-RevId: 9461b401368099d53ddc8cdc7b226c6156e2d651
2025-09-24 08:05:37 +00:00
Andrew Rumble
e3b3203410 Add helpers and prepare for importing them
GitOrigin-RevId: 115c5ba3160a4cc3751ae26c232a85fed6b42baf
2025-09-24 08:05:33 +00:00
David
e03d41f9a0 Merge pull request #28638 from overleaf/dp-new-user-cutoff
Update NEW_USER_CUTOFF_DATE for editor redesign split test

GitOrigin-RevId: 15027b191b39f8b9225802bd1c83bc09fa946c4f
2025-09-24 08:05:29 +00:00
David
b705a700c8 Merge pull request #28646 from overleaf/dp-link-contrast
Fix link contrast in new editor error logs

GitOrigin-RevId: 170543d7aeae84e9c4db320adc2a1e2a2c4e22fa
2025-09-24 08:05:24 +00:00
158 changed files with 4857 additions and 3450 deletions

View File

@@ -0,0 +1,7 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/access-token-encryptor/**
package-lock.json
package.json
patches/**

View File

@@ -0,0 +1,8 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/fetch-utils/**
libraries/o-error/**
package-lock.json
package.json
patches/**

View File

@@ -0,0 +1,9 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/fetch-utils/**
libraries/logger/**
libraries/o-error/**
package-lock.json
package.json
patches/**

View File

@@ -0,0 +1,7 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/metrics/**
package-lock.json
package.json
patches/**

View File

@@ -0,0 +1,7 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/mongo-utils/**
package-lock.json
package.json
patches/**

View File

@@ -0,0 +1,7 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/o-error/**
package-lock.json
package.json
patches/**

View File

@@ -0,0 +1,12 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/fetch-utils/**
libraries/logger/**
libraries/metrics/**
libraries/o-error/**
libraries/object-persistor/**
libraries/stream-utils/**
package-lock.json
package.json
patches/**

View File

@@ -0,0 +1,8 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/o-error/**
libraries/overleaf-editor-core/**
package-lock.json
package.json
patches/**

View File

@@ -294,6 +294,8 @@ class Change {
if (this.v2DocVersions) {
snapshot.updateV2DocVersions(this.v2DocVersions)
}
snapshot.setTimestamp(this.timestamp)
}
/**

View File

@@ -36,7 +36,8 @@ class Snapshot {
return new Snapshot(
FileMap.fromRaw(raw.files),
raw.projectVersion,
V2DocVersions.fromRaw(raw.v2DocVersions)
V2DocVersions.fromRaw(raw.v2DocVersions),
raw.timestamp ? new Date(raw.timestamp) : undefined
)
}
@@ -45,8 +46,15 @@ class Snapshot {
const raw = {
files: this.fileMap.toRaw(),
}
if (this.projectVersion) raw.projectVersion = this.projectVersion
if (this.v2DocVersions) raw.v2DocVersions = this.v2DocVersions.toRaw()
if (this.projectVersion) {
raw.projectVersion = this.projectVersion
}
if (this.v2DocVersions) {
raw.v2DocVersions = this.v2DocVersions.toRaw()
}
if (this.timestamp != null) {
raw.timestamp = this.timestamp.toISOString()
}
return raw
}
@@ -54,13 +62,15 @@ class Snapshot {
* @param {FileMap} [fileMap]
* @param {string} [projectVersion]
* @param {V2DocVersions} [v2DocVersions]
* @param {Date} [timestamp]
*/
constructor(fileMap, projectVersion, v2DocVersions) {
constructor(fileMap, projectVersion, v2DocVersions, timestamp) {
assert.maybe.instance(fileMap, FileMap, 'bad fileMap')
this.fileMap = fileMap || new FileMap({})
this.projectVersion = projectVersion
this.v2DocVersions = v2DocVersions
this.timestamp = timestamp ?? null
}
/**
@@ -109,6 +119,17 @@ class Snapshot {
v2DocVersions.applyTo(this)
}
getTimestamp() {
return this.timestamp
}
/**
* @param {Date} timestamp
*/
setTimestamp(timestamp) {
this.timestamp = timestamp
}
/**
* The underlying file map.
* @return {FileMap}
@@ -268,6 +289,7 @@ class Snapshot {
files: rawFiles,
projectVersion,
v2DocVersions: rawV2DocVersions,
timestamp: this.getTimestamp() ?? undefined,
}
}

View File

@@ -74,6 +74,7 @@ export type RawSnapshot = {
files: RawFileMap
projectVersion?: string
v2DocVersions?: RawV2DocVersions | null
timestamp?: string
}
export type RawHistory = {

View File

@@ -1,10 +1,15 @@
'use strict'
const { expect } = require('chai')
const core = require('..')
const Change = core.Change
const File = core.File
const Operation = core.Operation
const {
Change,
File,
Operation,
AddFileOperation,
Snapshot,
Origin,
RestoreFileOrigin,
} = require('..')
describe('Change', function () {
describe('findBlobHashes', function () {
@@ -37,9 +42,9 @@ describe('Change', function () {
describe('RestoreFileOrigin', function () {
it('should convert to and from raw', function () {
const origin = new core.RestoreFileOrigin(1, 'path', new Date())
const origin = new RestoreFileOrigin(1, 'path', new Date())
const raw = origin.toRaw()
const newOrigin = core.Origin.fromRaw(raw)
const newOrigin = Origin.fromRaw(raw)
expect(newOrigin).to.eql(origin)
})
@@ -56,7 +61,19 @@ describe('Change', function () {
},
})
expect(change.getOrigin()).to.be.an.instanceof(core.RestoreFileOrigin)
expect(change.getOrigin()).to.be.an.instanceof(RestoreFileOrigin)
})
})
describe('applyTo', function () {
it('sets the timestamp on the snapshot', function () {
const snapshot = new Snapshot()
snapshot.addFile('main.tex', File.fromString(''))
const operation = new AddFileOperation('main.tex', File.fromString(''))
const now = new Date()
const change = new Change([operation], now)
change.applyTo(snapshot)
expect(snapshot.getTimestamp().toISOString()).to.equal(now.toISOString())
})
})
})

View File

@@ -0,0 +1,7 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/promise-utils/**
package-lock.json
package.json
patches/**

View File

@@ -0,0 +1,7 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/ranges-tracker/**
package-lock.json
package.json
patches/**

View File

@@ -0,0 +1,10 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/fetch-utils/**
libraries/logger/**
libraries/o-error/**
libraries/redis-wrapper/**
package-lock.json
package.json
patches/**

View File

@@ -0,0 +1,7 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/settings/**
package-lock.json
package.json
patches/**

View File

@@ -0,0 +1,7 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/stream-utils/**
package-lock.json
package.json
patches/**

View File

@@ -0,0 +1,8 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/o-error/**
libraries/validation-tools/**
package-lock.json
package.json
patches/**

41
package-lock.json generated
View File

@@ -21364,17 +21364,17 @@
}
},
"node_modules/@writefull/core": {
"version": "1.27.21",
"resolved": "https://registry.npmjs.org/@writefull/core/-/core-1.27.21.tgz",
"integrity": "sha512-lZByln15uCkLB5rezWaDuiCRLkdN6GQv4WS9uW5E9KLpluUxNwEya0fGqdxjyOjjNA9+0GatAzmQcgBPZGvCMQ==",
"version": "1.27.24",
"resolved": "https://registry.npmjs.org/@writefull/core/-/core-1.27.24.tgz",
"integrity": "sha512-0f0zc4rb0+44dFBRDuognknrv/z/jfgyU15hX+s334q3H4lOH/H0N6g8HwxcYbCzvzIBQLyrWmsJA8Nr/iAs3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bugsnag/js": "^7.23.0",
"@bugsnag/plugin-react": "^7.24.0",
"@growthbook/growthbook": "^1.4.1",
"@writefull/ui": "^1.27.21",
"@writefull/utils": "^1.27.21",
"@writefull/ui": "^1.27.24",
"@writefull/utils": "^1.27.24",
"axios": "^1.8.3",
"idb": "^8.0.2",
"inversify": "^6.0.2",
@@ -21386,14 +21386,14 @@
}
},
"node_modules/@writefull/ui": {
"version": "1.27.21",
"resolved": "https://registry.npmjs.org/@writefull/ui/-/ui-1.27.21.tgz",
"integrity": "sha512-ELjJIJZX00RHX3QZX0G4RXol4pieT6gVi14cthyDs+2oA8FX7K29BxI8YSRjncd0BJz87TfuXDfLVZO8eJs6OQ==",
"version": "1.27.24",
"resolved": "https://registry.npmjs.org/@writefull/ui/-/ui-1.27.24.tgz",
"integrity": "sha512-Rxq5eSJIVGLkEruLPA6kXXfNdo1X7p1A2CKsFf2IIw6Kw7jfckqMu+jC9y/ItccbbzhY5pt7mQXZtLjL+U72wQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.27.5",
"@writefull/utils": "^1.27.21"
"@writefull/utils": "^1.27.24"
},
"peerDependencies": {
"react": ">= 18",
@@ -21401,9 +21401,9 @@
}
},
"node_modules/@writefull/utils": {
"version": "1.27.21",
"resolved": "https://registry.npmjs.org/@writefull/utils/-/utils-1.27.21.tgz",
"integrity": "sha512-TEQUSrPdWRMNlLB/sJ9Hl68hVSxgi5u8xW0L6b2SOqu1R2eI3IsHXLbh16FRLrKjf50iptbOE8pUK8+CHId2sg==",
"version": "1.27.24",
"resolved": "https://registry.npmjs.org/@writefull/utils/-/utils-1.27.24.tgz",
"integrity": "sha512-b+d4hhT6Z92w3+m2itJJmm3iCNt1v0SHKXZ8jxre+ZmjmIkcno2z/naOs55Q2weo9AgGZAEKhRiir0gSTLLi3A==",
"dev": true,
"license": "MIT"
},
@@ -52043,17 +52043,23 @@
"@overleaf/logger": "*",
"@overleaf/metrics": "*",
"@overleaf/mongo-utils": "*",
"@overleaf/o-error": "*",
"@overleaf/promise-utils": "*",
"@overleaf/settings": "*",
"@overleaf/validation-tools": "*",
"async": "^3.2.5",
"body-parser": "^1.20.3",
"bunyan": "^1.8.15",
"express": "^4.21.2",
"method-override": "^3.0.0",
"mongodb-legacy": "6.1.3"
"mongodb-legacy": "6.1.3",
"zod": "^4.1.7",
"zod-validation-error": "^4.0.1"
},
"devDependencies": {
"chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
"mocha": "^11.1.0",
"typescript": "^5.0.4",
"vitest": "^3.2.4"
}
@@ -52565,6 +52571,7 @@
"nodemailer": "^6.7.0",
"on-headers": "^1.0.2",
"otplib": "^12.0.1",
"overleaf-editor-core": "*",
"p-limit": "^2.3.0",
"p-props": "4.0.0",
"p-queue": "^8.1.0",
@@ -52622,6 +52629,7 @@
"@lezer/markdown": "^1.4.3",
"@overleaf/codemirror-tree-view": "^0.1.3",
"@overleaf/dictionaries": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.3.tar.gz",
"@overleaf/eslint-plugin": "*",
"@overleaf/ranges-tracker": "*",
"@overleaf/stream-utils": "*",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.16",
@@ -52677,9 +52685,9 @@
"@uppy/utils": "^5.7.0",
"@uppy/xhr-upload": "^3.6.0",
"@vitest/eslint-plugin": "1.1.44",
"@writefull/core": "^1.27.6",
"@writefull/ui": "^1.27.6",
"@writefull/utils": "^1.27.6",
"@writefull/core": "^1.27.24",
"@writefull/ui": "^1.27.24",
"@writefull/utils": "^1.27.24",
"5to6-codemod": "^1.8.0",
"abort-controller": "^3.0.0",
"acorn": "^7.1.1",
@@ -52752,7 +52760,6 @@
"mock-fs": "^5.1.2",
"nock": "^13.5.6",
"nvd3": "^1.8.6",
"overleaf-editor-core": "*",
"p-reflect": "^3.1.0",
"pdfjs-dist": "5.1.91",
"pirates": "^4.0.1",

View File

@@ -47,8 +47,8 @@ describe('LearnWiki', function () {
it('should render wiki page', () => {
login(REGULAR_USER)
cy.visit(UPLOADING_A_PROJECT_URL)
// Wiki content
cy.findByRole('heading', { name: 'Uploading a project' })
cy.contains(/how to create an Overleaf project/)
cy.findByRole('img', { name: 'Creating a new project on Overleaf' })
@@ -56,20 +56,28 @@ describe('LearnWiki', function () {
.and((el: any) => {
expect(el[0].naturalWidth, 'renders image').to.be.greaterThan(0)
})
// Wiki navigation
cy.findByRole('link', { name: 'Copying a project' }).should('exist')
cy.visit(COPYING_A_PROJECT_URL)
cy.findByRole('heading', { name: 'Copying a project' })
cy.findByRole('link', {
name: '1 How to copy a project (option 1)',
}).should('exist')
cy.findByRole('link', {
name: '2 How to copy a project (option 2)',
}).should('exist')
})
it('should navigate back and forth', function () {
it('should navigate within wiki page using table of contents', function () {
login(REGULAR_USER)
cy.visit(COPYING_A_PROJECT_URL)
cy.findByRole('heading', { name: 'Copying a project' })
cy.findByRole('link', { name: 'Uploading a project' }).click()
cy.url().should('contain', UPLOADING_A_PROJECT_URL)
cy.findByRole('heading', { name: 'Uploading a project' })
cy.findByRole('link', { name: 'Copying a project' }).click()
cy.findByRole('link', {
name: '2 How to copy a project (option 2)',
}).click()
cy.url().should('contain', COPYING_A_PROJECT_URL)
cy.findByRole('heading', { name: 'Copying a project' })
cy.findByRole('heading', {
name: 'How to copy a project (option 2)',
}).should('be.visible')
})
})

View File

@@ -0,0 +1,13 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/fetch-utils/**
libraries/logger/**
libraries/metrics/**
libraries/mongo-utils/**
libraries/o-error/**
libraries/settings/**
package-lock.json
package.json
patches/**
services/chat/**

View File

@@ -12,8 +12,8 @@
"test:acceptance:_run": "mocha --recursive --reporter spec --timeout 15000 --exit $@ test/acceptance/js",
"test:unit:_run": "mocha --recursive --reporter spec --exit $@ test/unit/js",
"lint": "eslint --max-warnings 0 --format unix .",
"format": "prettier --list-different $PWD/'**/{*.*js,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,Jenkinsfile}'",
"format": "prettier --list-different $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"lint:fix": "eslint --fix .",
"types:check": "tsc --noEmit"
},

View File

@@ -0,0 +1,14 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/fetch-utils/**
libraries/logger/**
libraries/metrics/**
libraries/o-error/**
libraries/promise-utils/**
libraries/settings/**
libraries/stream-utils/**
package-lock.json
package.json
patches/**
services/clsi/**

View File

@@ -7,5 +7,4 @@ clsi
--esmock-loader=False
--node-version=22.18.0
--public-repo=True
--script-version=4.7.0
--use-large-ci-runner=True

View File

@@ -11,8 +11,8 @@
"test:unit": "npm run test:unit:_run -- --grep=$MOCHA_GREP",
"nodemon": "node --watch app.js",
"lint": "eslint --max-warnings 0 --format unix .",
"format": "prettier --list-different $PWD/'**/{*.*js,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,Jenkinsfile}'",
"format": "prettier --list-different $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"lint:fix": "eslint --fix .",
"types:check": "tsc --noEmit"
},

View File

@@ -1,10 +1,13 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/fetch-utils/**
libraries/logger/**
libraries/metrics/**
libraries/mongo-utils/**
libraries/o-error/**
libraries/settings/**
package-lock.json
package.json
patches/**
services/contacts/**
.eslint*
.prettier*
package.json
package-lock.json

View File

@@ -12,8 +12,8 @@
"test:unit": "npm run test:unit:_run -- --grep=$MOCHA_GREP",
"nodemon": "node --watch app.js",
"lint": "eslint --max-warnings 0 --format unix .",
"format": "prettier --list-different $PWD/'**/{*.*js,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,Jenkinsfile}'",
"format": "prettier --list-different $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"lint:fix": "eslint --fix .",
"types:check": "tsc --noEmit"
},

View File

@@ -0,0 +1,16 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/fetch-utils/**
libraries/logger/**
libraries/metrics/**
libraries/mongo-utils/**
libraries/o-error/**
libraries/object-persistor/**
libraries/promise-utils/**
libraries/settings/**
libraries/stream-utils/**
package-lock.json
package.json
patches/**
services/docstore/**

View File

@@ -11,8 +11,8 @@
"test:unit": "npm run test:unit:_run -- --grep=$MOCHA_GREP",
"nodemon": "node --watch app.js",
"lint": "eslint --max-warnings 0 --format unix .",
"format": "prettier --list-different $PWD/'**/{*.*js,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,Jenkinsfile}'",
"format": "prettier --list-different $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"lint:fix": "eslint --fix .",
"types:check": "tsc --noEmit"
},

View File

@@ -0,0 +1,17 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/fetch-utils/**
libraries/logger/**
libraries/metrics/**
libraries/mongo-utils/**
libraries/o-error/**
libraries/overleaf-editor-core/**
libraries/promise-utils/**
libraries/ranges-tracker/**
libraries/redis-wrapper/**
libraries/settings/**
package-lock.json
package.json
patches/**
services/document-updater/**

View File

@@ -12,8 +12,8 @@
"nodemon": "node --watch app.js",
"benchmark:apply": "node benchmarks/apply",
"lint": "eslint --max-warnings 0 --format unix .",
"format": "prettier --list-different $PWD/'**/{*.*js,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,Jenkinsfile}'",
"format": "prettier --list-different $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"lint:fix": "eslint --fix .",
"types:check": "tsc --noEmit"
},

View File

@@ -0,0 +1,14 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/fetch-utils/**
libraries/logger/**
libraries/metrics/**
libraries/o-error/**
libraries/object-persistor/**
libraries/settings/**
libraries/stream-utils/**
package-lock.json
package.json
patches/**
services/filestore/**

View File

@@ -11,8 +11,8 @@
"start": "node app.js",
"nodemon": "node --watch app.js",
"lint": "eslint --max-warnings 0 --format unix .",
"format": "prettier --list-different $PWD/'**/{*.*js,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,Jenkinsfile}'",
"format": "prettier --list-different $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"test:acceptance:_run": "mocha --recursive --reporter spec --timeout 15000 --exit $@ test/acceptance/js",
"test:unit:_run": "mocha --recursive --reporter spec --exit $@ test/unit/js",
"lint:fix": "eslint --fix .",

View File

@@ -0,0 +1,18 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/fetch-utils/**
libraries/logger/**
libraries/metrics/**
libraries/mongo-utils/**
libraries/o-error/**
libraries/object-persistor/**
libraries/overleaf-editor-core/**
libraries/promise-utils/**
libraries/redis-wrapper/**
libraries/settings/**
libraries/stream-utils/**
package-lock.json
package.json
patches/**
services/history-v1/**

View File

@@ -28,6 +28,7 @@ const withTmpDir = require('./with_tmp_dir')
const StreamSizeLimit = require('./stream_size_limit')
const { getProjectBlobsBatch } = require('../../storage/lib/blob_store')
const assert = require('../../storage/lib/assert')
const { getChunkMetadataForVersion } = require('../../storage/lib/chunk_store')
const pipeline = promisify(Stream.pipeline)
@@ -396,7 +397,28 @@ async function getSnapshotAtVersion(projectId, version) {
chunk.getChanges(),
chunk.getEndVersion() - version
)
snapshot.applyAll(changes)
if (changes.length > 0) {
snapshot.applyAll(changes)
} else {
// There are no changes in this chunk; we need to look at the previous chunk
// to get the snapshot's timestamp
let chunkMetadata
try {
chunkMetadata = await getChunkMetadataForVersion(projectId, version)
} catch (err) {
if (err instanceof Chunk.VersionNotFoundError) {
// The snapshot is the first snapshot of the first chunk, so we can't
// find a timestamp. This shouldn't happen often. Ignore the error and
// leave the timestamp empty.
} else {
throw err
}
}
snapshot.setTimestamp(chunkMetadata.endTimestamp)
}
return snapshot
}

View File

@@ -62,8 +62,8 @@
"start": "node app.js",
"lint": "eslint --max-warnings 0 --format unix .",
"lint:fix": "eslint --fix .",
"format": "prettier --list-different $PWD/'**/{*.*js,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,Jenkinsfile}'",
"format": "prettier --list-different $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"test:unit": "npm run test:unit:_run -- --grep=$MOCHA_GREP",
"test:acceptance": "npm run test:acceptance:_run -- --grep=$MOCHA_GREP",
"test:unit:_run": "mocha --recursive --reporter spec --exit $@ test/unit/js",

View File

@@ -108,6 +108,7 @@ describe('persistChanges', function () {
content: '',
},
},
timestamp: thirdChange.getTimestamp().toISOString(),
})
const history = new History(snapshot, [thirdChange])
const currentChunk = new Chunk(history, 2)

View File

@@ -0,0 +1,15 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/fetch-utils/**
libraries/logger/**
libraries/metrics/**
libraries/mongo-utils/**
libraries/o-error/**
libraries/promise-utils/**
libraries/settings/**
libraries/validation-tools/**
package-lock.json
package.json
patches/**
services/notifications/**

View File

@@ -6,8 +6,10 @@ import logger from '@overleaf/logger'
import express from 'express'
import methodOverride from 'method-override'
import { mongoClient } from './app/js/mongodb.js'
import NotificationsController from './app/js/NotificationsController.js'
import HealthCheckController from './app/js/HealthCheckController.js'
import NotificationsController from './app/js/NotificationsController.ts'
import HealthCheckController from './app/js/HealthCheckController.ts'
import { isZodErrorLike } from 'zod-validation-error'
import { ParamsError } from '@overleaf/validation-tools'
const app = express()
@@ -42,6 +44,22 @@ app.get('/health_check', HealthCheckController.check)
app.get('*', (req, res) => res.sendStatus(404))
app.use(handleApiError)
function handleApiError(err, req, res, next) {
req.logger.addFields({ err })
if (err instanceof ParamsError) {
req.logger.setLevel('warn')
res.sendStatus(404)
} else if (isZodErrorLike(err)) {
req.logger.setLevel('warn')
res.sendStatus(400)
} else {
req.logger.setLevel('error')
res.sendStatus(500)
}
}
const host = Settings.internal.notifications?.host || '127.0.0.1'
const port = Settings.internal.notifications?.port || 3042
try {
@@ -51,6 +69,9 @@ try {
process.exit(1)
}
app.listen(port, host, () =>
logger.debug({}, `notifications starting up, listening on ${host}:${port}`)
)
if (import.meta.url === `file://${process.argv[1]}`) {
app.listen(port, host, () =>
logger.debug({}, `notifications starting up, listening on ${host}:${port}`)
)
}
export default app

View File

@@ -7,14 +7,19 @@ import {
RequestFailedError,
} from '@overleaf/fetch-utils'
import { expressify } from '@overleaf/promise-utils'
import { z, zz } from '@overleaf/validation-tools'
import type { Request, Response } from 'express'
const { port } = settings.internal.notifications
function makeUrl(userId, endPath = '') {
return new URL(`/user/${userId}/${endPath}`, `http://127.0.0.1:${port}`)
function makeUrl(userId: string, endPath?: string) {
return new URL(
`/user/${userId}${endPath ? `/${endPath}` : ''}`,
`http://127.0.0.1:${port}`
)
}
async function makeNotification(notificationKey, userId) {
async function makeNotification(notificationKey: string, userId: string) {
const postOpts = {
method: 'POST',
json: {
@@ -29,12 +34,23 @@ async function makeNotification(notificationKey, userId) {
await fetchNothing(url, postOpts)
}
async function getUsersNotifications(userId) {
const getUserNotificationsResponseSchema = z
.object({
_id: zz.objectId(),
key: z.string(),
messageOpts: z.string().optional(),
templateKey: z.string().optional(),
user_id: zz.objectId(),
})
.array()
async function getUsersNotifications(userId: string) {
const url = makeUrl(userId)
try {
return await fetchJson(url, {
const body = await fetchJson(url, {
signal: AbortSignal.timeout(5000),
})
return getUserNotificationsResponseSchema.parse(body)
} catch (err) {
if (err instanceof RequestFailedError) {
logger.err({ err }, 'Non-2xx status code received')
@@ -45,7 +61,7 @@ async function getUsersNotifications(userId) {
}
}
async function userHasNotification(userId, notificationKey) {
async function userHasNotification(userId: string, notificationKey: string) {
const body = await getUsersNotifications(userId)
const hasNotification = body.some(
notification =>
@@ -62,11 +78,15 @@ async function userHasNotification(userId, notificationKey) {
}
}
async function cleanupNotifications(userId) {
async function cleanupNotifications(userId: string) {
await db.notifications.deleteOne({ user_id: userId })
}
async function deleteNotification(userId, notificationId, notificationKey) {
async function deleteNotification(
userId: string,
notificationId: string,
notificationKey: string
) {
const deleteByIdUrl = makeUrl(userId, `notification/${notificationId}`)
try {
await fetchNothing(deleteByIdUrl, {
@@ -87,7 +107,7 @@ async function deleteNotification(userId, notificationId, notificationKey) {
await fetchNothing(deleteByKeyUrl, {
signal: AbortSignal.timeout(5000),
method: 'DELETE',
body: {
json: {
key: notificationKey,
},
})
@@ -100,7 +120,7 @@ async function deleteNotification(userId, notificationId, notificationKey) {
}
}
async function check(req, res) {
async function check(req: Request, res: Response) {
const userId = new ObjectId().toString()
let notificationKey = `smoke-test-notification-${new ObjectId()}`

View File

@@ -1,103 +0,0 @@
import logger from '@overleaf/logger'
import metrics from '@overleaf/metrics'
import Notifications from './Notifications.js'
import { expressify } from '@overleaf/promise-utils'
async function getUserNotifications(req, res, next) {
logger.debug(
{ userId: req.params.user_id },
'getting user unread notifications'
)
metrics.inc('getUserNotifications')
const notifications = await Notifications.getUserNotifications(
req.params.user_id
)
res.json(notifications)
}
async function addNotification(req, res) {
logger.debug(
{ userId: req.params.user_id, notification: req.body },
'adding notification'
)
metrics.inc('addNotification')
try {
await Notifications.addNotification(req.params.user_id, req.body)
res.sendStatus(200)
} catch (err) {
res.sendStatus(500)
}
}
async function removeNotificationId(req, res) {
logger.debug(
{
userId: req.params.user_id,
notificationId: req.params.notification_id,
},
'mark id notification as read'
)
metrics.inc('removeNotificationId')
await Notifications.removeNotificationId(
req.params.user_id,
req.params.notification_id
)
res.sendStatus(200)
}
async function removeNotificationKey(req, res) {
logger.debug(
{ userId: req.params.user_id, notificationKey: req.body.key },
'mark key notification as read'
)
metrics.inc('removeNotificationKey')
await Notifications.removeNotificationKey(req.params.user_id, req.body.key)
res.sendStatus(200)
}
async function removeNotificationByKeyOnly(req, res) {
const notificationKey = req.params.key
logger.debug({ notificationKey }, 'mark notification as read by key only')
metrics.inc('removeNotificationKey')
await Notifications.removeNotificationByKeyOnly(notificationKey)
res.sendStatus(200)
}
async function countNotificationsByKeyOnly(req, res) {
const notificationKey = req.params.key
try {
const count =
await Notifications.countNotificationsByKeyOnly(notificationKey)
res.json({ count })
} catch (err) {
logger.err({ err, notificationKey }, 'cannot count by key')
res.sendStatus(500)
}
}
async function deleteUnreadNotificationsByKeyOnlyBulk(req, res) {
const notificationKey = req.params.key
try {
const count =
await Notifications.deleteUnreadNotificationsByKeyOnlyBulk(
notificationKey
)
res.json({ count })
} catch (err) {
logger.err({ err, notificationKey }, 'cannot bulk remove by key')
res.sendStatus(500)
}
}
export default {
getUserNotifications: expressify(getUserNotifications),
addNotification: expressify(addNotification),
deleteUnreadNotificationsByKeyOnlyBulk: expressify(
deleteUnreadNotificationsByKeyOnlyBulk
),
removeNotificationByKeyOnly: expressify(removeNotificationByKeyOnly),
removeNotificationId: expressify(removeNotificationId),
removeNotificationKey: expressify(removeNotificationKey),
countNotificationsByKeyOnly: expressify(countNotificationsByKeyOnly),
}

View File

@@ -0,0 +1,160 @@
import logger from '@overleaf/logger'
import metrics from '@overleaf/metrics'
import Notifications from './Notifications.js'
import { expressify } from '@overleaf/promise-utils'
import { validateReq, z, zz } from '@overleaf/validation-tools'
import type { Request, Response } from 'express'
const getUserNotificationsSchema = z.object({
params: z.object({
user_id: zz.objectId(),
}),
})
async function getUserNotifications(req: Request, res: Response) {
const { params } = validateReq(req, getUserNotificationsSchema)
logger.debug({ userId: params.user_id }, 'getting user unread notifications')
metrics.inc('getUserNotifications')
const notifications = await Notifications.getUserNotifications(params.user_id)
res.json(notifications)
}
const addNotificationSchema = z.object({
params: z.object({
user_id: zz.objectId(),
}),
body: z.looseObject({}),
})
async function addNotification(req: Request, res: Response) {
const { params, body } = validateReq(req, addNotificationSchema)
logger.debug(
{ userId: params.user_id, notification: body },
'adding notification'
)
metrics.inc('addNotification')
try {
await Notifications.addNotification(params.user_id, body)
res.sendStatus(200)
} catch (err) {
res.sendStatus(500)
}
}
const removeNotificationIdSchema = z.object({
params: z.object({
user_id: zz.objectId(),
notification_id: zz.objectId(),
}),
})
async function removeNotificationId(req: Request, res: Response) {
const { params } = validateReq(req, removeNotificationIdSchema)
logger.debug(
{
userId: req.params.user_id,
notificationId: req.params.notification_id,
},
'mark id notification as read'
)
metrics.inc('removeNotificationId')
await Notifications.removeNotificationId(
params.user_id,
params.notification_id
)
res.sendStatus(200)
}
const removeNotificationKeySchema = z.object({
params: z.object({
user_id: zz.objectId(),
}),
body: z.object({
key: z.string(),
}),
})
async function removeNotificationKey(req: Request, res: Response) {
const { params, body } = validateReq(req, removeNotificationKeySchema)
logger.debug(
{ userId: req.params.user_id, notificationKey: body.key },
'mark key notification as read'
)
metrics.inc('removeNotificationKey')
await Notifications.removeNotificationKey(params.user_id, body.key)
res.sendStatus(200)
}
const removeNotificationByKeyOnlySchema = z.object({
params: z.object({
key: z.string(),
}),
})
async function removeNotificationByKeyOnly(req: Request, res: Response) {
const { params } = validateReq(req, removeNotificationByKeyOnlySchema)
const notificationKey = params.key
logger.debug({ notificationKey }, 'mark notification as read by key only')
metrics.inc('removeNotificationKey')
await Notifications.removeNotificationByKeyOnly(notificationKey)
res.sendStatus(200)
}
const countNotificationsByKeyOnlySchema = z.object({
params: z.object({
key: z.string(),
}),
})
async function countNotificationsByKeyOnly(req: Request, res: Response) {
const { params } = validateReq(req, countNotificationsByKeyOnlySchema)
const notificationKey = params.key
try {
const count =
await Notifications.countNotificationsByKeyOnly(notificationKey)
res.json({ count })
} catch (err) {
logger.err({ err, notificationKey }, 'cannot count by key')
res.sendStatus(500)
}
}
const deleteUnreadNotificationsByKeyOnlyBulkSchema = z.object({
params: z.object({
key: z.string(),
}),
})
async function deleteUnreadNotificationsByKeyOnlyBulk(
req: Request,
res: Response
) {
const { params } = validateReq(
req,
deleteUnreadNotificationsByKeyOnlyBulkSchema
)
const notificationKey = params.key
try {
const count =
await Notifications.deleteUnreadNotificationsByKeyOnlyBulk(
notificationKey
)
res.json({ count })
} catch (err) {
logger.err({ err, notificationKey }, 'cannot bulk remove by key')
res.sendStatus(500)
}
}
export default {
getUserNotifications: expressify(getUserNotifications),
addNotification: expressify(addNotification),
deleteUnreadNotificationsByKeyOnlyBulk: expressify(
deleteUnreadNotificationsByKeyOnlyBulk
),
removeNotificationByKeyOnly: expressify(removeNotificationByKeyOnly),
removeNotificationId: expressify(removeNotificationId),
removeNotificationKey: expressify(removeNotificationKey),
countNotificationsByKeyOnly: expressify(countNotificationsByKeyOnly),
}

View File

@@ -6,5 +6,6 @@ notifications
--esmock-loader=False
--node-version=22.18.0
--public-repo=True
--test-acceptance-vitest=True
--test-unit-vitest=True
--tsconfig-extra-includes=vitest.config.unit.cjs,vitest.config.acceptance.cjs

View File

@@ -33,6 +33,7 @@ services:
NODE_OPTIONS: "--unhandled-rejections=strict"
volumes:
- ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it
- ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json
depends_on:
mongo:
condition: service_started

View File

@@ -32,6 +32,7 @@ services:
- ../../node_modules:/overleaf/node_modules
- ../../libraries:/overleaf/libraries
- ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it
- ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json
working_dir: /overleaf/services/notifications
environment:
ELASTIC_SEARCH_DSN: es:9200

View File

@@ -6,13 +6,13 @@
"type": "module",
"scripts": {
"start": "node app.js",
"test:acceptance:_run": "mocha --recursive --reporter spec --timeout 15000 --exit $@ test/acceptance/js",
"test:acceptance": "npm run test:acceptance:_run -- --grep=$MOCHA_GREP",
"test:acceptance:_run": "vitest --config ./vitest.config.acceptance.cjs",
"test:acceptance": "npm run test:acceptance:_run",
"test:unit:_run": "vitest --config ./vitest.config.unit.cjs",
"test:unit": "npm run test:unit:_run -- --grep=$MOCHA_GREP",
"test:unit": "npm run test:unit:_run",
"lint": "eslint --max-warnings 0 --format unix .",
"format": "prettier --list-different $PWD/'**/{*.*js,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,Jenkinsfile}'",
"format": "prettier --list-different $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"lint:fix": "eslint --fix .",
"types:check": "tsc --noEmit",
"nodemon": "node --watch app.js"
@@ -24,14 +24,18 @@
"@overleaf/logger": "*",
"@overleaf/metrics": "*",
"@overleaf/mongo-utils": "*",
"@overleaf/o-error": "*",
"@overleaf/settings": "*",
"@overleaf/promise-utils": "*",
"@overleaf/validation-tools": "*",
"async": "^3.2.5",
"body-parser": "^1.20.3",
"bunyan": "^1.8.15",
"express": "^4.21.2",
"method-override": "^3.0.0",
"mongodb-legacy": "6.1.3"
"mongodb-legacy": "6.1.3",
"zod": "^4.1.7",
"zod-validation-error": "^4.0.1"
},
"devDependencies": {
"chai": "^4.3.6",

View File

@@ -0,0 +1,31 @@
import { beforeAll, describe, it, expect } from 'vitest'
import { fetchStringWithResponse } from '@overleaf/fetch-utils'
import app from '../../../app.js'
import logger from '@overleaf/logger'
let runAppPromise: Promise<void> | null = null
async function ensureRunning(hostname: string, port: number) {
if (!runAppPromise) {
runAppPromise = new Promise(resolve => {
app.listen(port, hostname, () => {
logger.info({ port, hostname }, 'notifications running in dev mode')
resolve()
})
})
}
await runAppPromise
}
describe('HealthCheck endpoint', () => {
beforeAll(async () => {
await ensureRunning('127.0.0.1', 3042)
})
it('should return 200 for GET /health_check', async () => {
const { response } = await fetchStringWithResponse(
'http://localhost:3042/health_check'
)
expect(response.status).toBe(200)
})
})

View File

@@ -1,163 +0,0 @@
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const modulePath = '../../../app/js/NotificationsController.js'
const userId = '51dc93e6fb625a261300003b'
const notificationId = 'fb625a26f09d'
const notificationKey = 'my-notification-key'
describe('Notifications Controller', () => {
let controller, notifications, stubbedNotification
beforeEach(async () => {
notifications = {
addNotification: vi.fn(),
getUserNotifications: vi.fn(),
removeNotificationByKeyOnly: vi.fn(),
removeNotificationId: vi.fn(),
removeNotificationKey: vi.fn(),
}
vi.doMock('../../../app/js/Notifications', () => ({
default: notifications,
}))
vi.doMock('@overleaf/metrics', () => ({
default: {
inc: vi.fn(),
},
}))
controller = (await import(modulePath)).default
stubbedNotification = [
{
key: notificationKey,
messageOpts: 'some info',
templateKey: 'template-key',
},
]
})
describe('getUserNotifications', () => {
it('should ask the notifications for the users notifications', async () => {
notifications.getUserNotifications.mockResolvedValue(stubbedNotification)
const req = {
params: {
user_id: userId,
},
}
await new Promise(resolve => {
controller.getUserNotifications(req, {
json: result => {
expect(result).toBe(stubbedNotification)
expect(notifications.getUserNotifications).toHaveBeenCalledWith(
userId
)
resolve()
},
})
})
})
})
describe('addNotification', () => {
it('should tell the notifications to add the notification for the user', async () => {
notifications.addNotification.mockResolvedValue()
const req = {
params: {
user_id: userId,
},
body: stubbedNotification,
}
await new Promise(resolve => {
controller.addNotification(req, {
sendStatus: code => {
expect(notifications.addNotification).toHaveBeenCalledWith(
userId,
stubbedNotification
)
expect(code).toBe(200)
resolve()
},
})
})
})
})
describe('removeNotificationId', () => {
it('should tell the notifications to mark the notification Id as read', async () => {
notifications.removeNotificationId.mockResolvedValue()
const req = {
params: {
user_id: userId,
notification_id: notificationId,
},
}
await new Promise(resolve => {
controller.removeNotificationId(req, {
sendStatus: code => {
expect(notifications.removeNotificationId).toHaveBeenCalledWith(
userId,
notificationId
)
expect(code).toBe(200)
resolve()
},
})
})
})
})
describe('removeNotificationKey', () => {
it('should tell the notifications to mark the notification Key as read', async () => {
notifications.removeNotificationKey.mockResolvedValue()
const req = {
params: {
user_id: userId,
},
body: { key: notificationKey },
}
await new Promise(resolve => {
controller.removeNotificationKey(req, {
sendStatus: code => {
expect(notifications.removeNotificationKey).toHaveBeenCalledWith(
userId,
notificationKey
)
expect(code).toBe(200)
resolve()
},
})
})
})
})
describe('removeNotificationByKeyOnly', () => {
it('should tell the notifications to mark the notification Key as read', async () => {
notifications.removeNotificationByKeyOnly.mockResolvedValue()
const req = {
params: {
key: notificationKey,
},
}
await new Promise(resolve =>
controller.removeNotificationByKeyOnly(req, {
sendStatus: code => {
expect(
notifications.removeNotificationByKeyOnly
).toHaveBeenCalledWith(notificationKey)
expect(code).toBe(200)
resolve()
},
})
)
})
})
})

View File

@@ -0,0 +1,216 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NotificationsController from '../../../app/js/NotificationsController.ts'
import Notifications from '../../../app/js/Notifications.js'
import { ObjectId } from 'mongodb-legacy'
const modulePath = '../../../app/js/NotificationsController.js'
const userId = '51dc93e6fb625a261300003b'
const notificationId = '51dc93e6fb625a261300003c'
const notificationKey = 'my-notification-key'
vi.mock('../../../app/js/Notifications', () => ({
default: {
addNotification: vi.fn(),
getUserNotifications: vi.fn(),
removeNotificationByKeyOnly: vi.fn(),
removeNotificationId: vi.fn(),
removeNotificationKey: vi.fn(),
},
}))
interface InputNotification {
user_id: string
key: string
messageOpts?: object
templateKey?: string
}
interface DatabaseNotification {
_id: ObjectId
user_id: ObjectId
key: string
messageOpts?: object
templateKey?: string
}
function convertInputNotificationToDatabaseNotification(
inputNotification: InputNotification
): DatabaseNotification {
return {
...inputNotification,
_id: new ObjectId(),
user_id: new ObjectId(inputNotification.user_id),
}
}
describe('Notifications Controller', () => {
let controller: typeof NotificationsController,
stubbedNotification: Array<InputNotification>
beforeEach(async () => {
vi.doMock('@overleaf/metrics', () => ({
default: {
inc: vi.fn(),
mongodb: {
monitor: vi.fn(),
},
},
}))
controller = (await import(modulePath)).default
stubbedNotification = [
{
user_id: new ObjectId().toString(),
key: notificationKey,
messageOpts: { info: 'some info' },
templateKey: 'template-key',
},
]
})
describe('getUserNotifications', () => {
it('should ask the notifications for the users notifications', async () => {
const databaseNotifications = stubbedNotification.map(
convertInputNotificationToDatabaseNotification
)
vi.mocked(Notifications.getUserNotifications).mockResolvedValue(
databaseNotifications
)
const req = {
params: {
user_id: userId,
},
}
await new Promise<void>(resolve => {
controller.getUserNotifications(req, {
json: result => {
expect(result).toBe(databaseNotifications)
expect(Notifications.getUserNotifications).toHaveBeenCalledWith(
userId
)
resolve()
},
})
})
})
})
describe('addNotification', () => {
it('should tell the notifications to add the notification for the user', async () => {
vi.mocked(Notifications.addNotification).mockResolvedValue({
acknowledged: true,
upsertedId: new ObjectId(),
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0,
})
const req = {
params: {
user_id: userId,
},
body: stubbedNotification[0],
}
await new Promise<void>(resolve => {
controller.addNotification(req, {
sendStatus: code => {
expect(Notifications.addNotification).toHaveBeenCalledWith(
userId,
stubbedNotification[0]
)
expect(code).toBe(200)
resolve()
},
})
})
})
})
describe('removeNotificationId', () => {
it('should tell the notifications to mark the notification Id as read', async () => {
vi.mocked(Notifications.removeNotificationId).mockResolvedValue({
acknowledged: true,
upsertedId: null,
upsertedCount: 0,
matchedCount: 1,
modifiedCount: 1,
})
const req = {
params: {
user_id: userId,
notification_id: notificationId,
},
}
await new Promise<void>(resolve => {
controller.removeNotificationId(req, {
sendStatus: code => {
expect(Notifications.removeNotificationId).toHaveBeenCalledWith(
userId,
notificationId
)
expect(code).toBe(200)
resolve()
},
})
})
})
})
describe('removeNotificationKey', () => {
it('should tell the notifications to mark the notification Key as read', async () => {
vi.mocked(Notifications.removeNotificationKey).mockResolvedValue({
acknowledged: true,
upsertedId: null,
upsertedCount: 0,
matchedCount: 1,
modifiedCount: 1,
})
const req = {
params: {
user_id: userId,
},
body: { key: notificationKey },
}
await new Promise<void>(resolve => {
controller.removeNotificationKey(req, {
sendStatus: code => {
expect(Notifications.removeNotificationKey).toHaveBeenCalledWith(
userId,
notificationKey
)
expect(code).toBe(200)
resolve()
},
})
})
})
})
describe('removeNotificationByKeyOnly', () => {
it('should tell the notifications to mark the notification Key as read', async () => {
vi.mocked(Notifications.removeNotificationByKeyOnly).mockResolvedValue({
acknowledged: true,
upsertedId: null,
upsertedCount: 0,
matchedCount: 1,
modifiedCount: 1,
})
const req = {
params: {
key: notificationKey,
},
}
await new Promise<void>(resolve =>
controller.removeNotificationByKeyOnly(req, {
sendStatus: code => {
expect(
Notifications.removeNotificationByKeyOnly
).toHaveBeenCalledWith(notificationKey)
expect(code).toBe(200)
resolve()
},
})
)
})
})
})

View File

@@ -0,0 +1,8 @@
const { defineConfig } = require('vitest/config')
module.exports = defineConfig({
test: {
include: ['test/acceptance/js/**/*.test.{js,ts}'],
isolate: false,
},
})

View File

@@ -2,7 +2,7 @@ const { defineConfig } = require('vitest/config')
module.exports = defineConfig({
test: {
include: ['test/unit/js/**/*.test.js'],
include: ['test/unit/js/**/*.test.{js,ts}'],
setupFiles: ['./test/setup.js'],
isolate: false,
},

View File

@@ -0,0 +1,17 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/fetch-utils/**
libraries/logger/**
libraries/metrics/**
libraries/mongo-utils/**
libraries/o-error/**
libraries/overleaf-editor-core/**
libraries/promise-utils/**
libraries/redis-wrapper/**
libraries/settings/**
libraries/stream-utils/**
package-lock.json
package.json
patches/**
services/project-history/**

View File

@@ -12,8 +12,8 @@
"test:acceptance:_run": "mocha --loader=esmock --recursive --reporter spec --timeout 15000 --exit $@ test/acceptance/js",
"test:unit:_run": "mocha --loader=esmock --recursive --reporter spec --exit $@ test/unit/js",
"lint": "eslint --max-warnings 0 --format unix .",
"format": "prettier --list-different $PWD/'**/{*.*js,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,Jenkinsfile}'",
"format": "prettier --list-different $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"lint:fix": "eslint --fix .",
"types:check": "tsc --noEmit"
},

View File

@@ -1,6 +1,7 @@
import { expect } from 'chai'
import mongodb from 'mongodb-legacy'
import nock from 'nock'
import { readFileSync } from 'node:fs'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
const { ObjectId } = mongodb
@@ -50,6 +51,12 @@ describe('LatestSnapshot', function () {
.get(`/api/projects/${this.historyId}/latest/history`)
.replyWithFile(200, fixture('chunks/0-3.json'))
const fixtureData = JSON.parse(
readFileSync(fixture('chunks/0-3.json'), 'utf8')
)
const changes = fixtureData.chunk.history.changes
const lastTimestamp = changes[changes.length - 1].timestamp
ProjectHistoryClient.getLatestSnapshot(this.projectId, (error, body) => {
if (error) {
throw error
@@ -69,6 +76,7 @@ describe('LatestSnapshot', function () {
operations: [{ textOperation: [26, '\n\nFour five six'] }],
},
},
timestamp: lastTimestamp,
},
version: 3,
})

View File

@@ -0,0 +1,13 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/fetch-utils/**
libraries/logger/**
libraries/metrics/**
libraries/o-error/**
libraries/redis-wrapper/**
libraries/settings/**
package-lock.json
package.json
patches/**
services/real-time/**

View File

@@ -11,8 +11,8 @@
"test:unit": "npm run test:unit:_run -- --grep=$MOCHA_GREP",
"nodemon": "node --watch app.js",
"lint": "eslint --max-warnings 0 --format unix .",
"format": "prettier --list-different $PWD/'**/{*.*js,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,Jenkinsfile}'",
"format": "prettier --list-different $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"format:fix": "prettier --write $PWD/'**/{*.*js,*.ts,Jenkinsfile}'",
"lint:fix": "eslint --fix .",
"types:check": "tsc --noEmit"
},

View File

@@ -1,7 +1,22 @@
libraries/**
patches/**
services/web/**
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
package.json
libraries/access-token-encryptor/**
libraries/eslint-plugin/**
libraries/fetch-utils/**
libraries/logger/**
libraries/metrics/**
libraries/mongo-utils/**
libraries/o-error/**
libraries/object-persistor/**
libraries/overleaf-editor-core/**
libraries/promise-utils/**
libraries/ranges-tracker/**
libraries/redis-wrapper/**
libraries/settings/**
libraries/stream-utils/**
libraries/validation-tools/**
package-lock.json
package.json
patches/**
services/web/**

View File

@@ -5,7 +5,7 @@ import EditorRealTimeController from '../Editor/EditorRealTimeController.js'
import SessionManager from '../Authentication/SessionManager.js'
import UserInfoManager from '../User/UserInfoManager.js'
import UserInfoController from '../User/UserInfoController.js'
import ChatManager from './ChatManager.js'
import ChatManager from './ChatManager.mjs'
async function sendMessage(req, res) {
const { project_id: projectId } = req.params

View File

@@ -1,6 +1,6 @@
const UserInfoController = require('../User/UserInfoController')
const UserGetter = require('../User/UserGetter')
const { callbackify } = require('@overleaf/promise-utils')
import UserInfoController from '../User/UserInfoController.js'
import UserGetter from '../User/UserGetter.js'
import { callbackify } from '@overleaf/promise-utils'
async function injectUserInfoIntoThreads(threads) {
const userIds = new Set()
@@ -38,7 +38,7 @@ async function injectUserInfoIntoThreads(threads) {
return threads
}
module.exports = {
export default {
injectUserInfoIntoThreads: callbackify(injectUserInfoIntoThreads),
promises: {
injectUserInfoIntoThreads,

View File

@@ -4,7 +4,7 @@ import { Cookie } from 'tough-cookie'
import OError from '@overleaf/o-error'
import Metrics from '@overleaf/metrics'
import ProjectGetter from '../Project/ProjectGetter.js'
import CompileManager from './CompileManager.js'
import CompileManager from './CompileManager.mjs'
import ClsiManager from './ClsiManager.js'
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'

View File

@@ -1,19 +1,16 @@
import Crypto from 'node:crypto'
import Settings from '@overleaf/settings'
import RedisWrapper from '../../infrastructure/RedisWrapper.js'
import ProjectGetter from '../Project/ProjectGetter.js'
import ProjectRootDocManager from '../Project/ProjectRootDocManager.js'
import UserGetter from '../User/UserGetter.js'
import ClsiManager from './ClsiManager.js'
import Metrics from '@overleaf/metrics'
import { RateLimiter } from '../../infrastructure/RateLimiter.js'
import UserAnalyticsIdCache from '../Analytics/UserAnalyticsIdCache.js'
import { callbackify, callbackifyMultiResult } from '@overleaf/promise-utils'
let CompileManager
const Crypto = require('crypto')
const Settings = require('@overleaf/settings')
const RedisWrapper = require('../../infrastructure/RedisWrapper')
const rclient = RedisWrapper.client('clsi_recently_compiled')
const ProjectGetter = require('../Project/ProjectGetter')
const ProjectRootDocManager = require('../Project/ProjectRootDocManager')
const UserGetter = require('../User/UserGetter')
const ClsiManager = require('./ClsiManager')
const Metrics = require('@overleaf/metrics')
const { RateLimiter } = require('../../infrastructure/RateLimiter')
const UserAnalyticsIdCache = require('../Analytics/UserAnalyticsIdCache')
const {
callbackify,
callbackifyMultiResult,
} = require('@overleaf/promise-utils')
function instrumentWithTimer(fn, key) {
return async (...args) => {
@@ -196,7 +193,7 @@ async function deleteAuxFiles(projectId, userId, clsiserverid) {
)
}
module.exports = CompileManager = {
export default CompileManager = {
promises: {
compile: instrumentedCompile,
deleteAuxFiles,

View File

@@ -1,7 +1,7 @@
import { isZodErrorLike, fromZodError } from 'zod-validation-error'
import Errors from './Errors.js'
import SessionManager from '../Authentication/SessionManager.js'
import SamlLogHandler from '../SamlLog/SamlLogHandler.js'
import SamlLogHandler from '../SamlLog/SamlLogHandler.mjs'
import HttpErrorHandler from './HttpErrorHandler.js'
import { plainTextResponse } from '../../infrastructure/Response.js'
import { expressifyErrorHandler } from '@overleaf/promise-utils'

View File

@@ -7,6 +7,7 @@ const ProjectDetailsHandler = require('../Project/ProjectDetailsHandler')
const { File } = require('../../models/File')
const OError = require('@overleaf/o-error')
const { promisifyAll } = require('@overleaf/promise-utils')
const Modules = require('../../infrastructure/Modules')
const FileStoreHandler = {
RETRY_ATTEMPTS: 3,
@@ -69,24 +70,47 @@ const FileStoreHandler = {
)
return callback(new Error('can not upload symlink'))
}
FileHashManager.computeHash(fsPath, function (err, hash) {
if (err) {
return callback(err)
}
FileStoreHandler._uploadToHistory(
historyId,
hash,
stat.size,
fsPath,
function (err) {
const size = stat.size
Modules.hooks.fire(
'preUploadFile',
{ projectId, historyId, fileArgs, fsPath, size },
preUploadErr => {
if (preUploadErr) {
return callback(preUploadErr)
}
FileHashManager.computeHash(fsPath, function (err, hash) {
if (err) {
return callback(err)
}
fileArgs = { ...fileArgs, hash }
callback(err, new File(fileArgs), true)
}
)
})
FileStoreHandler._uploadToHistory(
historyId,
hash,
stat.size,
fsPath,
function (err) {
if (err) {
return callback(err)
}
const fileRef = new File({ ...fileArgs, hash })
Modules.hooks.fire(
'postUploadFile',
{
projectId,
fileRef,
size,
},
postUploadErr => {
if (postUploadErr) {
return callback(postUploadErr)
}
callback(err, fileRef, true, size)
}
)
}
)
})
}
)
})
},
}
@@ -94,7 +118,7 @@ const FileStoreHandler = {
module.exports = FileStoreHandler
module.exports.promises = promisifyAll(FileStoreHandler, {
multiResult: {
uploadFileFromDisk: ['fileRef', 'createdBlob'],
uploadFileFromDiskWithHistoryId: ['fileRef', 'createdBlob'],
uploadFileFromDisk: ['fileRef', 'createdBlob', 'size'],
uploadFileFromDiskWithHistoryId: ['fileRef', 'createdBlob', 'size'],
},
})

View File

@@ -7,14 +7,13 @@ import EditorController from '../Editor/EditorController.js'
import Errors from '../Errors/Errors.js'
import moment from 'moment'
import { callbackifyAll } from '@overleaf/promise-utils'
import { fetchJson } from '@overleaf/fetch-utils'
import ProjectLocator from '../Project/ProjectLocator.js'
import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js'
import ChatApiHandler from '../Chat/ChatApiHandler.js'
import DocstoreManager from '../Docstore/DocstoreManager.js'
import logger from '@overleaf/logger'
import EditorRealTimeController from '../Editor/EditorRealTimeController.js'
import ChatManager from '../Chat/ChatManager.js'
import ChatManager from '../Chat/ChatManager.mjs'
import OError from '@overleaf/o-error'
import ProjectGetter from '../Project/ProjectGetter.js'
import ProjectEntityHandler from '../Project/ProjectEntityHandler.js'
@@ -69,6 +68,13 @@ const RestoreManager = {
)
const snapshot = Snapshot.fromRaw(snapshotRaw)
const origin = options.origin ?? {
kind: 'file-restore',
path: pathname,
version,
timestamp: snapshot.getTimestamp()?.toISOString(),
}
return await RestoreManager._revertSingleFile(
userId,
projectId,
@@ -76,7 +82,7 @@ const RestoreManager = {
pathname,
threadIds,
snapshot,
options
{ origin }
)
},
@@ -131,17 +137,9 @@ const RestoreManager = {
})
.catch(() => null)
const updates = await RestoreManager._getUpdatesFromHistory(
projectId,
version
)
const updateAtVersion = updates.find(update => update.toV === version)
const origin = options.origin || {
kind: 'file-restore',
path: pathname,
version,
timestamp: new Date(updateAtVersion.meta.end_ts).toISOString(),
const snapshotFile = projectSnapshotAtVersion.getFile(pathname)
if (!snapshotFile) {
throw new OError('file not found in snapshot', { pathname })
}
const importInfo = await FileSystemImportManager.promises.importFile(
@@ -162,7 +160,7 @@ const RestoreManager = {
projectId,
file.element._id,
file.type,
origin,
options.origin,
userId
)
@@ -177,25 +175,24 @@ const RestoreManager = {
threadIds.delete(file.element._id.toString())
}
const snapshotFile = projectSnapshotAtVersion.getFile(pathname)
if (!snapshotFile) {
throw new OError('file not found in snapshot', { pathname })
}
// Look for metadata indicating a linked file.
const fileMetadata = snapshotFile.getMetadata()
const isFileMetadata = fileMetadata && 'provider' in fileMetadata
logger.debug({ fileMetadata }, 'metadata from history')
if (importInfo.type === 'file' || isFileMetadata) {
if (
!snapshotFile.isEditable() ||
importInfo.type === 'file' ||
isFileMetadata
) {
const newFile = await EditorController.promises.upsertFile(
projectId,
parentFolderId,
basename,
fsPath,
fileMetadata,
origin,
options.origin,
userId
)
@@ -296,13 +293,17 @@ const RestoreManager = {
newCommentThreadData
)
const lines = snapshotFile
.getContent({ filterTrackedDeletes: true })
.split('\n')
const { _id } = await EditorController.promises.addDocWithRanges(
projectId,
parentFolderId,
basename,
importInfo.lines,
lines,
newRanges,
origin,
options.origin,
userId
)
@@ -362,31 +363,21 @@ const RestoreManager = {
throw new OError('project does not have ranges support', { projectId })
}
// Get project paths at version
const pathsAtPastVersion = await RestoreManager._getProjectPathsAtVersion(
projectId,
version
)
const updates = await RestoreManager._getUpdatesFromHistory(
projectId,
version
)
const updateAtVersion = updates.find(update => update.toV === version)
const origin = {
kind: 'project-restore',
version,
timestamp: new Date(updateAtVersion.meta.end_ts).toISOString(),
}
const threadIds = await getCommentThreadIds(projectId)
const snapshotRaw = await HistoryManager.promises.getContentAtVersion(
projectId,
version
)
const snapshot = Snapshot.fromRaw(snapshotRaw)
const pathsAtPastVersion = snapshot.getFilePathnames()
const origin = {
kind: 'project-restore',
version,
timestamp: snapshot.getTimestamp()?.toISOString(),
}
const threadIds = await getCommentThreadIds(projectId)
const reverted = []
for (const pathname of pathsAtPastVersion) {
const res = await RestoreManager._revertSingleFile(
@@ -437,18 +428,6 @@ const RestoreManager = {
}/project/${projectId}/version/${version}/${encodeURIComponent(pathname)}`
return await FileWriter.promises.writeUrlToDisk(projectId, url)
},
async _getUpdatesFromHistory(projectId, version) {
const url = `${Settings.apis.project_history.url}/project/${projectId}/updates?before=${version}&min_count=1`
const res = await fetchJson(url)
return res.updates
},
async _getProjectPathsAtVersion(projectId, version) {
const url = `${Settings.apis.project_history.url}/project/${projectId}/paths/version/${version}`
const res = await fetchJson(url)
return res.paths
},
}
export default { ...callbackifyAll(RestoreManager), promises: RestoreManager }

View File

@@ -163,20 +163,18 @@ function getUserAffiliations(userId, callback) {
if (body?.length > 0) {
const concurrencyLimit = 10
await promiseMapWithLimit(concurrencyLimit, body, async affiliation => {
if (!affiliation.institution.commonsAccount) {
const group = (
await Modules.promises.hooks.fire(
'getGroupWithDomainCaptureByV1Id',
affiliation.institution.id
)
)?.[0]
const group = (
await Modules.promises.hooks.fire(
'getGroupWithDomainCaptureByV1Id',
affiliation.institution.id
)
)?.[0]
if (group) {
affiliation.group = {
_id: group._id,
managedUsersEnabled: Boolean(group.managedUsersEnabled),
domainCaptureEnabled: Boolean(group.domainCaptureEnabled),
}
if (group) {
affiliation.group = {
_id: group._id,
managedUsersEnabled: Boolean(group.managedUsersEnabled),
domainCaptureEnabled: Boolean(group.domainCaptureEnabled),
}
}

View File

@@ -15,21 +15,7 @@ import Settings from '@overleaf/settings'
import _ from 'lodash'
import AnalyticsManager from '../../../../app/src/Features/Analytics/AnalyticsManager.js'
import LinkedFilesHandler from './LinkedFilesHandler.mjs'
import {
CompileFailedError,
UrlFetchFailedError,
InvalidUrlError,
AccessDeniedError,
BadEntityTypeError,
BadDataError,
ProjectNotFoundError,
V1ProjectNotFoundError,
SourceFileNotFoundError,
NotOriginalImporterError,
FeatureNotAvailableError,
RemoteServiceError,
FileCannotRefreshError,
} from './LinkedFilesErrors.js'
import LinkedFilesErrors from './LinkedFilesErrors.mjs'
import {
OutputFileFetchFailedError,
FileTooLargeError,
@@ -45,6 +31,22 @@ import ProjectOutputFileAgent from './ProjectOutputFileAgent.mjs'
import ProjectFileAgent from './ProjectFileAgent.mjs'
import UrlAgent from './UrlAgent.mjs'
const {
CompileFailedError,
UrlFetchFailedError,
InvalidUrlError,
AccessDeniedError,
BadEntityTypeError,
BadDataError,
ProjectNotFoundError,
V1ProjectNotFoundError,
SourceFileNotFoundError,
NotOriginalImporterError,
FeatureNotAvailableError,
RemoteServiceError,
FileCannotRefreshError,
} = LinkedFilesErrors
let LinkedFilesController
const createLinkedFileSchema = z.object({

View File

@@ -1,4 +1,4 @@
const { BackwardCompatibleError } = require('../Errors/Errors')
import { BackwardCompatibleError } from '../Errors/Errors.js'
class UrlFetchFailedError extends BackwardCompatibleError {}
@@ -26,7 +26,7 @@ class RemoteServiceError extends BackwardCompatibleError {}
class FileCannotRefreshError extends BackwardCompatibleError {}
module.exports = {
export default {
CompileFailedError,
UrlFetchFailedError,
InvalidUrlError,

View File

@@ -3,13 +3,12 @@ import EditorController from '../Editor/EditorController.js'
import ProjectLocator from '../Project/ProjectLocator.js'
import { Project } from '../../models/Project.js'
import ProjectGetter from '../Project/ProjectGetter.js'
import {
ProjectNotFoundError,
V1ProjectNotFoundError,
BadDataError,
} from './LinkedFilesErrors.js'
import LinkedFilesErrors from './LinkedFilesErrors.mjs'
import { callbackifyAll } from '@overleaf/promise-utils'
const { ProjectNotFoundError, V1ProjectNotFoundError, BadDataError } =
LinkedFilesErrors
const LinkedFilesHandler = {
async getFileById(projectId, fileId) {
const { element, path, folder } = await ProjectLocator.promises.findElement(

View File

@@ -16,16 +16,16 @@ import DocstoreManager from '../Docstore/DocstoreManager.js'
import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js'
import _ from 'lodash'
import LinkedFilesHandler from './LinkedFilesHandler.mjs'
import LinkedFilesErrors from './LinkedFilesErrors.mjs'
import { promisify } from '@overleaf/promise-utils'
import HistoryManager from '../History/HistoryManager.js'
import {
const {
BadDataError,
AccessDeniedError,
BadEntityTypeError,
SourceFileNotFoundError,
} from './LinkedFilesErrors.js'
import { promisify } from '@overleaf/promise-utils'
import HistoryManager from '../History/HistoryManager.js'
} = LinkedFilesErrors
let ProjectFileAgent

View File

@@ -1,17 +1,16 @@
import AuthorizationManager from '../Authorization/AuthorizationManager.js'
import CompileManager from '../Compile/CompileManager.js'
import CompileManager from '../Compile/CompileManager.mjs'
import ClsiManager from '../Compile/ClsiManager.js'
import ProjectFileAgent from './ProjectFileAgent.mjs'
import _ from 'lodash'
import {
CompileFailedError,
BadDataError,
AccessDeniedError,
} from './LinkedFilesErrors.js'
import LinkedFilesErrors from './LinkedFilesErrors.mjs'
import { OutputFileFetchFailedError } from '../Errors/Errors.js'
import LinkedFilesHandler from './LinkedFilesHandler.mjs'
import { promisify } from '@overleaf/promise-utils'
const { CompileFailedError, BadDataError, AccessDeniedError } =
LinkedFilesErrors
function _prepare(projectId, linkedFileData, userId, callback) {
_checkAuth(projectId, linkedFileData, userId, (err, allowed) => {
if (err) {

View File

@@ -1,12 +1,14 @@
import logger from '@overleaf/logger'
import urlValidator from 'valid-url'
import { InvalidUrlError, UrlFetchFailedError } from './LinkedFilesErrors.js'
import LinkedFilesErrors from './LinkedFilesErrors.mjs'
import LinkedFilesHandler from './LinkedFilesHandler.mjs'
import UrlHelper from '../Helpers/UrlHelper.js'
import { fetchStream, RequestFailedError } from '@overleaf/fetch-utils'
import { callbackify } from '@overleaf/promise-utils'
import { FileTooLargeError } from '../Errors/Errors.js'
const { InvalidUrlError, UrlFetchFailedError } = LinkedFilesErrors
async function createLinkedFile(
projectId,
linkedFileData,

View File

@@ -35,20 +35,20 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.js'
import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
import SplitTestSessionHandler from '../SplitTests/SplitTestSessionHandler.js'
import FeaturesUpdater from '../Subscription/FeaturesUpdater.js'
import SpellingHandler from '../Spelling/SpellingHandler.js'
import SpellingHandler from '../Spelling/SpellingHandler.mjs'
import { hasAdminAccess } from '../Helpers/AdminAuthorizationHelper.js'
import InstitutionsFeatures from '../Institutions/InstitutionsFeatures.js'
import InstitutionsGetter from '../Institutions/InstitutionsGetter.js'
import ProjectAuditLogHandler from './ProjectAuditLogHandler.mjs'
import PublicAccessLevels from '../Authorization/PublicAccessLevels.js'
import TagsHandler from '../Tags/TagsHandler.js'
import TutorialHandler from '../Tutorial/TutorialHandler.js'
import TutorialHandler from '../Tutorial/TutorialHandler.mjs'
import UserUpdater from '../User/UserUpdater.js'
import Modules from '../../infrastructure/Modules.js'
import { z, zz, validateReq } from '../../infrastructure/Validation.js'
import UserGetter from '../User/UserGetter.js'
import { isStandaloneAiAddOnPlanCode } from '../Subscription/AiHelper.js'
import SubscriptionController from '../Subscription/SubscriptionController.js'
import SubscriptionController from '../Subscription/SubscriptionController.mjs'
import { formatCurrency } from '../../util/currency.js'
const { ObjectId } = mongodb

View File

@@ -167,6 +167,7 @@ function getAllowedImagesForUser(user) {
return {
...image,
allowed: _imageAllowed(user, image),
rolling: image.monthlyExperimental,
}
})

View File

@@ -25,7 +25,7 @@ import NotificationsBuilder from '../Notifications/NotificationsBuilder.js'
import GeoIpLookup from '../../infrastructure/GeoIpLookup.js'
import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
import SplitTestSessionHandler from '../SplitTests/SplitTestSessionHandler.js'
import TutorialHandler from '../Tutorial/TutorialHandler.js'
import TutorialHandler from '../Tutorial/TutorialHandler.mjs'
import SubscriptionHelper from '../Subscription/SubscriptionHelper.js'
import PermissionsManager from '../Authorization/PermissionsManager.js'
import AnalyticsManager from '../Analytics/AnalyticsManager.js'
@@ -319,7 +319,9 @@ async function projectListPage(req, res, next) {
institutionName:
samlSession.linked.universityName ||
samlSession.linked.providerName,
templateKey: 'notification_institution_sso_linked',
templateKey: samlSession.domainCaptureEnabled
? 'notification_group_sso_linked'
: 'notification_institution_sso_linked',
})
}

View File

@@ -1,9 +1,9 @@
const { SamlLog } = require('../../models/SamlLog')
const SessionManager = require('../Authentication/SessionManager')
const logger = require('@overleaf/logger')
const { err: errSerializer } = require('@overleaf/logger/serializers')
const { callbackify } = require('util')
const Settings = require('@overleaf/settings')
import { SamlLog } from '../../models/SamlLog.js'
import SessionManager from '../Authentication/SessionManager.js'
import logger from '@overleaf/logger'
import loggerSerializers from '@overleaf/logger/serializers.js'
import { callbackify } from 'node:util'
import Settings from '@overleaf/settings'
const ALLOWED_PATHS = Settings.saml?.logAllowList || ['/saml/']
@@ -33,7 +33,7 @@ async function log(req, data, samlAssertion) {
data.samlSession = saml
if (data.error instanceof Error) {
const errSerialized = errSerializer(data.error)
const errSerialized = loggerSerializers.err(data.error)
if (data.error.tryAgain) {
errSerialized.tryAgain = data.error.tryAgain
}
@@ -82,4 +82,4 @@ const SamlLogHandler = {
},
}
module.exports = SamlLogHandler
export default SamlLogHandler

View File

@@ -5,7 +5,7 @@ import Settings from '@overleaf/settings'
import TpdsUpdateSender from '../ThirdPartyDataStore/TpdsUpdateSender.js'
import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.js'
import EditorRealTimeController from '../Editor/EditorRealTimeController.js'
import SystemMessageManager from '../SystemMessages/SystemMessageManager.js'
import SystemMessageManager from '../SystemMessages/SystemMessageManager.mjs'
const AdminController = {
_sendDisconnectAllUsersMessage: delay => {

View File

@@ -1,9 +1,9 @@
const OError = require('@overleaf/o-error')
const Metrics = require('@overleaf/metrics')
const { promisifyAll } = require('@overleaf/promise-utils')
const LearnedWordsManager = require('./LearnedWordsManager')
import OError from '@overleaf/o-error'
import Metrics from '@overleaf/metrics'
import { promisifyAll } from '@overleaf/promise-utils'
import LearnedWordsManager from './LearnedWordsManager.js'
module.exports = {
const SpellingHandler = {
getUserDictionary(userId, callback) {
const timer = new Metrics.Timer('spelling_get_dict')
LearnedWordsManager.getLearnedWords(userId, (error, words) => {
@@ -26,4 +26,4 @@ module.exports = {
},
}
module.exports.promises = promisifyAll(module.exports)
export default { ...SpellingHandler, promises: promisifyAll(SpellingHandler) }

View File

@@ -1,48 +1,48 @@
// @ts-check
const SessionManager = require('../Authentication/SessionManager')
const SubscriptionHandler = require('./SubscriptionHandler')
const SubscriptionHelper = require('./SubscriptionHelper')
const SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder')
const LimitationsManager = require('./LimitationsManager')
const RecurlyWrapper = require('./RecurlyWrapper')
const Settings = require('@overleaf/settings')
const logger = require('@overleaf/logger')
const GeoIpLookup = require('../../infrastructure/GeoIpLookup')
const FeaturesUpdater = require('./FeaturesUpdater')
const GroupPlansData = require('./GroupPlansData')
const V1SubscriptionManager = require('./V1SubscriptionManager')
const AnalyticsManager = require('../Analytics/AnalyticsManager')
const RecurlyEventHandler = require('./RecurlyEventHandler')
const { expressify } = require('@overleaf/promise-utils')
const OError = require('@overleaf/o-error')
import SessionManager from '../Authentication/SessionManager.js'
import SubscriptionHandler from './SubscriptionHandler.js'
import SubscriptionHelper from './SubscriptionHelper.js'
import SubscriptionViewModelBuilder from './SubscriptionViewModelBuilder.js'
import LimitationsManager from './LimitationsManager.js'
import RecurlyWrapper from './RecurlyWrapper.js'
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import GeoIpLookup from '../../infrastructure/GeoIpLookup.js'
import FeaturesUpdater from './FeaturesUpdater.js'
import GroupPlansData from './GroupPlansData.js'
import V1SubscriptionManager from './V1SubscriptionManager.js'
import AnalyticsManager from '../Analytics/AnalyticsManager.js'
import RecurlyEventHandler from './RecurlyEventHandler.js'
import { expressify } from '@overleaf/promise-utils'
import OError from '@overleaf/o-error'
import Errors from './Errors.js'
import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
import AuthorizationManager from '../Authorization/AuthorizationManager.js'
import Modules from '../../infrastructure/Modules.js'
import async from 'async'
import HttpErrorHandler from '../Errors/HttpErrorHandler.js'
import RecurlyClient from './RecurlyClient.js'
import {
AI_ADD_ON_CODE,
subscriptionChangeIsAiAssistUpgrade,
} from './AiHelper.js'
import PlansLocator from './PlansLocator.js'
import { User } from '../../models/User.js'
import UserGetter from '../User/UserGetter.js'
import PermissionsManager from '../Authorization/PermissionsManager.js'
import { sanitizeSessionUserForFrontEnd } from '../../infrastructure/FrontEndUser.js'
import { z, validateReq } from '../../infrastructure/Validation.js'
import { IndeterminateInvoiceError } from '../Errors/Errors.js'
import SubscriptionLocator from './SubscriptionLocator.js'
const {
DuplicateAddOnError,
AddOnNotPresentError,
PaymentActionRequiredError,
PaymentFailedError,
MissingBillingInfoError,
} = require('./Errors')
const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const AuthorizationManager = require('../Authorization/AuthorizationManager')
const Modules = require('../../infrastructure/Modules')
const async = require('async')
const HttpErrorHandler = require('../Errors/HttpErrorHandler')
const RecurlyClient = require('./RecurlyClient')
const {
AI_ADD_ON_CODE,
subscriptionChangeIsAiAssistUpgrade,
} = require('./AiHelper')
const PlansLocator = require('./PlansLocator')
const { User } = require('../../models/User')
const UserGetter = require('../User/UserGetter')
const PermissionsManager = require('../Authorization/PermissionsManager')
const {
sanitizeSessionUserForFrontEnd,
} = require('../../infrastructure/FrontEndUser')
const { z, validateReq } = require('../../infrastructure/Validation')
const { IndeterminateInvoiceError } = require('../Errors/Errors')
const SubscriptionLocator = require('./SubscriptionLocator')
} = Errors
const SUBSCRIPTION_PAUSED_REDIRECT_PATH =
'/user/subscription?redirect-reason=subscription-paused'
@@ -1101,7 +1101,7 @@ function makeChangePreview(
}
}
module.exports = {
export default {
userSubscriptionPage: expressify(userSubscriptionPage),
successfulSubscription: expressify(successfulSubscription),
cancelSubscription,

View File

@@ -3,7 +3,7 @@ import _ from 'lodash'
import OError from '@overleaf/o-error'
import SubscriptionUpdater from './SubscriptionUpdater.js'
import SubscriptionLocator from './SubscriptionLocator.js'
import SubscriptionController from './SubscriptionController.js'
import SubscriptionController from './SubscriptionController.mjs'
import SubscriptionHelper from './SubscriptionHelper.js'
import { Subscription } from '../../models/Subscription.js'
import { User } from '../../models/User.js'

View File

@@ -1,6 +1,6 @@
import AuthenticationController from '../Authentication/AuthenticationController.js'
import PermissionsController from '../Authorization/PermissionsController.mjs'
import SubscriptionController from './SubscriptionController.js'
import SubscriptionController from './SubscriptionController.mjs'
import SubscriptionGroupController from './SubscriptionGroupController.mjs'
import TeamInvitesController from './TeamInvitesController.mjs'
import { RateLimiter } from '../../infrastructure/RateLimiter.js'

View File

@@ -1,6 +1,6 @@
import Settings from '@overleaf/settings'
import SessionManager from '../Authentication/SessionManager.js'
import SystemMessageManager from './SystemMessageManager.js'
import SystemMessageManager from './SystemMessageManager.mjs'
const ProjectController = {
getMessages(req, res, next) {

View File

@@ -1,9 +1,7 @@
const { SystemMessage } = require('../../models/SystemMessage')
const {
addRequiredCleanupHandlerBeforeDrainingConnections,
} = require('../../infrastructure/GracefulShutdown')
const { callbackifyAll } = require('@overleaf/promise-utils')
const logger = require('@overleaf/logger')
import { SystemMessage } from '../../models/SystemMessage.js'
import { addRequiredCleanupHandlerBeforeDrainingConnections } from '../../infrastructure/GracefulShutdown.js'
import { callbackifyAll } from '@overleaf/promise-utils'
import logger from '@overleaf/logger'
const SystemMessageManager = {
_cachedMessages: [],
@@ -52,7 +50,7 @@ addRequiredCleanupHandlerBeforeDrainingConnections(
}
)
module.exports = {
export default {
getMessages: SystemMessageManager.getMessages.bind(SystemMessageManager),
...callbackifyAll(SystemMessageManager, { without: ['getMessages'] }),
promises: SystemMessageManager,

View File

@@ -1,16 +1,16 @@
const AuthenticationController = require('../Authentication/AuthenticationController')
const TemplatesController = require('./TemplatesController')
const TemplatesMiddleware = require('./TemplatesMiddleware')
const { RateLimiter } = require('../../infrastructure/RateLimiter')
const RateLimiterMiddleware = require('../Security/RateLimiterMiddleware')
const AnalyticsRegistrationSourceMiddleware = require('../Analytics/AnalyticsRegistrationSourceMiddleware')
import AuthenticationController from '../Authentication/AuthenticationController.js'
import TemplatesController from './TemplatesController.js'
import TemplatesMiddleware from './TemplatesMiddleware.js'
import { RateLimiter } from '../../infrastructure/RateLimiter.js'
import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
import AnalyticsRegistrationSourceMiddleware from '../Analytics/AnalyticsRegistrationSourceMiddleware.js'
const rateLimiter = new RateLimiter('create-project-from-template', {
points: 20,
duration: 60,
})
module.exports = {
export default {
rateLimiter,
apply(app) {
app.get(

View File

@@ -1,6 +1,6 @@
import { expressify } from '@overleaf/promise-utils'
import TpdsUpdateHandler from './TpdsUpdateHandler.mjs'
import UpdateMerger from './UpdateMerger.js'
import UpdateMerger from './UpdateMerger.mjs'
import Errors from '../Errors/Errors.js'
import logger from '@overleaf/logger'
import Path from 'node:path'

View File

@@ -1,5 +1,5 @@
import { callbackify } from 'node:util'
import UpdateMerger from './UpdateMerger.js'
import UpdateMerger from './UpdateMerger.mjs'
import logger from '@overleaf/logger'
import NotificationsBuilder from '../Notifications/NotificationsBuilder.js'
import ProjectCreationHandler from '../Project/ProjectCreationHandler.js'

View File

@@ -1,14 +1,14 @@
const { callbackify } = require('util')
const _ = require('lodash')
const fsPromises = require('fs/promises')
const fs = require('fs')
const logger = require('@overleaf/logger')
const EditorController = require('../Editor/EditorController')
const FileTypeManager = require('../Uploads/FileTypeManager')
const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
const crypto = require('crypto')
const Settings = require('@overleaf/settings')
const { pipeline } = require('stream/promises')
import { callbackify } from 'node:util'
import _ from 'lodash'
import fsPromises from 'node:fs/promises'
import fs from 'node:fs'
import logger from '@overleaf/logger'
import EditorController from '../Editor/EditorController.js'
import FileTypeManager from '../Uploads/FileTypeManager.js'
import ProjectEntityHandler from '../Project/ProjectEntityHandler.js'
import crypto from 'node:crypto'
import Settings from '@overleaf/settings'
import { pipeline } from 'node:stream/promises'
async function mergeUpdate(userId, projectId, path, updateRequest, source) {
const fsPath = await writeUpdateToDisk(projectId, updateRequest)
@@ -185,7 +185,7 @@ async function createFolder(projectId, path, userId) {
return folder
}
module.exports = {
export default {
mergeUpdate: callbackify(mergeUpdate),
_mergeUpdate: callbackify(_mergeUpdate),
deleteUpdate: callbackify(deleteUpdate),

View File

@@ -1,5 +1,5 @@
import SessionManager from '../Authentication/SessionManager.js'
import TutorialHandler from './TutorialHandler.js'
import TutorialHandler from './TutorialHandler.mjs'
import { expressify } from '@overleaf/promise-utils'
const VALID_KEYS = [
@@ -21,6 +21,7 @@ const VALID_KEYS = [
'ide-redesign-new-survey-promo',
'ide-redesign-beta-intro',
'ide-redesign-labs-user-beta-promo',
'rolling-compile-image-changed',
]
async function completeTutorial(req, res, next) {

View File

@@ -1,4 +1,4 @@
const UserUpdater = require('../User/UserUpdater')
import UserUpdater from '../User/UserUpdater.js'
const POSTPONE_DURATION_MS = 24 * 60 * 60 * 1000 // 1 day
@@ -59,4 +59,4 @@ function getInactiveTutorials(user, tutorialKey) {
return inactiveTutorials
}
module.exports = { setTutorialState, getInactiveTutorials }
export default { setTutorialState, getInactiveTutorials }

View File

@@ -62,6 +62,7 @@ const db = {
projectHistoryFailures: internalDb.collection('projectHistoryFailures'),
projectHistoryGlobalBlobs: internalDb.collection('projectHistoryGlobalBlobs'),
projectHistoryLabels: internalDb.collection('projectHistoryLabels'),
projectHistorySizes: internalDb.collection('projectHistorySizes'),
projectHistorySyncState: internalDb.collection('projectHistorySyncState'),
projectInvites: internalDb.collection('projectInvites'),
projects: internalDb.collection('projects'),

View File

@@ -24,7 +24,7 @@ import UserEmailsController from './Features/User/UserEmailsController.js'
import UserPagesController from './Features/User/UserPagesController.mjs'
import TutorialController from './Features/Tutorial/TutorialController.mjs'
import DocumentController from './Features/Documents/DocumentController.mjs'
import CompileManager from './Features/Compile/CompileManager.js'
import CompileManager from './Features/Compile/CompileManager.mjs'
import CompileController from './Features/Compile/CompileController.mjs'
import HealthCheckController from './Features/HealthCheck/HealthCheckController.mjs'
import ProjectDownloadsController from './Features/Downloads/ProjectDownloadsController.mjs'
@@ -52,7 +52,7 @@ import MetaController from './Features/Metadata/MetaController.mjs'
import TokenAccessController from './Features/TokenAccess/TokenAccessController.mjs'
import TokenAccessRouter from './Features/TokenAccess/TokenAccessRouter.mjs'
import LinkedFilesRouter from './Features/LinkedFiles/LinkedFilesRouter.mjs'
import TemplatesRouter from './Features/Templates/TemplatesRouter.js'
import TemplatesRouter from './Features/Templates/TemplatesRouter.mjs'
import UserMembershipRouter from './Features/UserMembership/UserMembershipRouter.mjs'
import SystemMessageController from './Features/SystemMessages/SystemMessageController.mjs'
import AnalyticsRegistrationSourceMiddleware from './Features/Analytics/AnalyticsRegistrationSourceMiddleware.js'

View File

@@ -0,0 +1,10 @@
web
--only-jenkins-include-file=True
--dependencies=
--docker-repos=us-east1-docker.pkg.dev/overleaf-ops/ol-docker
--env-add=
--env-pass-through=
--esmock-loader=False
--node-version=22.18.0
--public-repo=False
--script-version=4.7.0

View File

@@ -1006,6 +1006,7 @@ module.exports = {
v1ImportDataScreen: [],
snapshotUtils: [],
usGovBanner: [],
rollingBuildsUpdatedAlert: [],
offlineModeToolbarButtons: [],
settingsEntries: [],
autoCompleteExtensions: [],

View File

@@ -21,6 +21,7 @@
"a_new_reference_was_added_from_provider": "",
"a_new_reference_was_added_to_file": "",
"a_new_reference_was_added_to_file_from_provider": "",
"a_new_version_of_the_rolling_texlive_build_released": "",
"about_to_archive_projects": "",
"about_to_delete_cert": "",
"about_to_delete_projects": "",
@@ -50,6 +51,7 @@
"access_edit_your_projects": "",
"access_levels_changed": "",
"account_billed_manually": "",
"account_has_been_link_to_group_account": "",
"account_has_been_link_to_institution_account": "",
"account_has_past_due_invoice_change_plan_warning": "",
"account_help": "",
@@ -657,6 +659,7 @@
"get_most_subscription_by_checking_overleaf": "",
"get_most_subscription_by_checking_overleaf_ai_writefull": "",
"get_real_time_track_changes": "",
"get_regular_access_to_new_versions_of_tex_live": "",
"git": "",
"git_authentication_token": "",
"git_authentication_token_create_modal_info_1": "",
@@ -1615,6 +1618,7 @@
"showing_x_results_of_total": "",
"sign_up": "",
"simple_search_mode": "",
"since_this_project_is_set_to_the_rolling_build": "",
"single_sign_on_sso": "",
"size": "",
"something_not_right": "",
@@ -1779,11 +1783,11 @@
"test": "",
"test_configuration": "",
"test_configuration_successful": "",
"test_more_recent_versions_of_texlive": "",
"tex_live_version": "",
"texgpt": "",
"thank_you": "",
"thank_you_exclamation": "",
"thank_you_for_joining_the_rolling_texlive": "",
"thank_you_for_your_feedback": "",
"thanks_for_confirming_your_email_address": "",
"thanks_for_getting_in_touch": "",

View File

@@ -2,9 +2,16 @@ import { useTranslation } from 'react-i18next'
import { LostConnectionAlert } from './lost-connection-alert'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { debugging } from '@/utils/debugging'
import { ElementType } from 'react'
import { createPortal } from 'react-dom'
import { useGlobalAlertsContainer } from '@/features/ide-react/context/global-alerts-context'
import OLNotification from '@/shared/components/ol/ol-notification'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
const rollingBuildsUpdatedAlert: Array<{
import: { default: ElementType }
path: string
}> = importOverleafModules('rollingBuildsUpdatedAlert')
export function Alerts() {
const { t } = useTranslation()
@@ -23,6 +30,12 @@ export function Alerts() {
return createPortal(
<>
{rollingBuildsUpdatedAlert.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
{connectionState.forceDisconnected &&
// hide "disconnected" banner when displaying out of sync modal
connectionState.error !== 'out-of-sync' ? (

View File

@@ -5,8 +5,8 @@ import { isSplitTestEnabled, getSplitTestVariant } from '@/utils/splitTestUtils'
export const ignoringUserCutoffDate =
new URLSearchParams(window.location.search).get('skip-new-user-check') ===
'true'
// TODO: change this when we have a launch date
const NEW_USER_CUTOFF_DATE = new Date('2100-01-01')
const NEW_USER_CUTOFF_DATE = new Date(Date.UTC(2025, 8, 23, 13, 0, 0)) // 2pm British Summer Time on September 23, 2025
export const isNewUser = () => {
if (ignoringUserCutoffDate) return true

View File

@@ -1,9 +1,13 @@
import { useState } from 'react'
import { useCallback, useState } from 'react'
import LabsExperimentWidget from '../../shared/components/labs/labs-experiments-widget'
import { isInExperiment } from '@/utils/labs-utils'
import { useTranslation } from 'react-i18next'
import { useTranslation, Trans } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
export const TUTORIAL_KEY = 'rolling-compile-image-changed'
const MonthlyTexliveLabsWidget = ({
labsProgram,
@@ -15,6 +19,18 @@ const MonthlyTexliveLabsWidget = ({
const { t } = useTranslation()
const [optedIn, setOptedIn] = useState(isInExperiment('monthly-texlive'))
const optInWithCompletedTutorial = useCallback(
async (shouldOptIn: boolean) => {
try {
await postJSON(`/tutorial/${TUTORIAL_KEY}/complete`)
} catch (err) {
debugConsole.error(err)
}
setOptedIn(shouldOptIn)
},
[setOptedIn]
)
const monthlyTexLiveSplitTestEnabled = isSplitTestEnabled('monthly-texlive')
if (!monthlyTexLiveSplitTestEnabled) {
return null
@@ -27,15 +43,31 @@ const MonthlyTexliveLabsWidget = ({
className="rounded bg-primary-subtle"
/>
)
const optedInDescription = (
<Trans
i18nKey="thank_you_for_joining_the_rolling_texlive"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a
href="/learn/latex/Overleaf_and_TeX_Live#How_do_I_change_a_projects_TeX_Live_version?"
target="_blank"
key="getting-started-link"
/>,
]}
/>
)
return (
<LabsExperimentWidget
description={t('test_more_recent_versions_of_texlive')}
description={t('get_regular_access_to_new_versions_of_tex_live')}
optedInDescription={optedInDescription}
experimentName="monthly-texlive"
logo={logo}
labsEnabled={labsProgram}
setErrorMessage={setErrorMessage}
optedIn={optedIn}
setOptedIn={setOptedIn}
setOptedIn={optInWithCompletedTutorial}
title={t('rolling_texlive_build')}
/>
)

View File

@@ -0,0 +1,44 @@
import { useTutorial } from '@/shared/hooks/promotions/use-tutorial'
import { useEditorContext } from '@/shared/context/editor-context'
import { useProjectSettingsContext } from '../editor-left-menu/context/project-settings-context'
import OLNotification from '@/shared/components/ol/ol-notification'
import getMeta from '@/utils/meta'
import { useTranslation } from 'react-i18next'
import { useCallback } from 'react'
export const TUTORIAL_KEY = 'rolling-compile-image-changed'
const rollingImages = getMeta('ol-imageNames')
.filter(img => img.rolling)
.map(img => img.imageName)
const RollingCompileImageChangedAlert = () => {
const { completeTutorial } = useTutorial(TUTORIAL_KEY)
const { inactiveTutorials } = useEditorContext()
const { imageName } = useProjectSettingsContext()
const { t } = useTranslation()
const onClose = useCallback(() => {
completeTutorial({ event: 'promo-click', action: 'complete' })
}, [completeTutorial])
const onRollingBuild = imageName && rollingImages.includes(imageName)
if (inactiveTutorials.includes(TUTORIAL_KEY) || !onRollingBuild) {
return null
}
return (
<OLNotification
className="mt-5"
isDismissible
onDismiss={onClose}
content={t('since_this_project_is_set_to_the_rolling_build')}
type="info"
title={t('a_new_version_of_the_rolling_texlive_build_released')}
/>
)
}
export default RollingCompileImageChangedAlert

View File

@@ -88,6 +88,21 @@ function Institution() {
}
/>
)}
{templateKey === 'notification_group_sso_linked' && (
<Notification
type="info"
onDismiss={() => id && handleDismiss(id)}
content={
<Trans
i18nKey="account_has_been_link_to_group_account"
components={{ b: <b /> }}
values={{ appName, email, institutionName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
}
/>
)}
{templateKey === 'notification_institution_sso_non_canonical' && (
<Notification
type="warning"

View File

@@ -13,10 +13,12 @@ const en = {
cancel: 'Cancel',
yes: 'Yes',
no: 'No',
'do-not-know': 'Dont know',
equation: 'equation',
table: 'table',
or: 'or',
close: 'Close',
submit: 'Submit',
'add-unlimited-ai': 'Add unlimited AI',
'accept-and-continue': 'Accept and continue',
'copy-code': 'Copy code',
@@ -249,6 +251,21 @@ const en = {
'create-modal.disclaimer':
'AI can make mistakes. Review the code before applying it.',
'create-modal.your-result': 'Your __name__ suggestion:',
'fake-door-test.text-to-speech.try': 'Try Text to Speech',
'fake-door-test.text-to-speech.title':
'Sorry, text to speech isnt available yet',
'fake-door-test.text-to-speech.introduction_p1':
'We are considering adding text to speech to language suggestions in Overleaf and are gathering feedback.',
'fake-door-test.text-to-speech.introduction_p2':
'Is text to speech something that would help you with your writing in Overleaf?',
'fake-door-test.text-to-speech.more-information-if-yes':
'How would text to speech help you with your writing?',
'fake-door-test.text-to-speech.more-information-if-no':
'What would help you with your writing in Overleaf?',
'fake-door-test.text-to-speech.closing.title':
'Thank you for your feedback!',
'fake-door-test.text-to-speech.closing.body':
'Well use this to assess whether we add text to speech functionality to language suggestions in Overleaf.',
'language-model.using-this-feature':
'Using this feature means your text is sent to OpenAIs servers and may be kept there for up to 30 days. It is not used to train OpenAIs models. Writefull does not store or train on your texts.',
'language-model.learn-more': 'Learn more',
@@ -386,10 +403,12 @@ const es = {
cancel: 'Cancelar',
yes: 'Sí',
no: 'No',
'do-not-know': 'No lo sé',
equation: 'equación',
table: 'tabla',
or: 'o',
close: 'Cerrar',
submit: 'Enviar',
'add-unlimited-ai': 'Añadir IA ilimitada',
'accept-and-continue': 'Aceptar y continuar',
'copy-code': 'Copiar código',
@@ -640,6 +659,21 @@ const es = {
'create-modal.disclaimer':
'AI can make mistakes. Review the code before apply it.',
'create-modal.your-result': 'Your __name__ suggestion:',
'fake-door-test.text-to-speech.try': 'Probar texto a voz',
'fake-door-test.text-to-speech.title':
'Lo sentimos, el texto a voz aún no está disponible',
'fake-door-test.text-to-speech.introduction_p1':
'Estamos considerando agregar texto a voz a las sugerencias de idioma en Overleaf y estamos recopilando comentarios.',
'fake-door-test.text-to-speech.introduction_p2':
'¿Es el texto a voz algo que te ayudaría con tu escritura en Overleaf?',
'fake-door-test.text-to-speech.more-information-if-yes':
'¿Cómo te ayudaría el texto a voz con tu escritura?',
'fake-door-test.text-to-speech.more-information-if-no':
'¿Qué te ayudaría con tu escritura en Overleaf?',
'fake-door-test.text-to-speech.closing.title':
'¡Gracias por tus comentarios!',
'fake-door-test.text-to-speech.closing.body':
'Los utilizaremos para evaluar si agregamos la funcionalidad de texto a voz a las sugerencias de idioma en Overleaf.',
'language-model.using-this-feature':
'Usar esta función significa que su texto se envía a los servidores de OpenAI y puede mantenerse allí hasta por 30 días. No se utiliza para entrenar los modelos de OpenAI. Writefull no almacena ni entrena con sus textos.',
'language-model.learn-more': 'Aprende más',

View File

@@ -9,7 +9,8 @@ import getMeta from '@/utils/meta'
type IntegrationLinkingWidgetProps = {
logo: ReactNode
title: string
description: string
description: string | ReactNode
optedInDescription?: string | ReactNode
helpPath?: string
labsEnabled?: boolean
experimentName: string
@@ -22,6 +23,7 @@ export function LabsExperimentWidget({
logo,
title,
description,
optedInDescription,
helpPath,
labsEnabled,
experimentName,
@@ -69,7 +71,7 @@ export function LabsExperimentWidget({
{optedIn && <OLBadge bg="info">{t('enabled')}</OLBadge>}
</div>
<p className="small">
{description}{' '}
{optedIn && optedInDescription ? optedInDescription : description}{' '}
{helpPath && (
<a href={helpPath} target="_blank" rel="noreferrer">
{t('learn_more')}

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