diff --git a/develop/docker-compose.yml b/develop/docker-compose.yml index e37999f71d..750e11ac87 100644 --- a/develop/docker-compose.yml +++ b/develop/docker-compose.yml @@ -1,6 +1,5 @@ volumes: clsi-cache: - clsi-output: filestore-public-files: filestore-template-files: filestore-uploads: @@ -33,9 +32,9 @@ services: user: root volumes: - ${PWD}/compiles:/overleaf/services/clsi/compiles + - ${PWD}/output:/overleaf/services/clsi/output - ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock - clsi-cache:/overleaf/services/clsi/cache - - clsi-output:/overleaf/services/clsi/output contacts: build: diff --git a/docker-compose.yml b/docker-compose.yml index 2d43c2db18..e257716789 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -73,7 +73,11 @@ services: ## Server Pro ## ################ - ## Sandboxed Compiles: https://github.com/overleaf/overleaf/wiki/Server-Pro:-Sandboxed-Compiles + ## The Community Edition is intended for use in environments where all users are trusted and is not appropriate for + ## scenarios where isolation of users is required. Sandboxed Compiles are not available in the Community Edition, + ## so the following environment variables must be commented out to avoid compile issues. + ## + ## Sandboxed Compiles: https://docs.overleaf.com/on-premises/configuration/overleaf-toolkit/server-pro-only-configuration/sandboxed-compiles SANDBOXED_COMPILES: 'true' ### Bind-mount source for /var/lib/overleaf/data/compiles inside the container. SANDBOXED_COMPILES_HOST_DIR_COMPILES: '/home/user/sharelatex_data/data/compiles' diff --git a/libraries/access-token-encryptor/.dockerignore b/libraries/access-token-encryptor/.dockerignore deleted file mode 100644 index c2658d7d1b..0000000000 --- a/libraries/access-token-encryptor/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/libraries/access-token-encryptor/.gitignore b/libraries/access-token-encryptor/.gitignore deleted file mode 100644 index 66936c4121..0000000000 --- a/libraries/access-token-encryptor/.gitignore +++ /dev/null @@ -1,46 +0,0 @@ -compileFolder - -Compiled source # -################### -*.com -*.class -*.dll -*.exe -*.o -*.so - -# Packages # -############ -# it's better to unpack these files and commit the raw source -# git has its own built in compression methods -*.7z -*.dmg -*.gz -*.iso -*.jar -*.rar -*.tar -*.zip - -# Logs and databases # -###################### -*.log -*.sql -*.sqlite - -# OS generated files # -###################### -.DS_Store? -ehthumbs.db -Icon? -Thumbs.db - -/node_modules/* -data/*/* - -**.swp - -/log.json -hash_folder - -.npmrc diff --git a/libraries/access-token-encryptor/.nvmrc b/libraries/access-token-encryptor/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/libraries/access-token-encryptor/.nvmrc +++ b/libraries/access-token-encryptor/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/libraries/access-token-encryptor/buildscript.txt b/libraries/access-token-encryptor/buildscript.txt index 36fd724a80..74c3bbbd24 100644 --- a/libraries/access-token-encryptor/buildscript.txt +++ b/libraries/access-token-encryptor/buildscript.txt @@ -5,6 +5,6 @@ access-token-encryptor --env-pass-through= --esmock-loader=False --is-library=True ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 diff --git a/libraries/fetch-utils/.dockerignore b/libraries/fetch-utils/.dockerignore deleted file mode 100644 index c2658d7d1b..0000000000 --- a/libraries/fetch-utils/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/libraries/fetch-utils/.gitignore b/libraries/fetch-utils/.gitignore deleted file mode 100644 index edb0f85350..0000000000 --- a/libraries/fetch-utils/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ - -# managed by monorepo$ bin/update_build_scripts -.npmrc diff --git a/libraries/fetch-utils/.nvmrc b/libraries/fetch-utils/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/libraries/fetch-utils/.nvmrc +++ b/libraries/fetch-utils/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/libraries/fetch-utils/buildscript.txt b/libraries/fetch-utils/buildscript.txt index a158079aee..91548ff7c6 100644 --- a/libraries/fetch-utils/buildscript.txt +++ b/libraries/fetch-utils/buildscript.txt @@ -5,6 +5,6 @@ fetch-utils --env-pass-through= --esmock-loader=False --is-library=True ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 diff --git a/libraries/logger/.dockerignore b/libraries/logger/.dockerignore deleted file mode 100644 index c2658d7d1b..0000000000 --- a/libraries/logger/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/libraries/logger/.gitignore b/libraries/logger/.gitignore deleted file mode 100644 index 2006c875a4..0000000000 --- a/libraries/logger/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules - -.npmrc diff --git a/libraries/logger/.nvmrc b/libraries/logger/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/libraries/logger/.nvmrc +++ b/libraries/logger/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/libraries/logger/buildscript.txt b/libraries/logger/buildscript.txt index afe93c28dc..9008707b0e 100644 --- a/libraries/logger/buildscript.txt +++ b/libraries/logger/buildscript.txt @@ -5,6 +5,6 @@ logger --env-pass-through= --esmock-loader=False --is-library=True ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 diff --git a/libraries/metrics/.dockerignore b/libraries/metrics/.dockerignore deleted file mode 100644 index c2658d7d1b..0000000000 --- a/libraries/metrics/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/libraries/metrics/.gitignore b/libraries/metrics/.gitignore deleted file mode 100644 index 2006c875a4..0000000000 --- a/libraries/metrics/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules - -.npmrc diff --git a/libraries/metrics/.nvmrc b/libraries/metrics/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/libraries/metrics/.nvmrc +++ b/libraries/metrics/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/libraries/metrics/buildscript.txt b/libraries/metrics/buildscript.txt index 74fcbdb6c6..2c2e5d7531 100644 --- a/libraries/metrics/buildscript.txt +++ b/libraries/metrics/buildscript.txt @@ -5,6 +5,6 @@ metrics --env-pass-through= --esmock-loader=False --is-library=True ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 diff --git a/libraries/metrics/initialize.js b/libraries/metrics/initialize.js index 1028ee06c3..f1a77666c7 100644 --- a/libraries/metrics/initialize.js +++ b/libraries/metrics/initialize.js @@ -5,6 +5,8 @@ * before any other module to support code instrumentation. */ +const metricsModuleImportStartTime = performance.now() + const APP_NAME = process.env.METRICS_APP_NAME || 'unknown' const BUILD_VERSION = process.env.BUILD_VERSION const ENABLE_PROFILE_AGENT = process.env.ENABLE_PROFILE_AGENT === 'true' @@ -103,3 +105,5 @@ function recordProcessStart() { const metrics = require('.') metrics.inc('process_startup') } + +module.exports = { metricsModuleImportStartTime } diff --git a/libraries/metrics/package.json b/libraries/metrics/package.json index 384e58cfe5..19b566c2b0 100644 --- a/libraries/metrics/package.json +++ b/libraries/metrics/package.json @@ -9,7 +9,7 @@ "main": "index.js", "dependencies": { "@google-cloud/opentelemetry-cloud-trace-exporter": "^2.1.0", - "@google-cloud/profiler": "^6.0.0", + "@google-cloud/profiler": "^6.0.3", "@opentelemetry/api": "^1.4.1", "@opentelemetry/auto-instrumentations-node": "^0.39.1", "@opentelemetry/exporter-trace-otlp-http": "^0.41.2", diff --git a/libraries/mongo-utils/.dockerignore b/libraries/mongo-utils/.dockerignore deleted file mode 100644 index c2658d7d1b..0000000000 --- a/libraries/mongo-utils/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/libraries/mongo-utils/.gitignore b/libraries/mongo-utils/.gitignore deleted file mode 100644 index edb0f85350..0000000000 --- a/libraries/mongo-utils/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ - -# managed by monorepo$ bin/update_build_scripts -.npmrc diff --git a/libraries/mongo-utils/.nvmrc b/libraries/mongo-utils/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/libraries/mongo-utils/.nvmrc +++ b/libraries/mongo-utils/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/libraries/mongo-utils/buildscript.txt b/libraries/mongo-utils/buildscript.txt index a4e4fe7802..bda8d4f734 100644 --- a/libraries/mongo-utils/buildscript.txt +++ b/libraries/mongo-utils/buildscript.txt @@ -5,6 +5,6 @@ mongo-utils --env-pass-through= --esmock-loader=False --is-library=True ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 diff --git a/libraries/o-error/.dockerignore b/libraries/o-error/.dockerignore deleted file mode 100644 index c2658d7d1b..0000000000 --- a/libraries/o-error/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/libraries/o-error/.gitignore b/libraries/o-error/.gitignore deleted file mode 100644 index cf2f0ad3fb..0000000000 --- a/libraries/o-error/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.nyc_output -coverage -node_modules/ - -.npmrc diff --git a/libraries/o-error/.nvmrc b/libraries/o-error/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/libraries/o-error/.nvmrc +++ b/libraries/o-error/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/libraries/o-error/buildscript.txt b/libraries/o-error/buildscript.txt index 81d3eb3252..a4134b4b60 100644 --- a/libraries/o-error/buildscript.txt +++ b/libraries/o-error/buildscript.txt @@ -5,6 +5,6 @@ o-error --env-pass-through= --esmock-loader=False --is-library=True ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 diff --git a/libraries/o-error/index.cjs b/libraries/o-error/index.cjs index ef08b45f16..0bb7da653a 100644 --- a/libraries/o-error/index.cjs +++ b/libraries/o-error/index.cjs @@ -1,20 +1,34 @@ +// @ts-check + /** * Light-weight helpers for handling JavaScript Errors in node.js and the * browser. */ class OError extends Error { + /** + * The error that is the underlying cause of this error + * + * @type {unknown} + */ + cause + + /** + * List of errors encountered as the callback chain is unwound + * + * @type {TaggedError[] | undefined} + */ + _oErrorTags + /** * @param {string} message as for built-in Error * @param {Object} [info] extra data to attach to the error - * @param {Error} [cause] the internal error that caused this error + * @param {unknown} [cause] the internal error that caused this error */ constructor(message, info, cause) { super(message) this.name = this.constructor.name if (info) this.info = info if (cause) this.cause = cause - /** @private @type {Array | undefined} */ - this._oErrorTags // eslint-disable-line } /** @@ -31,7 +45,7 @@ class OError extends Error { /** * Wrap the given error, which caused this error. * - * @param {Error} cause the internal error that caused this error + * @param {unknown} cause the internal error that caused this error * @return {this} */ withCause(cause) { @@ -65,13 +79,16 @@ class OError extends Error { * } * } * - * @param {Error} error the error to tag + * @template {unknown} E + * @param {E} error the error to tag * @param {string} [message] message with which to tag `error` * @param {Object} [info] extra data with wich to tag `error` - * @return {Error} the modified `error` argument + * @return {E} the modified `error` argument */ static tag(error, message, info) { - const oError = /** @type{OError} */ (error) + const oError = /** @type {{ _oErrorTags: TaggedError[] | undefined }} */ ( + error + ) if (!oError._oErrorTags) oError._oErrorTags = [] @@ -102,7 +119,7 @@ class OError extends Error { * * If an info property is repeated, the last one wins. * - * @param {Error | null | undefined} error any error (may or may not be an `OError`) + * @param {unknown} error any error (may or may not be an `OError`) * @return {Object} */ static getFullInfo(error) { @@ -129,7 +146,7 @@ class OError extends Error { * Return the `stack` property from `error`, including the `stack`s for any * tagged errors added with `OError.tag` and for any `cause`s. * - * @param {Error | null | undefined} error any error (may or may not be an `OError`) + * @param {unknown} error any error (may or may not be an `OError`) * @return {string} */ static getFullStack(error) { @@ -143,7 +160,7 @@ class OError extends Error { stack += `\n${oError._oErrorTags.map(tag => tag.stack).join('\n')}` } - const causeStack = oError.cause && OError.getFullStack(oError.cause) + const causeStack = OError.getFullStack(oError.cause) if (causeStack) { stack += '\ncaused by:\n' + indent(causeStack) } diff --git a/libraries/o-error/test/o-error-util.test.js b/libraries/o-error/test/o-error-util.test.js index c86cec44a2..b29dd4900a 100644 --- a/libraries/o-error/test/o-error-util.test.js +++ b/libraries/o-error/test/o-error-util.test.js @@ -268,6 +268,11 @@ describe('utils', function () { expect(OError.getFullInfo(null)).to.deep.equal({}) }) + it('works when given a string', function () { + const err = 'not an error instance' + expect(OError.getFullInfo(err)).to.deep.equal({}) + }) + it('works on a normal error', function () { const err = new Error('foo') expect(OError.getFullInfo(err)).to.deep.equal({}) diff --git a/libraries/o-error/test/o-error.test.js b/libraries/o-error/test/o-error.test.js index 84244ec92e..db87f56992 100644 --- a/libraries/o-error/test/o-error.test.js +++ b/libraries/o-error/test/o-error.test.js @@ -35,6 +35,14 @@ describe('OError', function () { expect(err2.cause.message).to.equal('cause 2') }) + it('accepts non-Error causes', function () { + const err1 = new OError('foo', {}, 'not-an-error') + expect(err1.cause).to.equal('not-an-error') + + const err2 = new OError('foo').withCause('not-an-error') + expect(err2.cause).to.equal('not-an-error') + }) + it('handles a custom error type with a cause', function () { function doSomethingBadInternally() { throw new Error('internal error') diff --git a/libraries/object-persistor/.dockerignore b/libraries/object-persistor/.dockerignore deleted file mode 100644 index c2658d7d1b..0000000000 --- a/libraries/object-persistor/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/libraries/object-persistor/.gitignore b/libraries/object-persistor/.gitignore deleted file mode 100644 index 6a20893208..0000000000 --- a/libraries/object-persistor/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/node_modules -*.swp - -.npmrc diff --git a/libraries/object-persistor/.nvmrc b/libraries/object-persistor/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/libraries/object-persistor/.nvmrc +++ b/libraries/object-persistor/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/libraries/object-persistor/buildscript.txt b/libraries/object-persistor/buildscript.txt index 9ca6929a03..75d2e09382 100644 --- a/libraries/object-persistor/buildscript.txt +++ b/libraries/object-persistor/buildscript.txt @@ -5,6 +5,6 @@ object-persistor --env-pass-through= --esmock-loader=False --is-library=True ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 diff --git a/libraries/object-persistor/src/FSPersistor.js b/libraries/object-persistor/src/FSPersistor.js index 01aab72800..38a81407df 100644 --- a/libraries/object-persistor/src/FSPersistor.js +++ b/libraries/object-persistor/src/FSPersistor.js @@ -305,8 +305,10 @@ module.exports = class FSPersistor extends AbstractPersistor { async _listDirectory(path) { if (this.useSubdirectories) { + // eslint-disable-next-line @typescript-eslint/return-await return await glob(Path.join(path, '**')) } else { + // eslint-disable-next-line @typescript-eslint/return-await return await glob(`${path}_*`) } } diff --git a/libraries/overleaf-editor-core/.dockerignore b/libraries/overleaf-editor-core/.dockerignore deleted file mode 100644 index c2658d7d1b..0000000000 --- a/libraries/overleaf-editor-core/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/libraries/overleaf-editor-core/.gitignore b/libraries/overleaf-editor-core/.gitignore deleted file mode 100644 index 869500a2c7..0000000000 --- a/libraries/overleaf-editor-core/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -/coverage -/node_modules - -# managed by monorepo$ bin/update_build_scripts -.npmrc diff --git a/libraries/overleaf-editor-core/.nvmrc b/libraries/overleaf-editor-core/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/libraries/overleaf-editor-core/.nvmrc +++ b/libraries/overleaf-editor-core/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/libraries/overleaf-editor-core/buildscript.txt b/libraries/overleaf-editor-core/buildscript.txt index 03b7f06791..9b6508663b 100644 --- a/libraries/overleaf-editor-core/buildscript.txt +++ b/libraries/overleaf-editor-core/buildscript.txt @@ -5,6 +5,6 @@ overleaf-editor-core --env-pass-through= --esmock-loader=False --is-library=True ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 diff --git a/libraries/overleaf-editor-core/index.js b/libraries/overleaf-editor-core/index.js index df3548c2ed..33b3dcf5dc 100644 --- a/libraries/overleaf-editor-core/index.js +++ b/libraries/overleaf-editor-core/index.js @@ -18,6 +18,7 @@ const MoveFileOperation = require('./lib/operation/move_file_operation') const SetCommentStateOperation = require('./lib/operation/set_comment_state_operation') const EditFileOperation = require('./lib/operation/edit_file_operation') const EditNoOperation = require('./lib/operation/edit_no_operation') +const EditOperationTransformer = require('./lib/operation/edit_operation_transformer') const SetFileMetadataOperation = require('./lib/operation/set_file_metadata_operation') const NoOperation = require('./lib/operation/no_operation') const Operation = require('./lib/operation') @@ -43,6 +44,8 @@ const TrackingProps = require('./lib/file_data/tracking_props') const Range = require('./lib/range') const CommentList = require('./lib/file_data/comment_list') const LazyStringFileData = require('./lib/file_data/lazy_string_file_data') +const StringFileData = require('./lib/file_data/string_file_data') +const EditOperationBuilder = require('./lib/operation/edit_operation_builder') exports.AddCommentOperation = AddCommentOperation exports.Author = Author @@ -58,6 +61,7 @@ exports.DeleteCommentOperation = DeleteCommentOperation exports.File = File exports.FileMap = FileMap exports.LazyStringFileData = LazyStringFileData +exports.StringFileData = StringFileData exports.History = History exports.Label = Label exports.AddFileOperation = AddFileOperation @@ -65,6 +69,8 @@ exports.MoveFileOperation = MoveFileOperation exports.SetCommentStateOperation = SetCommentStateOperation exports.EditFileOperation = EditFileOperation exports.EditNoOperation = EditNoOperation +exports.EditOperationBuilder = EditOperationBuilder +exports.EditOperationTransformer = EditOperationTransformer exports.SetFileMetadataOperation = SetFileMetadataOperation exports.NoOperation = NoOperation exports.Operation = Operation diff --git a/libraries/overleaf-editor-core/lib/change.js b/libraries/overleaf-editor-core/lib/change.js index e36cda9ad3..ff4b973a3c 100644 --- a/libraries/overleaf-editor-core/lib/change.js +++ b/libraries/overleaf-editor-core/lib/change.js @@ -13,7 +13,7 @@ const V2DocVersions = require('./v2_doc_versions') /** * @import Author from "./author" - * @import { BlobStore } from "./types" + * @import { BlobStore, RawChange } from "./types" */ /** @@ -54,7 +54,7 @@ class Change { /** * For serialization. * - * @return {Object} + * @return {RawChange} */ toRaw() { function toRaw(object) { diff --git a/libraries/overleaf-editor-core/lib/file_data/string_file_data.js b/libraries/overleaf-editor-core/lib/file_data/string_file_data.js index 2613c30ebc..48df633461 100644 --- a/libraries/overleaf-editor-core/lib/file_data/string_file_data.js +++ b/libraries/overleaf-editor-core/lib/file_data/string_file_data.js @@ -88,6 +88,14 @@ class StringFileData extends FileData { return content } + /** + * Return docstore view of a doc: each line separated + * @return {string[]} + */ + getLines() { + return this.getContent({ filterTrackedDeletes: true }).split('\n') + } + /** @inheritdoc */ getByteLength() { return Buffer.byteLength(this.content) diff --git a/libraries/overleaf-editor-core/lib/operation/edit_operation_builder.js b/libraries/overleaf-editor-core/lib/operation/edit_operation_builder.js index febdebc034..7d5bb81aae 100644 --- a/libraries/overleaf-editor-core/lib/operation/edit_operation_builder.js +++ b/libraries/overleaf-editor-core/lib/operation/edit_operation_builder.js @@ -36,6 +36,20 @@ class EditOperationBuilder { } throw new Error('Unsupported operation in EditOperationBuilder.fromJSON') } + + /** + * @param {unknown} raw + * @return {raw is RawEditOperation} + */ + static isValid(raw) { + return ( + isTextOperation(raw) || + isRawAddCommentOperation(raw) || + isRawDeleteCommentOperation(raw) || + isRawSetCommentStateOperation(raw) || + isRawEditNoOperation(raw) + ) + } } /** diff --git a/libraries/promise-utils/.dockerignore b/libraries/promise-utils/.dockerignore deleted file mode 100644 index c2658d7d1b..0000000000 --- a/libraries/promise-utils/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/libraries/promise-utils/.gitignore b/libraries/promise-utils/.gitignore deleted file mode 100644 index edb0f85350..0000000000 --- a/libraries/promise-utils/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ - -# managed by monorepo$ bin/update_build_scripts -.npmrc diff --git a/libraries/promise-utils/.nvmrc b/libraries/promise-utils/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/libraries/promise-utils/.nvmrc +++ b/libraries/promise-utils/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/libraries/promise-utils/buildscript.txt b/libraries/promise-utils/buildscript.txt index 51a6dad532..73dec381c1 100644 --- a/libraries/promise-utils/buildscript.txt +++ b/libraries/promise-utils/buildscript.txt @@ -5,6 +5,6 @@ promise-utils --env-pass-through= --esmock-loader=False --is-library=True ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 diff --git a/libraries/ranges-tracker/.dockerignore b/libraries/ranges-tracker/.dockerignore deleted file mode 100644 index c2658d7d1b..0000000000 --- a/libraries/ranges-tracker/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/libraries/ranges-tracker/.gitignore b/libraries/ranges-tracker/.gitignore deleted file mode 100644 index eac200248b..0000000000 --- a/libraries/ranges-tracker/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -**.swp - -app.js -app/js/ -test/unit/js/ -public/build/ - -node_modules/ - -/public/js/chat.js -plato/ - -.npmrc diff --git a/libraries/ranges-tracker/.nvmrc b/libraries/ranges-tracker/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/libraries/ranges-tracker/.nvmrc +++ b/libraries/ranges-tracker/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/libraries/ranges-tracker/buildscript.txt b/libraries/ranges-tracker/buildscript.txt index d112f852a7..6276182679 100644 --- a/libraries/ranges-tracker/buildscript.txt +++ b/libraries/ranges-tracker/buildscript.txt @@ -5,6 +5,6 @@ ranges-tracker --env-pass-through= --esmock-loader=False --is-library=True ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 diff --git a/libraries/redis-wrapper/.dockerignore b/libraries/redis-wrapper/.dockerignore deleted file mode 100644 index c2658d7d1b..0000000000 --- a/libraries/redis-wrapper/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/libraries/redis-wrapper/.gitignore b/libraries/redis-wrapper/.gitignore deleted file mode 100644 index eac200248b..0000000000 --- a/libraries/redis-wrapper/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -**.swp - -app.js -app/js/ -test/unit/js/ -public/build/ - -node_modules/ - -/public/js/chat.js -plato/ - -.npmrc diff --git a/libraries/redis-wrapper/.nvmrc b/libraries/redis-wrapper/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/libraries/redis-wrapper/.nvmrc +++ b/libraries/redis-wrapper/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/libraries/redis-wrapper/RedisLocker.js b/libraries/redis-wrapper/RedisLocker.js index b819ad2188..17ad514246 100644 --- a/libraries/redis-wrapper/RedisLocker.js +++ b/libraries/redis-wrapper/RedisLocker.js @@ -97,7 +97,8 @@ module.exports = class RedisLocker { } /** - * @param {Callback} callback + * @param {string} id + * @param {function(Error, boolean, string): void} callback */ tryLock(id, callback) { if (callback == null) { @@ -106,7 +107,7 @@ module.exports = class RedisLocker { const lockValue = this.randomLock() const key = this.getKey(id) const startTime = Date.now() - return this.rclient.set( + this.rclient.set( key, lockValue, 'EX', @@ -121,7 +122,7 @@ module.exports = class RedisLocker { const timeTaken = Date.now() - startTime if (timeTaken > MAX_REDIS_REQUEST_LENGTH) { // took too long, so try to free the lock - return this.releaseLock(id, lockValue, function (err, result) { + this.releaseLock(id, lockValue, function (err, result) { if (err != null) { return callback(err) } // error freeing lock @@ -139,7 +140,8 @@ module.exports = class RedisLocker { } /** - * @param {Callback} callback + * @param {string} id + * @param {function(Error, string): void} callback */ getLock(id, callback) { if (callback == null) { @@ -153,7 +155,7 @@ module.exports = class RedisLocker { return callback(e) } - return this.tryLock(id, (error, gotLock, lockValue) => { + this.tryLock(id, (error, gotLock, lockValue) => { if (error != null) { return callback(error) } @@ -173,14 +175,15 @@ module.exports = class RedisLocker { } /** - * @param {Callback} callback + * @param {string} id + * @param {function(Error, boolean): void} callback */ checkLock(id, callback) { if (callback == null) { callback = function () {} } const key = this.getKey(id) - return this.rclient.exists(key, (err, exists) => { + this.rclient.exists(key, (err, exists) => { if (err != null) { return callback(err) } @@ -196,30 +199,26 @@ module.exports = class RedisLocker { } /** - * @param {Callback} callback + * @param {string} id + * @param {string} lockValue + * @param {function(Error, boolean): void} callback */ releaseLock(id, lockValue, callback) { const key = this.getKey(id) - return this.rclient.eval( - UNLOCK_SCRIPT, - 1, - key, - lockValue, - (err, result) => { - if (err != null) { - return callback(err) - } else if (result != null && result !== 1) { - // successful unlock should release exactly one key - logger.error( - { id, key, lockValue, redis_err: err, redis_result: result }, - 'unlocking error' - ) - metrics.inc(this.metricsPrefix + '-unlock-error') - return callback(new Error('tried to release timed out lock')) - } else { - return callback(null, result) - } + this.rclient.eval(UNLOCK_SCRIPT, 1, key, lockValue, (err, result) => { + if (err != null) { + return callback(err) + } else if (result != null && result !== 1) { + // successful unlock should release exactly one key + logger.error( + { id, key, lockValue, redis_err: err, redis_result: result }, + 'unlocking error' + ) + metrics.inc(this.metricsPrefix + '-unlock-error') + return callback(new Error('tried to release timed out lock')) + } else { + return callback(null, result) } - ) + }) } } diff --git a/libraries/redis-wrapper/buildscript.txt b/libraries/redis-wrapper/buildscript.txt index 89de51417a..1e4489a655 100644 --- a/libraries/redis-wrapper/buildscript.txt +++ b/libraries/redis-wrapper/buildscript.txt @@ -5,6 +5,6 @@ redis-wrapper --env-pass-through= --esmock-loader=False --is-library=True ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 diff --git a/libraries/settings/.dockerignore b/libraries/settings/.dockerignore deleted file mode 100644 index c2658d7d1b..0000000000 --- a/libraries/settings/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/libraries/settings/.gitignore b/libraries/settings/.gitignore deleted file mode 100644 index 06d8e1ddb2..0000000000 --- a/libraries/settings/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -/.npmrc -/node_modules - -# managed by monorepo$ bin/update_build_scripts -.npmrc diff --git a/libraries/settings/.nvmrc b/libraries/settings/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/libraries/settings/.nvmrc +++ b/libraries/settings/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/libraries/settings/buildscript.txt b/libraries/settings/buildscript.txt index ed79480d31..925234f561 100644 --- a/libraries/settings/buildscript.txt +++ b/libraries/settings/buildscript.txt @@ -5,6 +5,6 @@ settings --env-pass-through= --esmock-loader=False --is-library=True ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 diff --git a/libraries/stream-utils/.dockerignore b/libraries/stream-utils/.dockerignore deleted file mode 100644 index c2658d7d1b..0000000000 --- a/libraries/stream-utils/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/libraries/stream-utils/.gitignore b/libraries/stream-utils/.gitignore deleted file mode 100644 index edb0f85350..0000000000 --- a/libraries/stream-utils/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ - -# managed by monorepo$ bin/update_build_scripts -.npmrc diff --git a/libraries/stream-utils/.nvmrc b/libraries/stream-utils/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/libraries/stream-utils/.nvmrc +++ b/libraries/stream-utils/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/libraries/stream-utils/buildscript.txt b/libraries/stream-utils/buildscript.txt index ad7265549c..a04310e77f 100644 --- a/libraries/stream-utils/buildscript.txt +++ b/libraries/stream-utils/buildscript.txt @@ -5,6 +5,6 @@ stream-utils --env-pass-through= --esmock-loader=False --is-library=True ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 diff --git a/package-lock.json b/package-lock.json index 48f2da293a..4a14efb544 100644 --- a/package-lock.json +++ b/package-lock.json @@ -209,7 +209,7 @@ "version": "4.2.0", "dependencies": { "@google-cloud/opentelemetry-cloud-trace-exporter": "^2.1.0", - "@google-cloud/profiler": "^6.0.0", + "@google-cloud/profiler": "^6.0.3", "@opentelemetry/api": "^1.4.1", "@opentelemetry/auto-instrumentations-node": "^0.39.1", "@opentelemetry/exporter-trace-otlp-http": "^0.41.2", @@ -232,6 +232,15 @@ "@overleaf/logger": "*" } }, + "libraries/metrics/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "libraries/metrics/node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -3262,9 +3271,9 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.0.tgz", - "integrity": "sha512-q8VPEFaEP4ikSlt6ZxjB3zW72+7osfAYW9i8Zu943uqbKuz6utc1+F170hyLUCUltXORjQXRyYQNfkckzA/bPQ==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz", + "integrity": "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==", "dev": true, "license": "MIT", "dependencies": { @@ -3275,43 +3284,49 @@ } }, "node_modules/@codemirror/lang-css": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.0.0.tgz", - "integrity": "sha512-jBqc+BTuwhNOTlrimFghLlSrN6iFuE44HULKWoR4qKYObhOIl9Lci1iYj6zMIte1XTQmZguNvjXMyr43LUKwSw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", "dev": true, + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", - "@lezer/css": "^1.0.0" + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" } }, "node_modules/@codemirror/lang-html": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.1.0.tgz", - "integrity": "sha512-gA7NmJxqvnhwza05CvR7W/39Ap9r/4Vs9uiC0IeFYo1hSlJzc/8N6Evviz6vTW1x8SpHcRYyqKOf6rpl6LfWtg==", + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz", + "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==", "dev": true, + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", - "@codemirror/language": "^6.0.0", + "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", - "@lezer/html": "^1.0.0" + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.0" } }, "node_modules/@codemirror/lang-javascript": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.0.1.tgz", - "integrity": "sha512-kjGbBEosl+ozDU5ruDV48w4v3H6KECTFiDjqMLT0KhVwESPfv3wOvnDrTT0uaMOg3YRGnBWsyiIoKHl/tNWWDg==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", + "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", "dev": true, + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", - "@codemirror/language": "^6.0.0", + "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", + "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } @@ -3333,9 +3348,9 @@ } }, "node_modules/@codemirror/language": { - "version": "6.10.8", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.8.tgz", - "integrity": "sha512-wcP8XPPhDH2vTqf181U8MbZnW+tDyPYy0UzVOa+oHORjyT+mhhom9vBd7dApJwoDz9Nb/a8kHjJIsuA/t8vNFw==", + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.0.tgz", + "integrity": "sha512-A7+f++LodNNc1wGgoRDTt78cOwWm9KVezApgjOMp1W4hM0898nsqBXwF+sbePE7ZRcjN7Sa1Z5m2oN27XkmEjQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3348,9 +3363,9 @@ } }, "node_modules/@codemirror/lint": { - "version": "6.8.4", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.4.tgz", - "integrity": "sha512-u4q7PnZlJUojeRe8FJa/njJcMctISGgPQ4PnWsd9268R4ZTtU+tfFYmwkBvgcrK2+QQ8tYFVALVb5fVJykKc5A==", + "version": "6.8.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz", + "integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3382,9 +3397,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.36.3", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.36.3.tgz", - "integrity": "sha512-N2bilM47QWC8Hnx0rMdDxO2x2ImJ1FvZWXubwKgjeoOrWwEiFrtpA7SFHcuZ+o2Ze2VzbkgbzWVj4+V18LVkeg==", + "version": "6.36.8", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.36.8.tgz", + "integrity": "sha512-yoRo4f+FdnD01fFt4XpfpMCcCAo9QvZOtbrXExn4SqzH32YC6LgzqxfLZw/r6Ge65xyY03mK/UfUqrVw1gFiFg==", "dev": true, "license": "MIT", "dependencies": { @@ -4888,17 +4903,6 @@ "node": ">=10" } }, - "node_modules/@google-cloud/bigquery/node_modules/duplexify": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", - "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", - "dependencies": { - "end-of-stream": "^1.4.1", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1", - "stream-shift": "^1.0.0" - } - }, "node_modules/@google-cloud/bigquery/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -4926,17 +4930,6 @@ "node": ">=10" } }, - "node_modules/@google-cloud/common/node_modules/duplexify": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", - "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", - "dependencies": { - "end-of-stream": "^1.4.1", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1", - "stream-shift": "^1.0.0" - } - }, "node_modules/@google-cloud/logging": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/@google-cloud/logging/-/logging-11.1.0.tgz", @@ -5093,246 +5086,202 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/@google-cloud/logging-min": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/@google-cloud/logging-min/-/logging-min-10.4.0.tgz", - "integrity": "sha512-TcblDYAATO9hHcDcWYFh+vqt3pAV7Qddaih1JK3cpkzLa+BWjD5gAVAWww8W9Wr5yxOX+8CkssanH/xSS4n76Q==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@google-cloud/logging-min/-/logging-min-11.2.0.tgz", + "integrity": "sha512-o1mwzi1+9NMEjwYZJ0X3tK64obf9PzPVBAhzEJv65L0h7jVl3Fw7GswtsMUkdUvZexf96vAvlZZMvXB9jAIW2Q==", + "license": "Apache-2.0", "dependencies": { - "@google-cloud/common": "^4.0.0", - "@google-cloud/paginator": "^4.0.0", - "@google-cloud/projectify": "^3.0.0", - "@google-cloud/promisify": "^3.0.0", + "@google-cloud/common": "^5.0.0", + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "^4.0.0", + "@opentelemetry/api": "^1.7.0", "arrify": "^2.0.1", "dot-prop": "^6.0.0", "eventid": "^2.0.0", "extend": "^3.0.2", - "gcp-metadata": "^4.0.0", - "google-auth-library": "^8.0.2", - "google-gax": "^3.5.2", + "gcp-metadata": "^6.0.0", + "google-auth-library": "^9.0.0", + "google-gax": "^4.0.3", "on-finished": "^2.3.0", "pumpify": "^2.0.1", "stream-events": "^1.0.5", "uuid": "^9.0.0" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" } }, "node_modules/@google-cloud/logging-min/node_modules/@google-cloud/common": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-4.0.3.tgz", - "integrity": "sha512-fUoMo5b8iAKbrYpneIRV3z95AlxVJPrjpevxs4SKoclngWZvTXBSGpNisF5+x5m+oNGve7jfB1e6vNBZBUs7Fw==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-5.0.2.tgz", + "integrity": "sha512-V7bmBKYQyu0eVG2BFejuUjlBt+zrya6vtsKdY+JxMM/dNntPF41vZ9+LhOshEUH01zOHEqBSvI7Dad7ZS6aUeA==", + "license": "Apache-2.0", "dependencies": { - "@google-cloud/projectify": "^3.0.0", - "@google-cloud/promisify": "^3.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "^4.0.0", "arrify": "^2.0.1", "duplexify": "^4.1.1", - "ent": "^2.2.0", "extend": "^3.0.2", - "google-auth-library": "^8.0.2", - "retry-request": "^5.0.0", - "teeny-request": "^8.0.0" + "google-auth-library": "^9.0.0", + "html-entities": "^2.5.2", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" } }, "node_modules/@google-cloud/logging-min/node_modules/@google-cloud/paginator": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-4.0.1.tgz", - "integrity": "sha512-6G1ui6bWhNyHjmbYwavdN7mpVPRBtyDg/bfqBTAlwr413On2TnFNfDxc9UhTJctkgoCDgQXEKiRPLPR9USlkbQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", "dependencies": { "arrify": "^2.0.0", "extend": "^3.0.2" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" } }, "node_modules/@google-cloud/logging-min/node_modules/@google-cloud/projectify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-3.0.0.tgz", - "integrity": "sha512-HRkZsNmjScY6Li8/kb70wjGlDDyLkVk3KvoEo9uIoxSjYLJasGiCch9+PqRVDOCGUFvEIqyogl+BeqILL4OJHA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" } }, "node_modules/@google-cloud/logging-min/node_modules/@google-cloud/promisify": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-3.0.1.tgz", - "integrity": "sha512-z1CjRjtQyBOYL+5Qr9DdYIfrdLBe746jRTYfaYU6MeXkqp7UfYs/jX16lFFVzZ7PGEJvqZNqYUEtb1mvDww4pA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.1.0.tgz", + "integrity": "sha512-G/FQx5cE/+DqBbOpA5jKsegGwdPniU6PuIEMt+qxWgFxvxuFOzVmp6zYchtYuwAWV5/8Dgs0yAmjvNZv3uXLQg==", + "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@google-cloud/logging-min/node_modules/duplexify": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", - "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", + "node_modules/@google-cloud/logging-min/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@google-cloud/logging-min/node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@google-cloud/logging-min/node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", "dependencies": { - "end-of-stream": "^1.4.1", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1", - "stream-shift": "^1.0.0" + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/logging-min/node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" } }, "node_modules/@google-cloud/logging-min/node_modules/gcp-metadata": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-4.3.1.tgz", - "integrity": "sha512-x850LS5N7V1F3UcV7PoupzGsyD6iVwTVvsh3tbXfkctZnBnjW5yu5z1/3k3SehF7TyoTIe78rJs02GMMy+LF+A==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", "dependencies": { - "gaxios": "^4.0.0", + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" }, "engines": { - "node": ">=10" - } - }, - "node_modules/@google-cloud/logging-min/node_modules/gcp-metadata/node_modules/gaxios": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-4.3.3.tgz", - "integrity": "sha512-gSaYYIO1Y3wUtdfHmjDUZ8LWaxJQpiavzbF5Kq53akSzvmVg0RfyOcFDbO1KJ/KCGRFz2qG+lS81F0nkr7cRJA==", - "dependencies": { - "abort-controller": "^3.0.0", - "extend": "^3.0.2", - "https-proxy-agent": "^5.0.0", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.7" - }, - "engines": { - "node": ">=10" + "node": ">=14" } }, "node_modules/@google-cloud/logging-min/node_modules/google-auth-library": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-8.9.0.tgz", - "integrity": "sha512-f7aQCJODJFmYWN6PeNKzgvy9LI2tYmXnzpNDHEjG5sDNPgGb2FXQyTBnXeSH+PAtpKESFD+LmHw3Ox3mN7e1Fg==", + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", "dependencies": { - "arrify": "^2.0.0", "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", - "fast-text-encoding": "^1.0.0", - "gaxios": "^5.0.0", - "gcp-metadata": "^5.3.0", - "gtoken": "^6.1.0", - "jws": "^4.0.0", - "lru-cache": "^6.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@google-cloud/logging-min/node_modules/google-auth-library/node_modules/gcp-metadata": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", - "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", - "dependencies": { - "gaxios": "^5.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@google-cloud/logging-min/node_modules/google-gax": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-3.6.1.tgz", - "integrity": "sha512-g/lcUjGcB6DSw2HxgEmCDOrI/CByOwqRvsuUvNalHUK2iPPPlmAIpbMbl62u0YufGMr8zgE3JL7th6dCb1Ry+w==", - "dependencies": { - "@grpc/grpc-js": "~1.8.0", - "@grpc/proto-loader": "^0.7.0", - "@types/long": "^4.0.0", - "@types/rimraf": "^3.0.2", - "abort-controller": "^3.0.0", - "duplexify": "^4.0.0", - "fast-text-encoding": "^1.0.3", - "google-auth-library": "^8.0.2", - "is-stream-ended": "^0.1.4", - "node-fetch": "^2.6.1", - "object-hash": "^3.0.0", - "proto3-json-serializer": "^1.0.0", - "protobufjs": "7.2.4", - "protobufjs-cli": "1.1.1", - "retry-request": "^5.0.0" - }, - "bin": { - "compileProtos": "build/tools/compileProtos.js", - "minifyProtoJson": "build/tools/minify.js" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@google-cloud/logging-min/node_modules/google-p12-pem": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-4.0.1.tgz", - "integrity": "sha512-WPkN4yGtz05WZ5EhtlxNDWPhC4JIic6G8ePitwUWy4l+XPVYec+a0j0Ts47PDtW59y3RwAhUd9/h9ZZ63px6RQ==", - "dependencies": { - "node-forge": "^1.3.1" - }, - "bin": { - "gp12-pem": "build/src/bin/gp12-pem.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@google-cloud/logging-min/node_modules/gtoken": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-6.1.2.tgz", - "integrity": "sha512-4ccGpzz7YAr7lxrT2neugmXQ3hP9ho2gcaityLVkiUecAiwiy60Ii8gRbZeOsXV19fYaRjgBSshs8kXw+NKCPQ==", - "dependencies": { - "gaxios": "^5.0.1", - "google-p12-pem": "^4.0.0", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", "jws": "^4.0.0" }, "engines": { - "node": ">=12.0.0" + "node": ">=14" } }, - "node_modules/@google-cloud/logging-min/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "node_modules/@google-cloud/logging-min/node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", "dependencies": { - "yallist": "^4.0.0" + "gaxios": "^6.0.0", + "jws": "^4.0.0" }, "engines": { - "node": ">=10" - } - }, - "node_modules/@google-cloud/logging-min/node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "engines": { - "node": ">= 6" + "node": ">=14.0.0" } }, "node_modules/@google-cloud/logging-min/node_modules/retry-request": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-5.0.2.tgz", - "integrity": "sha512-wfI3pk7EE80lCIXprqh7ym48IHYdwmAAzESdbU8Q9l7pnRCk9LEhpbOTNKjz6FARLm/Bl5m+4F0ABxOkYUujSQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", "dependencies": { - "debug": "^4.1.1", - "extend": "^3.0.2" + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" }, "engines": { - "node": ">=12" + "node": ">=14" } }, "node_modules/@google-cloud/logging-min/node_modules/teeny-request": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-8.0.3.tgz", - "integrity": "sha512-jJZpA5He2y52yUhA7pyAGZlgQpcB+xLjcN0eUFxr9c8hP/H7uOXbBNVo/O0C/xVfJLJs680jvkFgVJEEvk9+ww==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", "dependencies": { "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", - "node-fetch": "^2.6.1", + "node-fetch": "^2.6.9", "stream-events": "^1.0.5", "uuid": "^9.0.0" }, "engines": { - "node": ">=12" + "node": ">=14" } }, "node_modules/@google-cloud/logging-min/node_modules/uuid": { @@ -5343,15 +5292,11 @@ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], + "license": "MIT", "bin": { "uuid": "dist/bin/uuid" } }, - "node_modules/@google-cloud/logging-min/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/@google-cloud/logging/node_modules/@google-cloud/common": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-5.0.2.tgz", @@ -5426,17 +5371,6 @@ } } }, - "node_modules/@google-cloud/logging/node_modules/duplexify": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", - "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", - "dependencies": { - "end-of-stream": "^1.4.1", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1", - "stream-shift": "^1.0.2" - } - }, "node_modules/@google-cloud/logging/node_modules/gaxios": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.6.0.tgz", @@ -5598,22 +5532,24 @@ } }, "node_modules/@google-cloud/profiler": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@google-cloud/profiler/-/profiler-6.0.0.tgz", - "integrity": "sha512-EAxPbDiNRidAKOEnlUK3M+CcOlqG+REkUEZKirLtxFwzI/m7LmGqDzQvrVWTOSFSEYJ9qQRRnO+Q1osNGk3NUg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@google-cloud/profiler/-/profiler-6.0.3.tgz", + "integrity": "sha512-Ey8li6Vc2CbfEzOTSZaqKolxPMGacxVUQuhChNT0Wi55a3nfImMiiuDgqYw1In/a9Q3Z62O7jUg2L8f1XwMN7Q==", + "license": "Apache-2.0", "dependencies": { "@google-cloud/common": "^5.0.0", - "@google-cloud/logging-min": "^10.0.0", + "@google-cloud/logging-min": "^11.0.0", + "@google-cloud/promisify": "~4.0.0", "@types/console-log-level": "^1.4.0", "@types/semver": "^7.0.0", "console-log-level": "^1.4.0", "delay": "^5.0.0", "extend": "^3.0.2", "gcp-metadata": "^6.0.0", - "parse-duration": "^1.0.0", - "pprof": "3.2.1", + "ms": "^2.1.3", + "pprof": "4.0.0", "pretty-ms": "^7.0.0", - "protobufjs": "~7.2.4", + "protobufjs": "~7.4.0", "semver": "^7.0.0", "teeny-request": "^9.0.0" }, @@ -5622,18 +5558,19 @@ } }, "node_modules/@google-cloud/profiler/node_modules/@google-cloud/common": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-5.0.0.tgz", - "integrity": "sha512-IsbTVr7Ag+04GMT87X738vDs85QU1rMvaesm2wEQrtTbZAR92tGmUQ8/D/kdnYgAi98Q4zmfhF+T8Xs/Lw4zAA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-5.0.2.tgz", + "integrity": "sha512-V7bmBKYQyu0eVG2BFejuUjlBt+zrya6vtsKdY+JxMM/dNntPF41vZ9+LhOshEUH01zOHEqBSvI7Dad7ZS6aUeA==", + "license": "Apache-2.0", "dependencies": { "@google-cloud/projectify": "^4.0.0", "@google-cloud/promisify": "^4.0.0", "arrify": "^2.0.1", "duplexify": "^4.1.1", - "ent": "^2.2.0", "extend": "^3.0.2", "google-auth-library": "^9.0.0", - "retry-request": "^6.0.0", + "html-entities": "^2.5.2", + "retry-request": "^7.0.0", "teeny-request": "^9.0.0" }, "engines": { @@ -5644,6 +5581,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", "engines": { "node": ">=14.0.0" } @@ -5652,68 +5590,43 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", "engines": { "node": ">=14" } }, "node_modules/@google-cloud/profiler/node_modules/agent-base": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", - "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", - "dependencies": { - "debug": "^4.3.4" - }, + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", "engines": { "node": ">= 14" } }, - "node_modules/@google-cloud/profiler/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@google-cloud/profiler/node_modules/duplexify": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", - "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", - "dependencies": { - "end-of-stream": "^1.4.1", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1", - "stream-shift": "^1.0.0" - } - }, "node_modules/@google-cloud/profiler/node_modules/gaxios": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.1.1.tgz", - "integrity": "sha512-bw8smrX+XlAoo9o1JAksBwX+hi/RG15J+NTSxmNPIclKC3ZVK6C2afwY8OSdRvOK0+ZLecUJYtj2MmjOt3Dm0w==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" }, "engines": { "node": ">=14" } }, "node_modules/@google-cloud/profiler/node_modules/gaxios/node_modules/https-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", - "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { @@ -5721,11 +5634,13 @@ } }, "node_modules/@google-cloud/profiler/node_modules/gcp-metadata": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.0.0.tgz", - "integrity": "sha512-Ozxyi23/1Ar51wjUT2RDklK+3HxqDr8TLBNK8rBBFQ7T85iIGnXnVusauj06QyqCXRFZig8LZC+TUddWbndlpQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", "dependencies": { - "gaxios": "^6.0.0", + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" }, "engines": { @@ -5733,26 +5648,27 @@ } }, "node_modules/@google-cloud/profiler/node_modules/google-auth-library": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.1.0.tgz", - "integrity": "sha512-1M9HdOcQNPV5BwSXqwwT238MTKodJFBxZ/V2JP397ieOLv4FjQdfYb9SooR7Mb+oUT2IJ92mLJQf804dyx0MJA==", + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^6.0.0", - "gcp-metadata": "^6.0.0", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", - "jws": "^4.0.0", - "lru-cache": "^6.0.0" + "jws": "^4.0.0" }, "engines": { "node": ">=14" } }, "node_modules/@google-cloud/profiler/node_modules/gtoken": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.0.1.tgz", - "integrity": "sha512-KcFVtoP1CVFtQu0aSk3AyAt2og66PFhZAlkUOuWKwzMLoulHXG5W5wE5xAnHb+yl3/wEFoqGW7/cDGMU8igDZQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" @@ -5761,41 +5677,49 @@ "node": ">=14.0.0" } }, - "node_modules/@google-cloud/profiler/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "node_modules/@google-cloud/profiler/node_modules/protobufjs": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", + "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", "dependencies": { - "yallist": "^4.0.0" + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=12.0.0" } }, - "node_modules/@google-cloud/profiler/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, "node_modules/@google-cloud/profiler/node_modules/retry-request": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-6.0.0.tgz", - "integrity": "sha512-24kaFMd3wCnT3n4uPnsQh90ZSV8OISpfTFXJ00Wi+/oD2OPrp63EQ8hznk6rhxdlpwx2QBhQSDz2Fg46ki852g==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", "dependencies": { - "debug": "^4.1.1", - "extend": "^3.0.2" + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" }, "engines": { "node": ">=14" } }, "node_modules/@google-cloud/profiler/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -5807,6 +5731,7 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", "dependencies": { "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", @@ -5826,15 +5751,11 @@ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], + "license": "MIT", "bin": { "uuid": "dist/bin/uuid" } }, - "node_modules/@google-cloud/profiler/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/@google-cloud/projectify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-2.1.1.tgz", @@ -5905,17 +5826,6 @@ "node": ">=12" } }, - "node_modules/@google-cloud/storage/node_modules/duplexify": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", - "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", - "dependencies": { - "end-of-stream": "^1.4.1", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1", - "stream-shift": "^1.0.0" - } - }, "node_modules/@google-cloud/storage/node_modules/google-auth-library": { "version": "8.7.0", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-8.7.0.tgz", @@ -6510,17 +6420,6 @@ "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" }, - "node_modules/@jsdoc/salty": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.8.tgz", - "integrity": "sha512-5e+SFVavj1ORKlKaKr2BmTOekmXbelU7dC0cDkQLqag7xfuTPuGMUFx7KWJuv4bYZrTsoL2Z18VVCOKYxzoHcg==", - "dependencies": { - "lodash": "^4.17.21" - }, - "engines": { - "node": ">=v12.0.0" - } - }, "node_modules/@jsonjoy.com/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", @@ -6599,20 +6498,23 @@ "license": "MIT" }, "node_modules/@lezer/css": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.0.0.tgz", - "integrity": "sha512-616VqgDKumHmYIuxs3tnX1irEQmoDHgF/TlP4O5ICWwyHwLMErq+8iKVuzTkOdBqvYAVmObqThcDEAaaMJjAdg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.2.0.tgz", + "integrity": "sha512-8FLXsWpwKWMqQ6XjDP0DWbMP4YdeqhIcwN8IulcBinGpu30PG74zz0c6w+Yi2DeQD9/4FXfeLp+XP90NflIkGA==", "dev": true, + "license": "MIT", "dependencies": { + "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@lezer/lr": "^1.3.0" } }, "node_modules/@lezer/generator": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@lezer/generator/-/generator-1.7.1.tgz", - "integrity": "sha512-MgPJN9Si+ccxzXl3OAmCeZuUKw4XiPl4y664FX/hnnyG9CTqUPq65N3/VGPA2jD23D7QgMTtNqflta+cPN+5mQ==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@lezer/generator/-/generator-1.7.3.tgz", + "integrity": "sha512-vAI2O1tPF8QMMgp+bdUeeJCneJNkOZvqsrtyb4ohnFVFdboSqPwBEacnt0HH4E+5h+qsIwTHUSAhffU4hzKl1A==", "dev": true, + "license": "MIT", "dependencies": { "@lezer/common": "^1.1.0", "@lezer/lr": "^1.3.0" @@ -6631,23 +6533,27 @@ } }, "node_modules/@lezer/html": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.0.0.tgz", - "integrity": "sha512-wZHBcieArLTxEi198hqRBBHMySzDKo5suWaESdUw0t44IXp01vkSRwX2brG1qBbKdwJ+C6U0iMl00vWNiyAROg==", + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz", + "integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==", "dev": true, + "license": "MIT", "dependencies": { + "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "node_modules/@lezer/javascript": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.0.1.tgz", - "integrity": "sha512-t7fpf3+gi/jiAtW+Gv734TbKdpPg6b8qATH01/jprW9H2oR++Tb688IHwJvZbk9F4GjpCEv86beuHMpUyC1b5g==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.1.tgz", + "integrity": "sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw==", "dev": true, + "license": "MIT", "dependencies": { - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" } }, "node_modules/@lezer/lr": { @@ -6660,9 +6566,9 @@ } }, "node_modules/@lezer/markdown": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.3.2.tgz", - "integrity": "sha512-Wu7B6VnrKTbBEohqa63h5vxXjiC4pO5ZQJ/TDbhJxPQaaIoRD/6UVDhSDtVsCwVZV12vvN9KxuLL3ATMnlG0oQ==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.4.3.tgz", + "integrity": "sha512-kfw+2uMrQ/wy/+ONfrH83OkdFNM0ye5Xq96cLlaCy7h5UT9FO54DU4oRoIc0CSBh5NWmWuiIJA7NGLMJbQ+Oxg==", "dev": true, "license": "MIT", "dependencies": { @@ -6692,14 +6598,15 @@ } }, "node_modules/@mapbox/node-pre-gyp": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.8.tgz", - "integrity": "sha512-CMGKi28CF+qlbXh26hDe6NxCd7amqeAzEqnS6IHeO6LoaKyM/n+Xw3HT1COdq8cuioOdlKdqn/hCmqPUOMOywg==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", "dependencies": { - "detect-libc": "^1.0.3", + "detect-libc": "^2.0.0", "https-proxy-agent": "^5.0.0", "make-dir": "^3.1.0", - "node-fetch": "^2.6.5", + "node-fetch": "^2.6.7", "nopt": "^5.0.0", "npmlog": "^5.0.1", "rimraf": "^3.0.2", @@ -6743,20 +6650,6 @@ "semver": "bin/semver.js" } }, - "node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -12294,15 +12187,6 @@ "@types/send": "*" } }, - "node_modules/@types/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==", - "dependencies": { - "@types/minimatch": "^5.1.2", - "@types/node": "*" - } - }, "node_modules/@types/glob-to-regexp": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/@types/glob-to-regexp/-/glob-to-regexp-0.4.4.tgz", @@ -12470,11 +12354,6 @@ "@types/node": "*" } }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==" - }, "node_modules/@types/lodash": { "version": "4.14.178", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz", @@ -12486,20 +12365,6 @@ "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz", "integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==" }, - "node_modules/@types/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-4NpsnpYl2Gt1ljyBGrKMxFYAYvpqbnnkgP/i/g+NLpjEUa3obn1XJCur9YbEXKDAkaXqsR1LbDnGEJ0MmKFxfg==", - "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } - }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==" - }, "node_modules/@types/mdx": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", @@ -12525,11 +12390,6 @@ "resolved": "https://registry.npmjs.org/@types/mime-db/-/mime-db-1.43.1.tgz", "integrity": "sha512-kGZJY+R+WnR5Rk+RPHUMERtb2qBRViIHCBdtUrY+NmwuGb8pQdfTqQiCKPrxpdoycl8KWm2DLdkpoSdt479XoQ==" }, - "node_modules/@types/minimatch": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==" - }, "node_modules/@types/mocha": { "version": "10.0.6", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.6.tgz", @@ -12794,15 +12654,6 @@ "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", "dev": true }, - "node_modules/@types/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-F3OznnSLAUxFrCEu/L5PY8+ny8DtcFRjx7fZZ9bycvXRi3KPTRS9HOitGZwvPg0juRhXFWIeKX58cnX5YqLohQ==", - "dependencies": { - "@types/glob": "*", - "@types/node": "*" - } - }, "node_modules/@types/semver": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz", @@ -16272,11 +16123,6 @@ "node": ">=0.10.0" } }, - "node_modules/base-x": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.0.tgz", - "integrity": "sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==" - }, "node_modules/base/node_modules/define-property": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", @@ -16506,6 +16352,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", "dependencies": { "file-uri-to-path": "1.0.0" } @@ -16648,9 +16495,9 @@ }, "node_modules/bootstrap-5": { "name": "bootstrap", - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", - "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz", + "integrity": "sha512-jX0GAcRzvdwISuvArXn3m7KZscWWFAf1MKBcnzaN02qWMb3jpMoUX4/qgeiGzqyIb4ojulRzs89UCUmGcFSzTA==", "dev": true, "funding": [ { @@ -16662,6 +16509,7 @@ "url": "https://opencollective.com/bootstrap" } ], + "license": "MIT", "peerDependencies": { "@popperjs/core": "^2.11.8" } @@ -17021,21 +16869,6 @@ "node": ">=10.12.0" } }, - "node_modules/c8/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/c8/node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", @@ -17259,17 +17092,6 @@ "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" }, - "node_modules/catharsis": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz", - "integrity": "sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==", - "dependencies": { - "lodash": "^4.17.15" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/celebrate": { "version": "15.0.3", "resolved": "https://registry.npmjs.org/celebrate/-/celebrate-15.0.3.tgz", @@ -18148,17 +17970,6 @@ "dev": true, "license": "MIT" }, - "node_modules/config": { - "version": "1.31.0", - "resolved": "https://registry.npmjs.org/config/-/config-1.31.0.tgz", - "integrity": "sha512-Ep/l9Rd1J9IPueztJfpbOqVzuKHQh4ZODMNt9xqTYdBBNRXbV4oTu34kCkkfdRVcDq0ohtpaeXGgb+c0LQxFRA==", - "dependencies": { - "json5": "^1.0.1" - }, - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -18169,17 +17980,6 @@ "proto-list": "~1.2.1" } }, - "node_modules/config/node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, "node_modules/connect-flash": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/connect-flash/-/connect-flash-0.1.1.tgz", @@ -18890,12 +18690,6 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "node_modules/css-mediaquery": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", - "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==", - "license": "BSD" - }, "node_modules/css-minimizer-webpack-plugin": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-5.0.1.tgz", @@ -20077,14 +19871,12 @@ } }, "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", - "bin": { - "detect-libc": "bin/detect-libc.js" - }, + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", "engines": { - "node": ">=0.10" + "node": ">=8" } }, "node_modules/detect-node": { @@ -20410,6 +20202,18 @@ "node": ">=0.10" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, "node_modules/duration": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/duration/-/duration-0.2.2.tgz", @@ -23227,7 +23031,8 @@ "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" }, "node_modules/filelist": { "version": "1.0.4", @@ -23448,6 +23253,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/findit2/-/findit2-2.2.3.tgz", "integrity": "sha512-lg/Moejf4qXovVutL0Lz4IsaPoNYMuxt4PA0nGqFxnJ1CTTGGlEO2wKgoDpwknhvZ8k4Q2F+eesgkLbG2Mxfog==", + "license": "MIT", "engines": { "node": ">=0.8.22" } @@ -23473,20 +23279,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/flat-cache/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/flatted": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", @@ -24633,6 +24425,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/google-p12-pem": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-3.1.3.tgz", @@ -25712,12 +25513,6 @@ "node": ">=10.18" } }, - "node_modules/hyphenate-style-name": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", - "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", - "license": "BSD-3-Clause" - }, "node_modules/i18next": { "version": "23.10.0", "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.10.0.tgz", @@ -26707,11 +26502,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-stream-ended": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/is-stream-ended/-/is-stream-ended-0.1.4.tgz", - "integrity": "sha512-xj0XPvmr7bQFTvirqnFr50o0hQIh6ZItDqloxt5aJrR4NQsYeSsyFQERYGCAzfindAcnKjINnwEEgLx4IqVzQw==" - }, "node_modules/is-string": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", @@ -27312,14 +27102,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/js2xmlparser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz", - "integrity": "sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==", - "dependencies": { - "xmlcreate": "^2.0.4" - } - }, "node_modules/jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", @@ -27391,34 +27173,6 @@ "node": ">=6.0.0" } }, - "node_modules/jsdoc": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.3.tgz", - "integrity": "sha512-Nu7Sf35kXJ1MWDZIMAuATRQTg1iIPdzh7tqJ6jjvaU/GfDf+qi5UV8zJR3Mo+/pYFvm8mzay4+6O5EWigaQBQw==", - "dependencies": { - "@babel/parser": "^7.20.15", - "@jsdoc/salty": "^0.2.1", - "@types/markdown-it": "^14.1.1", - "bluebird": "^3.7.2", - "catharsis": "^0.9.0", - "escape-string-regexp": "^2.0.0", - "js2xmlparser": "^4.0.2", - "klaw": "^3.0.0", - "markdown-it": "^14.1.0", - "markdown-it-anchor": "^8.6.7", - "marked": "^4.0.10", - "mkdirp": "^1.0.4", - "requizzle": "^0.2.3", - "strip-json-comments": "^3.1.0", - "underscore": "~1.13.2" - }, - "bin": { - "jsdoc": "jsdoc.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/jsdoc-type-pratt-parser": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", @@ -27429,25 +27183,6 @@ "node": ">=12.0.0" } }, - "node_modules/jsdoc/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "engines": { - "node": ">=8" - } - }, - "node_modules/jsdoc/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jsdom": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-19.0.0.tgz", @@ -27693,7 +27428,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, "bin": { "json5": "lib/cli.js" }, @@ -27909,14 +27643,6 @@ "node": ">=0.10.0" } }, - "node_modules/klaw": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", - "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==", - "dependencies": { - "graceful-fs": "^4.1.9" - } - }, "node_modules/klaw-sync": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", @@ -28302,19 +28028,6 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, - "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, - "node_modules/linkify-it/node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" - }, "node_modules/listr2": { "version": "3.14.0", "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz", @@ -28723,6 +28436,12 @@ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "license": "MIT" + }, "node_modules/lodash.support": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/lodash.support/-/lodash.support-2.4.1.tgz", @@ -29088,47 +28807,6 @@ "node": ">=0.10.0" } }, - "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, - "node_modules/markdown-it-anchor": { - "version": "8.6.7", - "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", - "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", - "peerDependencies": { - "@types/markdown-it": "*", - "markdown-it": "*" - } - }, - "node_modules/markdown-it/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/markdown-it/node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" - }, "node_modules/marked": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/marked/-/marked-4.1.0.tgz", @@ -29150,15 +28828,6 @@ "remove-accents": "0.4.2" } }, - "node_modules/matchmediaquery": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/matchmediaquery/-/matchmediaquery-0.4.2.tgz", - "integrity": "sha512-wrZpoT50ehYOudhDjt/YvUJc6eUzcdFPdmbizfgvswCKNHD1/OBOHYJpHie+HXpu6bSkEGieFMYk6VuutaiRfA==", - "license": "MIT", - "dependencies": { - "css-mediaquery": "^0.1.2" - } - }, "node_modules/material-colors": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz", @@ -29202,11 +28871,6 @@ "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", "dev": true }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==" - }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -30517,9 +30181,9 @@ } }, "node_modules/multer": { - "version": "1.4.5-lts.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", - "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.0.tgz", + "integrity": "sha512-bS8rPZurbAuHGAnApbM9d4h1wSoYqrOqkE+6a64KLMK9yWU7gJXBDDVklKQ3TPi9DRb85cRs6yXaC0+cjxRtRg==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -30531,7 +30195,7 @@ "xtend": "^4.0.0" }, "engines": { - "node": ">= 6.0.0" + "node": ">= 10.16.0" } }, "node_modules/multicast-dns": { @@ -30603,9 +30267,10 @@ "license": "MIT" }, "node_modules/nan": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz", - "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==" + "version": "2.22.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", + "integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", + "license": "MIT" }, "node_modules/nanoclone": { "version": "0.2.1", @@ -30876,16 +30541,6 @@ "node-gyp-build-optional-packages-test": "build-test.js" } }, - "node_modules/node-gyp-build-optional-packages/node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -31483,53 +31138,6 @@ "node": ">=0.10" } }, - "node_modules/optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dependencies": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/optionator/node_modules/levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", - "dependencies": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/optionator/node_modules/prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/optionator/node_modules/type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", - "dependencies": { - "prelude-ls": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/options": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/options/-/options-0.0.6.tgz", @@ -31780,11 +31388,6 @@ "node": ">=8" } }, - "node_modules/parse-duration": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/parse-duration/-/parse-duration-1.1.0.tgz", - "integrity": "sha512-z6t9dvSJYaPoQq7quMzdEagSFtpGu+utzHqqxmpVWNNZRIXnvqyCvn9XsTdh7c/w0Bqmdz3RB3YnRaKtpRtEXQ==" - }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -33928,41 +33531,34 @@ } }, "node_modules/pprof": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/pprof/-/pprof-3.2.1.tgz", - "integrity": "sha512-KnextTM3EHQ2zqN8fUjB0VpE+njcVR7cOfo7DjJSLKzIbKTPelDtokI04ScR/Vd8CLDj+M99tsaKV+K6FHzpzA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pprof/-/pprof-4.0.0.tgz", + "integrity": "sha512-Yhfk7Y0G1MYsy97oXxmSG5nvbM1sCz9EALiNhW/isAv5Xf7svzP+1RfGeBlS6mLSgRJvgSLh6Mi5DaisQuPttw==", "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { - "@mapbox/node-pre-gyp": "^1.0.0", + "@mapbox/node-pre-gyp": "^1.0.9", "bindings": "^1.2.1", "delay": "^5.0.0", "findit2": "^2.2.3", - "nan": "^2.14.0", + "nan": "^2.17.0", "p-limit": "^3.0.0", - "pify": "^5.0.0", "protobufjs": "~7.2.4", - "source-map": "^0.7.3", + "source-map": "~0.8.0-beta.0", "split": "^1.0.1" }, "engines": { - "node": ">=10.4.1" - } - }, - "node_modules/pprof/node_modules/pify": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", - "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=14.0.0" } }, "node_modules/pprof/node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "license": "BSD-3-Clause", + "dependencies": { + "whatwg-url": "^7.0.0" + }, "engines": { "node": ">= 8" } @@ -34243,17 +33839,6 @@ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "dev": true }, - "node_modules/proto3-json-serializer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-1.1.1.tgz", - "integrity": "sha512-AwAuY4g9nxx0u52DnSMkqqgyLHaW/XaPLtaAo3y/ZCfeaQB/g4YDH4kb8Wc/mWzWvu0YjOznVnfn373MVZZrgw==", - "dependencies": { - "protobufjs": "^7.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/protobufjs": { "version": "7.2.5", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.5.tgz", @@ -34277,151 +33862,6 @@ "node": ">=12.0.0" } }, - "node_modules/protobufjs-cli": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/protobufjs-cli/-/protobufjs-cli-1.1.1.tgz", - "integrity": "sha512-VPWMgIcRNyQwWUv8OLPyGQ/0lQY/QTQAVN5fh+XzfDwsVw1FZ2L3DM/bcBf8WPiRz2tNpaov9lPZfNcmNo6LXA==", - "dependencies": { - "chalk": "^4.0.0", - "escodegen": "^1.13.0", - "espree": "^9.0.0", - "estraverse": "^5.1.0", - "glob": "^8.0.0", - "jsdoc": "^4.0.0", - "minimist": "^1.2.0", - "semver": "^7.1.2", - "tmp": "^0.2.1", - "uglify-js": "^3.7.7" - }, - "bin": { - "pbjs": "bin/pbjs", - "pbts": "bin/pbts" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "protobufjs": "^7.0.0" - } - }, - "node_modules/protobufjs-cli/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/protobufjs-cli/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/protobufjs-cli/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/protobufjs-cli/node_modules/escodegen": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", - "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^4.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=4.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/protobufjs-cli/node_modules/escodegen/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/protobufjs-cli/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/protobufjs-cli/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/protobufjs-cli/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/protobufjs-cli/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -34594,17 +34034,6 @@ "pump": "^3.0.0" } }, - "node_modules/pumpify/node_modules/duplexify": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", - "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", - "dependencies": { - "end-of-stream": "^1.4.1", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1", - "stream-shift": "^1.0.0" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -34613,14 +34042,6 @@ "node": ">=6" } }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "engines": { - "node": ">=6" - } - }, "node_modules/qrcode": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.0.tgz", @@ -34940,17 +34361,17 @@ "react": ">=16.4.1" } }, - "node_modules/react-bootstrap-5": { - "name": "react-bootstrap", - "version": "2.10.5", - "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.5.tgz", - "integrity": "sha512-XueAOEn64RRkZ0s6yzUTdpFtdUXs5L5491QU//8ZcODKJNDLt/r01tNyriZccjgRImH1REynUc9pqjiRMpDLWQ==", + "node_modules/react-bootstrap": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.10.tgz", + "integrity": "sha512-gMckKUqn8aK/vCnfwoBpBVFUGT9SVQxwsYrp9yDHt0arXMamxALerliKBxr1TPbntirK/HGrUAHYbAeQTa9GHQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.24.7", "@restart/hooks": "^0.4.9", - "@restart/ui": "^1.6.9", + "@restart/ui": "^1.9.4", + "@types/prop-types": "^15.7.12", "@types/react-transition-group": "^4.4.6", "classnames": "^2.3.2", "dom-helpers": "^5.2.1", @@ -35253,24 +34674,6 @@ "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0" } }, - "node_modules/react-responsive": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-10.0.1.tgz", - "integrity": "sha512-OM5/cRvbtUWEX8le8RCT8scA8y2OPtb0Q/IViEyCEM5FBN8lRrkUOZnu87I88A6njxDldvxG+rLBxWiA7/UM9g==", - "license": "MIT", - "dependencies": { - "hyphenate-style-name": "^1.0.0", - "matchmediaquery": "^0.4.2", - "prop-types": "^15.6.1", - "shallow-equal": "^3.1.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, "node_modules/react-router": { "version": "6.30.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz", @@ -36012,14 +35415,6 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" }, - "node_modules/requizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz", - "integrity": "sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==", - "dependencies": { - "lodash": "^4.17.21" - } - }, "node_modules/reselect": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", @@ -36159,6 +35554,22 @@ "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", "dev": true }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rndm": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", @@ -37097,12 +36508,6 @@ "node": ">=8" } }, - "node_modules/shallow-equal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz", - "integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==", - "license": "MIT" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -37589,8 +36994,8 @@ } }, "node_modules/socket.io": { - "version": "0.9.19-overleaf-11", - "resolved": "git+ssh://git@github.com/overleaf/socket.io.git#5afa587036620afa232d0f7b778ebb1541d7e4d5", + "version": "0.9.19-overleaf-12", + "resolved": "git+ssh://git@github.com/overleaf/socket.io.git#7814a16800a7accb646bd34ed0995cd474fb82f6", "dependencies": { "base64id": "0.1.0", "policyfile": "0.0.4" @@ -37805,6 +37210,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "license": "MIT", "dependencies": { "through": "2" }, @@ -38259,19 +37665,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/stripe": { - "version": "17.7.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-17.7.0.tgz", - "integrity": "sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==", - "license": "MIT", - "dependencies": { - "@types/node": ">=8.1.0", - "qs": "^6.11.0" - }, - "engines": { - "node": ">=12.*" - } - }, "node_modules/strnum": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", @@ -39064,7 +38457,8 @@ "node_modules/swagger-tools/node_modules/path-to-regexp": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", - "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==" + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "license": "MIT" }, "node_modules/symbol-tree": { "version": "3.2.4", @@ -39768,6 +39162,7 @@ "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "dev": true, "engines": { "node": ">=14.14" } @@ -39951,6 +39346,15 @@ "node": ">= 4.0.0" } }, + "node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "license": "MIT", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/traverse": { "version": "0.6.9", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.9.tgz", @@ -40249,6 +39653,8 @@ "version": "3.15.0", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.15.0.tgz", "integrity": "sha512-x+xdeDWq7FiORDvyIJ0q/waWd4PhjBNOm5dQUOq2AKC0IEjxOS66Ha9tctiVDGcRQuh69K7fgU5oRuTK4cysSg==", + "dev": true, + "optional": true, "bin": { "uglifyjs": "bin/uglifyjs" }, @@ -41950,6 +41356,23 @@ "node": ">=12" } }, + "node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/whatwg-url/node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "license": "BSD-2-Clause" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -42116,6 +41539,7 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -42348,11 +41772,6 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, - "node_modules/xmlcreate": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", - "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==" - }, "node_modules/xmlhttprequest": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz", @@ -42577,15 +41996,6 @@ "node": ">= 10" } }, - "node_modules/zlib": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/zlib/-/zlib-1.0.5.tgz", - "integrity": "sha512-40fpE2II+Cd3k8HWTWONfeKE2jL+P42iWJ1zzps5W51qcTsOUKM5Q5m2PFb0CLxlmFAaUuUdJGc3OfZy947v0w==", - "hasInstallScript": true, - "engines": { - "node": ">=0.2.0" - } - }, "services/analytics": { "name": "@overleaf/analytics", "dependencies": { @@ -42846,13 +42256,6 @@ "tar-stream": "^2.1.4" } }, - "services/clsi/node_modules/nan": { - "version": "2.22.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", - "integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", - "license": "MIT", - "optional": true - }, "services/clsi/node_modules/protobufjs": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", @@ -43092,6 +42495,7 @@ "lodash": "^4.17.21", "minimist": "^1.2.8", "mongodb-legacy": "6.1.3", + "overleaf-editor-core": "*", "request": "^2.88.2", "requestretry": "^7.1.0" }, @@ -43188,8 +42592,7 @@ "@overleaf/metrics": "*", "express": "^4.21.2", "is-valid-hostname": "^1.0.2", - "tar-stream": "^2.2.0", - "zlib": "^1.0.5" + "tar-stream": "^2.2.0" }, "devDependencies": { "chai": "^4.3.6", @@ -43332,7 +42735,7 @@ "bunyan": "^1.8.12", "check-types": "^11.1.2", "command-line-args": "^3.0.3", - "config": "^1.19.0", + "config": "^3.3.12", "express": "^4.21.2", "fs-extra": "^9.0.1", "generic-pool": "^2.1.1", @@ -43426,6 +42829,18 @@ "command-line-args": "bin.js" } }, + "services/history-v1/node_modules/config": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/config/-/config-3.3.12.tgz", + "integrity": "sha512-Vmx389R/QVM3foxqBzXO8t2tUikYZP64Q6vQxGrsMpREeJc/aWRnPRERXWsYzOHAumx/AOoILWe6nU3ZJL+6Sw==", + "license": "MIT", + "dependencies": { + "json5": "^2.2.3" + }, + "engines": { + "node": ">= 10.0.0" + } + }, "services/history-v1/node_modules/cron-parser": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", @@ -43616,7 +43031,6 @@ "react-dropzone": "^14.2.3", "react-helmet": "^6.1.0", "react-redux": "^7.2.2", - "react-responsive": "^10.0.0", "react-router-dom": "^6.26.1", "redux": "^4.2.1", "redux-logger": "^3.0.1", @@ -43680,197 +43094,6 @@ "@babel/highlight": "^7.10.4" } }, - "services/latexqc/node_modules/@babel/core": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", - "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.10", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.10", - "@babel/types": "^7.26.10", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "services/latexqc/node_modules/@babel/core/node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "services/latexqc/node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "services/latexqc/node_modules/@babel/generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz", - "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "services/latexqc/node_modules/@babel/helpers": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", - "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "services/latexqc/node_modules/@babel/parser": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "services/latexqc/node_modules/@babel/template": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", - "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "services/latexqc/node_modules/@babel/template/node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "services/latexqc/node_modules/@babel/traverse": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz", - "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.27.0", - "@babel/parser": "^7.27.0", - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "services/latexqc/node_modules/@babel/traverse/node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "services/latexqc/node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "services/latexqc/node_modules/@babel/types": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", - "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, "services/latexqc/node_modules/@eslint/eslintrc": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", @@ -44180,13 +43403,6 @@ "node": ">= 16" } }, - "services/latexqc/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "services/latexqc/node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -45098,7 +44314,7 @@ "lodash": "^4.17.21", "proxy-addr": "^2.0.7", "request": "^2.88.2", - "socket.io": "github:overleaf/socket.io#0.9.19-overleaf-11", + "socket.io": "github:overleaf/socket.io#0.9.19-overleaf-12", "socket.io-client": "github:overleaf/socket.io-client#0.9.17-overleaf-5" }, "devDependencies": { @@ -45478,7 +44694,7 @@ "ajv": "^8.12.0", "archiver": "^5.3.0", "async": "^3.2.5", - "base-x": "^4.0.0", + "base-x": "^4.0.1", "basic-auth": "^2.0.1", "bcrypt": "^5.0.0", "body-parser": "^1.20.3", @@ -45524,7 +44740,7 @@ "moment": "^2.29.4", "mongodb-legacy": "6.1.3", "mongoose": "8.9.5", - "multer": "overleaf/multer#e1df247fbf8e7590520d20ae3601eaef9f3d2e9e", + "multer": "overleaf/multer#199c5ff05bd375c508f4074498237baead7f5148", "nocache": "^2.1.0", "node-fetch": "^2.7.0", "nodemailer": "^6.7.0", @@ -45549,7 +44765,7 @@ "request": "^2.88.2", "requestretry": "^7.1.0", "sanitize-html": "^2.8.1", - "stripe": "^17.7.0", + "stripe": "^18.1.0", "tough-cookie": "^4.0.0", "tsscmp": "^1.0.6", "uid-safe": "^2.1.5", @@ -45569,19 +44785,19 @@ "@babel/preset-typescript": "^7.27.0", "@babel/register": "^7.25.9", "@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#6445cd056671c98d12d1c597ba705e11327ec4c5", - "@codemirror/commands": "^6.8.0", + "@codemirror/commands": "^6.8.1", "@codemirror/lang-markdown": "^6.3.2", - "@codemirror/language": "^6.10.8", - "@codemirror/lint": "^6.8.4", + "@codemirror/language": "^6.11.0", + "@codemirror/lint": "^6.8.5", "@codemirror/search": "github:overleaf/codemirror-search#04380a528c339cd4b78fb10b3ef017f657ec17bd", "@codemirror/state": "^6.5.2", - "@codemirror/view": "^6.36.3", + "@codemirror/view": "^6.36.8", "@juggle/resize-observer": "^3.3.1", "@lezer/common": "^1.2.3", - "@lezer/generator": "^1.7.1", + "@lezer/generator": "^1.7.3", "@lezer/highlight": "^1.2.1", "@lezer/lr": "^1.4.2", - "@lezer/markdown": "^1.3.2", + "@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/ranges-tracker": "*", @@ -45645,7 +44861,7 @@ "babel-plugin-module-resolver": "^5.0.2", "backbone": "^1.6.0", "bootstrap": "^3.4.1", - "bootstrap-5": "npm:bootstrap@^5.3.3", + "bootstrap-5": "npm:bootstrap@^5.3.6", "c8": "^7.2.0", "chai": "^4.3.6", "chai-as-promised": "^7.1.1", @@ -45710,7 +44926,7 @@ "prop-types": "^15.7.2", "qrcode": "^1.4.4", "react": "^18.3.1", - "react-bootstrap-5": "npm:react-bootstrap@^2.10.5", + "react-bootstrap": "^2.10.10", "react-chartjs-2": "^5.0.1", "react-color": "^2.19.3", "react-dnd": "^16.0.1", @@ -46152,6 +45368,12 @@ "ajv": "^8.8.2" } }, + "services/web/node_modules/base-x": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", + "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==", + "license": "MIT" + }, "services/web/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -46437,9 +45659,9 @@ } }, "services/web/node_modules/multer": { - "version": "1.4.5-lts.1", - "resolved": "git+ssh://git@github.com/overleaf/multer.git#e1df247fbf8e7590520d20ae3601eaef9f3d2e9e", - "integrity": "sha512-3fJSnWF3iBZJ6Z9y8AjFVY+O4DUKspxSnzXidb3zCKqBYyEKRrpGp7OXjT9th2gWPd+9u64ZyRWUf+YRYn1GCw==", + "version": "2.0.0", + "resolved": "git+ssh://git@github.com/overleaf/multer.git#199c5ff05bd375c508f4074498237baead7f5148", + "integrity": "sha512-S5MlIoOgrDr+a2jLS8z7jQlbzvZ0m30U2tRwdyLrxhnnMUQZYEzkVysEv10Dw41RTpM5bQQDs563Vzl1LLhxhQ==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -46451,7 +45673,7 @@ "xtend": "^4.0.0" }, "engines": { - "node": ">= 6.0.0" + "node": ">= 10.16.0" } }, "services/web/node_modules/nise": { @@ -46624,6 +45846,26 @@ "resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-3.2.1.tgz", "integrity": "sha512-ApK+WTJ5bCOf0A2tlec1qhvr8bGEBM/sgXXB7mysdCYgZJO5DZeaV3h3G+g0HnAQ372P5IhiGqnW29zoLOfTzQ==" }, + "services/web/node_modules/stripe": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-18.1.0.tgz", + "integrity": "sha512-MLDiniPTHqcfIT3anyBPmOEcaiDhYa7/jRaNypQ3Rt2SJnayQZBvVbFghIziUCZdltGAndm/ZxVOSw6uuSCDig==", + "license": "MIT", + "dependencies": { + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + }, + "peerDependencies": { + "@types/node": ">=12.x.x" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "services/web/node_modules/teeny-request": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-8.0.2.tgz", diff --git a/package.json b/package.json index 7dc9a63e29..64fbd258ed 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ }, "swagger-tools": { "body-parser": "1.20.3", - "multer": "1.4.5-lts.1", + "multer": "2.0.0", "path-to-regexp": "3.3.0", "qs": "6.13.0" } diff --git a/server-ce/Dockerfile-base b/server-ce/Dockerfile-base index ca3c45cc1d..e130d7a414 100644 --- a/server-ce/Dockerfile-base +++ b/server-ce/Dockerfile-base @@ -2,7 +2,7 @@ # Overleaf Base Image (sharelatex/sharelatex-base) # -------------------------------------------------- -FROM phusion/baseimage:noble-1.0.0 +FROM phusion/baseimage:noble-1.0.2 # Makes sure LuaTex cache is writable # ----------------------------------- @@ -10,7 +10,7 @@ ENV TEXMFVAR=/var/lib/overleaf/tmp/texmf-var # Update to ensure dependencies are updated # ------------------------------------------ -ENV REBUILT_AFTER="2025-03-27" +ENV REBUILT_AFTER="2025-05-19" # Install dependencies # -------------------- @@ -30,7 +30,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ # install Node.js https://github.com/nodesource/distributions#nodejs && mkdir -p /etc/apt/keyrings \ && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ -&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \ +&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \ && apt-get update \ && apt-get install -y nodejs \ \ diff --git a/server-ce/config/crontab-history b/server-ce/config/crontab-history index cfa12f9fc8..ac6dcc1c7b 100644 --- a/server-ce/config/crontab-history +++ b/server-ce/config/crontab-history @@ -1,3 +1,4 @@ */20 * * * * root /overleaf/cron/project-history-periodic-flush.sh >> /var/log/overleaf/cron-project-history-periodic-flush.log 2>&1 30 * * * * root /overleaf/cron/project-history-retry-soft.sh >> /var/log/overleaf/project-history-retry-soft.log 2>&1 45 * * * * root /overleaf/cron/project-history-retry-hard.sh >> /var/log/overleaf/project-history-retry-hard.log 2>&1 +0 3 * * * root /overleaf/cron/project-history-flush-all.sh >> /var/log/overleaf/project-history-flush-all.log 2>&1 diff --git a/server-ce/config/settings.js b/server-ce/config/settings.js index 0cf2c5a7ec..164d8b0196 100644 --- a/server-ce/config/settings.js +++ b/server-ce/config/settings.js @@ -79,6 +79,7 @@ const settings = { host: process.env.OVERLEAF_REDIS_HOST || 'dockerhost', port: process.env.OVERLEAF_REDIS_PORT || '6379', password: process.env.OVERLEAF_REDIS_PASS || undefined, + tls: process.env.OVERLEAF_REDIS_TLS === 'true' ? {} : undefined, key_schema: { // document-updater blockingKey({ doc_id }) { diff --git a/server-ce/cron/project-history-flush-all.sh b/server-ce/cron/project-history-flush-all.sh new file mode 100755 index 0000000000..d8bbb184aa --- /dev/null +++ b/server-ce/cron/project-history-flush-all.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +set -eux + +echo "---------------------------------" +echo "Flush all project-history changes" +echo "---------------------------------" +date + +source /etc/container_environment.sh +source /etc/overleaf/env.sh +cd /overleaf/services/project-history && node scripts/flush_all.js + +echo "Done flushing all project-history changes" diff --git a/server-ce/hotfix/5.4.1/Dockerfile b/server-ce/hotfix/5.4.1/Dockerfile new file mode 100644 index 0000000000..46bde4013c --- /dev/null +++ b/server-ce/hotfix/5.4.1/Dockerfile @@ -0,0 +1,14 @@ +FROM sharelatex/sharelatex:5.4.0 + +RUN apt update && apt install -y linux-libc-dev \ + && unattended-upgrade --verbose --no-minimal-upgrade-steps \ + && rm -rf /var/lib/apt/lists/* +COPY package-lock.json.diff . +RUN patch package-lock.json < package-lock.json.diff +RUN npm install --omit=dev + + +# fix tls configuration in redis +COPY issue_24996.patch . +RUN patch -p0 /etc/overleaf/settings.js < issue_24996.patch \ + && rm issue_24996.patch diff --git a/server-ce/hotfix/5.4.1/issue_24996.patch b/server-ce/hotfix/5.4.1/issue_24996.patch new file mode 100644 index 0000000000..3067aab968 --- /dev/null +++ b/server-ce/hotfix/5.4.1/issue_24996.patch @@ -0,0 +1,10 @@ +--- settings.js ++++ settings.js +@@ -79,6 +79,7 @@ const settings = { + host: process.env.OVERLEAF_REDIS_HOST || 'dockerhost', + port: process.env.OVERLEAF_REDIS_PORT || '6379', + password: process.env.OVERLEAF_REDIS_PASS || undefined, ++ tls: process.env.OVERLEAF_REDIS_TLS === 'true' ? {} : undefined, + key_schema: { + // document-updater + blockingKey({ doc_id }) { diff --git a/server-ce/hotfix/5.4.1/package-lock.json.diff b/server-ce/hotfix/5.4.1/package-lock.json.diff new file mode 100644 index 0000000000..f4f7ae4788 --- /dev/null +++ b/server-ce/hotfix/5.4.1/package-lock.json.diff @@ -0,0 +1,2024 @@ +151c151 +< "@google-cloud/profiler": "^6.0.0", +--- +> "@google-cloud/profiler": "^6.0.3", +173a174,507 +> "libraries/metrics/node_modules/@google-cloud/logging-min": { +> "version": "11.2.0", +> "resolved": "https://registry.npmjs.org/@google-cloud/logging-min/-/logging-min-11.2.0.tgz", +> "integrity": "sha512-o1mwzi1+9NMEjwYZJ0X3tK64obf9PzPVBAhzEJv65L0h7jVl3Fw7GswtsMUkdUvZexf96vAvlZZMvXB9jAIW2Q==", +> "license": "Apache-2.0", +> "dependencies": { +> "@google-cloud/common": "^5.0.0", +> "@google-cloud/paginator": "^5.0.0", +> "@google-cloud/projectify": "^4.0.0", +> "@google-cloud/promisify": "^4.0.0", +> "@opentelemetry/api": "^1.7.0", +> "arrify": "^2.0.1", +> "dot-prop": "^6.0.0", +> "eventid": "^2.0.0", +> "extend": "^3.0.2", +> "gcp-metadata": "^6.0.0", +> "google-auth-library": "^9.0.0", +> "google-gax": "^4.0.3", +> "on-finished": "^2.3.0", +> "pumpify": "^2.0.1", +> "stream-events": "^1.0.5", +> "uuid": "^9.0.0" +> }, +> "engines": { +> "node": ">=14.0.0" +> } +> }, +> "libraries/metrics/node_modules/@google-cloud/paginator": { +> "version": "5.0.2", +> "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", +> "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", +> "license": "Apache-2.0", +> "dependencies": { +> "arrify": "^2.0.0", +> "extend": "^3.0.2" +> }, +> "engines": { +> "node": ">=14.0.0" +> } +> }, +> "libraries/metrics/node_modules/@google-cloud/profiler": { +> "version": "6.0.3", +> "resolved": "https://registry.npmjs.org/@google-cloud/profiler/-/profiler-6.0.3.tgz", +> "integrity": "sha512-Ey8li6Vc2CbfEzOTSZaqKolxPMGacxVUQuhChNT0Wi55a3nfImMiiuDgqYw1In/a9Q3Z62O7jUg2L8f1XwMN7Q==", +> "license": "Apache-2.0", +> "dependencies": { +> "@google-cloud/common": "^5.0.0", +> "@google-cloud/logging-min": "^11.0.0", +> "@google-cloud/promisify": "~4.0.0", +> "@types/console-log-level": "^1.4.0", +> "@types/semver": "^7.0.0", +> "console-log-level": "^1.4.0", +> "delay": "^5.0.0", +> "extend": "^3.0.2", +> "gcp-metadata": "^6.0.0", +> "ms": "^2.1.3", +> "pprof": "4.0.0", +> "pretty-ms": "^7.0.0", +> "protobufjs": "~7.4.0", +> "semver": "^7.0.0", +> "teeny-request": "^9.0.0" +> }, +> "engines": { +> "node": ">=14.0.0" +> } +> }, +> "libraries/metrics/node_modules/@google-cloud/profiler/node_modules/protobufjs": { +> "version": "7.4.0", +> "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", +> "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", +> "hasInstallScript": true, +> "license": "BSD-3-Clause", +> "dependencies": { +> "@protobufjs/aspromise": "^1.1.2", +> "@protobufjs/base64": "^1.1.2", +> "@protobufjs/codegen": "^2.0.4", +> "@protobufjs/eventemitter": "^1.1.0", +> "@protobufjs/fetch": "^1.1.0", +> "@protobufjs/float": "^1.0.2", +> "@protobufjs/inquire": "^1.1.0", +> "@protobufjs/path": "^1.1.2", +> "@protobufjs/pool": "^1.1.0", +> "@protobufjs/utf8": "^1.1.0", +> "@types/node": ">=13.7.0", +> "long": "^5.0.0" +> }, +> "engines": { +> "node": ">=12.0.0" +> } +> }, +> "libraries/metrics/node_modules/@mapbox/node-pre-gyp": { +> "version": "1.0.11", +> "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", +> "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", +> "license": "BSD-3-Clause", +> "dependencies": { +> "detect-libc": "^2.0.0", +> "https-proxy-agent": "^5.0.0", +> "make-dir": "^3.1.0", +> "node-fetch": "^2.6.7", +> "nopt": "^5.0.0", +> "npmlog": "^5.0.1", +> "rimraf": "^3.0.2", +> "semver": "^7.3.5", +> "tar": "^6.1.11" +> }, +> "bin": { +> "node-pre-gyp": "bin/node-pre-gyp" +> } +> }, +> "libraries/metrics/node_modules/@mapbox/node-pre-gyp/node_modules/agent-base": { +> "version": "6.0.2", +> "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", +> "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", +> "license": "MIT", +> "dependencies": { +> "debug": "4" +> }, +> "engines": { +> "node": ">= 6.0.0" +> } +> }, +> "libraries/metrics/node_modules/@mapbox/node-pre-gyp/node_modules/https-proxy-agent": { +> "version": "5.0.1", +> "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", +> "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", +> "license": "MIT", +> "dependencies": { +> "agent-base": "6", +> "debug": "4" +> }, +> "engines": { +> "node": ">= 6" +> } +> }, +> "libraries/metrics/node_modules/@opentelemetry/api": { +> "version": "1.9.0", +> "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", +> "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", +> "license": "Apache-2.0", +> "engines": { +> "node": ">=8.0.0" +> } +> }, +> "libraries/metrics/node_modules/agent-base": { +> "version": "7.1.3", +> "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", +> "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", +> "license": "MIT", +> "engines": { +> "node": ">= 14" +> } +> }, +> "libraries/metrics/node_modules/detect-libc": { +> "version": "2.0.4", +> "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", +> "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", +> "license": "Apache-2.0", +> "engines": { +> "node": ">=8" +> } +> }, +> "libraries/metrics/node_modules/gaxios": { +> "version": "6.7.1", +> "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", +> "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", +> "license": "Apache-2.0", +> "dependencies": { +> "extend": "^3.0.2", +> "https-proxy-agent": "^7.0.1", +> "is-stream": "^2.0.0", +> "node-fetch": "^2.6.9", +> "uuid": "^9.0.1" +> }, +> "engines": { +> "node": ">=14" +> } +> }, +> "libraries/metrics/node_modules/gcp-metadata": { +> "version": "6.1.1", +> "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", +> "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", +> "license": "Apache-2.0", +> "dependencies": { +> "gaxios": "^6.1.1", +> "google-logging-utils": "^0.0.2", +> "json-bigint": "^1.0.0" +> }, +> "engines": { +> "node": ">=14" +> } +> }, +> "libraries/metrics/node_modules/google-auth-library": { +> "version": "9.15.1", +> "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", +> "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", +> "license": "Apache-2.0", +> "dependencies": { +> "base64-js": "^1.3.0", +> "ecdsa-sig-formatter": "^1.0.11", +> "gaxios": "^6.1.1", +> "gcp-metadata": "^6.1.0", +> "gtoken": "^7.0.0", +> "jws": "^4.0.0" +> }, +> "engines": { +> "node": ">=14" +> } +> }, +> "libraries/metrics/node_modules/gtoken": { +> "version": "7.1.0", +> "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", +> "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", +> "license": "MIT", +> "dependencies": { +> "gaxios": "^6.0.0", +> "jws": "^4.0.0" +> }, +> "engines": { +> "node": ">=14.0.0" +> } +> }, +> "libraries/metrics/node_modules/https-proxy-agent": { +> "version": "7.0.6", +> "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", +> "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", +> "license": "MIT", +> "dependencies": { +> "agent-base": "^7.1.2", +> "debug": "4" +> }, +> "engines": { +> "node": ">= 14" +> } +> }, +> "libraries/metrics/node_modules/make-dir": { +> "version": "3.1.0", +> "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", +> "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", +> "license": "MIT", +> "dependencies": { +> "semver": "^6.0.0" +> }, +> "engines": { +> "node": ">=8" +> }, +> "funding": { +> "url": "https://github.com/sponsors/sindresorhus" +> } +> }, +> "libraries/metrics/node_modules/make-dir/node_modules/semver": { +> "version": "6.3.1", +> "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", +> "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", +> "license": "ISC", +> "bin": { +> "semver": "bin/semver.js" +> } +> }, +> "libraries/metrics/node_modules/pprof": { +> "version": "4.0.0", +> "resolved": "https://registry.npmjs.org/pprof/-/pprof-4.0.0.tgz", +> "integrity": "sha512-Yhfk7Y0G1MYsy97oXxmSG5nvbM1sCz9EALiNhW/isAv5Xf7svzP+1RfGeBlS6mLSgRJvgSLh6Mi5DaisQuPttw==", +> "hasInstallScript": true, +> "license": "Apache-2.0", +> "dependencies": { +> "@mapbox/node-pre-gyp": "^1.0.9", +> "bindings": "^1.2.1", +> "delay": "^5.0.0", +> "findit2": "^2.2.3", +> "nan": "^2.17.0", +> "p-limit": "^3.0.0", +> "protobufjs": "~7.2.4", +> "source-map": "~0.8.0-beta.0", +> "split": "^1.0.1" +> }, +> "engines": { +> "node": ">=14.0.0" +> } +> }, +> "libraries/metrics/node_modules/semver": { +> "version": "7.7.1", +> "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", +> "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", +> "license": "ISC", +> "bin": { +> "semver": "bin/semver.js" +> }, +> "engines": { +> "node": ">=10" +> } +> }, +> "libraries/metrics/node_modules/source-map": { +> "version": "0.8.0-beta.0", +> "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", +> "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", +> "license": "BSD-3-Clause", +> "dependencies": { +> "whatwg-url": "^7.0.0" +> }, +> "engines": { +> "node": ">= 8" +> } +> }, +> "libraries/metrics/node_modules/uuid": { +> "version": "9.0.1", +> "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", +> "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", +> "funding": [ +> "https://github.com/sponsors/broofa", +> "https://github.com/sponsors/ctavan" +> ], +> "license": "MIT", +> "bin": { +> "uuid": "dist/bin/uuid" +> } +> }, +> "libraries/metrics/node_modules/webidl-conversions": { +> "version": "4.0.2", +> "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", +> "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", +> "license": "BSD-2-Clause" +> }, +> "libraries/metrics/node_modules/whatwg-url": { +> "version": "7.1.0", +> "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", +> "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", +> "license": "MIT", +> "dependencies": { +> "lodash.sortby": "^4.7.0", +> "tr46": "^1.0.1", +> "webidl-conversions": "^4.0.2" +> } +> }, +3205a3540,3545 +> "node_modules/@balena/dockerignore": { +> "version": "1.0.2", +> "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", +> "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", +> "license": "Apache-2.0" +> }, +4095a4436,4550 +> "node_modules/@google-cloud/common": { +> "version": "5.0.2", +> "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-5.0.2.tgz", +> "integrity": "sha512-V7bmBKYQyu0eVG2BFejuUjlBt+zrya6vtsKdY+JxMM/dNntPF41vZ9+LhOshEUH01zOHEqBSvI7Dad7ZS6aUeA==", +> "license": "Apache-2.0", +> "dependencies": { +> "@google-cloud/projectify": "^4.0.0", +> "@google-cloud/promisify": "^4.0.0", +> "arrify": "^2.0.1", +> "duplexify": "^4.1.1", +> "extend": "^3.0.2", +> "google-auth-library": "^9.0.0", +> "html-entities": "^2.5.2", +> "retry-request": "^7.0.0", +> "teeny-request": "^9.0.0" +> }, +> "engines": { +> "node": ">=14.0.0" +> } +> }, +> "node_modules/@google-cloud/common/node_modules/agent-base": { +> "version": "7.1.3", +> "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", +> "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", +> "license": "MIT", +> "engines": { +> "node": ">= 14" +> } +> }, +> "node_modules/@google-cloud/common/node_modules/gaxios": { +> "version": "6.7.1", +> "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", +> "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", +> "license": "Apache-2.0", +> "dependencies": { +> "extend": "^3.0.2", +> "https-proxy-agent": "^7.0.1", +> "is-stream": "^2.0.0", +> "node-fetch": "^2.6.9", +> "uuid": "^9.0.1" +> }, +> "engines": { +> "node": ">=14" +> } +> }, +> "node_modules/@google-cloud/common/node_modules/gcp-metadata": { +> "version": "6.1.1", +> "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", +> "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", +> "license": "Apache-2.0", +> "dependencies": { +> "gaxios": "^6.1.1", +> "google-logging-utils": "^0.0.2", +> "json-bigint": "^1.0.0" +> }, +> "engines": { +> "node": ">=14" +> } +> }, +> "node_modules/@google-cloud/common/node_modules/google-auth-library": { +> "version": "9.15.1", +> "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", +> "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", +> "license": "Apache-2.0", +> "dependencies": { +> "base64-js": "^1.3.0", +> "ecdsa-sig-formatter": "^1.0.11", +> "gaxios": "^6.1.1", +> "gcp-metadata": "^6.1.0", +> "gtoken": "^7.0.0", +> "jws": "^4.0.0" +> }, +> "engines": { +> "node": ">=14" +> } +> }, +> "node_modules/@google-cloud/common/node_modules/gtoken": { +> "version": "7.1.0", +> "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", +> "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", +> "license": "MIT", +> "dependencies": { +> "gaxios": "^6.0.0", +> "jws": "^4.0.0" +> }, +> "engines": { +> "node": ">=14.0.0" +> } +> }, +> "node_modules/@google-cloud/common/node_modules/https-proxy-agent": { +> "version": "7.0.6", +> "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", +> "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", +> "license": "MIT", +> "dependencies": { +> "agent-base": "^7.1.2", +> "debug": "4" +> }, +> "engines": { +> "node": ">= 14" +> } +> }, +> "node_modules/@google-cloud/common/node_modules/uuid": { +> "version": "9.0.1", +> "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", +> "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", +> "funding": [ +> "https://github.com/sponsors/broofa", +> "https://github.com/sponsors/ctavan" +> ], +> "license": "MIT", +> "bin": { +> "uuid": "dist/bin/uuid" +> } +> }, +4251,4529d4705 +< "node_modules/@google-cloud/logging-min": { +< "version": "10.4.0", +< "resolved": "https://registry.npmjs.org/@google-cloud/logging-min/-/logging-min-10.4.0.tgz", +< "integrity": "sha512-TcblDYAATO9hHcDcWYFh+vqt3pAV7Qddaih1JK3cpkzLa+BWjD5gAVAWww8W9Wr5yxOX+8CkssanH/xSS4n76Q==", +< "dependencies": { +< "@google-cloud/common": "^4.0.0", +< "@google-cloud/paginator": "^4.0.0", +< "@google-cloud/projectify": "^3.0.0", +< "@google-cloud/promisify": "^3.0.0", +< "arrify": "^2.0.1", +< "dot-prop": "^6.0.0", +< "eventid": "^2.0.0", +< "extend": "^3.0.2", +< "gcp-metadata": "^4.0.0", +< "google-auth-library": "^8.0.2", +< "google-gax": "^3.5.2", +< "on-finished": "^2.3.0", +< "pumpify": "^2.0.1", +< "stream-events": "^1.0.5", +< "uuid": "^9.0.0" +< }, +< "engines": { +< "node": ">=12.0.0" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/@google-cloud/common": { +< "version": "4.0.3", +< "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-4.0.3.tgz", +< "integrity": "sha512-fUoMo5b8iAKbrYpneIRV3z95AlxVJPrjpevxs4SKoclngWZvTXBSGpNisF5+x5m+oNGve7jfB1e6vNBZBUs7Fw==", +< "dependencies": { +< "@google-cloud/projectify": "^3.0.0", +< "@google-cloud/promisify": "^3.0.0", +< "arrify": "^2.0.1", +< "duplexify": "^4.1.1", +< "ent": "^2.2.0", +< "extend": "^3.0.2", +< "google-auth-library": "^8.0.2", +< "retry-request": "^5.0.0", +< "teeny-request": "^8.0.0" +< }, +< "engines": { +< "node": ">=12.0.0" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/@google-cloud/paginator": { +< "version": "4.0.1", +< "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-4.0.1.tgz", +< "integrity": "sha512-6G1ui6bWhNyHjmbYwavdN7mpVPRBtyDg/bfqBTAlwr413On2TnFNfDxc9UhTJctkgoCDgQXEKiRPLPR9USlkbQ==", +< "dependencies": { +< "arrify": "^2.0.0", +< "extend": "^3.0.2" +< }, +< "engines": { +< "node": ">=12.0.0" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/@google-cloud/projectify": { +< "version": "3.0.0", +< "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-3.0.0.tgz", +< "integrity": "sha512-HRkZsNmjScY6Li8/kb70wjGlDDyLkVk3KvoEo9uIoxSjYLJasGiCch9+PqRVDOCGUFvEIqyogl+BeqILL4OJHA==", +< "engines": { +< "node": ">=12.0.0" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/@google-cloud/promisify": { +< "version": "3.0.1", +< "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-3.0.1.tgz", +< "integrity": "sha512-z1CjRjtQyBOYL+5Qr9DdYIfrdLBe746jRTYfaYU6MeXkqp7UfYs/jX16lFFVzZ7PGEJvqZNqYUEtb1mvDww4pA==", +< "engines": { +< "node": ">=12" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/duplexify": { +< "version": "4.1.2", +< "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", +< "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", +< "dependencies": { +< "end-of-stream": "^1.4.1", +< "inherits": "^2.0.3", +< "readable-stream": "^3.1.1", +< "stream-shift": "^1.0.0" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/gcp-metadata": { +< "version": "4.3.1", +< "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-4.3.1.tgz", +< "integrity": "sha512-x850LS5N7V1F3UcV7PoupzGsyD6iVwTVvsh3tbXfkctZnBnjW5yu5z1/3k3SehF7TyoTIe78rJs02GMMy+LF+A==", +< "dependencies": { +< "gaxios": "^4.0.0", +< "json-bigint": "^1.0.0" +< }, +< "engines": { +< "node": ">=10" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/gcp-metadata/node_modules/gaxios": { +< "version": "4.3.3", +< "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-4.3.3.tgz", +< "integrity": "sha512-gSaYYIO1Y3wUtdfHmjDUZ8LWaxJQpiavzbF5Kq53akSzvmVg0RfyOcFDbO1KJ/KCGRFz2qG+lS81F0nkr7cRJA==", +< "dependencies": { +< "abort-controller": "^3.0.0", +< "extend": "^3.0.2", +< "https-proxy-agent": "^5.0.0", +< "is-stream": "^2.0.0", +< "node-fetch": "^2.6.7" +< }, +< "engines": { +< "node": ">=10" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/google-auth-library": { +< "version": "8.9.0", +< "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-8.9.0.tgz", +< "integrity": "sha512-f7aQCJODJFmYWN6PeNKzgvy9LI2tYmXnzpNDHEjG5sDNPgGb2FXQyTBnXeSH+PAtpKESFD+LmHw3Ox3mN7e1Fg==", +< "dependencies": { +< "arrify": "^2.0.0", +< "base64-js": "^1.3.0", +< "ecdsa-sig-formatter": "^1.0.11", +< "fast-text-encoding": "^1.0.0", +< "gaxios": "^5.0.0", +< "gcp-metadata": "^5.3.0", +< "gtoken": "^6.1.0", +< "jws": "^4.0.0", +< "lru-cache": "^6.0.0" +< }, +< "engines": { +< "node": ">=12" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/google-auth-library/node_modules/gcp-metadata": { +< "version": "5.3.0", +< "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", +< "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", +< "dependencies": { +< "gaxios": "^5.0.0", +< "json-bigint": "^1.0.0" +< }, +< "engines": { +< "node": ">=12" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/google-gax": { +< "version": "3.6.1", +< "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-3.6.1.tgz", +< "integrity": "sha512-g/lcUjGcB6DSw2HxgEmCDOrI/CByOwqRvsuUvNalHUK2iPPPlmAIpbMbl62u0YufGMr8zgE3JL7th6dCb1Ry+w==", +< "dependencies": { +< "@grpc/grpc-js": "~1.8.0", +< "@grpc/proto-loader": "^0.7.0", +< "@types/long": "^4.0.0", +< "@types/rimraf": "^3.0.2", +< "abort-controller": "^3.0.0", +< "duplexify": "^4.0.0", +< "fast-text-encoding": "^1.0.3", +< "google-auth-library": "^8.0.2", +< "is-stream-ended": "^0.1.4", +< "node-fetch": "^2.6.1", +< "object-hash": "^3.0.0", +< "proto3-json-serializer": "^1.0.0", +< "protobufjs": "7.2.4", +< "protobufjs-cli": "1.1.1", +< "retry-request": "^5.0.0" +< }, +< "bin": { +< "compileProtos": "build/tools/compileProtos.js", +< "minifyProtoJson": "build/tools/minify.js" +< }, +< "engines": { +< "node": ">=12" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/google-p12-pem": { +< "version": "4.0.1", +< "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-4.0.1.tgz", +< "integrity": "sha512-WPkN4yGtz05WZ5EhtlxNDWPhC4JIic6G8ePitwUWy4l+XPVYec+a0j0Ts47PDtW59y3RwAhUd9/h9ZZ63px6RQ==", +< "dependencies": { +< "node-forge": "^1.3.1" +< }, +< "bin": { +< "gp12-pem": "build/src/bin/gp12-pem.js" +< }, +< "engines": { +< "node": ">=12.0.0" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/gtoken": { +< "version": "6.1.2", +< "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-6.1.2.tgz", +< "integrity": "sha512-4ccGpzz7YAr7lxrT2neugmXQ3hP9ho2gcaityLVkiUecAiwiy60Ii8gRbZeOsXV19fYaRjgBSshs8kXw+NKCPQ==", +< "dependencies": { +< "gaxios": "^5.0.1", +< "google-p12-pem": "^4.0.0", +< "jws": "^4.0.0" +< }, +< "engines": { +< "node": ">=12.0.0" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/lru-cache": { +< "version": "6.0.0", +< "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", +< "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", +< "dependencies": { +< "yallist": "^4.0.0" +< }, +< "engines": { +< "node": ">=10" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/object-hash": { +< "version": "3.0.0", +< "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", +< "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", +< "engines": { +< "node": ">= 6" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/retry-request": { +< "version": "5.0.2", +< "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-5.0.2.tgz", +< "integrity": "sha512-wfI3pk7EE80lCIXprqh7ym48IHYdwmAAzESdbU8Q9l7pnRCk9LEhpbOTNKjz6FARLm/Bl5m+4F0ABxOkYUujSQ==", +< "dependencies": { +< "debug": "^4.1.1", +< "extend": "^3.0.2" +< }, +< "engines": { +< "node": ">=12" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/teeny-request": { +< "version": "8.0.3", +< "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-8.0.3.tgz", +< "integrity": "sha512-jJZpA5He2y52yUhA7pyAGZlgQpcB+xLjcN0eUFxr9c8hP/H7uOXbBNVo/O0C/xVfJLJs680jvkFgVJEEvk9+ww==", +< "dependencies": { +< "http-proxy-agent": "^5.0.0", +< "https-proxy-agent": "^5.0.0", +< "node-fetch": "^2.6.1", +< "stream-events": "^1.0.5", +< "uuid": "^9.0.0" +< }, +< "engines": { +< "node": ">=12" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/uuid": { +< "version": "9.0.1", +< "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", +< "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", +< "funding": [ +< "https://github.com/sponsors/broofa", +< "https://github.com/sponsors/ctavan" +< ], +< "bin": { +< "uuid": "dist/bin/uuid" +< } +< }, +< "node_modules/@google-cloud/logging-min/node_modules/yallist": { +< "version": "4.0.0", +< "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", +< "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" +< }, +< "node_modules/@google-cloud/logging/node_modules/@google-cloud/common": { +< "version": "5.0.2", +< "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-5.0.2.tgz", +< "integrity": "sha512-V7bmBKYQyu0eVG2BFejuUjlBt+zrya6vtsKdY+JxMM/dNntPF41vZ9+LhOshEUH01zOHEqBSvI7Dad7ZS6aUeA==", +< "dependencies": { +< "@google-cloud/projectify": "^4.0.0", +< "@google-cloud/promisify": "^4.0.0", +< "arrify": "^2.0.1", +< "duplexify": "^4.1.1", +< "extend": "^3.0.2", +< "google-auth-library": "^9.0.0", +< "html-entities": "^2.5.2", +< "retry-request": "^7.0.0", +< "teeny-request": "^9.0.0" +< }, +< "engines": { +< "node": ">=14.0.0" +< } +< }, +4542,4557d4717 +< "node_modules/@google-cloud/logging/node_modules/@google-cloud/projectify": { +< "version": "4.0.0", +< "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", +< "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", +< "engines": { +< "node": ">=14.0.0" +< } +< }, +< "node_modules/@google-cloud/logging/node_modules/@google-cloud/promisify": { +< "version": "4.0.0", +< "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", +< "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", +< "engines": { +< "node": ">=14" +< } +< }, +4585,4595d4744 +< "node_modules/@google-cloud/logging/node_modules/duplexify": { +< "version": "4.1.3", +< "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", +< "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", +< "dependencies": { +< "end-of-stream": "^1.4.1", +< "inherits": "^2.0.3", +< "readable-stream": "^3.1.1", +< "stream-shift": "^1.0.2" +< } +< }, +4668,4695d4816 +< "node_modules/@google-cloud/logging/node_modules/retry-request": { +< "version": "7.0.2", +< "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", +< "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", +< "dependencies": { +< "@types/request": "^2.48.8", +< "extend": "^3.0.2", +< "teeny-request": "^9.0.0" +< }, +< "engines": { +< "node": ">=14" +< } +< }, +< "node_modules/@google-cloud/logging/node_modules/teeny-request": { +< "version": "9.0.0", +< "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", +< "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", +< "dependencies": { +< "http-proxy-agent": "^5.0.0", +< "https-proxy-agent": "^5.0.0", +< "node-fetch": "^2.6.9", +< "stream-events": "^1.0.5", +< "uuid": "^9.0.0" +< }, +< "engines": { +< "node": ">=14" +< } +< }, +4756,4799c4877 +< "node_modules/@google-cloud/profiler": { +< "version": "6.0.0", +< "resolved": "https://registry.npmjs.org/@google-cloud/profiler/-/profiler-6.0.0.tgz", +< "integrity": "sha512-EAxPbDiNRidAKOEnlUK3M+CcOlqG+REkUEZKirLtxFwzI/m7LmGqDzQvrVWTOSFSEYJ9qQRRnO+Q1osNGk3NUg==", +< "dependencies": { +< "@google-cloud/common": "^5.0.0", +< "@google-cloud/logging-min": "^10.0.0", +< "@types/console-log-level": "^1.4.0", +< "@types/semver": "^7.0.0", +< "console-log-level": "^1.4.0", +< "delay": "^5.0.0", +< "extend": "^3.0.2", +< "gcp-metadata": "^6.0.0", +< "parse-duration": "^1.0.0", +< "pprof": "3.2.1", +< "pretty-ms": "^7.0.0", +< "protobufjs": "~7.2.4", +< "semver": "^7.0.0", +< "teeny-request": "^9.0.0" +< }, +< "engines": { +< "node": ">=14.0.0" +< } +< }, +< "node_modules/@google-cloud/profiler/node_modules/@google-cloud/common": { +< "version": "5.0.0", +< "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-5.0.0.tgz", +< "integrity": "sha512-IsbTVr7Ag+04GMT87X738vDs85QU1rMvaesm2wEQrtTbZAR92tGmUQ8/D/kdnYgAi98Q4zmfhF+T8Xs/Lw4zAA==", +< "dependencies": { +< "@google-cloud/projectify": "^4.0.0", +< "@google-cloud/promisify": "^4.0.0", +< "arrify": "^2.0.1", +< "duplexify": "^4.1.1", +< "ent": "^2.2.0", +< "extend": "^3.0.2", +< "google-auth-library": "^9.0.0", +< "retry-request": "^6.0.0", +< "teeny-request": "^9.0.0" +< }, +< "engines": { +< "node": ">=14.0.0" +< } +< }, +< "node_modules/@google-cloud/profiler/node_modules/@google-cloud/projectify": { +--- +> "node_modules/@google-cloud/projectify": { +4802a4881 +> "license": "Apache-2.0", +4807c4886 +< "node_modules/@google-cloud/profiler/node_modules/@google-cloud/promisify": { +--- +> "node_modules/@google-cloud/promisify": { +4810a4890 +> "license": "Apache-2.0", +4815,4993d4894 +< "node_modules/@google-cloud/profiler/node_modules/agent-base": { +< "version": "7.1.0", +< "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", +< "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", +< "dependencies": { +< "debug": "^4.3.4" +< }, +< "engines": { +< "node": ">= 14" +< } +< }, +< "node_modules/@google-cloud/profiler/node_modules/debug": { +< "version": "4.3.4", +< "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", +< "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", +< "dependencies": { +< "ms": "2.1.2" +< }, +< "engines": { +< "node": ">=6.0" +< }, +< "peerDependenciesMeta": { +< "supports-color": { +< "optional": true +< } +< } +< }, +< "node_modules/@google-cloud/profiler/node_modules/duplexify": { +< "version": "4.1.2", +< "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", +< "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", +< "dependencies": { +< "end-of-stream": "^1.4.1", +< "inherits": "^2.0.3", +< "readable-stream": "^3.1.1", +< "stream-shift": "^1.0.0" +< } +< }, +< "node_modules/@google-cloud/profiler/node_modules/gaxios": { +< "version": "6.1.1", +< "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.1.1.tgz", +< "integrity": "sha512-bw8smrX+XlAoo9o1JAksBwX+hi/RG15J+NTSxmNPIclKC3ZVK6C2afwY8OSdRvOK0+ZLecUJYtj2MmjOt3Dm0w==", +< "dependencies": { +< "extend": "^3.0.2", +< "https-proxy-agent": "^7.0.1", +< "is-stream": "^2.0.0", +< "node-fetch": "^2.6.9" +< }, +< "engines": { +< "node": ">=14" +< } +< }, +< "node_modules/@google-cloud/profiler/node_modules/gaxios/node_modules/https-proxy-agent": { +< "version": "7.0.2", +< "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", +< "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", +< "dependencies": { +< "agent-base": "^7.0.2", +< "debug": "4" +< }, +< "engines": { +< "node": ">= 14" +< } +< }, +< "node_modules/@google-cloud/profiler/node_modules/gcp-metadata": { +< "version": "6.0.0", +< "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.0.0.tgz", +< "integrity": "sha512-Ozxyi23/1Ar51wjUT2RDklK+3HxqDr8TLBNK8rBBFQ7T85iIGnXnVusauj06QyqCXRFZig8LZC+TUddWbndlpQ==", +< "dependencies": { +< "gaxios": "^6.0.0", +< "json-bigint": "^1.0.0" +< }, +< "engines": { +< "node": ">=14" +< } +< }, +< "node_modules/@google-cloud/profiler/node_modules/google-auth-library": { +< "version": "9.1.0", +< "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.1.0.tgz", +< "integrity": "sha512-1M9HdOcQNPV5BwSXqwwT238MTKodJFBxZ/V2JP397ieOLv4FjQdfYb9SooR7Mb+oUT2IJ92mLJQf804dyx0MJA==", +< "dependencies": { +< "base64-js": "^1.3.0", +< "ecdsa-sig-formatter": "^1.0.11", +< "gaxios": "^6.0.0", +< "gcp-metadata": "^6.0.0", +< "gtoken": "^7.0.0", +< "jws": "^4.0.0", +< "lru-cache": "^6.0.0" +< }, +< "engines": { +< "node": ">=14" +< } +< }, +< "node_modules/@google-cloud/profiler/node_modules/gtoken": { +< "version": "7.0.1", +< "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.0.1.tgz", +< "integrity": "sha512-KcFVtoP1CVFtQu0aSk3AyAt2og66PFhZAlkUOuWKwzMLoulHXG5W5wE5xAnHb+yl3/wEFoqGW7/cDGMU8igDZQ==", +< "dependencies": { +< "gaxios": "^6.0.0", +< "jws": "^4.0.0" +< }, +< "engines": { +< "node": ">=14.0.0" +< } +< }, +< "node_modules/@google-cloud/profiler/node_modules/lru-cache": { +< "version": "6.0.0", +< "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", +< "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", +< "dependencies": { +< "yallist": "^4.0.0" +< }, +< "engines": { +< "node": ">=10" +< } +< }, +< "node_modules/@google-cloud/profiler/node_modules/ms": { +< "version": "2.1.2", +< "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", +< "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" +< }, +< "node_modules/@google-cloud/profiler/node_modules/retry-request": { +< "version": "6.0.0", +< "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-6.0.0.tgz", +< "integrity": "sha512-24kaFMd3wCnT3n4uPnsQh90ZSV8OISpfTFXJ00Wi+/oD2OPrp63EQ8hznk6rhxdlpwx2QBhQSDz2Fg46ki852g==", +< "dependencies": { +< "debug": "^4.1.1", +< "extend": "^3.0.2" +< }, +< "engines": { +< "node": ">=14" +< } +< }, +< "node_modules/@google-cloud/profiler/node_modules/semver": { +< "version": "7.5.4", +< "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", +< "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", +< "dependencies": { +< "lru-cache": "^6.0.0" +< }, +< "bin": { +< "semver": "bin/semver.js" +< }, +< "engines": { +< "node": ">=10" +< } +< }, +< "node_modules/@google-cloud/profiler/node_modules/teeny-request": { +< "version": "9.0.0", +< "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", +< "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", +< "dependencies": { +< "http-proxy-agent": "^5.0.0", +< "https-proxy-agent": "^5.0.0", +< "node-fetch": "^2.6.9", +< "stream-events": "^1.0.5", +< "uuid": "^9.0.0" +< }, +< "engines": { +< "node": ">=14" +< } +< }, +< "node_modules/@google-cloud/profiler/node_modules/uuid": { +< "version": "9.0.1", +< "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", +< "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", +< "funding": [ +< "https://github.com/sponsors/broofa", +< "https://github.com/sponsors/ctavan" +< ], +< "bin": { +< "uuid": "dist/bin/uuid" +< } +< }, +< "node_modules/@google-cloud/profiler/node_modules/yallist": { +< "version": "4.0.0", +< "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", +< "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" +< }, +5048,5058d4948 +< "node_modules/@google-cloud/storage/node_modules/duplexify": { +< "version": "4.1.2", +< "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", +< "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", +< "dependencies": { +< "end-of-stream": "^1.4.1", +< "inherits": "^2.0.3", +< "readable-stream": "^3.1.1", +< "stream-shift": "^1.0.0" +< } +< }, +5598,5608d5487 +< "node_modules/@jsdoc/salty": { +< "version": "0.2.8", +< "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.8.tgz", +< "integrity": "sha512-5e+SFVavj1ORKlKaKr2BmTOekmXbelU7dC0cDkQLqag7xfuTPuGMUFx7KWJuv4bYZrTsoL2Z18VVCOKYxzoHcg==", +< "dependencies": { +< "lodash": "^4.17.21" +< }, +< "engines": { +< "node": ">=v12.0.0" +< } +< }, +5809,5822d5687 +< "node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": { +< "version": "3.0.2", +< "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", +< "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", +< "dependencies": { +< "glob": "^7.1.3" +< }, +< "bin": { +< "rimraf": "bin.js" +< }, +< "funding": { +< "url": "https://github.com/sponsors/isaacs" +< } +< }, +11095,11103d10959 +< "node_modules/@types/glob": { +< "version": "8.1.0", +< "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", +< "integrity": "sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==", +< "dependencies": { +< "@types/minimatch": "^5.1.2", +< "@types/node": "*" +< } +< }, +11266,11270d11121 +< "node_modules/@types/linkify-it": { +< "version": "5.0.0", +< "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", +< "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==" +< }, +11282,11295d11132 +< "node_modules/@types/markdown-it": { +< "version": "14.1.1", +< "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.1.tgz", +< "integrity": "sha512-4NpsnpYl2Gt1ljyBGrKMxFYAYvpqbnnkgP/i/g+NLpjEUa3obn1XJCur9YbEXKDAkaXqsR1LbDnGEJ0MmKFxfg==", +< "dependencies": { +< "@types/linkify-it": "^5", +< "@types/mdurl": "^2" +< } +< }, +< "node_modules/@types/mdurl": { +< "version": "2.0.0", +< "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", +< "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==" +< }, +11321,11325d11157 +< "node_modules/@types/minimatch": { +< "version": "5.1.2", +< "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", +< "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==" +< }, +11574,11582d11405 +< "node_modules/@types/rimraf": { +< "version": "3.0.2", +< "resolved": "https://registry.npmjs.org/@types/rimraf/-/rimraf-3.0.2.tgz", +< "integrity": "sha512-F3OznnSLAUxFrCEu/L5PY8+ny8DtcFRjx7fZZ9bycvXRi3KPTRS9HOitGZwvPg0juRhXFWIeKX58cnX5YqLohQ==", +< "dependencies": { +< "@types/glob": "*", +< "@types/node": "*" +< } +< }, +13204a13028 +> "dev": true, +13842a13667,13675 +> "node_modules/asn1": { +> "version": "0.2.6", +> "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", +> "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", +> "license": "MIT", +> "dependencies": { +> "safer-buffer": "~2.1.0" +> } +> }, +14965a14799,14807 +> "node_modules/buildcheck": { +> "version": "0.0.6", +> "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", +> "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", +> "optional": true, +> "engines": { +> "node": ">=10.0.0" +> } +> }, +15160,15174d15001 +< "node_modules/c8/node_modules/rimraf": { +< "version": "3.0.2", +< "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", +< "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", +< "dev": true, +< "dependencies": { +< "glob": "^7.1.3" +< }, +< "bin": { +< "rimraf": "bin.js" +< }, +< "funding": { +< "url": "https://github.com/sponsors/isaacs" +< } +< }, +15367,15377d15193 +< "node_modules/catharsis": { +< "version": "0.9.0", +< "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz", +< "integrity": "sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==", +< "dependencies": { +< "lodash": "^4.17.15" +< }, +< "engines": { +< "node": ">= 10" +< } +< }, +16502,16514d16317 +< "node_modules/cpu-features": { +< "version": "0.0.2", +< "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.2.tgz", +< "integrity": "sha512-/2yieBqvMcRj8McNzkycjW2v3OIUOibBfd2dLEJ0nWts8NobAxwiyw9phVNS6oDL8x8tz9F7uNVFEVpJncQpeA==", +< "hasInstallScript": true, +< "optional": true, +< "dependencies": { +< "nan": "^2.14.1" +< }, +< "engines": { +< "node": ">=8.0.0" +< } +< }, +17652c17455,17456 +< "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" +--- +> "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", +> "dev": true +17910,17935d17713 +< "node_modules/docker-modem": { +< "version": "3.0.3", +< "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-3.0.3.tgz", +< "integrity": "sha512-Tgkn2a+yiNP9FoZgMa/D9Wk+D2Db///0KOyKSYZRJa8w4+DzKyzQMkczKSdR/adQ0x46BOpeNkoyEOKjPhCzjw==", +< "dependencies": { +< "debug": "^4.1.1", +< "readable-stream": "^3.5.0", +< "split-ca": "^1.0.1", +< "ssh2": "^1.4.0" +< }, +< "engines": { +< "node": ">= 8.0" +< } +< }, +< "node_modules/dockerode": { +< "version": "3.3.1", +< "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-3.3.1.tgz", +< "integrity": "sha512-AS2mr8Lp122aa5n6d99HkuTNdRV1wkkhHwBdcnY6V0+28D3DSYwhxAk85/mM9XwD3RMliTxyr63iuvn5ZblFYQ==", +< "dependencies": { +< "docker-modem": "^3.0.0", +< "tar-fs": "~2.0.1" +< }, +< "engines": { +< "node": ">= 8.0" +< } +< }, +18119a17898,17909 +> "node_modules/duplexify": { +> "version": "4.1.3", +> "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", +> "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", +> "license": "MIT", +> "dependencies": { +> "end-of-stream": "^1.4.1", +> "inherits": "^2.0.3", +> "readable-stream": "^3.1.1", +> "stream-shift": "^1.0.2" +> } +> }, +19831a19622 +> "dev": true, +19847a19639 +> "dev": true, +19858a19651 +> "dev": true, +19918a19712 +> "dev": true, +19936a19731 +> "dev": true, +20628c20423,20424 +< "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" +--- +> "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", +> "dev": true +21080,21094d20875 +< "node_modules/flat-cache/node_modules/rimraf": { +< "version": "3.0.2", +< "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", +< "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", +< "dev": true, +< "dependencies": { +< "glob": "^7.1.3" +< }, +< "bin": { +< "rimraf": "bin.js" +< }, +< "funding": { +< "url": "https://github.com/sponsors/isaacs" +< } +< }, +22169,22219d21949 +< "node_modules/google-gax/node_modules/retry-request": { +< "version": "7.0.2", +< "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", +< "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", +< "dependencies": { +< "@types/request": "^2.48.8", +< "extend": "^3.0.2", +< "teeny-request": "^9.0.0" +< }, +< "engines": { +< "node": ">=14" +< } +< }, +< "node_modules/google-gax/node_modules/teeny-request": { +< "version": "9.0.0", +< "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", +< "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", +< "dependencies": { +< "http-proxy-agent": "^5.0.0", +< "https-proxy-agent": "^5.0.0", +< "node-fetch": "^2.6.9", +< "stream-events": "^1.0.5", +< "uuid": "^9.0.0" +< }, +< "engines": { +< "node": ">=14" +< } +< }, +< "node_modules/google-gax/node_modules/teeny-request/node_modules/agent-base": { +< "version": "6.0.2", +< "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", +< "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", +< "dependencies": { +< "debug": "4" +< }, +< "engines": { +< "node": ">= 6.0.0" +< } +< }, +< "node_modules/google-gax/node_modules/teeny-request/node_modules/https-proxy-agent": { +< "version": "5.0.1", +< "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", +< "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", +< "dependencies": { +< "agent-base": "6", +< "debug": "4" +< }, +< "engines": { +< "node": ">= 6" +< } +< }, +22231a21962,21970 +> "node_modules/google-logging-utils": { +> "version": "0.0.2", +> "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", +> "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", +> "license": "Apache-2.0", +> "engines": { +> "node": ">=14" +> } +> }, +24058,24062d23796 +< "node_modules/is-stream-ended": { +< "version": "0.1.4", +< "resolved": "https://registry.npmjs.org/is-stream-ended/-/is-stream-ended-0.1.4.tgz", +< "integrity": "sha512-xj0XPvmr7bQFTvirqnFr50o0hQIh6ZItDqloxt5aJrR4NQsYeSsyFQERYGCAzfindAcnKjINnwEEgLx4IqVzQw==" +< }, +24518,24525d24251 +< "node_modules/js2xmlparser": { +< "version": "4.0.2", +< "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz", +< "integrity": "sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==", +< "dependencies": { +< "xmlcreate": "^2.0.4" +< } +< }, +24597,24624d24322 +< "node_modules/jsdoc": { +< "version": "4.0.3", +< "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.3.tgz", +< "integrity": "sha512-Nu7Sf35kXJ1MWDZIMAuATRQTg1iIPdzh7tqJ6jjvaU/GfDf+qi5UV8zJR3Mo+/pYFvm8mzay4+6O5EWigaQBQw==", +< "dependencies": { +< "@babel/parser": "^7.20.15", +< "@jsdoc/salty": "^0.2.1", +< "@types/markdown-it": "^14.1.1", +< "bluebird": "^3.7.2", +< "catharsis": "^0.9.0", +< "escape-string-regexp": "^2.0.0", +< "js2xmlparser": "^4.0.2", +< "klaw": "^3.0.0", +< "markdown-it": "^14.1.0", +< "markdown-it-anchor": "^8.6.7", +< "marked": "^4.0.10", +< "mkdirp": "^1.0.4", +< "requizzle": "^0.2.3", +< "strip-json-comments": "^3.1.0", +< "underscore": "~1.13.2" +< }, +< "bin": { +< "jsdoc": "jsdoc.js" +< }, +< "engines": { +< "node": ">=12.0.0" +< } +< }, +24635,24653d24332 +< "node_modules/jsdoc/node_modules/escape-string-regexp": { +< "version": "2.0.0", +< "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", +< "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", +< "engines": { +< "node": ">=8" +< } +< }, +< "node_modules/jsdoc/node_modules/mkdirp": { +< "version": "1.0.4", +< "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", +< "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", +< "bin": { +< "mkdirp": "bin/cmd.js" +< }, +< "engines": { +< "node": ">=10" +< } +< }, +25108,25115d24786 +< "node_modules/klaw": { +< "version": "3.0.0", +< "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", +< "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==", +< "dependencies": { +< "graceful-fs": "^4.1.9" +< } +< }, +25497,25509d25167 +< "node_modules/linkify-it": { +< "version": "5.0.0", +< "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", +< "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", +< "dependencies": { +< "uc.micro": "^2.0.0" +< } +< }, +< "node_modules/linkify-it/node_modules/uc.micro": { +< "version": "2.1.0", +< "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", +< "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" +< }, +25922,25923c25580 +< "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", +< "dev": true +--- +> "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=" +26248,26288d25904 +< "node_modules/markdown-it": { +< "version": "14.1.0", +< "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", +< "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", +< "dependencies": { +< "argparse": "^2.0.1", +< "entities": "^4.4.0", +< "linkify-it": "^5.0.0", +< "mdurl": "^2.0.0", +< "punycode.js": "^2.3.1", +< "uc.micro": "^2.1.0" +< }, +< "bin": { +< "markdown-it": "bin/markdown-it.mjs" +< } +< }, +< "node_modules/markdown-it-anchor": { +< "version": "8.6.7", +< "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", +< "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", +< "peerDependencies": { +< "@types/markdown-it": "*", +< "markdown-it": "*" +< } +< }, +< "node_modules/markdown-it/node_modules/entities": { +< "version": "4.5.0", +< "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", +< "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", +< "engines": { +< "node": ">=0.12" +< }, +< "funding": { +< "url": "https://github.com/fb55/entities?sponsor=1" +< } +< }, +< "node_modules/markdown-it/node_modules/uc.micro": { +< "version": "2.1.0", +< "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", +< "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" +< }, +26339,26343d25954 +< "node_modules/mdurl": { +< "version": "2.0.0", +< "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", +< "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==" +< }, +28390,28436d28000 +< "node_modules/optionator": { +< "version": "0.8.3", +< "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", +< "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", +< "dependencies": { +< "deep-is": "~0.1.3", +< "fast-levenshtein": "~2.0.6", +< "levn": "~0.3.0", +< "prelude-ls": "~1.1.2", +< "type-check": "~0.3.2", +< "word-wrap": "~1.2.3" +< }, +< "engines": { +< "node": ">= 0.8.0" +< } +< }, +< "node_modules/optionator/node_modules/levn": { +< "version": "0.3.0", +< "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", +< "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", +< "dependencies": { +< "prelude-ls": "~1.1.2", +< "type-check": "~0.3.2" +< }, +< "engines": { +< "node": ">= 0.8.0" +< } +< }, +< "node_modules/optionator/node_modules/prelude-ls": { +< "version": "1.1.2", +< "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", +< "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", +< "engines": { +< "node": ">= 0.8.0" +< } +< }, +< "node_modules/optionator/node_modules/type-check": { +< "version": "0.3.2", +< "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", +< "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", +< "dependencies": { +< "prelude-ls": "~1.1.2" +< }, +< "engines": { +< "node": ">= 0.8.0" +< } +< }, +28682,28686d28245 +< "node_modules/parse-duration": { +< "version": "1.1.0", +< "resolved": "https://registry.npmjs.org/parse-duration/-/parse-duration-1.1.0.tgz", +< "integrity": "sha512-z6t9dvSJYaPoQq7quMzdEagSFtpGu+utzHqqxmpVWNNZRIXnvqyCvn9XsTdh7c/w0Bqmdz3RB3YnRaKtpRtEXQ==" +< }, +30167,30206d29725 +< "node_modules/pprof": { +< "version": "3.2.1", +< "resolved": "https://registry.npmjs.org/pprof/-/pprof-3.2.1.tgz", +< "integrity": "sha512-KnextTM3EHQ2zqN8fUjB0VpE+njcVR7cOfo7DjJSLKzIbKTPelDtokI04ScR/Vd8CLDj+M99tsaKV+K6FHzpzA==", +< "hasInstallScript": true, +< "dependencies": { +< "@mapbox/node-pre-gyp": "^1.0.0", +< "bindings": "^1.2.1", +< "delay": "^5.0.0", +< "findit2": "^2.2.3", +< "nan": "^2.14.0", +< "p-limit": "^3.0.0", +< "pify": "^5.0.0", +< "protobufjs": "~7.2.4", +< "source-map": "^0.7.3", +< "split": "^1.0.1" +< }, +< "engines": { +< "node": ">=10.4.1" +< } +< }, +< "node_modules/pprof/node_modules/pify": { +< "version": "5.0.0", +< "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", +< "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==", +< "engines": { +< "node": ">=10" +< }, +< "funding": { +< "url": "https://github.com/sponsors/sindresorhus" +< } +< }, +< "node_modules/pprof/node_modules/source-map": { +< "version": "0.7.4", +< "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", +< "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", +< "engines": { +< "node": ">= 8" +< } +< }, +30457,30467d29975 +< "node_modules/proto3-json-serializer": { +< "version": "1.1.1", +< "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-1.1.1.tgz", +< "integrity": "sha512-AwAuY4g9nxx0u52DnSMkqqgyLHaW/XaPLtaAo3y/ZCfeaQB/g4YDH4kb8Wc/mWzWvu0YjOznVnfn373MVZZrgw==", +< "dependencies": { +< "protobufjs": "^7.0.0" +< }, +< "engines": { +< "node": ">=12.0.0" +< } +< }, +30491,30635d29998 +< "node_modules/protobufjs-cli": { +< "version": "1.1.1", +< "resolved": "https://registry.npmjs.org/protobufjs-cli/-/protobufjs-cli-1.1.1.tgz", +< "integrity": "sha512-VPWMgIcRNyQwWUv8OLPyGQ/0lQY/QTQAVN5fh+XzfDwsVw1FZ2L3DM/bcBf8WPiRz2tNpaov9lPZfNcmNo6LXA==", +< "dependencies": { +< "chalk": "^4.0.0", +< "escodegen": "^1.13.0", +< "espree": "^9.0.0", +< "estraverse": "^5.1.0", +< "glob": "^8.0.0", +< "jsdoc": "^4.0.0", +< "minimist": "^1.2.0", +< "semver": "^7.1.2", +< "tmp": "^0.2.1", +< "uglify-js": "^3.7.7" +< }, +< "bin": { +< "pbjs": "bin/pbjs", +< "pbts": "bin/pbts" +< }, +< "engines": { +< "node": ">=12.0.0" +< }, +< "peerDependencies": { +< "protobufjs": "^7.0.0" +< } +< }, +< "node_modules/protobufjs-cli/node_modules/ansi-styles": { +< "version": "4.3.0", +< "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", +< "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", +< "dependencies": { +< "color-convert": "^2.0.1" +< }, +< "engines": { +< "node": ">=8" +< }, +< "funding": { +< "url": "https://github.com/chalk/ansi-styles?sponsor=1" +< } +< }, +< "node_modules/protobufjs-cli/node_modules/brace-expansion": { +< "version": "2.0.1", +< "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", +< "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", +< "dependencies": { +< "balanced-match": "^1.0.0" +< } +< }, +< "node_modules/protobufjs-cli/node_modules/chalk": { +< "version": "4.1.2", +< "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", +< "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", +< "dependencies": { +< "ansi-styles": "^4.1.0", +< "supports-color": "^7.1.0" +< }, +< "engines": { +< "node": ">=10" +< }, +< "funding": { +< "url": "https://github.com/chalk/chalk?sponsor=1" +< } +< }, +< "node_modules/protobufjs-cli/node_modules/escodegen": { +< "version": "1.14.3", +< "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", +< "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", +< "dependencies": { +< "esprima": "^4.0.1", +< "estraverse": "^4.2.0", +< "esutils": "^2.0.2", +< "optionator": "^0.8.1" +< }, +< "bin": { +< "escodegen": "bin/escodegen.js", +< "esgenerate": "bin/esgenerate.js" +< }, +< "engines": { +< "node": ">=4.0" +< }, +< "optionalDependencies": { +< "source-map": "~0.6.1" +< } +< }, +< "node_modules/protobufjs-cli/node_modules/escodegen/node_modules/estraverse": { +< "version": "4.3.0", +< "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", +< "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", +< "engines": { +< "node": ">=4.0" +< } +< }, +< "node_modules/protobufjs-cli/node_modules/glob": { +< "version": "8.1.0", +< "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", +< "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", +< "deprecated": "Glob versions prior to v9 are no longer supported", +< "dependencies": { +< "fs.realpath": "^1.0.0", +< "inflight": "^1.0.4", +< "inherits": "2", +< "minimatch": "^5.0.1", +< "once": "^1.3.0" +< }, +< "engines": { +< "node": ">=12" +< }, +< "funding": { +< "url": "https://github.com/sponsors/isaacs" +< } +< }, +< "node_modules/protobufjs-cli/node_modules/minimatch": { +< "version": "5.1.6", +< "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", +< "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", +< "dependencies": { +< "brace-expansion": "^2.0.1" +< }, +< "engines": { +< "node": ">=10" +< } +< }, +< "node_modules/protobufjs-cli/node_modules/semver": { +< "version": "7.6.2", +< "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", +< "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", +< "bin": { +< "semver": "bin/semver.js" +< }, +< "engines": { +< "node": ">=10" +< } +< }, +< "node_modules/protobufjs-cli/node_modules/supports-color": { +< "version": "7.2.0", +< "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", +< "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", +< "dependencies": { +< "has-flag": "^4.0.0" +< }, +< "engines": { +< "node": ">=8" +< } +< }, +30797,30807d30159 +< "node_modules/pumpify/node_modules/duplexify": { +< "version": "4.1.2", +< "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", +< "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", +< "dependencies": { +< "end-of-stream": "^1.4.1", +< "inherits": "^2.0.3", +< "readable-stream": "^3.1.1", +< "stream-shift": "^1.0.0" +< } +< }, +30816,30823d30167 +< "node_modules/punycode.js": { +< "version": "2.3.1", +< "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", +< "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", +< "engines": { +< "node": ">=6" +< } +< }, +31992,31999d31335 +< "node_modules/requizzle": { +< "version": "0.2.4", +< "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz", +< "integrity": "sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==", +< "dependencies": { +< "lodash": "^4.17.21" +< } +< }, +32106a31443,31456 +> "node_modules/retry-request": { +> "version": "7.0.2", +> "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", +> "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", +> "license": "MIT", +> "dependencies": { +> "@types/request": "^2.48.8", +> "extend": "^3.0.2", +> "teeny-request": "^9.0.0" +> }, +> "engines": { +> "node": ">=14" +> } +> }, +32122a31473,31488 +> "node_modules/rimraf": { +> "version": "3.0.2", +> "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", +> "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", +> "deprecated": "Rimraf versions prior to v4 are no longer supported", +> "license": "ISC", +> "dependencies": { +> "glob": "^7.1.3" +> }, +> "bin": { +> "rimraf": "bin.js" +> }, +> "funding": { +> "url": "https://github.com/sponsors/isaacs" +> } +> }, +33320c32686 +< "devOptional": true, +--- +> "dev": true, +33488,33512d32853 +< "node_modules/ssh2": { +< "version": "1.6.0", +< "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.6.0.tgz", +< "integrity": "sha512-lxc+uvXqOxyQ99N2M7k5o4pkYDO5GptOTYduWw7hIM41icxvoBcCNHcj+LTKrjkL0vFcAl+qfZekthoSFRJn2Q==", +< "hasInstallScript": true, +< "dependencies": { +< "asn1": "^0.2.4", +< "bcrypt-pbkdf": "^1.0.2" +< }, +< "engines": { +< "node": ">=10.16.0" +< }, +< "optionalDependencies": { +< "cpu-features": "0.0.2", +< "nan": "^2.15.0" +< } +< }, +< "node_modules/ssh2/node_modules/asn1": { +< "version": "0.2.6", +< "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", +< "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", +< "dependencies": { +< "safer-buffer": "~2.1.0" +< } +< }, +33537,33544d32877 +< "node_modules/sshpk/node_modules/asn1": { +< "version": "0.2.6", +< "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", +< "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", +< "dependencies": { +< "safer-buffer": "~2.1.0" +< } +< }, +33896a33230 +> "dev": true, +34789,34799d34122 +< "node_modules/tar-fs": { +< "version": "2.0.1", +< "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz", +< "integrity": "sha512-6tzWDMeroL87uF/+lin46k+Q+46rAJ0SyPGz7OW7wTgblI273hsBqk2C1j0/xNadNLKDTUL9BukSjB7cwgmlPA==", +< "dependencies": { +< "chownr": "^1.1.1", +< "mkdirp-classic": "^0.5.2", +< "pump": "^3.0.0", +< "tar-stream": "^2.0.0" +< } +< }, +34862a34186,34214 +> "node_modules/teeny-request": { +> "version": "9.0.0", +> "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", +> "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", +> "license": "Apache-2.0", +> "dependencies": { +> "http-proxy-agent": "^5.0.0", +> "https-proxy-agent": "^5.0.0", +> "node-fetch": "^2.6.9", +> "stream-events": "^1.0.5", +> "uuid": "^9.0.0" +> }, +> "engines": { +> "node": ">=14" +> } +> }, +> "node_modules/teeny-request/node_modules/uuid": { +> "version": "9.0.1", +> "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", +> "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", +> "funding": [ +> "https://github.com/sponsors/broofa", +> "https://github.com/sponsors/ctavan" +> ], +> "license": "MIT", +> "bin": { +> "uuid": "dist/bin/uuid" +> } +> }, +35286a34639 +> "dev": true, +35442d34794 +< "dev": true, +35745a35098,35099 +> "dev": true, +> "optional": true, +37308,37315d36661 +< "node_modules/word-wrap": { +< "version": "1.2.5", +< "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", +< "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", +< "engines": { +< "node": ">=0.10.0" +< } +< }, +37546,37550d36891 +< "node_modules/xmlcreate": { +< "version": "2.0.4", +< "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", +< "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==" +< }, +37861c37202 +< "dockerode": "^3.1.0", +--- +> "dockerode": "^4.0.6", +37904a37246,37272 +> "services/clsi/node_modules/@grpc/grpc-js": { +> "version": "1.13.3", +> "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.3.tgz", +> "integrity": "sha512-FTXHdOoPbZrBjlVLHuKbDZnsTxXv2BlHF57xw6LuThXacXvtkahEPED0CKMk6obZDf65Hv4k3z62eyPNpvinIg==", +> "license": "Apache-2.0", +> "dependencies": { +> "@grpc/proto-loader": "^0.7.13", +> "@js-sdsl/ordered-map": "^4.4.2" +> }, +> "engines": { +> "node": ">=12.10.0" +> } +> }, +> "services/clsi/node_modules/cpu-features": { +> "version": "0.0.10", +> "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", +> "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", +> "hasInstallScript": true, +> "optional": true, +> "dependencies": { +> "buildcheck": "~0.0.6", +> "nan": "^2.19.0" +> }, +> "engines": { +> "node": ">=10.0.0" +> } +> }, +37913a37282,37345 +> "services/clsi/node_modules/docker-modem": { +> "version": "5.0.6", +> "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz", +> "integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==", +> "license": "Apache-2.0", +> "dependencies": { +> "debug": "^4.1.1", +> "readable-stream": "^3.5.0", +> "split-ca": "^1.0.1", +> "ssh2": "^1.15.0" +> }, +> "engines": { +> "node": ">= 8.0" +> } +> }, +> "services/clsi/node_modules/dockerode": { +> "version": "4.0.6", +> "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.6.tgz", +> "integrity": "sha512-FbVf3Z8fY/kALB9s+P9epCpWhfi/r0N2DgYYcYpsAUlaTxPjdsitsFobnltb+lyCgAIvf9C+4PSWlTnHlJMf1w==", +> "license": "Apache-2.0", +> "dependencies": { +> "@balena/dockerignore": "^1.0.2", +> "@grpc/grpc-js": "^1.11.1", +> "@grpc/proto-loader": "^0.7.13", +> "docker-modem": "^5.0.6", +> "protobufjs": "^7.3.2", +> "tar-fs": "~2.1.2", +> "uuid": "^10.0.0" +> }, +> "engines": { +> "node": ">= 8.0" +> } +> }, +> "services/clsi/node_modules/nan": { +> "version": "2.22.2", +> "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", +> "integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", +> "license": "MIT", +> "optional": true +> }, +> "services/clsi/node_modules/protobufjs": { +> "version": "7.5.0", +> "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.0.tgz", +> "integrity": "sha512-Z2E/kOY1QjoMlCytmexzYfDm/w5fKAiRwpSzGtdnXW1zC88Z2yXazHHrOtwCzn+7wSxyE8PYM4rvVcMphF9sOA==", +> "hasInstallScript": true, +> "license": "BSD-3-Clause", +> "dependencies": { +> "@protobufjs/aspromise": "^1.1.2", +> "@protobufjs/base64": "^1.1.2", +> "@protobufjs/codegen": "^2.0.4", +> "@protobufjs/eventemitter": "^1.1.0", +> "@protobufjs/fetch": "^1.1.0", +> "@protobufjs/float": "^1.0.2", +> "@protobufjs/inquire": "^1.1.0", +> "@protobufjs/path": "^1.1.2", +> "@protobufjs/pool": "^1.1.0", +> "@protobufjs/utf8": "^1.1.0", +> "@types/node": ">=13.7.0", +> "long": "^5.0.0" +> }, +> "engines": { +> "node": ">=12.0.0" +> } +> }, +37932a37365,37381 +> "services/clsi/node_modules/ssh2": { +> "version": "1.16.0", +> "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz", +> "integrity": "sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==", +> "hasInstallScript": true, +> "dependencies": { +> "asn1": "^0.2.6", +> "bcrypt-pbkdf": "^1.0.2" +> }, +> "engines": { +> "node": ">=10.16.0" +> }, +> "optionalDependencies": { +> "cpu-features": "~0.0.10", +> "nan": "^2.20.0" +> } +> }, +37942a37392,37416 +> } +> }, +> "services/clsi/node_modules/tar-fs": { +> "version": "2.1.2", +> "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", +> "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", +> "license": "MIT", +> "dependencies": { +> "chownr": "^1.1.1", +> "mkdirp-classic": "^0.5.2", +> "pump": "^3.0.0", +> "tar-stream": "^2.1.4" +> } +> }, +> "services/clsi/node_modules/uuid": { +> "version": "10.0.0", +> "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", +> "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", +> "funding": [ +> "https://github.com/sponsors/broofa", +> "https://github.com/sponsors/ctavan" +> ], +> "license": "MIT", +> "bin": { +> "uuid": "dist/bin/uuid" diff --git a/server-ce/test/Dockerfile b/server-ce/test/Dockerfile index 721b99c06b..ad89208541 100644 --- a/server-ce/test/Dockerfile +++ b/server-ce/test/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.18.2 +FROM node:22.15.1 RUN curl -fsSL https://download.docker.com/linux/debian/gpg | apt-key add - \ && echo \ "deb [arch=$(dpkg --print-architecture)] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ diff --git a/server-ce/test/admin.spec.ts b/server-ce/test/admin.spec.ts index 8020deeb4b..9031e21b68 100644 --- a/server-ce/test/admin.spec.ts +++ b/server-ce/test/admin.spec.ts @@ -294,7 +294,7 @@ describe('admin panel', function () { cy.log('navigate to thrashed projects and delete the project') cy.get('.project-list-sidebar-scroll').within(() => { - cy.findByText('Trashed Projects').click() + cy.findByText('Trashed projects').click() }) findProjectRow(deletedProjectName).within(() => cy.findByRole('button', { name: 'Delete' }).click() @@ -319,7 +319,7 @@ describe('admin panel', function () { login(user1) cy.visit('/project') cy.get('.project-list-sidebar-scroll').within(() => { - cy.findByText('Trashed Projects').click() + cy.findByText('Trashed projects').click() }) cy.findByText(`${deletedProjectName} (Restored)`) }) diff --git a/server-ce/test/create-and-compile-project.spec.ts b/server-ce/test/create-and-compile-project.spec.ts index 2be4f208e2..959a3be107 100644 --- a/server-ce/test/create-and-compile-project.spec.ts +++ b/server-ce/test/create-and-compile-project.spec.ts @@ -51,7 +51,7 @@ describe('Project creation and compilation', function () { login('user@example.com') createProject(sourceProjectName, { - type: 'Example Project', + type: 'Example project', open: false, }).as('sourceProjectId') createProject(targetProjectName) @@ -79,7 +79,7 @@ describe('Project creation and compilation', function () { const targetProjectName = `${sourceProjectName}-target` login('user@example.com') createProject(sourceProjectName, { - type: 'Example Project', + type: 'Example project', open: false, }).as('sourceProjectId') createProject(targetProjectName).as('targetProjectId') diff --git a/server-ce/test/editor.spec.ts b/server-ce/test/editor.spec.ts index 648c55a907..7a34f26573 100644 --- a/server-ce/test/editor.spec.ts +++ b/server-ce/test/editor.spec.ts @@ -1,11 +1,7 @@ import { createNewFile, createProject, - enableLinkSharing, - openFile, openProjectById, - openProjectViaLinkSharingAsUser, - toggleTrackChanges, } from './helpers/project' import { isExcludedBySharding, startWith } from './helpers/config' import { ensureUserExists, login } from './helpers/login' @@ -26,7 +22,7 @@ describe('editor', () => { beforeWithReRunOnTestRetry(function () { projectName = `project-${uuid()}` login('user@example.com') - createProject(projectName, { type: 'Example Project', open: false }).then( + createProject(projectName, { type: 'Example project', open: false }).then( id => (projectId = id) ) ;({ recompile, waitForCompileRateLimitCoolOff } = @@ -66,12 +62,12 @@ describe('editor', () => { cy.log('add word to dictionary') cy.get('.ol-cm-spelling-error').contains(word).rightclick() - cy.findByText('Add to Dictionary').click() + cy.findByText('Add to dictionary').click() cy.get('.ol-cm-spelling-error').should('not.exist') cy.log('remove word from dictionary') cy.get('button').contains('Menu').click() - cy.get('button').contains('Edit').click() + cy.get('button#dictionary-settings').contains('Edit').click() cy.get('[id="dictionary-modal"]').within(() => { cy.findByText(word) .parent() @@ -92,136 +88,6 @@ describe('editor', () => { }) }) - describe('collaboration', () => { - beforeWithReRunOnTestRetry(function () { - enableLinkSharing().then(({ linkSharingReadAndWrite }) => { - const email = 'collaborator@example.com' - login(email) - openProjectViaLinkSharingAsUser( - linkSharingReadAndWrite, - projectName, - email - ) - }) - - login('user@example.com') - waitForCompileRateLimitCoolOff(() => { - openProjectById(projectId) - }) - }) - - it('track-changes', () => { - cy.log('disable track-changes before populating doc') - toggleTrackChanges(false) - - const fileName = createNewFile() - const oldContent = 'oldContent' - cy.get('.cm-line').type(`${oldContent}\n\nstatic`) - - cy.log('recompile to force flush') - recompile() - - cy.log('enable track-changes for everyone') - toggleTrackChanges(true) - - login('collaborator@example.com') - waitForCompileRateLimitCoolOff(() => { - openProjectById(projectId) - }) - openFile(fileName, 'static') - - cy.log('make changes in main file') - // cy.type() "clicks" in the center of the selected element before typing. This "click" discards the text as selected by the dblclick. - // Go down to the lower level event based typing, the frontend tests in web use similar events. - cy.get('.cm-editor').as('editor') - cy.get('@editor').findByText(oldContent).dblclick() - cy.get('@editor').trigger('keydown', { key: 'Delete' }) - cy.get('@editor').trigger('keydown', { key: 'Enter' }) - cy.get('@editor').trigger('keydown', { key: 'Enter' }) - - cy.log('recompile to force flush') - recompile() - - login('user@example.com') - waitForCompileRateLimitCoolOff(() => { - openProjectById(projectId) - }) - openFile(fileName, 'static') - - cy.log('reject changes') - cy.contains('.toolbar-item', 'Review').click() - cy.get('.cm-content').should('not.contain.text', oldContent) - cy.findByText('Reject change').click({ force: true }) - cy.contains('.toolbar-item', 'Review').click() - - cy.log('recompile to force flush') - recompile() - - cy.log('verify the changes are applied') - cy.get('.cm-content').should('contain.text', oldContent) - - cy.log('disable track-changes for everyone again') - toggleTrackChanges(false) - }) - - it('track-changes rich text', () => { - cy.log('disable track-changes before populating doc') - toggleTrackChanges(false) - - const fileName = createNewFile() - const oldContent = 'oldContent' - cy.get('.cm-line').type(`\\section{{}${oldContent}}\n\nstatic`) - - cy.log('recompile to force flush') - recompile() - - cy.log('enable track-changes for everyone') - toggleTrackChanges(true) - - login('collaborator@example.com') - waitForCompileRateLimitCoolOff(() => { - openProjectById(projectId) - }) - cy.log('enable visual editor and make changes in main file') - cy.findByText('Visual Editor').click() - - openFile(fileName, 'static') - - // cy.type() "clicks" in the center of the selected element before typing. This "click" discards the text as selected by the dblclick. - // Go down to the lower level event based typing, the frontend tests in web use similar events. - cy.get('.cm-editor').as('editor') - cy.get('@editor').findByText(oldContent).dblclick() - cy.get('@editor').trigger('keydown', { key: 'Delete' }) - cy.get('@editor').trigger('keydown', { key: 'Enter' }) - cy.get('@editor').trigger('keydown', { key: 'Enter' }) - - cy.log('recompile to force flush') - recompile() - - login('user@example.com') - waitForCompileRateLimitCoolOff(() => { - openProjectById(projectId) - }) - openFile(fileName, 'static') - - cy.log('reject changes') - cy.contains('.toolbar-item', 'Review').click() - cy.get('.cm-content').should('not.contain.text', oldContent) - cy.findAllByText('Reject change').first().click({ force: true }) - cy.contains('.toolbar-item', 'Review').click() - - cy.log('recompile to force flush') - recompile() - - cy.log('verify the changes are applied in the visual editor') - cy.findByText('Visual Editor').click() - cy.get('.cm-content').should('contain.text', oldContent) - - cy.log('disable track-changes for everyone again') - toggleTrackChanges(false) - }) - }) - describe('editor', () => { it('renders jpg', () => { cy.findByTestId('file-tree').findByText('frog.jpg').click() diff --git a/server-ce/test/git-bridge.spec.ts b/server-ce/test/git-bridge.spec.ts index 071091bdfd..447f28bfd2 100644 --- a/server-ce/test/git-bridge.spec.ts +++ b/server-ce/test/git-bridge.spec.ts @@ -46,7 +46,7 @@ describe('git-bridge', function () { function maybeClearAllTokens() { cy.visit('/user/settings') - cy.findByText('Git Integration') + cy.findByText('Git integration') cy.get('button') .contains(/Generate token|Add another token/) .then(btn => { @@ -63,7 +63,7 @@ describe('git-bridge', function () { it('should render the git-bridge UI in the settings', () => { maybeClearAllTokens() cy.visit('/user/settings') - cy.findByText('Git Integration') + cy.findByText('Git integration') cy.get('button').contains('Generate token').click() cy.get('code') .contains(/olp_[a-zA-Z0-9]{16}/) @@ -93,7 +93,7 @@ describe('git-bridge', function () { cy.get('code').contains(`git clone ${gitURL(id.toString())}`) }) cy.findByRole('button', { - name: 'Generate token', + name: /generate token/i, }).click() cy.get('code').contains(/olp_[a-zA-Z0-9]{16}/) }) @@ -107,7 +107,7 @@ describe('git-bridge', function () { cy.get('code').contains(`git clone ${gitURL(id.toString())}`) }) cy.findByText('Generate token').should('not.exist') - cy.findByText(/generate a new one in Account Settings/) + cy.findByText(/generate a new one in Account settings/i) cy.findByText('Go to settings') .should('have.attr', 'target', '_blank') .and('have.attr', 'href', '/user/settings') @@ -196,7 +196,7 @@ describe('git-bridge', function () { cy.get('code').contains(`git clone ${gitURL(projectId.toString())}`) }) cy.findByRole('button', { - name: 'Generate token', + name: /generate token/i, }).click() cy.get('code') .contains(/olp_[a-zA-Z0-9]{16}/) @@ -365,7 +365,7 @@ Hello world it('should not render the git-bridge UI in the settings', () => { login('user@example.com') cy.visit('/user/settings') - cy.findByText('Git Integration').should('not.exist') + cy.findByText('Git integration').should('not.exist') }) it('should not render the git-bridge UI in the editor', function () { login('user@example.com') diff --git a/server-ce/test/helpers/project.ts b/server-ce/test/helpers/project.ts index 662327d6f2..8fb6aa2404 100644 --- a/server-ce/test/helpers/project.ts +++ b/server-ce/test/helpers/project.ts @@ -5,11 +5,11 @@ import { v4 as uuid } from 'uuid' export function createProject( name: string, { - type = 'Blank Project', + type = 'Blank project', newProjectButtonMatcher = /new project/i, open = true, }: { - type?: 'Blank Project' | 'Example Project' + type?: 'Blank project' | 'Example project' newProjectButtonMatcher?: RegExp open?: boolean } = {} @@ -37,7 +37,7 @@ export function createProject( } cy.findAllByRole('button').contains(newProjectButtonMatcher).click() // FIXME: This should only look in the left menu - cy.findAllByText(type).first().click() + cy.findAllByText(new RegExp(type, 'i')).first().click() cy.findByRole('dialog').within(() => { cy.get('input').type(name) cy.findByText('Create').click() @@ -215,37 +215,3 @@ export function createNewFile() { return fileName } - -export function toggleTrackChanges(state: boolean) { - cy.findByText('Review').click() - cy.get('.track-changes-menu-button').then(el => { - // when the menu is expanded renders the `expand_more` icon, - // and the `chevron_right` icon when it's collapsed - if (!el.text().includes('expand_more')) { - el.click() - } - }) - - cy.findByText('Everyone') - .parent() - .within(() => { - cy.get('.form-check-input').then(el => { - if (el.prop('checked') === state) return - - const id = uuid() - const alias = `@${id}` - cy.intercept({ - method: 'POST', - url: '**/track_changes', - times: 1, - }).as(id) - if (state) { - cy.get('.form-check-input').check() - } else { - cy.get('.form-check-input').uncheck() - } - cy.wait(alias) - }) - }) - cy.contains('.toolbar-item', 'Review').click() -} diff --git a/server-ce/test/project-list.spec.ts b/server-ce/test/project-list.spec.ts index 3056242a0f..6b038f320c 100644 --- a/server-ce/test/project-list.spec.ts +++ b/server-ce/test/project-list.spec.ts @@ -32,7 +32,7 @@ describe('Project List', () => { before(() => { login(REGULAR_USER) - createProject(projectName, { type: 'Example Project', open: false }) + createProject(projectName, { type: 'Example project', open: false }) }) beforeEach(function () { login(REGULAR_USER) diff --git a/server-ce/test/sandboxed-compiles.spec.ts b/server-ce/test/sandboxed-compiles.spec.ts index f39a00161b..c84af1897b 100644 --- a/server-ce/test/sandboxed-compiles.spec.ts +++ b/server-ce/test/sandboxed-compiles.spec.ts @@ -59,7 +59,9 @@ describe('SandboxedCompiles', function () { }) function checkSyncTeX() { - describe('SyncTeX', function () { + // TODO(25342): re-enable + // eslint-disable-next-line mocha/no-skipped-tests + describe.skip('SyncTeX', function () { let projectName: string beforeEach(function () { projectName = `Project ${uuid()}` diff --git a/server-ce/test/templates.spec.ts b/server-ce/test/templates.spec.ts index e36e99315d..4959e149fc 100644 --- a/server-ce/test/templates.spec.ts +++ b/server-ce/test/templates.spec.ts @@ -47,7 +47,9 @@ describe('Templates', () => { cy.url().should('match', /\/templates$/) }) - it('should have templates feature', () => { + // TODO(25342): re-enable + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('should have templates feature', () => { login(TEMPLATES_USER) const name = `Template ${Date.now()}` const description = `Template Description ${Date.now()}` diff --git a/services/chat/.gitignore b/services/chat/.gitignore deleted file mode 100644 index f0cf94b147..0000000000 --- a/services/chat/.gitignore +++ /dev/null @@ -1,12 +0,0 @@ -**.swp - -public/build/ - -node_modules/ - -plato/ - -**/*.map - -# managed by dev-environment$ bin/update_build_scripts -.npmrc diff --git a/services/chat/.nvmrc b/services/chat/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/services/chat/.nvmrc +++ b/services/chat/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/services/chat/Dockerfile b/services/chat/Dockerfile index 14056c2d29..2fa7112407 100644 --- a/services/chat/Dockerfile +++ b/services/chat/Dockerfile @@ -2,7 +2,7 @@ # Instead run bin/update_build_scripts from # https://github.com/overleaf/internal/ -FROM node:20.18.2 AS base +FROM node:22.15.1 AS base WORKDIR /overleaf/services/chat diff --git a/services/chat/Makefile b/services/chat/Makefile index 94f0afb567..d3f6641f44 100644 --- a/services/chat/Makefile +++ b/services/chat/Makefile @@ -32,12 +32,12 @@ HERE=$(shell pwd) MONOREPO=$(shell cd ../../ && pwd) # Run the linting commands in the scope of the monorepo. # Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:20.18.2 npm run --silent +RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:22.15.1 npm run --silent RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) npm run --silent # Same but from the top of the monorepo -RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:20.18.2 npm run --silent +RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:22.15.1 npm run --silent SHELLCHECK_OPTS = \ --shell=bash \ diff --git a/services/chat/buildscript.txt b/services/chat/buildscript.txt index 1dc88e9fa6..287aa49cb7 100644 --- a/services/chat/buildscript.txt +++ b/services/chat/buildscript.txt @@ -4,6 +4,6 @@ chat --env-add= --env-pass-through= --esmock-loader=False ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 diff --git a/services/chat/docker-compose.ci.yml b/services/chat/docker-compose.ci.yml index 51eb64d126..8fd86c1fbb 100644 --- a/services/chat/docker-compose.ci.yml +++ b/services/chat/docker-compose.ci.yml @@ -39,7 +39,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root mongo: - image: mongo:6.0.13 + image: mongo:7.0.20 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/chat/docker-compose.yml b/services/chat/docker-compose.yml index b830d25453..89a48339bd 100644 --- a/services/chat/docker-compose.yml +++ b/services/chat/docker-compose.yml @@ -6,7 +6,7 @@ version: "2.3" services: test_unit: - image: node:20.18.2 + image: node:22.15.1 volumes: - .:/overleaf/services/chat - ../../node_modules:/overleaf/node_modules @@ -21,7 +21,7 @@ services: user: node test_acceptance: - image: node:20.18.2 + image: node:22.15.1 volumes: - .:/overleaf/services/chat - ../../node_modules:/overleaf/node_modules @@ -42,7 +42,7 @@ services: command: npm run --silent test:acceptance mongo: - image: mongo:6.0.13 + image: mongo:7.0.20 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/clsi/.gitignore b/services/clsi/.gitignore index 360466227e..a85e6b757a 100644 --- a/services/clsi/.gitignore +++ b/services/clsi/.gitignore @@ -1,14 +1,3 @@ -**.swp -node_modules -test/acceptance/fixtures/tmp compiles output -.DS_Store -*~ cache -.vagrant -config/* -npm-debug.log - -# managed by dev-environment$ bin/update_build_scripts -.npmrc diff --git a/services/clsi/.nvmrc b/services/clsi/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/services/clsi/.nvmrc +++ b/services/clsi/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/services/clsi/Dockerfile b/services/clsi/Dockerfile index c5f46c1c19..581e95b832 100644 --- a/services/clsi/Dockerfile +++ b/services/clsi/Dockerfile @@ -2,7 +2,7 @@ # Instead run bin/update_build_scripts from # https://github.com/overleaf/internal/ -FROM node:20.18.2 AS base +FROM node:22.15.1 AS base WORKDIR /overleaf/services/clsi COPY services/clsi/install_deps.sh /overleaf/services/clsi/ diff --git a/services/clsi/Makefile b/services/clsi/Makefile index 2f673dbd87..f18e7201c1 100644 --- a/services/clsi/Makefile +++ b/services/clsi/Makefile @@ -24,7 +24,6 @@ DOCKER_COMPOSE_TEST_UNIT = \ clean: -docker rmi ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) - -docker rmi gcr.io/overleaf-ops/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) -docker rmi us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) -$(DOCKER_COMPOSE_TEST_UNIT) down --rmi local -$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down --rmi local @@ -33,12 +32,12 @@ HERE=$(shell pwd) MONOREPO=$(shell cd ../../ && pwd) # Run the linting commands in the scope of the monorepo. # Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:20.18.2 npm run --silent +RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:22.15.1 npm run --silent RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) npm run --silent # Same but from the top of the monorepo -RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:20.18.2 npm run --silent +RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:22.15.1 npm run --silent SHELLCHECK_OPTS = \ --shell=bash \ @@ -129,11 +128,10 @@ build: --pull \ --build-arg BUILDKIT_INLINE_CACHE=1 \ --tag ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) \ - --tag gcr.io/overleaf-ops/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) \ - --tag gcr.io/overleaf-ops/$(PROJECT_NAME):$(BRANCH_NAME) \ - --cache-from gcr.io/overleaf-ops/$(PROJECT_NAME):$(BRANCH_NAME) \ - --cache-from gcr.io/overleaf-ops/$(PROJECT_NAME):main \ --tag us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) \ + --tag us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME):$(BRANCH_NAME) \ + --cache-from us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME):$(BRANCH_NAME) \ + --cache-from us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME):main \ --file Dockerfile \ ../.. diff --git a/services/clsi/app.js b/services/clsi/app.js index 8de9d89b9b..872f612d9c 100644 --- a/services/clsi/app.js +++ b/services/clsi/app.js @@ -249,6 +249,9 @@ app.get('/health_check', function (req, res) { if (Settings.processTooOld) { return res.status(500).json({ processTooOld: true }) } + if (ProjectPersistenceManager.isAnyDiskCriticalLow()) { + return res.status(500).json({ diskCritical: true }) + } smokeTest.sendLastResult(res) }) @@ -296,9 +299,14 @@ const loadTcpServer = net.createServer(function (socket) { } const freeLoad = availableWorkingCpus - currentLoad - const freeLoadPercentage = Math.round( - (freeLoad / availableWorkingCpus) * 100 - ) + let freeLoadPercentage = Math.round((freeLoad / availableWorkingCpus) * 100) + if (ProjectPersistenceManager.isAnyDiskCriticalLow()) { + freeLoadPercentage = 0 + } + if (ProjectPersistenceManager.isAnyDiskLow()) { + freeLoadPercentage = freeLoadPercentage / 2 + } + if ( Settings.internal.load_balancer_agent.allow_maintenance && freeLoadPercentage <= 0 diff --git a/services/clsi/app/js/CLSICacheHandler.js b/services/clsi/app/js/CLSICacheHandler.js index de6f512987..b9415ae3ec 100644 --- a/services/clsi/app/js/CLSICacheHandler.js +++ b/services/clsi/app/js/CLSICacheHandler.js @@ -20,6 +20,19 @@ const TIMING_BUCKETS = [ 0, 10, 100, 1000, 2000, 5000, 10000, 15000, 20000, 30000, ] const MAX_ENTRIES_IN_OUTPUT_TAR = 100 +const OBJECT_ID_REGEX = /^[0-9a-f]{24}$/ + +/** + * @param {string} projectId + * @return {{shard: string, url: string}} + */ +function getShard(projectId) { + // [timestamp 4bytes][random per machine 5bytes][counter 3bytes] + // [32bit 4bytes] + const last4Bytes = Buffer.from(projectId, 'hex').subarray(8, 12) + const idx = last4Bytes.readUInt32BE() % Settings.apis.clsiCache.shards.length + return Settings.apis.clsiCache.shards[idx] +} /** * @param {string} projectId @@ -29,6 +42,7 @@ const MAX_ENTRIES_IN_OUTPUT_TAR = 100 * @param {[{path: string}]} outputFiles * @param {string} compileGroup * @param {Record} options + * @return {string | undefined} */ function notifyCLSICacheAboutBuild({ projectId, @@ -39,14 +53,16 @@ function notifyCLSICacheAboutBuild({ compileGroup, options, }) { - if (!Settings.apis.clsiCache.enabled) return + if (!Settings.apis.clsiCache.enabled) return undefined + if (!OBJECT_ID_REGEX.test(projectId)) return undefined + const { url, shard } = getShard(projectId) /** * @param {[{path: string}]} files */ const enqueue = files => { Metrics.count('clsi_cache_enqueue_files', files.length) - fetchNothing(`${Settings.apis.clsiCache.url}/enqueue`, { + fetchNothing(`${url}/enqueue`, { method: 'POST', json: { projectId, @@ -97,6 +113,8 @@ function notifyCLSICacheAboutBuild({ 'build output.tar.gz for clsi cache failed' ) }) + + return shard } /** @@ -155,6 +173,7 @@ async function downloadOutputDotSynctexFromCompileCache( outputDir ) { if (!Settings.apis.clsiCache.enabled) return false + if (!OBJECT_ID_REGEX.test(projectId)) return false const timer = new Metrics.Timer( 'clsi_cache_download', @@ -165,7 +184,7 @@ async function downloadOutputDotSynctexFromCompileCache( let stream try { stream = await fetchStream( - `${Settings.apis.clsiCache.url}/project/${projectId}/${ + `${getShard(projectId).url}/project/${projectId}/${ userId ? `user/${userId}/` : '' }build/${editorId}-${buildId}/search/output/output.synctex.gz`, { @@ -205,8 +224,9 @@ async function downloadOutputDotSynctexFromCompileCache( */ async function downloadLatestCompileCache(projectId, userId, compileDir) { if (!Settings.apis.clsiCache.enabled) return false + if (!OBJECT_ID_REGEX.test(projectId)) return false - const url = `${Settings.apis.clsiCache.url}/project/${projectId}/${ + const url = `${getShard(projectId).url}/project/${projectId}/${ userId ? `user/${userId}/` : '' }latest/output/output.tar.gz` const timer = new Metrics.Timer( diff --git a/services/clsi/app/js/CompileController.js b/services/clsi/app/js/CompileController.js index 87a7db6ec2..7329c14342 100644 --- a/services/clsi/app/js/CompileController.js +++ b/services/clsi/app/js/CompileController.js @@ -112,12 +112,13 @@ function compile(req, res, next) { buildId = error.buildId } + let clsiCacheShard if ( status === 'success' && request.editorId && request.populateClsiCache ) { - notifyCLSICacheAboutBuild({ + clsiCacheShard = notifyCLSICacheAboutBuild({ projectId: request.project_id, userId: request.user_id, buildId: outputFiles[0].build, @@ -144,6 +145,7 @@ function compile(req, res, next) { stats, timings, buildId, + clsiCacheShard, outputUrlPrefix: Settings.apis.clsi.outputUrlPrefix, outputFiles: outputFiles.map(file => ({ url: @@ -188,7 +190,8 @@ function clearCache(req, res, next) { } function syncFromCode(req, res, next) { - const { file, editorId, buildId, compileFromClsiCache } = req.query + const { file, editorId, buildId } = req.query + const compileFromClsiCache = req.query.compileFromClsiCache === 'true' const line = parseInt(req.query.line, 10) const column = parseInt(req.query.column, 10) const { imageName } = req.query @@ -201,12 +204,13 @@ function syncFromCode(req, res, next) { line, column, { imageName, editorId, buildId, compileFromClsiCache }, - function (error, pdfPositions) { + function (error, pdfPositions, downloadedFromCache) { if (error) { return next(error) } res.json({ pdf: pdfPositions, + downloadedFromCache, }) } ) @@ -216,7 +220,8 @@ function syncFromPdf(req, res, next) { const page = parseInt(req.query.page, 10) const h = parseFloat(req.query.h) const v = parseFloat(req.query.v) - const { imageName, editorId, buildId, compileFromClsiCache } = req.query + const { imageName, editorId, buildId } = req.query + const compileFromClsiCache = req.query.compileFromClsiCache === 'true' const projectId = req.params.project_id const userId = req.params.user_id CompileManager.syncFromPdf( @@ -226,12 +231,13 @@ function syncFromPdf(req, res, next) { h, v, { imageName, editorId, buildId, compileFromClsiCache }, - function (error, codePositions) { + function (error, codePositions, downloadedFromCache) { if (error) { return next(error) } res.json({ code: codePositions, + downloadedFromCache, }) } ) diff --git a/services/clsi/app/js/CompileManager.js b/services/clsi/app/js/CompileManager.js index b65fb3cd02..1b66927412 100644 --- a/services/clsi/app/js/CompileManager.js +++ b/services/clsi/app/js/CompileManager.js @@ -23,6 +23,7 @@ const { downloadLatestCompileCache, downloadOutputDotSynctexFromCompileCache, } = require('./CLSICacheHandler') +const { callbackifyMultiResult } = require('@overleaf/promise-utils') const COMPILE_TIME_BUCKETS = [ // NOTE: These buckets are locked in per metric name. @@ -447,12 +448,20 @@ async function syncFromCode(projectId, userId, filename, line, column, opts) { '-o', outputFilePath, ] - const stdout = await _runSynctex(projectId, userId, command, opts) + const { stdout, downloadedFromCache } = await _runSynctex( + projectId, + userId, + command, + opts + ) logger.debug( { projectId, userId, filename, line, column, command, stdout }, 'synctex code output' ) - return SynctexOutputParser.parseViewOutput(stdout) + return { + codePositions: SynctexOutputParser.parseViewOutput(stdout), + downloadedFromCache, + } } async function syncFromPdf(projectId, userId, page, h, v, opts) { @@ -465,9 +474,17 @@ async function syncFromPdf(projectId, userId, page, h, v, opts) { '-o', `${page}:${h}:${v}:${outputFilePath}`, ] - const stdout = await _runSynctex(projectId, userId, command, opts) + const { stdout, downloadedFromCache } = await _runSynctex( + projectId, + userId, + command, + opts + ) logger.debug({ projectId, userId, page, h, v, stdout }, 'synctex pdf output') - return SynctexOutputParser.parseEditOutput(stdout, baseDir) + return { + pdfPositions: SynctexOutputParser.parseEditOutput(stdout, baseDir), + downloadedFromCache, + } } async function _checkFileExists(dir, filename) { @@ -522,9 +539,10 @@ async function _runSynctex(projectId, userId, command, opts) { return await OutputCacheManager.promises.queueDirOperation( outputDir, /** - * @return {Promise} + * @return {Promise<{stdout: string, downloadedFromCache: boolean}>} */ async () => { + let downloadedFromCache = false try { await _checkFileExists(directory, 'output.synctex.gz') } catch (err) { @@ -535,13 +553,14 @@ async function _runSynctex(projectId, userId, command, opts) { buildId ) { try { - await downloadOutputDotSynctexFromCompileCache( - projectId, - userId, - editorId, - buildId, - directory - ) + downloadedFromCache = + await downloadOutputDotSynctexFromCompileCache( + projectId, + userId, + editorId, + buildId, + directory + ) } catch (err) { logger.warn( { err, projectId, userId, editorId, buildId }, @@ -554,7 +573,7 @@ async function _runSynctex(projectId, userId, command, opts) { } } try { - const output = await CommandRunner.promises.run( + const { stdout } = await CommandRunner.promises.run( compileName, command, directory, @@ -563,7 +582,10 @@ async function _runSynctex(projectId, userId, command, opts) { {}, compileGroup ) - return output.stdout + return { + stdout, + downloadedFromCache, + } } catch (error) { throw OError.tag(error, 'error running synctex', { command, @@ -686,8 +708,14 @@ module.exports = { stopCompile: callbackify(stopCompile), clearProject: callbackify(clearProject), clearExpiredProjects: callbackify(clearExpiredProjects), - syncFromCode: callbackify(syncFromCode), - syncFromPdf: callbackify(syncFromPdf), + syncFromCode: callbackifyMultiResult(syncFromCode, [ + 'codePositions', + 'downloadedFromCache', + ]), + syncFromPdf: callbackifyMultiResult(syncFromPdf, [ + 'pdfPositions', + 'downloadedFromCache', + ]), wordcount: callbackify(wordcount), promises: { doCompileWithLock, diff --git a/services/clsi/app/js/ProjectPersistenceManager.js b/services/clsi/app/js/ProjectPersistenceManager.js index e96a4591c3..41cdd07f4d 100644 --- a/services/clsi/app/js/ProjectPersistenceManager.js +++ b/services/clsi/app/js/ProjectPersistenceManager.js @@ -22,6 +22,9 @@ const fs = require('node:fs') // projectId -> timestamp mapping. const LAST_ACCESS = new Map() +let ANY_DISK_LOW = false +let ANY_DISK_CRITICAL_LOW = false + async function collectDiskStats() { const paths = [ Settings.path.compilesDir, @@ -30,6 +33,8 @@ async function collectDiskStats() { ] const diskStats = {} + let anyDiskLow = false + let anyDiskCriticalLow = false for (const path of paths) { try { const { blocks, bavail, bsize } = await fs.promises.statfs(path) @@ -45,10 +50,16 @@ async function collectDiskStats() { }) const lowDisk = diskAvailablePercent < 10 diskStats[path] = { stats, lowDisk } + + const criticalLowDisk = diskAvailablePercent < 3 + anyDiskLow = anyDiskLow || lowDisk + anyDiskCriticalLow = anyDiskCriticalLow || criticalLowDisk } catch (err) { logger.err({ err, path }, 'error getting disk usage') } } + ANY_DISK_LOW = anyDiskLow + ANY_DISK_CRITICAL_LOW = anyDiskCriticalLow return diskStats } @@ -70,11 +81,22 @@ async function refreshExpiryTimeout() { break } } + Metrics.gauge( + 'project_persistence_expiry_timeout', + ProjectPersistenceManager.EXPIRY_TIMEOUT + ) } module.exports = ProjectPersistenceManager = { EXPIRY_TIMEOUT: Settings.project_cache_length_ms || oneDay * 2.5, + isAnyDiskLow() { + return ANY_DISK_LOW + }, + isAnyDiskCriticalLow() { + return ANY_DISK_CRITICAL_LOW + }, + promises: { refreshExpiryTimeout, }, @@ -125,12 +147,12 @@ module.exports = ProjectPersistenceManager = { ) }) - // Collect disk stats frequently to have them ready the next time /metrics is scraped (60s +- jitter). + // Collect disk stats frequently to have them ready the next time /metrics is scraped (60s +- jitter) or every 5th scrape of the load agent (3s +- jitter). setInterval(() => { collectDiskStats().catch(err => { logger.err({ err }, 'low level error collecting disk stats') }) - }, 50_000) + }, 15_000) }, markProjectAsJustAccessed(projectId, callback) { diff --git a/services/clsi/buildscript.txt b/services/clsi/buildscript.txt index 1834ac9648..709ade18c3 100644 --- a/services/clsi/buildscript.txt +++ b/services/clsi/buildscript.txt @@ -1,11 +1,11 @@ clsi --data-dirs=cache,compiles,output --dependencies= ---docker-repos=gcr.io/overleaf-ops,us-east1-docker.pkg.dev/overleaf-ops/ol-docker ---env-add=ENABLE_PDF_CACHING="true",PDF_CACHING_ENABLE_WORKER_POOL="true",ALLOWED_IMAGES=quay.io/sharelatex/texlive-full:2017.1,TEXLIVE_IMAGE=quay.io/sharelatex/texlive-full:2017.1,TEX_LIVE_IMAGE_NAME_OVERRIDE=gcr.io/overleaf-ops,TEXLIVE_IMAGE_USER="tex",DOCKER_RUNNER="true",COMPILES_HOST_DIR=$PWD/compiles,OUTPUT_HOST_DIR=$PWD/output +--docker-repos=us-east1-docker.pkg.dev/overleaf-ops/ol-docker +--env-add=ENABLE_PDF_CACHING="true",PDF_CACHING_ENABLE_WORKER_POOL="true",ALLOWED_IMAGES=quay.io/sharelatex/texlive-full:2017.1,TEXLIVE_IMAGE=quay.io/sharelatex/texlive-full:2017.1,TEX_LIVE_IMAGE_NAME_OVERRIDE=us-east1-docker.pkg.dev/overleaf-ops/ol-docker,TEXLIVE_IMAGE_USER="tex",DOCKER_RUNNER="true",COMPILES_HOST_DIR=$PWD/compiles,OUTPUT_HOST_DIR=$PWD/output --env-pass-through= --esmock-loader=False ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=True --script-version=4.7.0 --use-large-ci-runner=True diff --git a/services/clsi/config/settings.defaults.js b/services/clsi/config/settings.defaults.js index 6f16e01a89..d187fe273e 100644 --- a/services/clsi/config/settings.defaults.js +++ b/services/clsi/config/settings.defaults.js @@ -1,10 +1,6 @@ const Path = require('node:path') -const http = require('node:http') -const https = require('node:https') const os = require('node:os') -http.globalAgent.keepAlive = false -https.globalAgent.keepAlive = false const isPreEmptible = process.env.PREEMPTIBLE === 'TRUE' const CLSI_SERVER_ID = os.hostname().replace('-ctr', '') @@ -60,9 +56,8 @@ module.exports = { }`, }, clsiCache: { - enabled: !!process.env.CLSI_CACHE_HOST, - url: `http://${process.env.CLSI_CACHE_HOST}:3044`, - downloadURL: `http://${process.env.CLSI_CACHE_NGINX_HOST || process.env.CLSI_CACHE_HOST}:8080`, + enabled: !!process.env.CLSI_CACHE_SHARDS, + shards: JSON.parse(process.env.CLSI_CACHE_SHARDS || '[]'), }, }, diff --git a/services/clsi/docker-compose.ci.yml b/services/clsi/docker-compose.ci.yml index 1754a3a916..b6643008f7 100644 --- a/services/clsi/docker-compose.ci.yml +++ b/services/clsi/docker-compose.ci.yml @@ -27,7 +27,7 @@ services: PDF_CACHING_ENABLE_WORKER_POOL: "true" ALLOWED_IMAGES: quay.io/sharelatex/texlive-full:2017.1 TEXLIVE_IMAGE: quay.io/sharelatex/texlive-full:2017.1 - TEX_LIVE_IMAGE_NAME_OVERRIDE: gcr.io/overleaf-ops + TEX_LIVE_IMAGE_NAME_OVERRIDE: us-east1-docker.pkg.dev/overleaf-ops/ol-docker TEXLIVE_IMAGE_USER: "tex" DOCKER_RUNNER: "true" COMPILES_HOST_DIR: $PWD/compiles diff --git a/services/clsi/docker-compose.yml b/services/clsi/docker-compose.yml index 3e70c256ea..e0f29ab09d 100644 --- a/services/clsi/docker-compose.yml +++ b/services/clsi/docker-compose.yml @@ -45,7 +45,7 @@ services: PDF_CACHING_ENABLE_WORKER_POOL: "true" ALLOWED_IMAGES: quay.io/sharelatex/texlive-full:2017.1 TEXLIVE_IMAGE: quay.io/sharelatex/texlive-full:2017.1 - TEX_LIVE_IMAGE_NAME_OVERRIDE: gcr.io/overleaf-ops + TEX_LIVE_IMAGE_NAME_OVERRIDE: us-east1-docker.pkg.dev/overleaf-ops/ol-docker TEXLIVE_IMAGE_USER: "tex" DOCKER_RUNNER: "true" COMPILES_HOST_DIR: $PWD/compiles diff --git a/services/clsi/entrypoint.sh b/services/clsi/entrypoint.sh index bb551c91eb..b45899ab17 100755 --- a/services/clsi/entrypoint.sh +++ b/services/clsi/entrypoint.sh @@ -8,7 +8,6 @@ usermod -aG dockeronhost node # compatibility: initial volume setup mkdir -p /overleaf/services/clsi/cache && chown node:node /overleaf/services/clsi/cache mkdir -p /overleaf/services/clsi/compiles && chown node:node /overleaf/services/clsi/compiles -mkdir -p /overleaf/services/clsi/db && chown node:node /overleaf/services/clsi/db mkdir -p /overleaf/services/clsi/output && chown node:node /overleaf/services/clsi/output exec runuser -u node -- "$@" diff --git a/services/clsi/kube.yaml b/services/clsi/kube.yaml deleted file mode 100644 index d3fb04291e..0000000000 --- a/services/clsi/kube.yaml +++ /dev/null @@ -1,41 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: clsi - namespace: default -spec: - type: LoadBalancer - ports: - - port: 80 - protocol: TCP - targetPort: 80 - selector: - run: clsi ---- -apiVersion: extensions/v1beta1 -kind: Deployment -metadata: - name: clsi - namespace: default -spec: - replicas: 2 - template: - metadata: - labels: - run: clsi - spec: - containers: - - name: clsi - image: gcr.io/henry-terraform-admin/clsi - imagePullPolicy: Always - readinessProbe: - httpGet: - path: status - port: 80 - periodSeconds: 5 - initialDelaySeconds: 0 - failureThreshold: 3 - successThreshold: 1 - - - diff --git a/services/clsi/test/acceptance/js/AllowedImageNamesTests.js b/services/clsi/test/acceptance/js/AllowedImageNamesTests.js index 897f5d9c85..9cd7a65930 100644 --- a/services/clsi/test/acceptance/js/AllowedImageNamesTests.js +++ b/services/clsi/test/acceptance/js/AllowedImageNamesTests.js @@ -109,6 +109,7 @@ Hello world width: 343.71106, }, ], + downloadedFromCache: false, }) done() } @@ -146,6 +147,7 @@ Hello world expect(error).to.not.exist expect(result).to.deep.equal({ code: [{ file: 'main.tex', line: 3, column: -1 }], + downloadedFromCache: false, }) done() } diff --git a/services/clsi/test/acceptance/js/SynctexTests.js b/services/clsi/test/acceptance/js/SynctexTests.js index 5ba5bb5b5f..049f260259 100644 --- a/services/clsi/test/acceptance/js/SynctexTests.js +++ b/services/clsi/test/acceptance/js/SynctexTests.js @@ -67,6 +67,7 @@ Hello world width: 343.71106, }, ], + downloadedFromCache: false, }) return done() } @@ -87,6 +88,7 @@ Hello world } expect(codePositions).to.deep.equal({ code: [{ file: 'main.tex', line: 3, column: -1 }], + downloadedFromCache: false, }) return done() } diff --git a/services/clsi/test/unit/js/CompileControllerTests.js b/services/clsi/test/unit/js/CompileControllerTests.js index e6d21aed9f..2ac8d9c2d7 100644 --- a/services/clsi/test/unit/js/CompileControllerTests.js +++ b/services/clsi/test/unit/js/CompileControllerTests.js @@ -129,6 +129,7 @@ describe('CompileController', function () { url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`, ...file, })), + clsiCacheShard: undefined, }, }) .should.equal(true) @@ -156,6 +157,7 @@ describe('CompileController', function () { url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`, ...file, })), + clsiCacheShard: undefined, }, }) .should.equal(true) @@ -203,6 +205,7 @@ describe('CompileController', function () { url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`, ...file, })), + clsiCacheShard: undefined, }, }) }) @@ -250,6 +253,7 @@ describe('CompileController', function () { url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`, ...file, })), + clsiCacheShard: undefined, }, }) }) @@ -281,6 +285,7 @@ describe('CompileController', function () { buildId: this.buildId, stats: this.stats, timings: this.timings, + clsiCacheShard: undefined, }, }) .should.equal(true) @@ -315,6 +320,7 @@ describe('CompileController', function () { timings: this.timings, // JSON.stringify will omit these undefined values buildId: undefined, + clsiCacheShard: undefined, }, }) .should.equal(true) @@ -348,6 +354,7 @@ describe('CompileController', function () { timings: this.timings, // JSON.stringify will omit these undefined values buildId: undefined, + clsiCacheShard: undefined, }, }) .should.equal(true) @@ -379,6 +386,7 @@ describe('CompileController', function () { timings: this.timings, // JSON.stringify will omit these undefined values buildId: undefined, + clsiCacheShard: undefined, }, }) .should.equal(true) @@ -402,7 +410,7 @@ describe('CompileController', function () { this.CompileManager.syncFromCode = sinon .stub() - .yields(null, (this.pdfPositions = ['mock-positions'])) + .yields(null, (this.pdfPositions = ['mock-positions']), true) this.CompileController.syncFromCode(this.req, this.res, this.next) }) @@ -422,6 +430,7 @@ describe('CompileController', function () { this.res.json .calledWith({ pdf: this.pdfPositions, + downloadedFromCache: true, }) .should.equal(true) }) @@ -443,7 +452,7 @@ describe('CompileController', function () { this.CompileManager.syncFromPdf = sinon .stub() - .yields(null, (this.codePositions = ['mock-positions'])) + .yields(null, (this.codePositions = ['mock-positions']), true) this.CompileController.syncFromPdf(this.req, this.res, this.next) }) @@ -457,6 +466,7 @@ describe('CompileController', function () { this.res.json .calledWith({ code: this.codePositions, + downloadedFromCache: true, }) .should.equal(true) }) diff --git a/services/clsi/test/unit/js/CompileManagerTests.js b/services/clsi/test/unit/js/CompileManagerTests.js index 33a43ae029..30ef538ac3 100644 --- a/services/clsi/test/unit/js/CompileManagerTests.js +++ b/services/clsi/test/unit/js/CompileManagerTests.js @@ -35,7 +35,7 @@ describe('CompileManager', function () { build: 1234, }, ] - this.buildId = 'build-id-123' + this.buildId = '00000000000-0000000000000000' this.commandOutput = 'Dummy output' this.compileBaseDir = '/compile/dir' this.outputBaseDir = '/output/dir' @@ -61,6 +61,8 @@ describe('CompileManager', function () { }, } this.OutputCacheManager = { + BUILD_REGEX: /^[0-9a-f]+-[0-9a-f]+$/, + CACHE_SUBDIR: 'generated-files', promises: { queueDirOperation: sinon.stub().callsArg(1), saveOutputFiles: sinon @@ -88,9 +90,10 @@ describe('CompileManager', function () { execFile: sinon.stub().yields(), } this.CommandRunner = { + canRunSyncTeXInOutputDir: sinon.stub().returns(false), promises: { run: sinon.stub().callsFake((_1, _2, _3, _4, _5, _6, compileGroup) => { - if (compileGroup === 'synctex') { + if (compileGroup === 'synctex' || compileGroup === 'synctex-output') { return Promise.resolve({ stdout: this.commandOutput }) } else { return Promise.resolve({ @@ -141,6 +144,12 @@ describe('CompileManager', function () { .withArgs(Path.join(this.compileDir, 'output.synctex.gz')) .resolves(this.fileStats) + this.CLSICacheHandler = { + notifyCLSICacheAboutBuild: sinon.stub(), + downloadLatestCompileCache: sinon.stub().resolves(), + downloadOutputDotSynctexFromCompileCache: sinon.stub().resolves(), + } + this.CompileManager = SandboxedModule.require(MODULE_PATH, { requires: { './LatexRunner': this.LatexRunner, @@ -161,11 +170,7 @@ describe('CompileManager', function () { './LockManager': this.LockManager, './SynctexOutputParser': this.SynctexOutputParser, 'fs/promises': this.fsPromises, - './CLSICacheHandler': { - notifyCLSICacheAboutBuild: sinon.stub(), - downloadLatestCompileCache: sinon.stub().resolves(), - downloadOutputDotSynctexFromCompileCache: sinon.stub().resolves(), - }, + './CLSICacheHandler': this.CLSICacheHandler, }, }) }) @@ -462,12 +467,83 @@ describe('CompileManager', function () { this.compileDir, this.Settings.clsi.docker.image, 60000, - {} + {}, + 'synctex' ) }) it('should return the parsed output', function () { - expect(this.result).to.deep.equal(this.records) + expect(this.result).to.deep.equal({ + codePositions: this.records, + downloadedFromCache: false, + }) + }) + }) + + describe('from cache in docker', function () { + beforeEach(async function () { + this.CommandRunner.canRunSyncTeXInOutputDir.returns(true) + this.Settings.path.synctexBaseDir + .withArgs(`${this.projectId}-${this.userId}`) + .returns('/compile') + + const errNotFound = new Error() + errNotFound.code = 'ENOENT' + this.outputDir = `${this.outputBaseDir}/${this.projectId}-${this.userId}/${this.OutputCacheManager.CACHE_SUBDIR}/${this.buildId}` + const filename = Path.join(this.outputDir, 'output.synctex.gz') + this.fsPromises.stat + .withArgs(this.outputDir) + .onFirstCall() + .rejects(errNotFound) + this.fsPromises.stat + .withArgs(this.outputDir) + .onSecondCall() + .resolves(this.dirStats) + this.fsPromises.stat.withArgs(filename).resolves(this.fileStats) + this.CLSICacheHandler.downloadOutputDotSynctexFromCompileCache.resolves( + true + ) + this.result = await this.CompileManager.promises.syncFromCode( + this.projectId, + this.userId, + this.filename, + this.line, + this.column, + { + imageName: 'image', + editorId: '00000000-0000-0000-0000-000000000000', + buildId: this.buildId, + compileFromClsiCache: true, + } + ) + }) + + it('should run in output dir', function () { + const outputFilePath = '/compile/output.pdf' + const inputFilePath = `/compile/${this.filename}` + expect(this.CommandRunner.promises.run).to.have.been.calledWith( + `${this.projectId}-${this.userId}`, + [ + 'synctex', + 'view', + '-i', + `${this.line}:${this.column}:${inputFilePath}`, + '-o', + outputFilePath, + ], + this.outputDir, + 'image', + 60000, + {}, + 'synctex-output' + ) + }) + + it('should return the parsed output', function () { + expect(this.result).to.deep.equal({ + codePositions: this.records, + downloadedFromCache: true, + }) }) }) @@ -500,7 +576,8 @@ describe('CompileManager', function () { this.compileDir, customImageName, 60000, - {} + {}, + 'synctex' ) }) }) @@ -544,7 +621,10 @@ describe('CompileManager', function () { }) it('should return the parsed output', function () { - expect(this.result).to.deep.equal(this.records) + expect(this.result).to.deep.equal({ + pdfPositions: this.records, + downloadedFromCache: false, + }) }) }) diff --git a/services/contacts/.gitignore b/services/contacts/.gitignore deleted file mode 100644 index 80bac793a7..0000000000 --- a/services/contacts/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -forever - -# managed by dev-environment$ bin/update_build_scripts -.npmrc diff --git a/services/contacts/.nvmrc b/services/contacts/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/services/contacts/.nvmrc +++ b/services/contacts/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/services/contacts/Dockerfile b/services/contacts/Dockerfile index 69d2d35b3c..d3db63bb25 100644 --- a/services/contacts/Dockerfile +++ b/services/contacts/Dockerfile @@ -2,7 +2,7 @@ # Instead run bin/update_build_scripts from # https://github.com/overleaf/internal/ -FROM node:20.18.2 AS base +FROM node:22.15.1 AS base WORKDIR /overleaf/services/contacts diff --git a/services/contacts/Makefile b/services/contacts/Makefile index 97a348d219..035a8ea5ac 100644 --- a/services/contacts/Makefile +++ b/services/contacts/Makefile @@ -32,12 +32,12 @@ HERE=$(shell pwd) MONOREPO=$(shell cd ../../ && pwd) # Run the linting commands in the scope of the monorepo. # Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:20.18.2 npm run --silent +RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:22.15.1 npm run --silent RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) npm run --silent # Same but from the top of the monorepo -RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:20.18.2 npm run --silent +RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:22.15.1 npm run --silent SHELLCHECK_OPTS = \ --shell=bash \ diff --git a/services/contacts/buildscript.txt b/services/contacts/buildscript.txt index 8563d1b71e..f03bcebd64 100644 --- a/services/contacts/buildscript.txt +++ b/services/contacts/buildscript.txt @@ -4,6 +4,6 @@ contacts --env-add= --env-pass-through= --esmock-loader=True ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 diff --git a/services/contacts/docker-compose.ci.yml b/services/contacts/docker-compose.ci.yml index 51eb64d126..8fd86c1fbb 100644 --- a/services/contacts/docker-compose.ci.yml +++ b/services/contacts/docker-compose.ci.yml @@ -39,7 +39,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root mongo: - image: mongo:6.0.13 + image: mongo:7.0.20 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/contacts/docker-compose.yml b/services/contacts/docker-compose.yml index 310220bd20..65e1a578cd 100644 --- a/services/contacts/docker-compose.yml +++ b/services/contacts/docker-compose.yml @@ -6,7 +6,7 @@ version: "2.3" services: test_unit: - image: node:20.18.2 + image: node:22.15.1 volumes: - .:/overleaf/services/contacts - ../../node_modules:/overleaf/node_modules @@ -21,7 +21,7 @@ services: user: node test_acceptance: - image: node:20.18.2 + image: node:22.15.1 volumes: - .:/overleaf/services/contacts - ../../node_modules:/overleaf/node_modules @@ -42,7 +42,7 @@ services: command: npm run --silent test:acceptance mongo: - image: mongo:6.0.13 + image: mongo:7.0.20 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/docstore/.gitignore b/services/docstore/.gitignore deleted file mode 100644 index 84bf300f7f..0000000000 --- a/services/docstore/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -node_modules -forever - -# managed by dev-environment$ bin/update_build_scripts -.npmrc - -# Jetbrains IDEs -.idea diff --git a/services/docstore/.nvmrc b/services/docstore/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/services/docstore/.nvmrc +++ b/services/docstore/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/services/docstore/Dockerfile b/services/docstore/Dockerfile index 60a024ea91..811e7c2553 100644 --- a/services/docstore/Dockerfile +++ b/services/docstore/Dockerfile @@ -2,7 +2,7 @@ # Instead run bin/update_build_scripts from # https://github.com/overleaf/internal/ -FROM node:20.18.2 AS base +FROM node:22.15.1 AS base WORKDIR /overleaf/services/docstore diff --git a/services/docstore/Makefile b/services/docstore/Makefile index 6efd053025..715d5cf351 100644 --- a/services/docstore/Makefile +++ b/services/docstore/Makefile @@ -32,12 +32,12 @@ HERE=$(shell pwd) MONOREPO=$(shell cd ../../ && pwd) # Run the linting commands in the scope of the monorepo. # Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:20.18.2 npm run --silent +RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:22.15.1 npm run --silent RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) npm run --silent # Same but from the top of the monorepo -RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:20.18.2 npm run --silent +RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:22.15.1 npm run --silent SHELLCHECK_OPTS = \ --shell=bash \ diff --git a/services/docstore/buildscript.txt b/services/docstore/buildscript.txt index c329d7b571..fda91d775e 100644 --- a/services/docstore/buildscript.txt +++ b/services/docstore/buildscript.txt @@ -4,6 +4,6 @@ docstore --env-add= --env-pass-through= --esmock-loader=False ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=True --script-version=4.7.0 diff --git a/services/docstore/docker-compose.ci.yml b/services/docstore/docker-compose.ci.yml index a1a9995f60..ff222f6514 100644 --- a/services/docstore/docker-compose.ci.yml +++ b/services/docstore/docker-compose.ci.yml @@ -44,7 +44,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root mongo: - image: mongo:6.0.13 + image: mongo:7.0.20 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/docstore/docker-compose.yml b/services/docstore/docker-compose.yml index 93a029b00a..4a4fa2f10c 100644 --- a/services/docstore/docker-compose.yml +++ b/services/docstore/docker-compose.yml @@ -6,7 +6,7 @@ version: "2.3" services: test_unit: - image: node:20.18.2 + image: node:22.15.1 volumes: - .:/overleaf/services/docstore - ../../node_modules:/overleaf/node_modules @@ -21,7 +21,7 @@ services: user: node test_acceptance: - image: node:20.18.2 + image: node:22.15.1 volumes: - .:/overleaf/services/docstore - ../../node_modules:/overleaf/node_modules @@ -47,7 +47,7 @@ services: command: npm run --silent test:acceptance mongo: - image: mongo:6.0.13 + image: mongo:7.0.20 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/document-updater/.gitignore b/services/document-updater/.gitignore deleted file mode 100644 index 624e78f096..0000000000 --- a/services/document-updater/.gitignore +++ /dev/null @@ -1,52 +0,0 @@ -compileFolder - -Compiled source # -################### -*.com -*.class -*.dll -*.exe -*.o -*.so - -# Packages # -############ -# it's better to unpack these files and commit the raw source -# git has its own built in compression methods -*.7z -*.dmg -*.gz -*.iso -*.jar -*.rar -*.tar -*.zip - -# Logs and databases # -###################### -*.log -*.sql -*.sqlite - -# OS generated files # -###################### -.DS_Store? -ehthumbs.db -Icon? -Thumbs.db - -/node_modules/* - - - -forever/ - -**.swp - -# Redis cluster -**/appendonly.aof -**/dump.rdb -**/nodes.conf - -# managed by dev-environment$ bin/update_build_scripts -.npmrc diff --git a/services/document-updater/.nvmrc b/services/document-updater/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/services/document-updater/.nvmrc +++ b/services/document-updater/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/services/document-updater/Dockerfile b/services/document-updater/Dockerfile index 436d722577..77f18e8f28 100644 --- a/services/document-updater/Dockerfile +++ b/services/document-updater/Dockerfile @@ -2,7 +2,7 @@ # Instead run bin/update_build_scripts from # https://github.com/overleaf/internal/ -FROM node:20.18.2 AS base +FROM node:22.15.1 AS base WORKDIR /overleaf/services/document-updater diff --git a/services/document-updater/Makefile b/services/document-updater/Makefile index 55f483fc89..4f6bae1816 100644 --- a/services/document-updater/Makefile +++ b/services/document-updater/Makefile @@ -32,12 +32,12 @@ HERE=$(shell pwd) MONOREPO=$(shell cd ../../ && pwd) # Run the linting commands in the scope of the monorepo. # Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:20.18.2 npm run --silent +RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:22.15.1 npm run --silent RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) npm run --silent # Same but from the top of the monorepo -RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:20.18.2 npm run --silent +RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:22.15.1 npm run --silent SHELLCHECK_OPTS = \ --shell=bash \ diff --git a/services/document-updater/app/js/DiffCodec.js b/services/document-updater/app/js/DiffCodec.js index 245903ca13..8c574cff70 100644 --- a/services/document-updater/app/js/DiffCodec.js +++ b/services/document-updater/app/js/DiffCodec.js @@ -1,4 +1,5 @@ const DMP = require('diff-match-patch') +const { TextOperation } = require('overleaf-editor-core') const dmp = new DMP() // Do not attempt to produce a diff for more than 100ms @@ -16,8 +17,7 @@ module.exports = { const ops = [] let position = 0 for (const diff of diffs) { - const type = diff[0] - const content = diff[1] + const [type, content] = diff if (type === this.ADDED) { ops.push({ i: content, @@ -37,4 +37,24 @@ module.exports = { } return ops }, + + diffAsHistoryV1EditOperation(before, after) { + const diffs = dmp.diff_main(before, after) + dmp.diff_cleanupSemantic(diffs) + + const op = new TextOperation() + for (const diff of diffs) { + const [type, content] = diff + if (type === this.ADDED) { + op.insert(content) + } else if (type === this.REMOVED) { + op.remove(content.length) + } else if (type === this.UNCHANGED) { + op.retain(content.length) + } else { + throw new Error('Unknown type') + } + } + return op + }, } diff --git a/services/document-updater/app/js/DocumentManager.js b/services/document-updater/app/js/DocumentManager.js index dc20c27d7f..4803056423 100644 --- a/services/document-updater/app/js/DocumentManager.js +++ b/services/document-updater/app/js/DocumentManager.js @@ -11,10 +11,16 @@ const RangesManager = require('./RangesManager') const { extractOriginOrSource } = require('./Utils') const { getTotalSizeOfLines } = require('./Limits') const Settings = require('@overleaf/settings') +const { StringFileData } = require('overleaf-editor-core') const MAX_UNFLUSHED_AGE = 300 * 1000 // 5 mins, document should be flushed to mongo this time after a change const DocumentManager = { + /** + * @param {string} projectId + * @param {string} docId + * @return {Promise<{lines: (string[] | StringFileRawData), version: number, ranges: Ranges, resolvedCommentIds: any[], pathname: string, projectHistoryId: string, unflushedTime: any, alreadyLoaded: boolean, historyRangesSupport: boolean, type: OTType}>} + */ async getDoc(projectId, docId) { const { lines, @@ -75,6 +81,7 @@ const DocumentManager = { unflushedTime: null, alreadyLoaded: false, historyRangesSupport, + type: Array.isArray(lines) ? 'sharejs-text-ot' : 'history-ot', } } else { return { @@ -87,16 +94,25 @@ const DocumentManager = { unflushedTime, alreadyLoaded: true, historyRangesSupport, + type: Array.isArray(lines) ? 'sharejs-text-ot' : 'history-ot', } } }, async getDocAndRecentOps(projectId, docId, fromVersion) { - const { lines, version, ranges, pathname, projectHistoryId } = + const { lines, version, ranges, pathname, projectHistoryId, type } = await DocumentManager.getDoc(projectId, docId) if (fromVersion === -1) { - return { lines, version, ops: [], ranges, pathname, projectHistoryId } + return { + lines, + version, + ops: [], + ranges, + pathname, + projectHistoryId, + type, + } } else { const ops = await RedisManager.promises.getPreviousDocOps( docId, @@ -110,15 +126,21 @@ const DocumentManager = { ranges, pathname, projectHistoryId, + type, } } }, async appendToDoc(projectId, docId, linesToAppend, originOrSource, userId) { - const { lines: currentLines } = await DocumentManager.getDoc( + let { lines: currentLines, type } = await DocumentManager.getDoc( projectId, docId ) + if (type === 'history-ot') { + const file = StringFileData.fromRaw(currentLines) + // TODO(24596): tc support for history-ot + currentLines = file.getLines() + } const currentLineSize = getTotalSizeOfLines(currentLines) const addedSize = getTotalSizeOfLines(linesToAppend) const newlineSize = '\n'.length @@ -153,22 +175,42 @@ const DocumentManager = { throw new Error('No lines were provided to setDoc') } + // Circular dependencies. Import at runtime. + const HistoryOTUpdateManager = require('./HistoryOTUpdateManager') const UpdateManager = require('./UpdateManager') + const { lines: oldLines, version, alreadyLoaded, + type, } = await DocumentManager.getDoc(projectId, docId) logger.debug( { docId, projectId, oldLines, newLines }, 'setting a document via http' ) - const op = DiffCodec.diffAsShareJsOp(oldLines, newLines) - if (undoing) { - for (const o of op || []) { - o.u = true - } // Turn on undo flag for each op for track changes + + let op + if (type === 'history-ot') { + const file = StringFileData.fromRaw(oldLines) + const operation = DiffCodec.diffAsHistoryV1EditOperation( + // TODO(24596): tc support for history-ot + file.getContent({ filterTrackedDeletes: true }), + newLines.join('\n') + ) + if (operation.isNoop()) { + op = [] + } else { + op = [operation.toJSON()] + } + } else { + op = DiffCodec.diffAsShareJsOp(oldLines, newLines) + if (undoing) { + for (const o of op || []) { + o.u = true + } // Turn on undo flag for each op for track changes + } } const { origin, source } = extractOriginOrSource(originOrSource) @@ -203,7 +245,11 @@ const DocumentManager = { // this update, otherwise the doc would never be // removed from redis. if (op.length > 0) { - await UpdateManager.promises.applyUpdate(projectId, docId, update) + if (type === 'history-ot') { + await HistoryOTUpdateManager.applyUpdate(projectId, docId, update) + } else { + await UpdateManager.promises.applyUpdate(projectId, docId, update) + } } // If the document was loaded already, then someone has it open @@ -224,7 +270,7 @@ const DocumentManager = { }, async flushDocIfLoaded(projectId, docId) { - const { + let { lines, version, ranges, @@ -245,6 +291,11 @@ const DocumentManager = { logger.debug({ projectId, docId, version }, 'flushing doc') Metrics.inc('flush-doc-if-loaded', 1, { status: 'modified' }) + if (!Array.isArray(lines)) { + const file = StringFileData.fromRaw(lines) + // TODO(24596): tc support for history-ot + lines = file.getLines() + } const result = await PersistenceManager.promises.setDoc( projectId, docId, @@ -294,6 +345,7 @@ const DocumentManager = { throw new Errors.NotFoundError(`document not found: ${docId}`) } + // TODO(24596): tc support for history-ot const newRanges = RangesManager.acceptChanges( projectId, docId, @@ -360,6 +412,7 @@ const DocumentManager = { }, async getComment(projectId, docId, commentId) { + // TODO(24596): tc support for history-ot const { ranges } = await DocumentManager.getDoc(projectId, docId) const comment = ranges?.comments?.find(comment => comment.id === commentId) @@ -381,6 +434,7 @@ const DocumentManager = { throw new Errors.NotFoundError(`document not found: ${docId}`) } + // TODO(24596): tc support for history-ot const newRanges = RangesManager.deleteComment(commentId, ranges) await RedisManager.promises.updateDocument( @@ -420,7 +474,7 @@ const DocumentManager = { }, async getDocAndFlushIfOld(projectId, docId) { - const { lines, version, unflushedTime, alreadyLoaded } = + let { lines, version, unflushedTime, alreadyLoaded } = await DocumentManager.getDoc(projectId, docId) // if doc was already loaded see if it needs to be flushed @@ -432,6 +486,12 @@ const DocumentManager = { await DocumentManager.flushDocIfLoaded(projectId, docId) } + if (!Array.isArray(lines)) { + const file = StringFileData.fromRaw(lines) + // TODO(24596): tc support for history-ot + lines = file.getLines() + } + return { lines, version } }, @@ -476,6 +536,11 @@ const DocumentManager = { if (opts.historyRangesMigration) { historyRangesSupport = opts.historyRangesMigration === 'forwards' } + if (!Array.isArray(lines)) { + const file = StringFileData.fromRaw(lines) + // TODO(24596): tc support for history-ot + lines = file.getLines() + } await ProjectHistoryRedisManager.promises.queueResyncDocContent( projectId, @@ -684,6 +749,7 @@ module.exports = { 'ranges', 'pathname', 'projectHistoryId', + 'type', ], getDocAndRecentOpsWithLock: [ 'lines', @@ -692,6 +758,7 @@ module.exports = { 'ranges', 'pathname', 'projectHistoryId', + 'type', ], getCommentWithLock: ['comment'], }, diff --git a/services/document-updater/app/js/Errors.js b/services/document-updater/app/js/Errors.js index a43f69ad35..ac1f5875fa 100644 --- a/services/document-updater/app/js/Errors.js +++ b/services/document-updater/app/js/Errors.js @@ -5,6 +5,15 @@ class OpRangeNotAvailableError extends OError {} class ProjectStateChangedError extends OError {} class DeleteMismatchError extends OError {} class FileTooLargeError extends OError {} +class OTTypeMismatchError extends OError { + /** + * @param {OTType} got + * @param {OTType} want + */ + constructor(got, want) { + super('ot type mismatch', { got, want }) + } +} module.exports = { NotFoundError, @@ -12,4 +21,5 @@ module.exports = { ProjectStateChangedError, DeleteMismatchError, FileTooLargeError, + OTTypeMismatchError, } diff --git a/services/document-updater/app/js/HistoryOTUpdateManager.js b/services/document-updater/app/js/HistoryOTUpdateManager.js new file mode 100644 index 0000000000..5a8b92099e --- /dev/null +++ b/services/document-updater/app/js/HistoryOTUpdateManager.js @@ -0,0 +1,158 @@ +// @ts-check + +const Profiler = require('./Profiler') +const DocumentManager = require('./DocumentManager') +const Errors = require('./Errors') +const RedisManager = require('./RedisManager') +const { + EditOperationBuilder, + StringFileData, + EditOperationTransformer, +} = require('overleaf-editor-core') +const Metrics = require('./Metrics') +const ProjectHistoryRedisManager = require('./ProjectHistoryRedisManager') +const HistoryManager = require('./HistoryManager') +const RealTimeRedisManager = require('./RealTimeRedisManager') + +/** + * @typedef {import("./types").Update} Update + * @typedef {import("./types").HistoryOTEditOperationUpdate} HistoryOTEditOperationUpdate + */ + +/** + * @param {Update} update + * @return {update is HistoryOTEditOperationUpdate} + */ +function isHistoryOTEditOperationUpdate(update) { + return ( + update && + 'doc' in update && + 'op' in update && + 'v' in update && + Array.isArray(update.op) && + EditOperationBuilder.isValid(update.op[0]) + ) +} + +/** + * Try to apply an update to the given document + * + * @param {string} projectId + * @param {string} docId + * @param {HistoryOTEditOperationUpdate} update + * @param {Profiler} profiler + */ +async function tryApplyUpdate(projectId, docId, update, profiler) { + let { lines, version, pathname, type } = + await DocumentManager.promises.getDoc(projectId, docId) + profiler.log('getDoc') + + if (lines == null || version == null) { + throw new Errors.NotFoundError(`document not found: ${docId}`) + } + if (type !== 'history-ot') { + throw new Errors.OTTypeMismatchError(type, 'history-ot') + } + + let op = EditOperationBuilder.fromJSON(update.op[0]) + if (version !== update.v) { + const transformUpdates = await RedisManager.promises.getPreviousDocOps( + docId, + update.v, + version + ) + for (const transformUpdate of transformUpdates) { + if (!isHistoryOTEditOperationUpdate(transformUpdate)) { + throw new Errors.OTTypeMismatchError('sharejs-text-ot', 'history-ot') + } + + if ( + transformUpdate.meta.source && + update.dupIfSource?.includes(transformUpdate.meta.source) + ) { + update.dup = true + break + } + const other = EditOperationBuilder.fromJSON(transformUpdate.op[0]) + op = EditOperationTransformer.transform(op, other)[0] + } + update.op = [op.toJSON()] + } + + if (!update.dup) { + const file = StringFileData.fromRaw(lines) + file.edit(op) + version += 1 + update.meta.ts = Date.now() + await RedisManager.promises.updateDocument( + projectId, + docId, + file.toRaw(), + version, + [update], + {}, + update.meta + ) + + Metrics.inc('history-queue', 1, { status: 'project-history' }) + try { + const projectOpsLength = + await ProjectHistoryRedisManager.promises.queueOps(projectId, [ + JSON.stringify({ + ...update, + meta: { + ...update.meta, + pathname, + }, + }), + ]) + HistoryManager.recordAndFlushHistoryOps( + projectId, + [update], + projectOpsLength + ) + profiler.log('recordAndFlushHistoryOps') + } catch (err) { + // The full project history can re-sync a project in case + // updates went missing. + // Just record the error here and acknowledge the write-op. + Metrics.inc('history-queue-error') + } + } + RealTimeRedisManager.sendData({ + project_id: projectId, + doc_id: docId, + op: update, + }) +} + +/** + * Apply an update to the given document + * + * @param {string} projectId + * @param {string} docId + * @param {HistoryOTEditOperationUpdate} update + */ +async function applyUpdate(projectId, docId, update) { + const profiler = new Profiler('applyUpdate', { + project_id: projectId, + doc_id: docId, + type: 'history-ot', + }) + + try { + await tryApplyUpdate(projectId, docId, update, profiler) + } catch (error) { + RealTimeRedisManager.sendData({ + project_id: projectId, + doc_id: docId, + error: error instanceof Error ? error.message : error, + }) + profiler.log('sendData') + throw error + } finally { + profiler.end() + } +} + +module.exports = { isHistoryOTEditOperationUpdate, applyUpdate } diff --git a/services/document-updater/app/js/HttpController.js b/services/document-updater/app/js/HttpController.js index 95fe9b7ba9..0a6ae3b2b4 100644 --- a/services/document-updater/app/js/HttpController.js +++ b/services/document-updater/app/js/HttpController.js @@ -9,6 +9,7 @@ const Metrics = require('./Metrics') const DeleteQueueManager = require('./DeleteQueueManager') const { getTotalSizeOfLines } = require('./Limits') const async = require('async') +const { StringFileData } = require('overleaf-editor-core') function getDoc(req, res, next) { let fromVersion @@ -27,7 +28,7 @@ function getDoc(req, res, next) { projectId, docId, fromVersion, - (error, lines, version, ops, ranges, pathname) => { + (error, lines, version, ops, ranges, pathname, _projectHistoryId, type) => { timer.done() if (error) { return next(error) @@ -36,6 +37,11 @@ function getDoc(req, res, next) { if (lines == null || version == null) { return next(new Errors.NotFoundError('document not found')) } + if (!Array.isArray(lines) && req.query.historyOTSupport !== 'true') { + const file = StringFileData.fromRaw(lines) + // TODO(24596): tc support for history-ot + lines = file.getLines() + } res.json({ id: docId, lines, @@ -44,6 +50,7 @@ function getDoc(req, res, next) { ranges, pathname, ttlInS: RedisManager.DOC_OPS_TTL, + type, }) } ) @@ -84,6 +91,11 @@ function peekDoc(req, res, next) { if (lines == null || version == null) { return next(new Errors.NotFoundError('document not found')) } + if (!Array.isArray(lines) && req.query.historyOTSupport !== 'true') { + const file = StringFileData.fromRaw(lines) + // TODO(24596): tc support for history-ot + lines = file.getLines() + } res.json({ id: docId, lines, version }) }) } diff --git a/services/document-updater/app/js/PersistenceManager.js b/services/document-updater/app/js/PersistenceManager.js index b08994ae41..6e832f9aa7 100644 --- a/services/document-updater/app/js/PersistenceManager.js +++ b/services/document-updater/app/js/PersistenceManager.js @@ -95,6 +95,13 @@ function getDoc(projectId, docId, options = {}, _callback) { status: body.pathname === '' ? 'zero-length' : 'undefined', }) } + + if (body.otMigrationStage > 0) { + // Use history-ot + body.lines = { content: body.lines.join('\n') } + body.ranges = {} + } + callback( null, body.lines, diff --git a/services/document-updater/app/js/Profiler.js b/services/document-updater/app/js/Profiler.js index 8daac4ca41..aac8a9706e 100644 --- a/services/document-updater/app/js/Profiler.js +++ b/services/document-updater/app/js/Profiler.js @@ -1,68 +1,52 @@ -/* eslint-disable - no-unused-vars, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS206: Consider reworking classes to avoid initClass - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -let Profiler -const Settings = require('@overleaf/settings') const logger = require('@overleaf/logger') -const deltaMs = function (ta, tb) { +function deltaMs(ta, tb) { const nanoSeconds = (ta[0] - tb[0]) * 1e9 + (ta[1] - tb[1]) const milliSeconds = Math.floor(nanoSeconds * 1e-6) return milliSeconds } -module.exports = Profiler = (function () { - Profiler = class Profiler { - static initClass() { - this.prototype.LOG_CUTOFF_TIME = 15 * 1000 - this.prototype.LOG_SYNC_CUTOFF_TIME = 1000 - } +class Profiler { + LOG_CUTOFF_TIME = 15 * 1000 + LOG_SYNC_CUTOFF_TIME = 1000 - constructor(name, args) { - this.name = name - this.args = args - this.t0 = this.t = process.hrtime() - this.start = new Date() - this.updateTimes = [] - this.totalSyncTime = 0 - } - - log(label, options = {}) { - const t1 = process.hrtime() - const dtMilliSec = deltaMs(t1, this.t) - this.t = t1 - this.totalSyncTime += options.sync ? dtMilliSec : 0 - this.updateTimes.push([label, dtMilliSec]) // timings in ms - return this // make it chainable - } - - end(message) { - const totalTime = deltaMs(this.t, this.t0) - const exceedsCutoff = totalTime > this.LOG_CUTOFF_TIME - const exceedsSyncCutoff = this.totalSyncTime > this.LOG_SYNC_CUTOFF_TIME - if (exceedsCutoff || exceedsSyncCutoff) { - // log anything greater than cutoffs - const args = {} - for (const k in this.args) { - const v = this.args[k] - args[k] = v - } - args.updateTimes = this.updateTimes - args.start = this.start - args.end = new Date() - args.status = { exceedsCutoff, exceedsSyncCutoff } - logger.warn(args, this.name) - } - return totalTime - } + constructor(name, args) { + this.name = name + this.args = args + this.t0 = this.t = process.hrtime() + this.start = new Date() + this.updateTimes = [] + this.totalSyncTime = 0 } - Profiler.initClass() - return Profiler -})() + + log(label, options = {}) { + const t1 = process.hrtime() + const dtMilliSec = deltaMs(t1, this.t) + this.t = t1 + this.totalSyncTime += options.sync ? dtMilliSec : 0 + this.updateTimes.push([label, dtMilliSec]) // timings in ms + return this // make it chainable + } + + end() { + const totalTime = deltaMs(this.t, this.t0) + const exceedsCutoff = totalTime > this.LOG_CUTOFF_TIME + const exceedsSyncCutoff = this.totalSyncTime > this.LOG_SYNC_CUTOFF_TIME + if (exceedsCutoff || exceedsSyncCutoff) { + // log anything greater than cutoffs + const args = {} + for (const k in this.args) { + const v = this.args[k] + args[k] = v + } + args.updateTimes = this.updateTimes + args.start = this.start + args.end = new Date() + args.status = { exceedsCutoff, exceedsSyncCutoff } + logger.warn(args, this.name) + } + return totalTime + } +} + +module.exports = Profiler diff --git a/services/document-updater/app/js/RealTimeRedisManager.js b/services/document-updater/app/js/RealTimeRedisManager.js index 08bf132dec..2b67971c5c 100644 --- a/services/document-updater/app/js/RealTimeRedisManager.js +++ b/services/document-updater/app/js/RealTimeRedisManager.js @@ -49,7 +49,7 @@ const RealTimeRedisManager = { MAX_OPS_PER_ITERATION, -1 ) - return multi.exec(function (error, replys) { + multi.exec(function (error, replys) { if (error != null) { return callback(error) } @@ -80,7 +80,7 @@ const RealTimeRedisManager = { }, getUpdatesLength(docId, callback) { - return rclient.llen(Keys.pendingUpdates({ doc_id: docId }), callback) + rclient.llen(Keys.pendingUpdates({ doc_id: docId }), callback) }, sendCanaryAppliedOp({ projectId, docId, op }) { @@ -132,5 +132,5 @@ const RealTimeRedisManager = { module.exports = RealTimeRedisManager module.exports.promises = promisifyAll(RealTimeRedisManager, { - without: ['sendData'], + without: ['sendCanaryAppliedOp', 'sendData'], }) diff --git a/services/document-updater/app/js/RedisManager.js b/services/document-updater/app/js/RedisManager.js index f8e97f38b4..7f86036427 100644 --- a/services/document-updater/app/js/RedisManager.js +++ b/services/document-updater/app/js/RedisManager.js @@ -48,6 +48,7 @@ const RedisManager = { timer.done() _callback(error) } + const shareJSTextOT = Array.isArray(docLines) const docLinesArray = docLines docLines = JSON.stringify(docLines) if (docLines.indexOf('\u0000') !== -1) { @@ -60,7 +61,10 @@ const RedisManager = { // Do an optimised size check on the docLines using the serialised // length as an upper bound const sizeBound = docLines.length - if (docIsTooLarge(sizeBound, docLinesArray, Settings.max_doc_length)) { + if ( + shareJSTextOT && // editor-core has a size check in TextOperation.apply and TextOperation.applyToLength. + docIsTooLarge(sizeBound, docLinesArray, Settings.max_doc_length) + ) { const docSize = docLines.length const err = new Error('blocking doc insert into redis: doc is too large') logger.error({ projectId, docId, err, docSize }, err.message) @@ -461,6 +465,7 @@ const RedisManager = { if (appliedOps == null) { appliedOps = [] } + const shareJSTextOT = Array.isArray(docLines) RedisManager.getDocVersion(docId, (error, currentVersion) => { if (error) { return callback(error) @@ -500,7 +505,10 @@ const RedisManager = { // Do an optimised size check on the docLines using the serialised // length as an upper bound const sizeBound = newDocLines.length - if (docIsTooLarge(sizeBound, docLines, Settings.max_doc_length)) { + if ( + shareJSTextOT && // editor-core has a size check in TextOperation.apply and TextOperation.applyToLength. + docIsTooLarge(sizeBound, docLines, Settings.max_doc_length) + ) { const err = new Error('blocking doc update: doc is too large') const docSize = newDocLines.length logger.error({ projectId, docId, err, docSize }, err.message) diff --git a/services/document-updater/app/js/UpdateManager.js b/services/document-updater/app/js/UpdateManager.js index 1f58a751f7..e5df48575e 100644 --- a/services/document-updater/app/js/UpdateManager.js +++ b/services/document-updater/app/js/UpdateManager.js @@ -15,9 +15,10 @@ const RangesManager = require('./RangesManager') const SnapshotManager = require('./SnapshotManager') const Profiler = require('./Profiler') const { isInsert, isDelete, getDocLength, computeDocHash } = require('./Utils') +const HistoryOTUpdateManager = require('./HistoryOTUpdateManager') /** - * @import { DeleteOp, InsertOp, Op, Ranges, Update, HistoryUpdate } from "./types" + * @import { Ranges, Update, HistoryUpdate } from "./types" */ const UpdateManager = { @@ -80,7 +81,11 @@ const UpdateManager = { profile.log('getPendingUpdatesForDoc') for (const update of updates) { - await UpdateManager.applyUpdate(projectId, docId, update) + if (HistoryOTUpdateManager.isHistoryOTEditOperationUpdate(update)) { + await HistoryOTUpdateManager.applyUpdate(projectId, docId, update) + } else { + await UpdateManager.applyUpdate(projectId, docId, update) + } profile.log('applyUpdate') } profile.log('async done').end() @@ -110,12 +115,16 @@ const UpdateManager = { pathname, projectHistoryId, historyRangesSupport, + type, } = await DocumentManager.promises.getDoc(projectId, docId) profile.log('getDoc') if (lines == null || version == null) { throw new Errors.NotFoundError(`document not found: ${docId}`) } + if (type !== 'sharejs-text-ot') { + throw new Errors.OTTypeMismatchError(type, 'sharejs-text-ot') + } const previousVersion = version const incomingUpdateVersion = update.v diff --git a/services/document-updater/app/js/types.ts b/services/document-updater/app/js/types.ts index b3085adb1b..851e62d8c8 100644 --- a/services/document-updater/app/js/types.ts +++ b/services/document-updater/app/js/types.ts @@ -1,12 +1,17 @@ import { TrackingPropsRawData, ClearTrackingPropsRawData, + RawEditOperation, } from 'overleaf-editor-core/lib/types' +export type OTType = 'sharejs-text-ot' | 'history-ot' + /** * An update coming from the editor */ export type Update = { + dup?: boolean + dupIfSource?: string[] doc: string op: Op[] v: number @@ -18,6 +23,11 @@ export type Update = { projectHistoryId?: string } +export type HistoryOTEditOperationUpdate = Omit & { + op: RawEditOperation[] + meta: Update['meta'] & { source: string } +} + export type Op = InsertOp | DeleteOp | CommentOp | RetainOp export type InsertOp = { diff --git a/services/document-updater/buildscript.txt b/services/document-updater/buildscript.txt index ee013ebea9..8252032dd4 100644 --- a/services/document-updater/buildscript.txt +++ b/services/document-updater/buildscript.txt @@ -4,6 +4,6 @@ document-updater --env-add= --env-pass-through= --esmock-loader=False ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=True --script-version=4.7.0 diff --git a/services/document-updater/docker-compose.ci.yml b/services/document-updater/docker-compose.ci.yml index 6deaad433d..2fe97bd9b3 100644 --- a/services/document-updater/docker-compose.ci.yml +++ b/services/document-updater/docker-compose.ci.yml @@ -52,7 +52,7 @@ services: retries: 20 mongo: - image: mongo:6.0.13 + image: mongo:7.0.20 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/document-updater/docker-compose.yml b/services/document-updater/docker-compose.yml index e33174f9e2..8a94d1a24c 100644 --- a/services/document-updater/docker-compose.yml +++ b/services/document-updater/docker-compose.yml @@ -6,7 +6,7 @@ version: "2.3" services: test_unit: - image: node:20.18.2 + image: node:22.15.1 volumes: - .:/overleaf/services/document-updater - ../../node_modules:/overleaf/node_modules @@ -21,7 +21,7 @@ services: user: node test_acceptance: - image: node:20.18.2 + image: node:22.15.1 volumes: - .:/overleaf/services/document-updater - ../../node_modules:/overleaf/node_modules @@ -55,7 +55,7 @@ services: retries: 20 mongo: - image: mongo:6.0.13 + image: mongo:7.0.20 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/document-updater/package.json b/services/document-updater/package.json index 4fb45d6cd4..7d892689e9 100644 --- a/services/document-updater/package.json +++ b/services/document-updater/package.json @@ -34,6 +34,7 @@ "lodash": "^4.17.21", "minimist": "^1.2.8", "mongodb-legacy": "6.1.3", + "overleaf-editor-core": "*", "request": "^2.88.2", "requestretry": "^7.1.0" }, diff --git a/services/document-updater/test/acceptance/js/ApplyingUpdatesToADocTests.js b/services/document-updater/test/acceptance/js/ApplyingUpdatesToADocTests.js index 0df2e72a08..39ec6c2ac7 100644 --- a/services/document-updater/test/acceptance/js/ApplyingUpdatesToADocTests.js +++ b/services/document-updater/test/acceptance/js/ApplyingUpdatesToADocTests.js @@ -31,6 +31,12 @@ describe('Applying updates to a doc', function () { op: [this.op], v: this.version, } + this.historyOTUpdate = { + doc: this.doc_id, + op: [{ textOperation: [4, 'one and a half\n', 9] }], + v: this.version, + meta: { source: 'random-publicId' }, + } this.result = ['one', 'one and a half', 'two', 'three'] DocUpdaterApp.ensureRunning(done) }) @@ -284,6 +290,260 @@ describe('Applying updates to a doc', function () { }) }) + describe('when the document is not loaded (history-ot)', function () { + beforeEach(function (done) { + this.startTime = Date.now() + MockWebApi.insertDoc(this.project_id, this.doc_id, { + lines: this.lines, + version: this.version, + otMigrationStage: 1, + }) + DocUpdaterClient.sendUpdate( + this.project_id, + this.doc_id, + this.historyOTUpdate, + error => { + if (error != null) { + throw error + } + setTimeout(() => { + rclientProjectHistory.get( + ProjectHistoryKeys.projectHistoryFirstOpTimestamp({ + project_id: this.project_id, + }), + (error, result) => { + if (error != null) { + throw error + } + result = parseInt(result, 10) + this.firstOpTimestamp = result + done() + } + ) + }, 200) + } + ) + }) + + it('should load the document from the web API', function () { + MockWebApi.getDocument + .calledWith(this.project_id, this.doc_id) + .should.equal(true) + }) + + it('should update the doc', function (done) { + DocUpdaterClient.getDoc( + this.project_id, + this.doc_id, + (error, res, doc) => { + if (error) done(error) + doc.lines.should.deep.equal(this.result) + done() + } + ) + }) + + it('should push the applied updates to the project history changes api', function (done) { + rclientProjectHistory.lrange( + ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }), + 0, + -1, + (error, updates) => { + if (error != null) { + throw error + } + JSON.parse(updates[0]).op.should.deep.equal(this.historyOTUpdate.op) + JSON.parse(updates[0]).meta.pathname.should.equal('/a/b/c.tex') + + done() + } + ) + }) + + it('should set the first op timestamp', function () { + this.firstOpTimestamp.should.be.within(this.startTime, Date.now()) + }) + + it('should yield last updated time', function (done) { + DocUpdaterClient.getProjectLastUpdatedAt( + this.project_id, + (error, res, body) => { + if (error != null) { + throw error + } + res.statusCode.should.equal(200) + body.lastUpdatedAt.should.be.within(this.startTime, Date.now()) + done() + } + ) + }) + + it('should yield no last updated time for another project', function (done) { + DocUpdaterClient.getProjectLastUpdatedAt( + DocUpdaterClient.randomId(), + (error, res, body) => { + if (error != null) { + throw error + } + res.statusCode.should.equal(200) + body.should.deep.equal({}) + done() + } + ) + }) + + describe('when sending another update', function () { + beforeEach(function (done) { + this.timeout(10000) + this.second_update = Object.assign({}, this.historyOTUpdate) + this.second_update.op = [ + { + textOperation: [4, 'one and a half\n', 24], + }, + ] + this.second_update.v = this.version + 1 + this.secondStartTime = Date.now() + DocUpdaterClient.sendUpdate( + this.project_id, + this.doc_id, + this.second_update, + error => { + if (error != null) { + throw error + } + setTimeout(done, 200) + } + ) + }) + + it('should update the doc', function (done) { + DocUpdaterClient.getDoc( + this.project_id, + this.doc_id, + (error, res, doc) => { + if (error) done(error) + doc.lines.should.deep.equal([ + 'one', + 'one and a half', + 'one and a half', + 'two', + 'three', + ]) + done() + } + ) + }) + + it('should not change the first op timestamp', function (done) { + rclientProjectHistory.get( + ProjectHistoryKeys.projectHistoryFirstOpTimestamp({ + project_id: this.project_id, + }), + (error, result) => { + if (error != null) { + throw error + } + result = parseInt(result, 10) + result.should.equal(this.firstOpTimestamp) + done() + } + ) + }) + + it('should yield last updated time', function (done) { + DocUpdaterClient.getProjectLastUpdatedAt( + this.project_id, + (error, res, body) => { + if (error != null) { + throw error + } + res.statusCode.should.equal(200) + body.lastUpdatedAt.should.be.within( + this.secondStartTime, + Date.now() + ) + done() + } + ) + }) + }) + + describe('when another client is sending a concurrent update', function () { + beforeEach(function (done) { + this.timeout(10000) + this.otherUpdate = { + doc: this.doc_id, + op: [{ textOperation: [8, 'two and a half\n', 5] }], + v: this.version, + meta: { source: 'other-random-publicId' }, + } + this.secondStartTime = Date.now() + DocUpdaterClient.sendUpdate( + this.project_id, + this.doc_id, + this.otherUpdate, + error => { + if (error != null) { + throw error + } + setTimeout(done, 200) + } + ) + }) + + it('should update the doc', function (done) { + DocUpdaterClient.getDoc( + this.project_id, + this.doc_id, + (error, res, doc) => { + if (error) done(error) + doc.lines.should.deep.equal([ + 'one', + 'one and a half', + 'two', + 'two and a half', + 'three', + ]) + done() + } + ) + }) + + it('should not change the first op timestamp', function (done) { + rclientProjectHistory.get( + ProjectHistoryKeys.projectHistoryFirstOpTimestamp({ + project_id: this.project_id, + }), + (error, result) => { + if (error != null) { + throw error + } + result = parseInt(result, 10) + result.should.equal(this.firstOpTimestamp) + done() + } + ) + }) + + it('should yield last updated time', function (done) { + DocUpdaterClient.getProjectLastUpdatedAt( + this.project_id, + (error, res, body) => { + if (error != null) { + throw error + } + res.statusCode.should.equal(200) + body.lastUpdatedAt.should.be.within( + this.secondStartTime, + Date.now() + ) + done() + } + ) + }) + }) + }) + describe('when the document is loaded', function () { beforeEach(function (done) { MockWebApi.insertDoc(this.project_id, this.doc_id, { @@ -390,6 +650,58 @@ describe('Applying updates to a doc', function () { }) }) + describe('when the document is loaded (history-ot)', function () { + beforeEach(function (done) { + MockWebApi.insertDoc(this.project_id, this.doc_id, { + lines: this.lines, + version: this.version, + otMigrationStage: 1, + }) + DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => { + if (error != null) { + throw error + } + DocUpdaterClient.sendUpdate( + this.project_id, + this.doc_id, + this.historyOTUpdate, + error => { + if (error != null) { + throw error + } + setTimeout(done, 200) + } + ) + }) + }) + + it('should update the doc', function (done) { + DocUpdaterClient.getDoc( + this.project_id, + this.doc_id, + (error, res, doc) => { + if (error) return done(error) + doc.lines.should.deep.equal(this.result) + done() + } + ) + }) + + it('should push the applied updates to the project history changes api', function (done) { + rclientProjectHistory.lrange( + ProjectHistoryKeys.projectHistoryOps({ project_id: this.project_id }), + 0, + -1, + (error, updates) => { + if (error) return done(error) + JSON.parse(updates[0]).op.should.deep.equal(this.historyOTUpdate.op) + JSON.parse(updates[0]).meta.pathname.should.equal('/a/b/c.tex') + done() + } + ) + }) + }) + describe('when the document has been deleted', function () { describe('when the ops come in a single linear order', function () { beforeEach(function (done) { @@ -596,6 +908,160 @@ describe('Applying updates to a doc', function () { }) }) + describe('with a broken update (history-ot)', function () { + beforeEach(function (done) { + this.broken_update = { + doc: this.doc_id, + v: this.version, + op: [{ textOperation: [99, -1] }], + meta: { source: '42' }, + } + MockWebApi.insertDoc(this.project_id, this.doc_id, { + lines: this.lines, + version: this.version, + otMigrationStage: 1, + }) + + DocUpdaterClient.subscribeToAppliedOps( + (this.messageCallback = sinon.stub()) + ) + + DocUpdaterClient.sendUpdate( + this.project_id, + this.doc_id, + this.broken_update, + error => { + if (error != null) { + throw error + } + setTimeout(done, 200) + } + ) + }) + + it('should not update the doc', function (done) { + DocUpdaterClient.getDoc( + this.project_id, + this.doc_id, + (error, res, doc) => { + if (error) return done(error) + doc.lines.should.deep.equal(this.lines) + done() + } + ) + }) + + it('should send a message with an error', function () { + this.messageCallback.called.should.equal(true) + const [channel, message] = this.messageCallback.args[0] + channel.should.equal('applied-ops') + JSON.parse(message).should.deep.include({ + project_id: this.project_id, + doc_id: this.doc_id, + error: + "The operation's base length must be equal to the string's length.", + }) + }) + }) + + describe('when mixing ot types (sharejs-text-ot -> history-ot)', function () { + beforeEach(function (done) { + MockWebApi.insertDoc(this.project_id, this.doc_id, { + lines: this.lines, + version: this.version, + otMigrationStage: 0, + }) + + DocUpdaterClient.subscribeToAppliedOps( + (this.messageCallback = sinon.stub()) + ) + + DocUpdaterClient.sendUpdate( + this.project_id, + this.doc_id, + this.historyOTUpdate, + error => { + if (error != null) { + throw error + } + setTimeout(done, 200) + } + ) + }) + + it('should not update the doc', function (done) { + DocUpdaterClient.getDoc( + this.project_id, + this.doc_id, + (error, res, doc) => { + if (error) return done(error) + doc.lines.should.deep.equal(this.lines) + done() + } + ) + }) + + it('should send a message with an error', function () { + this.messageCallback.called.should.equal(true) + const [channel, message] = this.messageCallback.args[0] + channel.should.equal('applied-ops') + JSON.parse(message).should.deep.include({ + project_id: this.project_id, + doc_id: this.doc_id, + error: 'ot type mismatch', + }) + }) + }) + + describe('when mixing ot types (history-ot -> sharejs-text-ot)', function () { + beforeEach(function (done) { + MockWebApi.insertDoc(this.project_id, this.doc_id, { + lines: this.lines, + version: this.version, + otMigrationStage: 1, + }) + + DocUpdaterClient.subscribeToAppliedOps( + (this.messageCallback = sinon.stub()) + ) + + DocUpdaterClient.sendUpdate( + this.project_id, + this.doc_id, + this.update, + error => { + if (error != null) { + throw error + } + setTimeout(done, 200) + } + ) + }) + + it('should not update the doc', function (done) { + DocUpdaterClient.getDoc( + this.project_id, + this.doc_id, + (error, res, doc) => { + if (error) return done(error) + doc.lines.should.deep.equal(this.lines) + done() + } + ) + }) + + it('should send a message with an error', function () { + this.messageCallback.called.should.equal(true) + const [channel, message] = this.messageCallback.args[0] + channel.should.equal('applied-ops') + JSON.parse(message).should.deep.include({ + project_id: this.project_id, + doc_id: this.doc_id, + error: 'ot type mismatch', + }) + }) + }) + describe('when there is no version in Mongo', function () { beforeEach(function (done) { MockWebApi.insertDoc(this.project_id, this.doc_id, { @@ -716,6 +1182,84 @@ describe('Applying updates to a doc', function () { }) }) + describe('when sending duplicate ops (history-ot)', function () { + beforeEach(function (done) { + MockWebApi.insertDoc(this.project_id, this.doc_id, { + lines: this.lines, + version: this.version, + otMigrationStage: 1, + }) + + DocUpdaterClient.subscribeToAppliedOps( + (this.messageCallback = sinon.stub()) + ) + + // One user delete 'one', the next turns it into 'once'. The second becomes a NOP. + DocUpdaterClient.sendUpdate( + this.project_id, + this.doc_id, + { + doc: this.doc_id, + op: [{ textOperation: [4, 'one and a half\n', 9] }], + v: this.version, + meta: { + source: 'ikHceq3yfAdQYzBo4-xZ', + }, + }, + error => { + if (error != null) { + throw error + } + setTimeout(() => { + DocUpdaterClient.sendUpdate( + this.project_id, + this.doc_id, + { + doc: this.doc_id, + op: [ + { + textOperation: [4, 'one and a half\n', 9], + }, + ], + v: this.version, + dupIfSource: ['ikHceq3yfAdQYzBo4-xZ'], + meta: { + source: 'ikHceq3yfAdQYzBo4-xZ', + }, + }, + error => { + if (error != null) { + throw error + } + setTimeout(done, 200) + } + ) + }, 200) + } + ) + }) + + it('should update the doc', function (done) { + DocUpdaterClient.getDoc( + this.project_id, + this.doc_id, + (error, res, doc) => { + if (error) return done(error) + doc.lines.should.deep.equal(this.result) + done() + } + ) + }) + + it('should return a message about duplicate ops', function () { + this.messageCallback.calledTwice.should.equal(true) + this.messageCallback.args[0][0].should.equal('applied-ops') + expect(JSON.parse(this.messageCallback.args[0][1]).op.dup).to.be.undefined + this.messageCallback.args[1][0].should.equal('applied-ops') + expect(JSON.parse(this.messageCallback.args[1][1]).op.dup).to.equal(true) + }) + }) + describe('when sending updates for a non-existing doc id', function () { beforeEach(function (done) { this.non_existing = { diff --git a/services/document-updater/test/acceptance/js/SettingADocumentTests.js b/services/document-updater/test/acceptance/js/SettingADocumentTests.js index 5b0c4ab281..fd1851a221 100644 --- a/services/document-updater/test/acceptance/js/SettingADocumentTests.js +++ b/services/document-updater/test/acceptance/js/SettingADocumentTests.js @@ -196,6 +196,167 @@ describe('Setting a document', function () { }) }) + describe('when the updated doc exists in the doc updater (history-ot)', function () { + before(function (done) { + numberOfReceivedUpdates = 0 + this.project_id = DocUpdaterClient.randomId() + this.doc_id = DocUpdaterClient.randomId() + this.historyOTUpdate = { + doc: this.doc_id, + op: [{ textOperation: [4, 'one and a half\n', 9] }], + v: this.version, + meta: { source: 'random-publicId' }, + } + MockWebApi.insertDoc(this.project_id, this.doc_id, { + lines: this.lines, + version: this.version, + otMigrationStage: 1, + }) + DocUpdaterClient.preloadDoc(this.project_id, this.doc_id, error => { + if (error) { + throw error + } + DocUpdaterClient.sendUpdate( + this.project_id, + this.doc_id, + this.historyOTUpdate, + error => { + if (error) { + throw error + } + setTimeout(() => { + DocUpdaterClient.setDocLines( + this.project_id, + this.doc_id, + this.newLines, + this.source, + this.user_id, + false, + (error, res, body) => { + if (error) { + return done(error) + } + this.statusCode = res.statusCode + this.body = body + done() + } + ) + }, 200) + } + ) + }) + }) + + after(function () { + MockProjectHistoryApi.flushProject.resetHistory() + MockWebApi.setDocument.resetHistory() + }) + + it('should return a 200 status code', function () { + this.statusCode.should.equal(200) + }) + + it('should emit two updates (from sendUpdate and setDocLines)', function () { + expect(numberOfReceivedUpdates).to.equal(2) + }) + + it('should send the updated doc lines and version to the web api', function () { + MockWebApi.setDocument + .calledWith(this.project_id, this.doc_id, this.newLines) + .should.equal(true) + }) + + it('should update the lines in the doc updater', function (done) { + DocUpdaterClient.getDoc( + this.project_id, + this.doc_id, + (error, res, doc) => { + if (error) { + return done(error) + } + doc.lines.should.deep.equal(this.newLines) + done() + } + ) + }) + + it('should bump the version in the doc updater', function (done) { + DocUpdaterClient.getDoc( + this.project_id, + this.doc_id, + (error, res, doc) => { + if (error) { + return done(error) + } + doc.version.should.equal(this.version + 2) + done() + } + ) + }) + + it('should leave the document in redis', function (done) { + docUpdaterRedis.get( + Keys.docLines({ doc_id: this.doc_id }), + (error, lines) => { + if (error) { + throw error + } + expect(JSON.parse(lines)).to.deep.equal({ + content: this.newLines.join('\n'), + }) + done() + } + ) + }) + + it('should return the mongo rev in the json response', function () { + this.body.should.deep.equal({ rev: '123' }) + }) + + describe('when doc has the same contents', function () { + beforeEach(function (done) { + numberOfReceivedUpdates = 0 + DocUpdaterClient.setDocLines( + this.project_id, + this.doc_id, + this.newLines, + this.source, + this.user_id, + false, + (error, res, body) => { + if (error) { + return done(error) + } + this.statusCode = res.statusCode + this.body = body + done() + } + ) + }) + + it('should not bump the version in doc updater', function (done) { + DocUpdaterClient.getDoc( + this.project_id, + this.doc_id, + (error, res, doc) => { + if (error) { + return done(error) + } + doc.version.should.equal(this.version + 2) + done() + } + ) + }) + + it('should not emit any updates', function (done) { + setTimeout(() => { + expect(numberOfReceivedUpdates).to.equal(0) + done() + }, 100) // delay by 100ms: make sure we do not check too early! + }) + }) + }) + describe('when the updated doc does not exist in the doc updater', function () { before(function (done) { this.project_id = DocUpdaterClient.randomId() diff --git a/services/document-updater/test/setup.js b/services/document-updater/test/setup.js index 1099724329..8ba17d922f 100644 --- a/services/document-updater/test/setup.js +++ b/services/document-updater/test/setup.js @@ -31,6 +31,7 @@ SandboxedModule.configure({ requires: { '@overleaf/logger': stubs.logger, 'mongodb-legacy': require('mongodb-legacy'), // for ObjectId comparisons + 'overleaf-editor-core': require('overleaf-editor-core'), // does not play nice with sandbox }, globals: { Buffer, JSON, Math, console, process }, sourceTransformers: { diff --git a/services/document-updater/test/unit/js/DocumentManager/DocumentManagerTests.js b/services/document-updater/test/unit/js/DocumentManager/DocumentManagerTests.js index e9d68ee414..1816579103 100644 --- a/services/document-updater/test/unit/js/DocumentManager/DocumentManagerTests.js +++ b/services/document-updater/test/unit/js/DocumentManager/DocumentManagerTests.js @@ -49,6 +49,9 @@ describe('DocumentManager', function () { applyUpdate: sinon.stub().resolves(), }, } + this.HistoryOTUpdateManager = { + applyUpdate: sinon.stub().resolves(), + } this.RangesManager = { acceptChanges: sinon.stub(), deleteComment: sinon.stub(), @@ -66,6 +69,7 @@ describe('DocumentManager', function () { './Metrics': this.Metrics, './DiffCodec': this.DiffCodec, './UpdateManager': this.UpdateManager, + './HistoryOTUpdateManager': this.HistoryOTUpdateManager, './RangesManager': this.RangesManager, './Errors': Errors, '@overleaf/settings': this.Settings, @@ -222,6 +226,7 @@ describe('DocumentManager', function () { ranges: this.ranges, pathname: this.pathname, projectHistoryId: this.projectHistoryId, + type: 'sharejs-text-ot', }) this.RedisManager.promises.getPreviousDocOps.resolves(this.ops) this.result = await this.DocumentManager.promises.getDocAndRecentOps( @@ -251,6 +256,7 @@ describe('DocumentManager', function () { ranges: this.ranges, pathname: this.pathname, projectHistoryId: this.projectHistoryId, + type: 'sharejs-text-ot', }) }) }) @@ -263,6 +269,7 @@ describe('DocumentManager', function () { ranges: this.ranges, pathname: this.pathname, projectHistoryId: this.projectHistoryId, + type: 'sharejs-text-ot', }) this.RedisManager.promises.getPreviousDocOps.resolves(this.ops) this.result = await this.DocumentManager.promises.getDocAndRecentOps( @@ -290,6 +297,7 @@ describe('DocumentManager', function () { ranges: this.ranges, pathname: this.pathname, projectHistoryId: this.projectHistoryId, + type: 'sharejs-text-ot', }) }) }) @@ -333,6 +341,7 @@ describe('DocumentManager', function () { unflushedTime: this.unflushedTime, alreadyLoaded: true, historyRangesSupport: this.historyRangesSupport, + type: 'sharejs-text-ot', }) }) }) @@ -400,6 +409,7 @@ describe('DocumentManager', function () { unflushedTime: null, alreadyLoaded: false, historyRangesSupport: this.historyRangesSupport, + type: 'sharejs-text-ot', }) }) }) diff --git a/services/document-updater/test/unit/js/HttpController/HttpControllerTests.js b/services/document-updater/test/unit/js/HttpController/HttpControllerTests.js index 2b8d288ef8..333da10d15 100644 --- a/services/document-updater/test/unit/js/HttpController/HttpControllerTests.js +++ b/services/document-updater/test/unit/js/HttpController/HttpControllerTests.js @@ -26,6 +26,7 @@ describe('HttpController', function () { this.Metrics.Timer.prototype.done = sinon.stub() this.project_id = 'project-id-123' + this.projectHistoryId = '123' this.doc_id = 'doc-id-123' this.source = 'editor' this.next = sinon.stub() @@ -65,7 +66,9 @@ describe('HttpController', function () { this.version, [], this.ranges, - this.pathname + this.pathname, + this.projectHistoryId, + 'sharejs-text-ot' ) this.HttpController.getDoc(this.req, this.res, this.next) }) @@ -77,17 +80,16 @@ describe('HttpController', function () { }) it('should return the doc as JSON', function () { - this.res.json - .calledWith({ - id: this.doc_id, - lines: this.lines, - version: this.version, - ops: [], - ranges: this.ranges, - pathname: this.pathname, - ttlInS: 42, - }) - .should.equal(true) + this.res.json.should.have.been.calledWith({ + id: this.doc_id, + lines: this.lines, + version: this.version, + ops: [], + ranges: this.ranges, + pathname: this.pathname, + ttlInS: 42, + type: 'sharejs-text-ot', + }) }) it('should log the request', function () { @@ -115,7 +117,9 @@ describe('HttpController', function () { this.version, this.ops, this.ranges, - this.pathname + this.pathname, + this.projectHistoryId, + 'sharejs-text-ot' ) this.req.query = { fromVersion: `${this.fromVersion}` } this.HttpController.getDoc(this.req, this.res, this.next) @@ -128,17 +132,16 @@ describe('HttpController', function () { }) it('should return the doc as JSON', function () { - this.res.json - .calledWith({ - id: this.doc_id, - lines: this.lines, - version: this.version, - ops: this.ops, - ranges: this.ranges, - pathname: this.pathname, - ttlInS: 42, - }) - .should.equal(true) + this.res.json.should.have.been.calledWith({ + id: this.doc_id, + lines: this.lines, + version: this.version, + ops: this.ops, + ranges: this.ranges, + pathname: this.pathname, + ttlInS: 42, + type: 'sharejs-text-ot', + }) }) it('should log the request', function () { diff --git a/services/document-updater/test/unit/js/UpdateManager/UpdateManagerTests.js b/services/document-updater/test/unit/js/UpdateManager/UpdateManagerTests.js index dba66456b7..912707e01d 100644 --- a/services/document-updater/test/unit/js/UpdateManager/UpdateManagerTests.js +++ b/services/document-updater/test/unit/js/UpdateManager/UpdateManagerTests.js @@ -331,6 +331,7 @@ describe('UpdateManager', function () { pathname: this.pathname, projectHistoryId: this.projectHistoryId, historyRangesSupport: false, + type: 'sharejs-text-ot', }) this.RangesManager.applyUpdate.returns({ newRanges: this.updated_ranges, @@ -502,6 +503,7 @@ describe('UpdateManager', function () { pathname: this.pathname, projectHistoryId: this.projectHistoryId, historyRangesSupport: true, + type: 'sharejs-text-ot', }) await this.UpdateManager.promises.applyUpdate( this.project_id, diff --git a/services/filestore/.gitignore b/services/filestore/.gitignore index a2f4b5afb2..1772191882 100644 --- a/services/filestore/.gitignore +++ b/services/filestore/.gitignore @@ -1,54 +1,3 @@ -compileFolder - -Compiled source # -################### -*.com -*.class -*.dll -*.exe -*.o -*.so - -# Packages # -############ -# it's better to unpack these files and commit the raw source -# git has its own built in compression methods -*.7z -*.dmg -*.gz -*.iso -*.jar -*.rar -*.tar -*.zip - -# Logs and databases # -###################### -*.log -*.sql -*.sqlite - -# OS generated files # -###################### -.DS_Store? -ehthumbs.db -Icon? -Thumbs.db - -/node_modules/* -data/*/* - -**/*.map -cookies.txt uploads/* - user_files/* template_files/* - -**.swp - -/log.json -hash_folder - -# managed by dev-environment$ bin/update_build_scripts -.npmrc diff --git a/services/filestore/.nvmrc b/services/filestore/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/services/filestore/.nvmrc +++ b/services/filestore/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/services/filestore/Dockerfile b/services/filestore/Dockerfile index 8e336d4630..43477aa349 100644 --- a/services/filestore/Dockerfile +++ b/services/filestore/Dockerfile @@ -2,7 +2,7 @@ # Instead run bin/update_build_scripts from # https://github.com/overleaf/internal/ -FROM node:20.18.2 AS base +FROM node:22.15.1 AS base WORKDIR /overleaf/services/filestore COPY services/filestore/install_deps.sh /overleaf/services/filestore/ diff --git a/services/filestore/Makefile b/services/filestore/Makefile index cc1724589d..9b32f44f1b 100644 --- a/services/filestore/Makefile +++ b/services/filestore/Makefile @@ -32,12 +32,12 @@ HERE=$(shell pwd) MONOREPO=$(shell cd ../../ && pwd) # Run the linting commands in the scope of the monorepo. # Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:20.18.2 npm run --silent +RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:22.15.1 npm run --silent RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) npm run --silent # Same but from the top of the monorepo -RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:20.18.2 npm run --silent +RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:22.15.1 npm run --silent SHELLCHECK_OPTS = \ --shell=bash \ diff --git a/services/filestore/buildscript.txt b/services/filestore/buildscript.txt index 75a491c18a..8ffa1e058f 100644 --- a/services/filestore/buildscript.txt +++ b/services/filestore/buildscript.txt @@ -5,7 +5,7 @@ filestore --env-add=ENABLE_CONVERSIONS="true",USE_PROM_METRICS="true",AWS_S3_USER_FILES_STORAGE_CLASS=REDUCED_REDUNDANCY,AWS_S3_USER_FILES_BUCKET_NAME=fake-user-files,AWS_S3_USER_FILES_DEK_BUCKET_NAME=fake-user-files-dek,AWS_S3_TEMPLATE_FILES_BUCKET_NAME=fake-template-files,GCS_USER_FILES_BUCKET_NAME=fake-gcs-user-files,GCS_TEMPLATE_FILES_BUCKET_NAME=fake-gcs-template-files --env-pass-through= --esmock-loader=False ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=True --script-version=4.7.0 --test-acceptance-shards=SHARD_01_,SHARD_02_,SHARD_03_ diff --git a/services/filestore/docker-compose.ci.yml b/services/filestore/docker-compose.ci.yml index febd6e4c13..3656e1d6b1 100644 --- a/services/filestore/docker-compose.ci.yml +++ b/services/filestore/docker-compose.ci.yml @@ -64,7 +64,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root certs: - image: node:20.18.2 + image: node:22.15.1 volumes: - ./test/acceptance/certs:/certs working_dir: /certs diff --git a/services/filestore/docker-compose.yml b/services/filestore/docker-compose.yml index cc58997445..a15323c722 100644 --- a/services/filestore/docker-compose.yml +++ b/services/filestore/docker-compose.yml @@ -72,7 +72,7 @@ services: command: npm run --silent test:acceptance certs: - image: node:20.18.2 + image: node:22.15.1 volumes: - ./test/acceptance/certs:/certs working_dir: /certs diff --git a/services/git-bridge/.gitignore b/services/git-bridge/.gitignore index 74a7f43d6e..f35e2ee038 100644 --- a/services/git-bridge/.gitignore +++ b/services/git-bridge/.gitignore @@ -1,53 +1,6 @@ -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# Let's not share anything because we're using Maven. - -.idea -*.iml - -# User-specific stuff: -.idea/workspace.xml -.idea/tasks.xml -.idea/dictionaries -.idea/vcs.xml -.idea/jsLibraryMappings.xml - -# Sensitive or high-churn files: -.idea/dataSources.ids -.idea/dataSources.xml -.idea/dataSources.local.xml -.idea/sqlDataSources.xml -.idea/dynamic.xml -.idea/uiDesigner.xml - -# Gradle: -.idea/gradle.xml -.idea/libraries - -# Mongo Explorer plugin: -.idea/mongoSettings.xml - -## File-based project format: -*.iws - -## Plugin-specific files: - -# IntelliJ +# Build output /out/ target/ -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - # Local configuration files conf/runtime.json diff --git a/services/history-v1/.gitignore b/services/history-v1/.gitignore deleted file mode 100644 index edb0f85350..0000000000 --- a/services/history-v1/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ - -# managed by monorepo$ bin/update_build_scripts -.npmrc diff --git a/services/history-v1/.nvmrc b/services/history-v1/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/services/history-v1/.nvmrc +++ b/services/history-v1/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/services/history-v1/Dockerfile b/services/history-v1/Dockerfile index 0aa6a2fc0f..7eb6f00832 100644 --- a/services/history-v1/Dockerfile +++ b/services/history-v1/Dockerfile @@ -2,7 +2,7 @@ # Instead run bin/update_build_scripts from # https://github.com/overleaf/internal/ -FROM node:20.18.2 AS base +FROM node:22.15.1 AS base WORKDIR /overleaf/services/history-v1 COPY services/history-v1/install_deps.sh /overleaf/services/history-v1/ diff --git a/services/history-v1/Makefile b/services/history-v1/Makefile index 1f03a21f18..53fa3f17e3 100644 --- a/services/history-v1/Makefile +++ b/services/history-v1/Makefile @@ -32,12 +32,12 @@ HERE=$(shell pwd) MONOREPO=$(shell cd ../../ && pwd) # Run the linting commands in the scope of the monorepo. # Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:20.18.2 npm run --silent +RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:22.15.1 npm run --silent RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) npm run --silent # Same but from the top of the monorepo -RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:20.18.2 npm run --silent +RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:22.15.1 npm run --silent SHELLCHECK_OPTS = \ --shell=bash \ diff --git a/services/history-v1/api/controllers/project_import.js b/services/history-v1/api/controllers/project_import.js index 5dec84d843..edffb19a25 100644 --- a/services/history-v1/api/controllers/project_import.js +++ b/services/history-v1/api/controllers/project_import.js @@ -95,7 +95,9 @@ async function importChanges(req, res, next) { } async function buildResultSnapshot(resultChunk) { - const chunk = resultChunk || (await chunkStore.loadLatest(projectId)) + const chunk = + resultChunk || + (await chunkStore.loadLatest(projectId, { persistedOnly: true })) const snapshot = chunk.getSnapshot() snapshot.applyAll(chunk.getChanges()) const rawSnapshot = await snapshot.store(hashCheckBlobStore) @@ -130,7 +132,9 @@ async function importChanges(req, res, next) { } if (returnSnapshot === 'none') { - res.status(HTTPStatus.CREATED).json({}) + res.status(HTTPStatus.CREATED).json({ + resyncNeeded: result.resyncNeeded, + }) } else { const rawSnapshot = await buildResultSnapshot(result && result.currentChunk) res.status(HTTPStatus.CREATED).json(rawSnapshot) diff --git a/services/history-v1/api/controllers/projects.js b/services/history-v1/api/controllers/projects.js index d42e29ab81..47a1d959ad 100644 --- a/services/history-v1/api/controllers/projects.js +++ b/services/history-v1/api/controllers/projects.js @@ -91,7 +91,7 @@ async function getLatestHistoryRaw(req, res, next) { const readOnly = req.swagger.params.readOnly.value try { const { startVersion, endVersion, endTimestamp } = - await chunkStore.loadLatestRaw(projectId, { readOnly }) + await chunkStore.getLatestChunkMetadata(projectId, { readOnly }) res.json({ startVersion, endVersion, @@ -152,29 +152,27 @@ async function getChanges(req, res, next) { }) } - const changes = [] - let chunk = await chunkStore.loadLatest(projectId) - - if (since > chunk.getEndVersion()) { - return res.status(400).json({ - error: `Version out of bounds: ${since}`, + let chunk + try { + chunk = await chunkStore.loadAtVersion(projectId, since, { + preferNewer: true, }) + } catch (err) { + if (err instanceof Chunk.VersionNotFoundError) { + return res.status(400).json({ + error: `Version out of bounds: ${since}`, + }) + } + throw err } - // Fetch all chunks that come after the chunk that contains the start version - while (chunk.getStartVersion() > since) { - const changesInChunk = chunk.getChanges() - changes.unshift(...changesInChunk) - chunk = await chunkStore.loadAtVersion(projectId, chunk.getStartVersion()) - } + const latestChunkMetadata = await chunkStore.getLatestChunkMetadata(projectId) // Extract the relevant changes from the chunk that contains the start version - const changesInChunk = chunk - .getChanges() - .slice(since - chunk.getStartVersion()) - changes.unshift(...changesInChunk) + const changes = chunk.getChanges().slice(since - chunk.getStartVersion()) + const hasMore = latestChunkMetadata.endVersion > chunk.getEndVersion() - res.json(changes.map(change => change.toRaw())) + res.json({ changes: changes.map(change => change.toRaw()), hasMore }) } async function getZip(req, res, next) { @@ -255,11 +253,13 @@ async function createProjectBlob(req, res, next) { const blobStore = new BlobStore(projectId) const newBlob = await blobStore.putFile(tmpPath) - try { - const { backupBlob } = await import('../../storage/lib/backupBlob.mjs') - await backupBlob(projectId, newBlob, tmpPath) - } catch (error) { - logger.warn({ error, projectId, hash }, 'Failed to backup blob') + if (config.has('backupStore')) { + try { + const { backupBlob } = await import('../../storage/lib/backupBlob.mjs') + await backupBlob(projectId, newBlob, tmpPath) + } catch (error) { + logger.warn({ error, projectId, hash }, 'Failed to backup blob') + } } res.status(HTTPStatus.CREATED).end() }) diff --git a/services/history-v1/buildscript.txt b/services/history-v1/buildscript.txt index f3e029ba33..7f6f209f1f 100644 --- a/services/history-v1/buildscript.txt +++ b/services/history-v1/buildscript.txt @@ -4,7 +4,7 @@ history-v1 --env-add= --env-pass-through= --esmock-loader=False ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 --tsconfig-extra-includes=backup-deletion-app.mjs,backup-verifier-app.mjs,backup-worker-app.mjs,api/**/*,migrations/**/*,storage/**/* diff --git a/services/history-v1/docker-compose.ci.yml b/services/history-v1/docker-compose.ci.yml index 06d5d55161..0dfe8b99d3 100644 --- a/services/history-v1/docker-compose.ci.yml +++ b/services/history-v1/docker-compose.ci.yml @@ -73,7 +73,7 @@ services: retries: 20 mongo: - image: mongo:6.0.13 + image: mongo:7.0.20 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js @@ -98,7 +98,7 @@ services: retries: 20 certs: - image: node:20.18.2 + image: node:22.15.1 volumes: - ./test/acceptance/certs:/certs working_dir: /certs diff --git a/services/history-v1/docker-compose.yml b/services/history-v1/docker-compose.yml index f4c885d467..b87d859e1e 100644 --- a/services/history-v1/docker-compose.yml +++ b/services/history-v1/docker-compose.yml @@ -81,7 +81,7 @@ services: retries: 20 mongo: - image: mongo:6.0.13 + image: mongo:7.0.20 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js @@ -107,7 +107,7 @@ services: retries: 20 certs: - image: node:20.18.2 + image: node:22.15.1 volumes: - ./test/acceptance/certs:/certs working_dir: /certs diff --git a/services/history-v1/package.json b/services/history-v1/package.json index 3219be9af4..1fdfd95c45 100644 --- a/services/history-v1/package.json +++ b/services/history-v1/package.json @@ -24,7 +24,7 @@ "bunyan": "^1.8.12", "check-types": "^11.1.2", "command-line-args": "^3.0.3", - "config": "^1.19.0", + "config": "^3.3.12", "express": "^4.21.2", "fs-extra": "^9.0.1", "generic-pool": "^2.1.1", diff --git a/services/history-v1/storage/index.js b/services/history-v1/storage/index.js index 5fe283a34c..2aa492f46e 100644 --- a/services/history-v1/storage/index.js +++ b/services/history-v1/storage/index.js @@ -1,7 +1,6 @@ exports.BatchBlobStore = require('./lib/batch_blob_store') exports.blobHash = require('./lib/blob_hash') exports.HashCheckBlobStore = require('./lib/hash_check_blob_store') -exports.chunkBuffer = require('./lib/chunk_buffer') exports.chunkStore = require('./lib/chunk_store') exports.historyStore = require('./lib/history_store').historyStore exports.knex = require('./lib/knex') diff --git a/services/history-v1/storage/lib/backupGenerator.mjs b/services/history-v1/storage/lib/backupGenerator.mjs index 4c18929d54..d8f1b0e99a 100644 --- a/services/history-v1/storage/lib/backupGenerator.mjs +++ b/services/history-v1/storage/lib/backupGenerator.mjs @@ -31,7 +31,8 @@ async function lookBehindForSeenBlobs( // so we find the set of backed up blobs from the previous chunk const previousChunk = await chunkStore.loadAtVersion( projectId, - lastBackedUpVersion + lastBackedUpVersion, + { persistedOnly: true } ) const previousChunkHistory = previousChunk.getHistory() previousChunkHistory.findBlobHashes(seenBlobs) diff --git a/services/history-v1/storage/lib/chunk_buffer/index.js b/services/history-v1/storage/lib/chunk_buffer/index.js deleted file mode 100644 index 5ef533ddba..0000000000 --- a/services/history-v1/storage/lib/chunk_buffer/index.js +++ /dev/null @@ -1,39 +0,0 @@ -'use strict' - -/** - * @module storage/lib/chunk_buffer - */ - -const chunkStore = require('../chunk_store') -const redisBackend = require('../chunk_store/redis') -const metrics = require('@overleaf/metrics') -/** - * Load the latest Chunk stored for a project, including blob metadata. - * - * @param {string} projectId - * @return {Promise.} - */ -async function loadLatest(projectId) { - const chunkRecord = await chunkStore.loadLatestRaw(projectId) - const cachedChunk = await redisBackend.getCurrentChunkIfValid( - projectId, - chunkRecord - ) - if (cachedChunk) { - metrics.inc('chunk_buffer.loadLatest', 1, { - status: 'cache-hit', - }) - return cachedChunk - } else { - metrics.inc('chunk_buffer.loadLatest', 1, { - status: 'cache-miss', - }) - const chunk = await chunkStore.loadLatest(projectId) - await redisBackend.setCurrentChunk(projectId, chunk) - return chunk - } -} - -module.exports = { - loadLatest, -} diff --git a/services/history-v1/storage/lib/chunk_store/errors.js b/services/history-v1/storage/lib/chunk_store/errors.js index 5f0eba6aac..75b830f9a0 100644 --- a/services/history-v1/storage/lib/chunk_store/errors.js +++ b/services/history-v1/storage/lib/chunk_store/errors.js @@ -1,7 +1,15 @@ const OError = require('@overleaf/o-error') class ChunkVersionConflictError extends OError {} +class BaseVersionConflictError extends OError {} +class JobNotFoundError extends OError {} +class JobNotReadyError extends OError {} +class VersionOutOfBoundsError extends OError {} module.exports = { ChunkVersionConflictError, + BaseVersionConflictError, + JobNotFoundError, + JobNotReadyError, + VersionOutOfBoundsError, } diff --git a/services/history-v1/storage/lib/chunk_store/index.js b/services/history-v1/storage/lib/chunk_store/index.js index f75c017552..6dab84f929 100644 --- a/services/history-v1/storage/lib/chunk_store/index.js +++ b/services/history-v1/storage/lib/chunk_store/index.js @@ -32,7 +32,15 @@ const { BlobStore } = require('../blob_store') const { historyStore } = require('../history_store') const mongoBackend = require('./mongo') const postgresBackend = require('./postgres') -const { ChunkVersionConflictError } = require('./errors') +const redisBackend = require('./redis') +const { + ChunkVersionConflictError, + VersionOutOfBoundsError, +} = require('./errors') + +/** + * @import { Change } from 'overleaf-editor-core' + */ const DEFAULT_DELETE_BATCH_SIZE = parseInt(config.get('maxDeleteKeys'), 10) const DEFAULT_DELETE_TIMEOUT_SECS = 3000 // 50 minutes @@ -88,37 +96,55 @@ async function lazyLoadHistoryFiles(history, batchBlobStore) { * @param {boolean} [opts.readOnly] * @return {Promise<{id: string, startVersion: number, endVersion: number, endTimestamp: Date}>} */ -async function loadLatestRaw(projectId, opts) { +async function getLatestChunkMetadata(projectId, opts) { assert.projectId(projectId, 'bad projectId') const backend = getBackend(projectId) - const chunkRecord = await backend.getLatestChunk(projectId, opts) - if (chunkRecord == null) { + const chunkMetadata = await backend.getLatestChunk(projectId, opts) + if (chunkMetadata == null) { throw new Chunk.NotFoundError(projectId) } - return chunkRecord + return chunkMetadata } /** * Load the latest Chunk stored for a project, including blob metadata. * * @param {string} projectId - * @return {Promise.} + * @param {object} [opts] + * @param {boolean} [opts.persistedOnly] - only include persisted changes + * @return {Promise} */ -async function loadLatest(projectId) { - const chunkRecord = await loadLatestRaw(projectId) - const rawHistory = await historyStore.loadRaw(projectId, chunkRecord.id) +async function loadLatest(projectId, opts = {}) { + const chunkMetadata = await getLatestChunkMetadata(projectId) + const rawHistory = await historyStore.loadRaw(projectId, chunkMetadata.id) const history = History.fromRaw(rawHistory) + + if (!opts.persistedOnly) { + const nonPersistedChanges = await getChunkExtension( + projectId, + chunkMetadata.endVersion + ) + history.pushChanges(nonPersistedChanges) + } + const blobStore = new BlobStore(projectId) const batchBlobStore = new BatchBlobStore(blobStore) await lazyLoadHistoryFiles(history, batchBlobStore) - return new Chunk(history, chunkRecord.startVersion) + return new Chunk(history, chunkMetadata.startVersion) } /** * Load the the chunk that contains the given version, including blob metadata. + * + * @param {string} projectId + * @param {number} version + * @param {object} [opts] + * @param {boolean} [opts.persistedOnly] - only include persisted changes + * @param {boolean} [opts.preferNewer] - If the version is at the boundary of + * two chunks, return the newer chunk. */ -async function loadAtVersion(projectId, version) { +async function loadAtVersion(projectId, version, opts = {}) { assert.projectId(projectId, 'bad projectId') assert.integer(version, 'bad version') @@ -126,9 +152,20 @@ async function loadAtVersion(projectId, version) { const blobStore = new BlobStore(projectId) const batchBlobStore = new BatchBlobStore(blobStore) - const chunkRecord = await backend.getChunkForVersion(projectId, version) + const chunkRecord = await backend.getChunkForVersion(projectId, version, { + preferNewer: opts.preferNewer, + }) const rawHistory = await historyStore.loadRaw(projectId, chunkRecord.id) const history = History.fromRaw(rawHistory) + + if (!opts.persistedOnly) { + const nonPersistedChanges = await getChunkExtension( + projectId, + chunkRecord.endVersion + ) + history.pushChanges(nonPersistedChanges) + } + await lazyLoadHistoryFiles(history, batchBlobStore) return new Chunk(history, chunkRecord.endVersion - history.countChanges()) } @@ -136,8 +173,13 @@ async function loadAtVersion(projectId, version) { /** * Load the chunk that contains the version that was current at the given * timestamp, including blob metadata. + * + * @param {string} projectId + * @param {Date} timestamp + * @param {object} [opts] + * @param {boolean} [opts.persistedOnly] - only include persisted changes */ -async function loadAtTimestamp(projectId, timestamp) { +async function loadAtTimestamp(projectId, timestamp, opts = {}) { assert.projectId(projectId, 'bad projectId') assert.date(timestamp, 'bad timestamp') @@ -148,6 +190,15 @@ async function loadAtTimestamp(projectId, timestamp) { const chunkRecord = await backend.getChunkForTimestamp(projectId, timestamp) const rawHistory = await historyStore.loadRaw(projectId, chunkRecord.id) const history = History.fromRaw(rawHistory) + + if (!opts.persistedOnly) { + const nonPersistedChanges = await getChunkExtension( + projectId, + chunkRecord.endVersion + ) + history.pushChanges(nonPersistedChanges) + } + await lazyLoadHistoryFiles(history, batchBlobStore) return new Chunk(history, chunkRecord.endVersion - history.countChanges()) } @@ -166,16 +217,29 @@ async function create(projectId, chunk, earliestChangeTimestamp) { const backend = getBackend(projectId) const chunkStart = chunk.getStartVersion() - const chunkId = await uploadChunk(projectId, chunk) const opts = {} if (chunkStart > 0) { - opts.oldChunkId = await getChunkIdForVersion(projectId, chunkStart - 1) + const oldChunk = await backend.getChunkForVersion(projectId, chunkStart - 1) + + if (oldChunk.endVersion !== chunkStart) { + throw new ChunkVersionConflictError( + 'unexpected end version on chunk to be updated', + { + projectId, + expectedVersion: chunkStart, + actualVersion: oldChunk.endVersion, + } + ) + } + + opts.oldChunkId = oldChunk.id } if (earliestChangeTimestamp != null) { opts.earliestChangeTimestamp = earliestChangeTimestamp } + const chunkId = await uploadChunk(projectId, chunk) await backend.confirmCreate(projectId, chunk, chunkId, opts) } @@ -206,24 +270,44 @@ async function uploadChunk(projectId, chunk) { * chunk. * * @param {string} projectId - * @param {number} oldEndVersion * @param {Chunk} newChunk * @param {Date} [earliestChangeTimestamp] * @return {Promise} */ -async function update( - projectId, - oldEndVersion, - newChunk, - earliestChangeTimestamp -) { +async function update(projectId, newChunk, earliestChangeTimestamp) { assert.projectId(projectId, 'bad projectId') - assert.integer(oldEndVersion, 'bad oldEndVersion') assert.instance(newChunk, Chunk, 'bad newChunk') assert.maybe.date(earliestChangeTimestamp, 'bad timestamp') const backend = getBackend(projectId) - const oldChunkId = await getChunkIdForVersion(projectId, oldEndVersion) + const oldChunk = await backend.getChunkForVersion( + projectId, + newChunk.getStartVersion(), + { preferNewer: true } + ) + + if (oldChunk.startVersion !== newChunk.getStartVersion()) { + throw new ChunkVersionConflictError( + 'unexpected start version on chunk to be updated', + { + projectId, + expectedVersion: newChunk.getStartVersion(), + actualVersion: oldChunk.startVersion, + } + ) + } + + if (oldChunk.endVersion > newChunk.getEndVersion()) { + throw new ChunkVersionConflictError( + 'chunk update would decrease chunk version', + { + projectId, + currentVersion: oldChunk.endVersion, + newVersion: newChunk.getEndVersion(), + } + ) + } + const newChunkId = await uploadChunk(projectId, newChunk) const opts = {} @@ -231,7 +315,13 @@ async function update( opts.earliestChangeTimestamp = earliestChangeTimestamp } - await backend.confirmUpdate(projectId, oldChunkId, newChunk, newChunkId, opts) + await backend.confirmUpdate( + projectId, + oldChunk.id, + newChunk, + newChunkId, + opts + ) } /** @@ -307,7 +397,7 @@ async function loadByChunkRecord(projectId, chunkRecord) { */ async function* getProjectChunksFromVersion(projectId, version) { const backend = getBackend(projectId) - const latestChunkMetadata = await loadLatestRaw(projectId) + const latestChunkMetadata = await getLatestChunkMetadata(projectId) if (!latestChunkMetadata || version > latestChunkMetadata.endVersion) { return } @@ -418,6 +508,31 @@ function getBackend(projectId) { } } +/** + * Gets non-persisted changes that could extend a chunk + * + * @param {string} projectId + * @param {number} chunkEndVersion - end version of the chunk to extend + * + * @return {Promise} + */ +async function getChunkExtension(projectId, chunkEndVersion) { + try { + const changes = await redisBackend.getNonPersistedChanges( + projectId, + chunkEndVersion + ) + return changes + } catch (err) { + if (err instanceof VersionOutOfBoundsError) { + // If we can't extend the chunk, simply return an empty list + return [] + } else { + throw err + } + } +} + class AlreadyInitialized extends OError { constructor(projectId) { super('Project is already initialized', { projectId }) @@ -428,7 +543,7 @@ module.exports = { getBackend, initializeProject, loadLatest, - loadLatestRaw, + getLatestChunkMetadata, loadAtVersion, loadAtTimestamp, loadByChunkRecord, diff --git a/services/history-v1/storage/lib/chunk_store/mongo.js b/services/history-v1/storage/lib/chunk_store/mongo.js index a34b7194af..26c1bc48ec 100644 --- a/services/history-v1/storage/lib/chunk_store/mongo.js +++ b/services/history-v1/storage/lib/chunk_store/mongo.js @@ -3,6 +3,7 @@ const { ObjectId, ReadPreference, MongoError } = require('mongodb') const { Chunk } = require('overleaf-editor-core') const OError = require('@overleaf/o-error') +const config = require('config') const assert = require('../assert') const mongodb = require('../mongodb') const { ChunkVersionConflictError } = require('./errors') @@ -43,8 +44,14 @@ async function getLatestChunk(projectId, opts = {}) { /** * Get the metadata for the chunk that contains the given version. + * + * @param {string} projectId + * @param {number} version + * @param {object} [opts] + * @param {boolean} [opts.preferNewer] - If the version is at the boundary of + * two chunks, return the newer chunk. */ -async function getChunkForVersion(projectId, version) { +async function getChunkForVersion(projectId, version, opts = {}) { assert.mongoId(projectId, 'bad projectId') assert.integer(version, 'bad version') @@ -55,7 +62,7 @@ async function getChunkForVersion(projectId, version) { startVersion: { $lte: version }, endVersion: { $gte: version }, }, - { sort: { startVersion: 1 } } + { sort: { startVersion: opts.preferNewer ? -1 : 1 } } ) if (record == null) { throw new Chunk.VersionNotFoundError(projectId, version) @@ -253,6 +260,9 @@ async function updateProjectRecord( earliestChangeTimestamp, mongoOpts = {} ) { + if (!config.has('backupStore')) { + return + } // record the end version against the project await mongodb.projects.updateOne( { diff --git a/services/history-v1/storage/lib/chunk_store/postgres.js b/services/history-v1/storage/lib/chunk_store/postgres.js index 0c33c0fd82..bfb5c6954a 100644 --- a/services/history-v1/storage/lib/chunk_store/postgres.js +++ b/services/history-v1/storage/lib/chunk_store/postgres.js @@ -38,14 +38,18 @@ async function getLatestChunk(projectId, opts = {}) { * * @param {string} projectId * @param {number} version + * @param {object} [opts] + * @param {boolean} [opts.preferNewer] - If the version is at the boundary of + * two chunks, return the newer chunk. */ -async function getChunkForVersion(projectId, version) { +async function getChunkForVersion(projectId, version, opts = {}) { assert.postgresId(projectId, 'bad projectId') const record = await knex('chunks') .where('doc_id', parseInt(projectId, 10)) + .where('start_version', '<=', version) .where('end_version', '>=', version) - .orderBy('end_version') + .orderBy('end_version', opts.preferNewer ? 'desc' : 'asc') .first() if (!record) { throw new Chunk.VersionNotFoundError(projectId, version) diff --git a/services/history-v1/storage/lib/chunk_store/redis.js b/services/history-v1/storage/lib/chunk_store/redis.js index d9c423861d..0ae7cee2e5 100644 --- a/services/history-v1/storage/lib/chunk_store/redis.js +++ b/services/history-v1/storage/lib/chunk_store/redis.js @@ -1,20 +1,31 @@ -const metrics = require('@overleaf/metrics') -const logger = require('@overleaf/logger') -const redis = require('../redis') -const rclient = redis.rclientHistory // -const { Snapshot, Change, History, Chunk } = require('overleaf-editor-core') +// @ts-check -const TEMPORARY_CACHE_LIFETIME = 300 // 5 minutes +const metrics = require('@overleaf/metrics') +const OError = require('@overleaf/o-error') +const { Change, Snapshot } = require('overleaf-editor-core') +const redis = require('../redis') +const rclient = redis.rclientHistory +const { + BaseVersionConflictError, + JobNotFoundError, + JobNotReadyError, + VersionOutOfBoundsError, +} = require('./errors') + +const MAX_PERSISTED_CHANGES = 100 // Maximum number of persisted changes to keep in the buffer for clients that need to catch up. +const PROJECT_TTL_MS = 3600 * 1000 // Amount of time a project can stay inactive before it gets expired +const MAX_PERSIST_DELAY_MS = 300 * 1000 // Maximum amount of time before a change is persisted +const RETRY_DELAY_MS = 120 * 1000 // Time before a claimed job is considered stale and a worker can retry it. const keySchema = { - snapshot({ projectId }) { - return `snapshot:{${projectId}}` + head({ projectId }) { + return `head:{${projectId}}` }, - startVersion({ projectId }) { - return `snapshot-version:{${projectId}}` + headVersion({ projectId }) { + return `head-version:{${projectId}}` }, - changes({ projectId }) { - return `changes:{${projectId}}` + persistedVersion({ projectId }) { + return `persisted-version:{${projectId}}` }, expireTime({ projectId }) { return `expire-time:{${projectId}}` @@ -22,457 +33,738 @@ const keySchema = { persistTime({ projectId }) { return `persist-time:{${projectId}}` }, + changes({ projectId }) { + return `changes:{${projectId}}` + }, } -rclient.defineCommand('get_current_chunk', { - numberOfKeys: 3, - lua: ` - local startVersionValue = redis.call('GET', KEYS[2]) - if not startVersionValue then - return nil -- this is a cache-miss - end - local snapshotValue = redis.call('GET', KEYS[1]) - local changesValues = redis.call('LRANGE', KEYS[3], 0, -1) - return {snapshotValue, startVersionValue, changesValues} - `, -}) - -/** - * Retrieves the current chunk of project history from Redis storage - * @param {string} projectId - The unique identifier of the project - * @returns {Promise} A Promise that resolves to a Chunk object containing project history, - * or null if retrieval fails - * @throws {Error} If Redis operations fail - */ -async function getCurrentChunk(projectId) { - try { - const result = await rclient.get_current_chunk( - keySchema.snapshot({ projectId }), - keySchema.startVersion({ projectId }), - keySchema.changes({ projectId }) - ) - if (!result) { - return null // cache-miss - } - const snapshot = Snapshot.fromRaw(JSON.parse(result[0])) - const startVersion = JSON.parse(result[1]) - const changes = result[2].map(c => Change.fromRaw(JSON.parse(c))) - const history = new History(snapshot, changes) - const chunk = new Chunk(history, startVersion) - metrics.inc('chunk_store.redis.get_current_chunk', 1, { status: 'success' }) - return chunk - } catch (err) { - logger.error({ err, projectId }, 'error getting current chunk from redis') - metrics.inc('chunk_store.redis.get_current_chunk', 1, { status: 'error' }) - return null - } -} - -rclient.defineCommand('get_current_chunk_if_valid', { - numberOfKeys: 3, - lua: ` - local expectedStartVersion = ARGV[1] - local expectedChangesCount = tonumber(ARGV[2]) - local startVersionValue = redis.call('GET', KEYS[2]) - if not startVersionValue then - return nil -- this is a cache-miss - end - if startVersionValue ~= expectedStartVersion then - return nil -- this is a cache-miss - end - local changesCount = redis.call('LLEN', KEYS[3]) - if changesCount ~= expectedChangesCount then - return nil -- this is a cache-miss - end - local snapshotValue = redis.call('GET', KEYS[1]) - local changesValues = redis.call('LRANGE', KEYS[3], 0, -1) - return {snapshotValue, startVersionValue, changesValues} - `, -}) - -async function getCurrentChunkIfValid(projectId, chunkRecord) { - try { - const changesCount = chunkRecord.endVersion - chunkRecord.startVersion - const result = await rclient.get_current_chunk_if_valid( - keySchema.snapshot({ projectId }), - keySchema.startVersion({ projectId }), - keySchema.changes({ projectId }), - chunkRecord.startVersion, - changesCount - ) - if (!result) { - return null // cache-miss - } - const snapshot = Snapshot.fromRaw(JSON.parse(result[0])) - const startVersion = parseInt(result[1], 10) - const changes = result[2].map(c => Change.fromRaw(JSON.parse(c))) - const history = new History(snapshot, changes) - const chunk = new Chunk(history, startVersion) - metrics.inc('chunk_store.redis.get_current_chunk_if_valid', 1, { - status: 'success', - }) - return chunk - } catch (err) { - logger.error( - { err, projectId, chunkRecord }, - 'error getting current chunk from redis' - ) - metrics.inc('chunk_store.redis.get_current_chunk_if_valid', 1, { - status: 'error', - }) - return null - } -} - -rclient.defineCommand('get_current_chunk_metadata', { +rclient.defineCommand('get_head_snapshot', { numberOfKeys: 2, lua: ` - local startVersionValue = redis.call('GET', KEYS[1]) - if not startVersionValue then - return nil -- this is a cache-miss + local headSnapshotKey = KEYS[1] + local headVersionKey = KEYS[2] + + -- Check if the head version exists. If not, consider it a cache miss. + local version = redis.call('GET', headVersionKey) + if not version then + return nil end - local changesCount = redis.call('LLEN', KEYS[2]) - return {startVersionValue, changesCount} + + -- Retrieve the snapshot value + local snapshot = redis.call('GET', headSnapshotKey) + return {snapshot, version} `, }) /** - * Retrieves the current chunk metadata for a given project from Redis - * @param {string} projectId - The ID of the project to get metadata for - * @returns {Promise} Object containing startVersion and changesCount if found, null on error or cache miss - * @property {number} startVersion - The starting version information - * @property {number} changesCount - The number of changes in the chunk + * Retrieves the head snapshot from Redis storage + * @param {string} projectId - The unique identifier of the project + * @returns {Promise<{version: number, snapshot: Snapshot}|null>} A Promise that resolves to an object containing the version and Snapshot, + * or null if retrieval fails or cache miss + * @throws {Error} If Redis operations fail */ -async function getCurrentChunkMetadata(projectId) { +async function getHeadSnapshot(projectId) { try { - const result = await rclient.get_current_chunk_metadata( - keySchema.startVersion({ projectId }), - keySchema.changes({ projectId }) + const result = await rclient.get_head_snapshot( + keySchema.head({ projectId }), + keySchema.headVersion({ projectId }) ) if (!result) { + metrics.inc('chunk_store.redis.get_head_snapshot', 1, { + status: 'cache-miss', + }) return null // cache-miss } - const startVersion = JSON.parse(result[0]) - const changesCount = parseInt(result[1], 10) - return { startVersion, changesCount } + const snapshot = Snapshot.fromRaw(JSON.parse(result[0])) + const version = parseInt(result[1], 10) + metrics.inc('chunk_store.redis.get_head_snapshot', 1, { + status: 'success', + }) + return { version, snapshot } } catch (err) { - return null - } -} - -rclient.defineCommand('set_current_chunk', { - numberOfKeys: 4, - lua: ` - local snapshotValue = ARGV[1] - local startVersionValue = ARGV[2] - local expireTime = ARGV[3] - redis.call('SET', KEYS[1], snapshotValue) - redis.call('SET', KEYS[2], startVersionValue) - redis.call('SET', KEYS[3], expireTime) - redis.call('DEL', KEYS[4]) -- clear the old changes list - if #ARGV >= 4 then - redis.call('RPUSH', KEYS[4], unpack(ARGV, 4)) - end - - `, -}) - -/** - * Stores the current chunk of project history in Redis - * @param {string} projectId - The ID of the project - * @param {Chunk} chunk - The chunk object containing history data - * @returns {Promise<*>} Returns the result of the Redis operation, or null if an error occurs - * @throws {Error} May throw Redis-related errors which are caught internally - */ -async function setCurrentChunk(projectId, chunk) { - try { - const snapshotKey = keySchema.snapshot({ projectId }) - const startVersionKey = keySchema.startVersion({ projectId }) - const changesKey = keySchema.changes({ projectId }) - const expireTimeKey = keySchema.expireTime({ projectId }) - - const snapshot = chunk.history.snapshot - const startVersion = chunk.startVersion - const changes = chunk.history.changes - const expireTime = Date.now() + TEMPORARY_CACHE_LIFETIME * 1000 - - await rclient.set_current_chunk( - snapshotKey, // KEYS[1] - startVersionKey, // KEYS[2] - expireTimeKey, // KEYS[3] - changesKey, // KEYS[4] - JSON.stringify(snapshot.toRaw()), // ARGV[1] - startVersion, // ARGV[2] - expireTime, // ARGV[3] - ...changes.map(c => JSON.stringify(c.toRaw())) // ARGV[4..] - ) - metrics.inc('chunk_store.redis.set_current_chunk', 1, { status: 'success' }) - } catch (err) { - logger.error( - { err, projectId, chunk }, - 'error setting current chunk in redis' - ) - metrics.inc('chunk_store.redis.set_current_chunk', 1, { status: 'error' }) - return null // while testing we will suppress any errors - } -} - -/** - * Checks whether a cached chunk's version metadata matches the current chunk's metadata - * @param {Chunk} cachedChunk - The chunk retrieved from cache - * @param {Chunk} currentChunk - The current chunk to compare against - * @returns {boolean} - Returns true if the chunks have matching start and end versions, false otherwise - */ -function checkCacheValidity(cachedChunk, currentChunk) { - return Boolean( - cachedChunk && - cachedChunk.getStartVersion() === currentChunk.getStartVersion() && - cachedChunk.getEndVersion() === currentChunk.getEndVersion() - ) -} - -/** - * Validates if a cached chunk matches the current chunk metadata by comparing versions - * @param {Object} cachedChunk - The cached chunk object to validate - * @param {Object} currentChunkMetadata - The current chunk metadata to compare against - * @param {number} currentChunkMetadata.startVersion - The starting version number - * @param {number} currentChunkMetadata.endVersion - The ending version number - * @returns {boolean} - True if the cached chunk is valid, false otherwise - */ -function checkCacheValidityWithMetadata(cachedChunk, currentChunkMetadata) { - return Boolean( - cachedChunk && - cachedChunk.getStartVersion() === currentChunkMetadata.startVersion && - cachedChunk.getEndVersion() === currentChunkMetadata.endVersion - ) -} - -/** - * Compares two chunks for equality using stringified JSON comparison - * @param {string} projectId - The ID of the project - * @param {Chunk} cachedChunk - The cached chunk to compare - * @param {Chunk} currentChunk - The current chunk to compare against - * @returns {boolean} - Returns false if either chunk is null/undefined, otherwise returns the comparison result - */ -function compareChunks(projectId, cachedChunk, currentChunk) { - if (!cachedChunk || !currentChunk) { - return false - } - const identical = JSON.stringify(cachedChunk) === JSON.stringify(currentChunk) - if (!identical) { - try { - logger.error( - { - projectId, - cachedChunkStartVersion: cachedChunk.getStartVersion(), - cachedChunkEndVersion: cachedChunk.getEndVersion(), - currentChunkStartVersion: currentChunk.getStartVersion(), - currentChunkEndVersion: currentChunk.getEndVersion(), - }, - 'chunk cache mismatch' - ) - } catch (err) { - // ignore errors while logging - } - } - metrics.inc('chunk_store.redis.compare_chunks', 1, { - status: identical ? 'success' : 'fail', - }) - return identical -} - -// Define Lua script for atomic cache clearing -rclient.defineCommand('expire_chunk_cache', { - numberOfKeys: 5, - lua: ` - local persistTimeExists = redis.call('EXISTS', KEYS[5]) - if persistTimeExists == 1 then - return nil -- chunk has changes pending, do not expire - end - local currentTime = tonumber(ARGV[1]) - local expireTimeValue = redis.call('GET', KEYS[4]) - if not expireTimeValue then - return nil -- this is a cache-miss - end - local expireTime = tonumber(expireTimeValue) - if currentTime < expireTime then - return nil -- cache is still valid - end - -- Cache is expired and all changes are persisted, proceed to delete the keys atomically - redis.call('DEL', KEYS[1]) -- snapshot key - redis.call('DEL', KEYS[2]) -- startVersion key - redis.call('DEL', KEYS[3]) -- changes key - redis.call('DEL', KEYS[4]) -- expireTime key - return 1 - `, -}) - -/** - * Expire cache entries for a project's chunk data if needed - * @param {string} projectId - The ID of the project whose cache should be cleared - * @returns {Promise} A promise that resolves to true if successful, false on error - */ -async function expireCurrentChunk(projectId, currentTime) { - try { - const snapshotKey = keySchema.snapshot({ projectId }) - const startVersionKey = keySchema.startVersion({ projectId }) - const changesKey = keySchema.changes({ projectId }) - const expireTimeKey = keySchema.expireTime({ projectId }) - const persistTimeKey = keySchema.persistTime({ projectId }) - const result = await rclient.expire_chunk_cache( - snapshotKey, - startVersionKey, - changesKey, - expireTimeKey, - persistTimeKey, - currentTime || Date.now() - ) - if (!result) { - logger.debug( - { projectId }, - 'chunk cache not expired due to pending changes' - ) - metrics.inc('chunk_store.redis.expire_cache', 1, { - status: 'skip-due-to-pending-changes', - }) - return false // not expired - } - metrics.inc('chunk_store.redis.expire_cache', 1, { status: 'success' }) - return true - } catch (err) { - logger.error({ err, projectId }, 'error clearing chunk cache from redis') - metrics.inc('chunk_store.redis.expire_cache', 1, { status: 'error' }) - return false - } -} - -// Define Lua script for atomic cache clearing -rclient.defineCommand('clear_chunk_cache', { - numberOfKeys: 5, - lua: ` - local persistTimeExists = redis.call('EXISTS', KEYS[5]) - if persistTimeExists == 1 then - return nil -- chunk has changes pending, do not clear - end - -- Delete all keys related to a project's chunk cache atomically - redis.call('DEL', KEYS[1]) -- snapshot key - redis.call('DEL', KEYS[2]) -- startVersion key - redis.call('DEL', KEYS[3]) -- changes key - redis.call('DEL', KEYS[4]) -- expireTime key - return 1 - `, -}) - -/** - * Clears all cache entries for a project's chunk data - * @param {string} projectId - The ID of the project whose cache should be cleared - * @returns {Promise} A promise that resolves to true if successful, false on error - */ -async function clearCache(projectId) { - try { - const snapshotKey = keySchema.snapshot({ projectId }) - const startVersionKey = keySchema.startVersion({ projectId }) - const changesKey = keySchema.changes({ projectId }) - const expireTimeKey = keySchema.expireTime({ projectId }) - const persistTimeKey = keySchema.persistTime({ projectId }) // Add persistTimeKey - - const result = await rclient.clear_chunk_cache( - snapshotKey, - startVersionKey, - changesKey, - expireTimeKey, - persistTimeKey - ) - if (result === null) { - logger.debug( - { projectId }, - 'chunk cache not cleared due to pending changes' - ) - metrics.inc('chunk_store.redis.clear_cache', 1, { - status: 'skip-due-to-pending-changes', - }) - return false - } - metrics.inc('chunk_store.redis.clear_cache', 1, { status: 'success' }) - return true - } catch (err) { - logger.error({ err, projectId }, 'error clearing chunk cache from redis') - metrics.inc('chunk_store.redis.clear_cache', 1, { status: 'error' }) - return false - } -} - -// Define Lua script for getting chunk status -rclient.defineCommand('get_chunk_status', { - numberOfKeys: 2, // expireTimeKey, persistTimeKey - lua: ` - local expireTimeValue = redis.call('GET', KEYS[1]) - local persistTimeValue = redis.call('GET', KEYS[2]) - return {expireTimeValue, persistTimeValue} - `, -}) - -/** - * Retrieves the current chunk status for a given project from Redis - * @param {string} projectId - The ID of the project to get status for - * @returns {Promise} Object containing expireTime and persistTime, or nulls on error - * @property {number|null} expireTime - The expiration time of the chunk - * @property {number|null} persistTime - The persistence time of the chunk - */ -async function getCurrentChunkStatus(projectId) { - try { - const expireTimeKey = keySchema.expireTime({ projectId }) - const persistTimeKey = keySchema.persistTime({ projectId }) - - const result = await rclient.get_chunk_status(expireTimeKey, persistTimeKey) - - // Lua script returns an array [expireTimeValue, persistTimeValue] - // Redis nil replies are converted to null by ioredis - const [expireTime, persistTime] = result - - return { - expireTime: expireTime ? parseInt(expireTime, 10) : null, // Parse to number or null - persistTime: persistTime ? parseInt(persistTime, 10) : null, // Parse to number or null - } - } catch (err) { - logger.warn({ err, projectId }, 'error getting chunk status from redis') - return { expireTime: null, persistTime: null } // Return nulls on error - } -} - -/** - * Sets the persist time for a project's chunk cache. - * This is primarily intended for testing purposes. - * @param {string} projectId - The ID of the project. - * @param {number} timestamp - The timestamp to set as the persist time. - * @returns {Promise} - */ -async function setPersistTime(projectId, timestamp) { - try { - const persistTimeKey = keySchema.persistTime({ projectId }) - await rclient.set(persistTimeKey, timestamp) - metrics.inc('chunk_store.redis.set_persist_time', 1, { status: 'success' }) - } catch (err) { - logger.error( - { err, projectId, timestamp }, - 'error setting persist time in redis' - ) - metrics.inc('chunk_store.redis.set_persist_time', 1, { status: 'error' }) - // Re-throw the error so the test fails if setting fails + metrics.inc('chunk_store.redis.get_head_snapshot', 1, { status: 'error' }) throw err } } -module.exports = { - getCurrentChunk, - getCurrentChunkIfValid, - setCurrentChunk, - getCurrentChunkMetadata, - checkCacheValidity, - checkCacheValidityWithMetadata, - compareChunks, - expireCurrentChunk, - clearCache, - getCurrentChunkStatus, - setPersistTime, // Export the new function +rclient.defineCommand('queue_changes', { + numberOfKeys: 5, + lua: ` + local headSnapshotKey = KEYS[1] + local headVersionKey = KEYS[2] + local changesKey = KEYS[3] + local expireTimeKey = KEYS[4] + local persistTimeKey = KEYS[5] + + local baseVersion = tonumber(ARGV[1]) + local head = ARGV[2] + local persistTime = tonumber(ARGV[3]) + local expireTime = tonumber(ARGV[4]) + local onlyIfExists = ARGV[5] + local changesIndex = 6 -- Changes start here + + local headVersion = tonumber(redis.call('GET', headVersionKey)) + + -- Check if updates should only be queued if the project already exists (used for gradual rollouts) + if not headVersion and onlyIfExists == 'true' then + return 'ignore' + end + + -- Check that the supplied baseVersion matches the head version + -- If headVersion is nil, it means the project does not exist yet and will be created. + if headVersion and headVersion ~= baseVersion then + return 'conflict' + end + + -- Check if there are any changes to queue + if #ARGV < changesIndex then + return 'no_changes_provided' + end + + -- Store the changes + -- RPUSH changesKey change1 change2 ... + redis.call('RPUSH', changesKey, unpack(ARGV, changesIndex, #ARGV)) + + -- Update head snapshot only if changes were successfully pushed + redis.call('SET', headSnapshotKey, head) + + -- Update the head version + local numChanges = #ARGV - changesIndex + 1 + local newHeadVersion = baseVersion + numChanges + redis.call('SET', headVersionKey, newHeadVersion) + + -- Update the persist time if the new time is sooner + local currentPersistTime = tonumber(redis.call('GET', persistTimeKey)) + if not currentPersistTime or persistTime < currentPersistTime then + redis.call('SET', persistTimeKey, persistTime) + end + + -- Update the expire time + redis.call('SET', expireTimeKey, expireTime) + + return 'ok' + `, +}) + +/** + * Atomically queues changes to the project history in Redis if the baseVersion matches. + * Updates head snapshot, version, persist time, and expire time. + * + * @param {string} projectId - The project identifier. + * @param {Snapshot} headSnapshot - The new head snapshot after applying changes. + * @param {number} baseVersion - The expected current head version. + * @param {Change[]} changes - An array of Change objects to queue. + * @param {object} [opts] + * @param {number} [opts.persistTime] - Timestamp (ms since epoch) when the + * oldest change in the buffer should be persisted. + * @param {number} [opts.expireTime] - Timestamp (ms since epoch) when the + * project buffer should expire if inactive. + * @param {boolean} [opts.onlyIfExists] - If true, only queue changes if the + * project already exists in Redis, otherwise ignore. + * @returns {Promise} Resolves on success to either 'ok' or 'ignore'. + * @throws {BaseVersionConflictError} If the baseVersion does not match the current head version in Redis. + * @throws {Error} If changes array is empty or if Redis operations fail. + */ +async function queueChanges( + projectId, + headSnapshot, + baseVersion, + changes, + opts = {} +) { + if (!changes || changes.length === 0) { + throw new Error('Cannot queue empty changes array') + } + + const persistTime = opts.persistTime ?? Date.now() + MAX_PERSIST_DELAY_MS + const expireTime = opts.expireTime ?? Date.now() + PROJECT_TTL_MS + const onlyIfExists = Boolean(opts.onlyIfExists) + + try { + const keys = [ + keySchema.head({ projectId }), + keySchema.headVersion({ projectId }), + keySchema.changes({ projectId }), + keySchema.expireTime({ projectId }), + keySchema.persistTime({ projectId }), + ] + + const args = [ + baseVersion.toString(), + JSON.stringify(headSnapshot.toRaw()), + persistTime.toString(), + expireTime.toString(), + onlyIfExists.toString(), // Only queue changes if the snapshot already exists + ...changes.map(change => JSON.stringify(change.toRaw())), // Serialize changes + ] + + const status = await rclient.queue_changes(keys, args) + metrics.inc('chunk_store.redis.queue_changes', 1, { status }) + if (status === 'ok') { + return status + } + if (status === 'ignore') { + return status // skip changes when project does not exist and onlyIfExists is true + } + if (status === 'conflict') { + throw new BaseVersionConflictError('base version mismatch', { + projectId, + baseVersion, + }) + } else { + throw new Error(`unexpected result queuing changes: ${status}`) + } + } catch (err) { + if (err instanceof BaseVersionConflictError) { + // Re-throw conflict errors directly + throw err + } + metrics.inc('chunk_store.redis.queue_changes', 1, { status: 'error' }) + throw err + } +} + +rclient.defineCommand('get_state', { + numberOfKeys: 6, // Number of keys defined in keySchema + lua: ` + local headSnapshotKey = KEYS[1] + local headVersionKey = KEYS[2] + local persistedVersionKey = KEYS[3] + local expireTimeKey = KEYS[4] + local persistTimeKey = KEYS[5] + local changesKey = KEYS[6] + + local headSnapshot = redis.call('GET', headSnapshotKey) + local headVersion = redis.call('GET', headVersionKey) + local persistedVersion = redis.call('GET', persistedVersionKey) + local expireTime = redis.call('GET', expireTimeKey) + local persistTime = redis.call('GET', persistTimeKey) + local changes = redis.call('LRANGE', changesKey, 0, -1) -- Get all changes in the list + + return {headSnapshot, headVersion, persistedVersion, expireTime, persistTime, changes} + `, +}) + +/** + * Retrieves the entire state associated with a project from Redis atomically. + * @param {string} projectId - The unique identifier of the project. + * @returns {Promise} A Promise that resolves to an object containing the project state, + * or null if the project state does not exist (e.g., head version is missing). + * @throws {Error} If Redis operations fail. + */ +async function getState(projectId) { + const keys = [ + keySchema.head({ projectId }), + keySchema.headVersion({ projectId }), + keySchema.persistedVersion({ projectId }), + keySchema.expireTime({ projectId }), + keySchema.persistTime({ projectId }), + keySchema.changes({ projectId }), + ] + + // Pass keys individually, not as an array + const result = await rclient.get_state(...keys) + + const [ + rawHeadSnapshot, + rawHeadVersion, + rawPersistedVersion, + rawExpireTime, + rawPersistTime, + rawChanges, + ] = result + + // Safely parse values, providing defaults or nulls if necessary + const headSnapshot = rawHeadSnapshot + ? JSON.parse(rawHeadSnapshot) + : rawHeadSnapshot + const headVersion = rawHeadVersion ? parseInt(rawHeadVersion, 10) : null // Should always exist if result is not null + const persistedVersion = rawPersistedVersion + ? parseInt(rawPersistedVersion, 10) + : null + const expireTime = rawExpireTime ? parseInt(rawExpireTime, 10) : null + const persistTime = rawPersistTime ? parseInt(rawPersistTime, 10) : null + const changes = rawChanges ? rawChanges.map(JSON.parse) : null + + return { + headSnapshot, + headVersion, + persistedVersion, + expireTime, + persistTime, + changes, + } +} + +rclient.defineCommand('get_changes_since_version', { + numberOfKeys: 2, + lua: ` + local headVersionKey = KEYS[1] + local changesKey = KEYS[2] + + local requestedVersion = tonumber(ARGV[1]) + + -- Check if head version exists + local headVersion = tonumber(redis.call('GET', headVersionKey)) + if not headVersion then + return {'not_found'} + end + + -- If requested version equals head version, return empty array + if requestedVersion == headVersion then + return {'ok', {}} + end + + -- If requested version is greater than head version, return error + if requestedVersion > headVersion then + return {'out_of_bounds'} + end + + -- Get length of changes list + local changesCount = redis.call('LLEN', changesKey) + + -- Check if requested version is too old (changes already removed from buffer) + if requestedVersion < (headVersion - changesCount) then + return {'out_of_bounds'} + end + + -- Calculate the starting index, using negative indexing to count backwards + -- from the end of the list + local startIndex = requestedVersion - headVersion + + -- Get changes using LRANGE + local changes = redis.call('LRANGE', changesKey, startIndex, -1) + + return {'ok', changes} + `, +}) + +/** + * Retrieves changes since a specific version for a project from Redis. + * + * @param {string} projectId - The unique identifier of the project. + * @param {number} version - The version number to retrieve changes since. + * @returns {Promise<{status: string, changes?: Array}>} A Promise that resolves to an object containing: + * - status: 'OK', 'NOT_FOUND', or 'OUT_OF_BOUNDS' + * - changes: Array of Change objects (only when status is 'OK') + * @throws {Error} If Redis operations fail. + */ +async function getChangesSinceVersion(projectId, version) { + try { + const keys = [ + keySchema.headVersion({ projectId }), + keySchema.changes({ projectId }), + ] + + const args = [version.toString()] + + const result = await rclient.get_changes_since_version(keys, args) + const status = result[0] + + if (status === 'ok') { + // If status is OK, parse the changes + const changes = result[1] + ? result[1].map(rawChange => + typeof rawChange === 'string' ? JSON.parse(rawChange) : rawChange + ) + : [] + + metrics.inc('chunk_store.redis.get_changes_since_version', 1, { + status: 'success', + }) + return { status, changes } + } else { + // For other statuses, just return the status + metrics.inc('chunk_store.redis.get_changes_since_version', 1, { + status, + }) + return { status } + } + } catch (err) { + metrics.inc('chunk_store.redis.get_changes_since_version', 1, { + status: 'error', + }) + throw err + } +} + +rclient.defineCommand('get_non_persisted_changes', { + numberOfKeys: 3, + lua: ` + local headVersionKey = KEYS[1] + local persistedVersionKey = KEYS[2] + local changesKey = KEYS[3] + local baseVersion = tonumber(ARGV[1]) + + -- Check if head version exists + local headVersion = tonumber(redis.call('GET', headVersionKey)) + if not headVersion then + return {'not_found'} + end + + -- Check if persisted version exists + local persistedVersion = tonumber(redis.call('GET', persistedVersionKey)) + if not persistedVersion then + local changesCount = tonumber(redis.call('LLEN', changesKey)) + persistedVersion = headVersion - changesCount + end + + if baseVersion < persistedVersion or baseVersion > headVersion then + return {'out_of_bounds'} + elseif baseVersion == headVersion then + return {'ok', {}} + else + local numChanges = headVersion - baseVersion + local changes = redis.call('LRANGE', changesKey, -numChanges, -1) + + if #changes < numChanges then + -- We didn't get as many changes as we expected + return {'out_of_bounds'} + end + + return {'ok', changes} + end + `, +}) + +/** + * Retrieves non-persisted changes for a project from Redis. + * + * @param {string} projectId - The unique identifier of the project. + * @param {number} baseVersion - The version on top of which the changes should + * be applied. + * @returns {Promise} Changes that can be applied on top of + * baseVersion. An empty array means that the project doesn't have + * changes to persist. A null value means that the non-persisted + * changes can't be applied to the given base version. + * + * @throws {Error} If Redis operations fail. + */ +async function getNonPersistedChanges(projectId, baseVersion) { + let result + try { + result = await rclient.get_non_persisted_changes( + keySchema.headVersion({ projectId }), + keySchema.persistedVersion({ projectId }), + keySchema.changes({ projectId }), + baseVersion.toString() + ) + } catch (err) { + metrics.inc('chunk_store.redis.get_non_persisted_changes', 1, { + status: 'error', + }) + throw err + } + + const status = result[0] + metrics.inc('chunk_store.redis.get_non_persisted_changes', 1, { + status, + }) + + if (status === 'ok') { + return result[1].map(json => Change.fromRaw(JSON.parse(json))) + } else if (status === 'not_found') { + return [] + } else if (status === 'out_of_bounds') { + throw new VersionOutOfBoundsError( + "Non-persisted changes can't be applied to base version", + { projectId, baseVersion } + ) + } else { + throw new OError('unknown status for get_non_persisted_changes', { + projectId, + baseVersion, + status, + }) + } +} + +rclient.defineCommand('set_persisted_version', { + numberOfKeys: 3, + lua: ` + local headVersionKey = KEYS[1] + local persistedVersionKey = KEYS[2] + local changesKey = KEYS[3] + + local newPersistedVersion = tonumber(ARGV[1]) + local maxPersistedChanges = tonumber(ARGV[2]) + + -- Check if head version exists + local headVersion = tonumber(redis.call('GET', headVersionKey)) + if not headVersion then + return 'not_found' + end + + -- Get current persisted version + local persistedVersion = tonumber(redis.call('GET', persistedVersionKey)) + if persistedVersion and persistedVersion > newPersistedVersion then + return 'too_low' + end + + -- Set the persisted version + redis.call('SET', persistedVersionKey, newPersistedVersion) + + -- Calculate the starting index, to keep only maxPersistedChanges beyond the persisted version + -- Using negative indexing to count backwards from the end of the list + local startIndex = newPersistedVersion - headVersion - maxPersistedChanges + + -- Trim the changes list to keep only the specified number of changes beyond persisted version + if startIndex < 0 then + redis.call('LTRIM', changesKey, startIndex, -1) + end + + return 'ok' + `, +}) + +/** + * Sets the persisted version for a project in Redis and trims the changes list. + * + * @param {string} projectId - The unique identifier of the project. + * @param {number} persistedVersion - The version number to set as persisted. + * @returns {Promise} A Promise that resolves to 'OK' or 'NOT_FOUND'. + * @throws {Error} If Redis operations fail. + */ +async function setPersistedVersion(projectId, persistedVersion) { + try { + const keys = [ + keySchema.headVersion({ projectId }), + keySchema.persistedVersion({ projectId }), + keySchema.changes({ projectId }), + ] + + const args = [persistedVersion.toString(), MAX_PERSISTED_CHANGES.toString()] + + const status = await rclient.set_persisted_version(keys, args) + + metrics.inc('chunk_store.redis.set_persisted_version', 1, { + status, + }) + + return status + } catch (err) { + metrics.inc('chunk_store.redis.set_persisted_version', 1, { + status: 'error', + }) + throw err + } +} + +rclient.defineCommand('set_expire_time', { + numberOfKeys: 2, + lua: ` + local expireTimeKey = KEYS[1] + local headVersionKey = KEYS[2] + local expireTime = tonumber(ARGV[1]) + + -- Only set the expire time if the project is loaded in Redis + local headVersion = redis.call('GET', headVersionKey) + if headVersion then + redis.call('SET', expireTimeKey, expireTime) + end + `, +}) + +/** + * Sets the expire version for a project in Redis + * + * @param {string} projectId + * @param {number} expireTime - Timestamp (ms since epoch) when the project + * buffer should expire if inactive + */ +async function setExpireTime(projectId, expireTime) { + try { + await rclient.set_expire_time( + keySchema.expireTime({ projectId }), + keySchema.headVersion({ projectId }), + expireTime.toString() + ) + metrics.inc('chunk_store.redis.set_expire_time', 1, { status: 'success' }) + } catch (err) { + metrics.inc('chunk_store.redis.set_expire_time', 1, { status: 'error' }) + throw err + } +} + +rclient.defineCommand('expire_project', { + numberOfKeys: 6, + lua: ` + local headKey = KEYS[1] + local headVersionKey = KEYS[2] + local changesKey = KEYS[3] + local persistedVersionKey = KEYS[4] + local persistTimeKey = KEYS[5] + local expireTimeKey = KEYS[6] + + local headVersion = tonumber(redis.call('GET', headVersionKey)) + if not headVersion then + return 'not-found' + end + + local persistedVersion = tonumber(redis.call('GET', persistedVersionKey)) + if not persistedVersion or persistedVersion ~= headVersion then + return 'not-persisted' + end + + redis.call('DEL', + headKey, + headVersionKey, + changesKey, + persistedVersionKey, + persistTimeKey, + expireTimeKey + ) + return 'success' + `, +}) + +async function expireProject(projectId) { + try { + const status = await rclient.expire_project( + keySchema.head({ projectId }), + keySchema.headVersion({ projectId }), + keySchema.changes({ projectId }), + keySchema.persistedVersion({ projectId }), + keySchema.persistTime({ projectId }), + keySchema.expireTime({ projectId }) + ) + metrics.inc('chunk_store.redis.set_persisted_version', 1, { + status, + }) + } catch (err) { + metrics.inc('chunk_store.redis.set_persisted_version', 1, { + status: 'error', + }) + throw err + } +} + +rclient.defineCommand('claim_job', { + numberOfKeys: 1, + lua: ` + local jobTimeKey = KEYS[1] + local currentTime = tonumber(ARGV[1]) + local retryDelay = tonumber(ARGV[2]) + + local jobTime = tonumber(redis.call('GET', jobTimeKey)) + if not jobTime then + return {'no-job'} + end + + local msUntilReady = jobTime - currentTime + if msUntilReady <= 0 then + local retryTime = currentTime + retryDelay + redis.call('SET', jobTimeKey, retryTime) + return {'ok', retryTime} + else + return {'wait', msUntilReady} + end + `, +}) + +rclient.defineCommand('close_job', { + numberOfKeys: 1, + lua: ` + local jobTimeKey = KEYS[1] + local expectedJobTime = tonumber(ARGV[1]) + + local jobTime = tonumber(redis.call('GET', jobTimeKey)) + if jobTime and jobTime == expectedJobTime then + redis.call('DEL', jobTimeKey) + end + `, +}) + +/** + * Claim an expire job + * + * @param {string} projectId + * @return {Promise} + */ +async function claimExpireJob(projectId) { + return await claimJob(keySchema.expireTime({ projectId })) +} + +/** + * Claim a persist job + * + * @param {string} projectId + * @return {Promise} + */ +async function claimPersistJob(projectId) { + return await claimJob(keySchema.persistTime({ projectId })) +} + +/** + * Claim a persist or expire job + * + * @param {string} jobKey - the Redis key containing the time at which the job + * is ready + * @return {Promise} + */ +async function claimJob(jobKey) { + let result, status + try { + result = await rclient.claim_job(jobKey, Date.now(), RETRY_DELAY_MS) + status = result[0] + metrics.inc('chunk_store.redis.claim_job', 1, { status }) + } catch (err) { + metrics.inc('chunk_store.redis.claim_job', 1, { status: 'error' }) + throw err + } + + if (status === 'ok') { + return new Job(jobKey, parseInt(result[1], 10)) + } else if (status === 'wait') { + throw new JobNotReadyError('job not ready', { + jobKey, + retryTime: result[1], + }) + } else if (status === 'no-job') { + throw new JobNotFoundError('job not found', { jobKey }) + } else { + throw new OError('unknown status for claim_job', { jobKey, status }) + } +} + +/** + * Handle for a claimed job + */ +class Job { + /** + * @param {string} redisKey + * @param {number} claimTimestamp + */ + constructor(redisKey, claimTimestamp) { + this.redisKey = redisKey + this.claimTimestamp = claimTimestamp + } + + async close() { + try { + await rclient.close_job(this.redisKey, this.claimTimestamp.toString()) + metrics.inc('chunk_store.redis.close_job', 1, { status: 'success' }) + } catch (err) { + metrics.inc('chunk_store.redis.close_job', 1, { status: 'error' }) + throw err + } + } +} + +module.exports = { + getHeadSnapshot, + queueChanges, + getState, + getChangesSinceVersion, + getNonPersistedChanges, + setPersistedVersion, + setExpireTime, + expireProject, + claimExpireJob, + claimPersistJob, + MAX_PERSISTED_CHANGES, + MAX_PERSIST_DELAY_MS, + PROJECT_TTL_MS, + RETRY_DELAY_MS, + keySchema, } diff --git a/services/history-v1/storage/lib/persist_changes.js b/services/history-v1/storage/lib/persist_changes.js index 8a848aa214..5b80285eb0 100644 --- a/services/history-v1/storage/lib/persist_changes.js +++ b/services/history-v1/storage/lib/persist_changes.js @@ -4,6 +4,7 @@ const _ = require('lodash') const logger = require('@overleaf/logger') +const metrics = require('@overleaf/metrics') const core = require('overleaf-editor-core') const Chunk = core.Chunk @@ -14,6 +15,7 @@ const chunkStore = require('./chunk_store') const { BlobStore } = require('./blob_store') const { InvalidChangeError } = require('./errors') const { getContentHash } = require('./content_hash') +const redisBackend = require('./chunk_store/redis') function countChangeBytes(change) { // Note: This is not quite accurate, because the raw change may contain raw @@ -80,6 +82,7 @@ async function persistChanges(projectId, allChanges, limits, clientEndVersion) { let originalEndVersion let changesToPersist + let resyncNeeded = false limits = limits || {} _.defaults(limits, { @@ -164,12 +167,14 @@ async function persistChanges(projectId, allChanges, limits, clientEndVersion) { const actualHash = content != null ? getContentHash(content) : null logger.debug({ expectedHash, actualHash }, 'validating content hash') if (actualHash !== expectedHash) { - throw new InvalidChangeError('content hash mismatch', { - projectId, - path, - expectedHash, - actualHash, - }) + // only log a warning on the first mismatch in each persistChanges call + if (!resyncNeeded) { + logger.warn( + { projectId, path, expectedHash, actualHash }, + 'content hash mismatch' + ) + } + resyncNeeded = true } // Remove the content hash from the change before storing it in the chunk. @@ -179,8 +184,10 @@ async function persistChanges(projectId, allChanges, limits, clientEndVersion) { } } - async function extendLastChunkIfPossible() { - const latestChunk = await chunkStore.loadLatest(projectId) + async function loadLatestChunk() { + const latestChunk = await chunkStore.loadLatest(projectId, { + persistedOnly: true, + }) currentChunk = latestChunk originalEndVersion = latestChunk.getEndVersion() @@ -192,9 +199,50 @@ async function persistChanges(projectId, allChanges, limits, clientEndVersion) { } currentSnapshot = latestChunk.getSnapshot().clone() - const timer = new Timer() - currentSnapshot.applyAll(latestChunk.getChanges()) + currentSnapshot.applyAll(currentChunk.getChanges()) + } + async function queueChangesInRedis() { + const hollowSnapshot = currentSnapshot.clone() + // We're transforming a lazy snapshot to a hollow snapshot, so loadFiles() + // doesn't really need a blobStore, but its signature still requires it. + const blobStore = new BlobStore(projectId) + await hollowSnapshot.loadFiles('hollow', blobStore) + hollowSnapshot.applyAll(changesToPersist, { strict: true }) + const baseVersion = currentChunk.getEndVersion() + await redisBackend.queueChanges( + projectId, + hollowSnapshot, + baseVersion, + changesToPersist + ) + } + + async function fakePersistRedisChanges() { + const baseVersion = currentChunk.getEndVersion() + const nonPersistedChanges = await redisBackend.getNonPersistedChanges( + projectId, + baseVersion + ) + + if ( + serializeChanges(nonPersistedChanges) === + serializeChanges(changesToPersist) + ) { + metrics.inc('persist_redis_changes_verification', 1, { status: 'match' }) + } else { + logger.warn({ projectId }, 'mismatch of non-persisted changes from Redis') + metrics.inc('persist_redis_changes_verification', 1, { + status: 'mismatch', + }) + } + + const persistedVersion = baseVersion + nonPersistedChanges.length + await redisBackend.setPersistedVersion(projectId, persistedVersion) + } + + async function extendLastChunkIfPossible() { + const timer = new Timer() const changesPushed = await fillChunk(currentChunk, changesToPersist) if (!changesPushed) { return @@ -202,12 +250,7 @@ async function persistChanges(projectId, allChanges, limits, clientEndVersion) { checkElapsedTime(timer) - await chunkStore.update( - projectId, - originalEndVersion, - currentChunk, - earliestChangeTimestamp - ) + await chunkStore.update(projectId, currentChunk, earliestChangeTimestamp) } async function createNewChunksAsNeeded() { @@ -245,6 +288,13 @@ async function persistChanges(projectId, allChanges, limits, clientEndVersion) { changesToPersist = oldChanges const numberOfChangesToPersist = oldChanges.length + await loadLatestChunk() + try { + await queueChangesInRedis() + await fakePersistRedisChanges() + } catch (err) { + logger.error({ err }, 'Chunk buffer verification failed') + } await extendLastChunkIfPossible() await createNewChunksAsNeeded() @@ -252,10 +302,18 @@ async function persistChanges(projectId, allChanges, limits, clientEndVersion) { numberOfChangesPersisted: numberOfChangesToPersist, originalEndVersion, currentChunk, + resyncNeeded, } } else { return null } } +/** + * @param {core.Change[]} changes + */ +function serializeChanges(changes) { + return JSON.stringify(changes.map(change => change.toRaw())) +} + module.exports = persistChanges diff --git a/services/history-v1/storage/lib/scan.js b/services/history-v1/storage/lib/scan.js index 45d0c327fe..fe4b8d514e 100644 --- a/services/history-v1/storage/lib/scan.js +++ b/services/history-v1/storage/lib/scan.js @@ -1,3 +1,5 @@ +const logger = require('@overleaf/logger') + const BATCH_SIZE = 1000 // Default batch size for SCAN /** @@ -49,4 +51,134 @@ function extractKeyId(key) { return null } -module.exports = { scanRedisCluster, extractKeyId } +/** + * Fetches timestamps for a list of project IDs based on a given key name. + * + * @param {string[]} projectIds - Array of project identifiers. + * @param {object} rclient - The Redis client instance. + * @param {string} keyName - The base name for the Redis keys storing the timestamps (e.g., "expire-time", "persist-time"). + * @param {number} currentTime - The current time (timestamp in milliseconds) to compare against. + * @returns {Promise>} + * A promise that resolves to an array of objects, each containing a projectId and + * its corresponding timestampValue, for due projects only. + */ +async function fetchOverdueProjects(projectIds, rclient, keyName, currentTime) { + if (!projectIds || projectIds.length === 0) { + return [] + } + const timestampKeys = projectIds.map(id => `${keyName}:{${id}}`) + const timestamps = await rclient.mget(timestampKeys) + + const dueProjects = [] + for (let i = 0; i < projectIds.length; i++) { + const projectId = projectIds[i] + const timestampValue = timestamps[i] + + if (timestampValue !== null) { + const timestamp = parseInt(timestampValue, 10) + if (!isNaN(timestamp) && currentTime > timestamp) { + dueProjects.push({ projectId, timestampValue }) + } + } + } + return dueProjects +} + +/** + * Scans Redis for keys matching a pattern derived from keyName, identifies items that are "due" based on a timestamp, + * and performs a specified action on them. + * + * @param {object} rclient - The Redis client instance. + * @param {string} taskName - A descriptive name for the task (used in logging). + * @param {string} keyName - The base name for the Redis keys (e.g., "expire-time", "persist-time"). + * The function will derive the key prefix as `${keyName}:` and scan pattern as `${keyName}:{*}`. + * @param {function(string): Promise} actionFn - An async function that takes a projectId and performs an action. + * @param {boolean} DRY_RUN - If true, logs actions that would be taken without performing them. + * @returns {Promise<{scannedKeyCount: number, processedKeyCount: number}>} Counts of scanned and processed keys. + */ +async function scanAndProcessDueItems( + rclient, + taskName, + keyName, + actionFn, + DRY_RUN +) { + let scannedKeyCount = 0 + let processedKeyCount = 0 + const START_TIME = Date.now() + const logContext = { taskName, dryRun: DRY_RUN } + + const scanPattern = `${keyName}:{*}` + + if (DRY_RUN) { + logger.info(logContext, `Starting ${taskName} scan in DRY RUN mode`) + } else { + logger.info(logContext, `Starting ${taskName} scan`) + } + + for await (const keysBatch of scanRedisCluster(rclient, scanPattern)) { + scannedKeyCount += keysBatch.length + const projectIds = keysBatch.map(extractKeyId).filter(id => id != null) + + if (projectIds.length === 0) { + continue + } + + const currentTime = Date.now() + const overdueProjects = await fetchOverdueProjects( + projectIds, + rclient, + keyName, + currentTime + ) + + for (const project of overdueProjects) { + const { projectId } = project + if (DRY_RUN) { + logger.info( + { ...logContext, projectId }, + `[Dry Run] Would perform ${taskName} for project` + ) + } else { + try { + await actionFn(projectId) + logger.debug( + { ...logContext, projectId }, + `Successfully performed ${taskName} for project` + ) + } catch (err) { + logger.error( + { ...logContext, projectId, err }, + `Error performing ${taskName} for project` + ) + continue + } + } + processedKeyCount++ + + if (processedKeyCount % 1000 === 0 && processedKeyCount > 0) { + logger.info( + { ...logContext, scannedKeyCount, processedKeyCount }, + `${taskName} scan progress` + ) + } + } + } + + logger.info( + { + ...logContext, + scannedKeyCount, + processedKeyCount, + elapsedTimeInSeconds: Math.floor((Date.now() - START_TIME) / 1000), + }, + `${taskName} scan complete` + ) + return { scannedKeyCount, processedKeyCount } +} + +module.exports = { + scanRedisCluster, + extractKeyId, + scanAndProcessDueItems, +} diff --git a/services/history-v1/storage/scripts/backup.mjs b/services/history-v1/storage/scripts/backup.mjs index 9ae6101105..94df24f66d 100644 --- a/services/history-v1/storage/scripts/backup.mjs +++ b/services/history-v1/storage/scripts/backup.mjs @@ -5,7 +5,7 @@ import commandLineArgs from 'command-line-args' import { Chunk, History, Snapshot } from 'overleaf-editor-core' import { getProjectChunks, - loadLatestRaw, + getLatestChunkMetadata, create, } from '../lib/chunk_store/index.js' import { client } from '../lib/mongodb.js' @@ -444,7 +444,7 @@ async function analyseBackupStatus(projectId) { await getBackupStatus(projectId) // TODO: when we have confidence that the latestChunkMetadata always matches // the values from the backupStatus we can skip loading it here - const latestChunkMetadata = await loadLatestRaw(historyId, { + const latestChunkMetadata = await getLatestChunkMetadata(historyId, { readOnly: Boolean(USE_SECONDARY), }) if ( @@ -454,7 +454,7 @@ async function analyseBackupStatus(projectId) { // compare the current end version with the latest chunk metadata to check that // the updates to the project collection are reliable // expect some failures due to the time window between getBackupStatus and - // loadLatestRaw where the project is being actively edited. + // getLatestChunkMetadata where the project is being actively edited. logger.warn( { projectId, diff --git a/services/history-v1/storage/scripts/backup_scheduler.mjs b/services/history-v1/storage/scripts/backup_scheduler.mjs index 3fac053f12..38b6e6ef04 100644 --- a/services/history-v1/storage/scripts/backup_scheduler.mjs +++ b/services/history-v1/storage/scripts/backup_scheduler.mjs @@ -17,8 +17,8 @@ const redisOptions = config.get('redis.queue') const backupQueue = new Queue('backup', { redis: redisOptions, defaultJobOptions: { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { age: 60 }, // keep completed jobs for 60 seconds + removeOnFail: { age: 7 * 24 * 3600, count: 1000 }, // keep failed jobs for 7 days, max 1000 }, }) diff --git a/services/history-v1/storage/scripts/expire_redis_chunks.js b/services/history-v1/storage/scripts/expire_redis_chunks.js index 11b34101da..af2be097b6 100644 --- a/services/history-v1/storage/scripts/expire_redis_chunks.js +++ b/services/history-v1/storage/scripts/expire_redis_chunks.js @@ -1,11 +1,10 @@ const logger = require('@overleaf/logger') -const commandLineArgs = require('command-line-args') // Add this line +const commandLineArgs = require('command-line-args') const redis = require('../lib/redis') -const { scanRedisCluster, extractKeyId } = require('../lib/scan') -const { expireCurrentChunk } = require('../lib/chunk_store/redis') +const { scanAndProcessDueItems } = require('../lib/scan') +const { expireProject, claimExpireJob } = require('../lib/chunk_store/redis') const rclient = redis.rclientHistory -const EXPIRE_TIME_KEY_PATTERN = `expire-time:{*}` const optionDefinitions = [{ name: 'dry-run', alias: 'd', type: Boolean }] const options = commandLineArgs(optionDefinitions) @@ -13,86 +12,41 @@ const DRY_RUN = options['dry-run'] || false logger.initialize('expire-redis-chunks') -function isExpiredKey(expireTimestamp, currentTime) { - const expireTime = parseInt(expireTimestamp, 10) - if (isNaN(expireTime)) { - return false - } - logger.debug( - { - expireTime, - currentTime, - expireIn: expireTime - currentTime, - expired: currentTime > expireTime, - }, - 'Checking if key is expired' - ) - return currentTime > expireTime -} - -async function processKeysBatch(keysBatch, rclient) { - let clearedKeyCount = 0 - if (keysBatch.length === 0) { - return 0 - } - // For efficiency, we use MGET to fetch all the timestamps in a single request - const expireTimestamps = await rclient.mget(keysBatch) - const currentTime = Date.now() - for (let i = 0; i < keysBatch.length; i++) { - const key = keysBatch[i] - // For each key, do a quick check to see if the key is expired before calling - // the LUA script to expire the chunk atomically. - if (isExpiredKey(expireTimestamps[i], currentTime)) { - const projectId = extractKeyId(key) - if (DRY_RUN) { - logger.info({ projectId }, '[Dry Run] Would expire chunk for project') - } else { - await expireCurrentChunk(projectId) - } - clearedKeyCount++ +async function expireProjectAction(projectId) { + const job = await claimExpireJob(projectId) + try { + await expireProject(projectId) + } finally { + if (job && job.close) { + await job.close() } } - return clearedKeyCount } -async function expireRedisChunks() { - let scannedKeyCount = 0 - let clearedKeyCount = 0 - const START_TIME = Date.now() - - if (DRY_RUN) { - // Use global DRY_RUN - logger.info({}, 'starting expireRedisChunks scan in DRY RUN mode') - } else { - logger.info({}, 'starting expireRedisChunks scan') - } - - for await (const keysBatch of scanRedisCluster( +async function runExpireChunks() { + await scanAndProcessDueItems( rclient, - EXPIRE_TIME_KEY_PATTERN - )) { - scannedKeyCount += keysBatch.length - clearedKeyCount += await processKeysBatch(keysBatch, rclient) - if (scannedKeyCount % 1000 === 0) { - logger.info( - { scannedKeyCount, clearedKeyCount }, - 'expireRedisChunks scan progress' - ) - } - } - logger.info( - { - scannedKeyCount, - clearedKeyCount, - elapsedTimeInSeconds: Math.floor((Date.now() - START_TIME) / 1000), - dryRun: DRY_RUN, - }, - 'expireRedisChunks scan complete' + 'expireChunks', + 'expire-time', + expireProjectAction, + DRY_RUN ) - await redis.disconnect() } -expireRedisChunks().catch(err => { - logger.fatal({ err }, 'unhandled error in expireRedisChunks') - process.exit(1) -}) +if (require.main === module) { + runExpireChunks() + .catch(err => { + logger.fatal( + { err, taskName: 'expireChunks' }, + 'Unhandled error in runExpireChunks' + ) + process.exit(1) + }) + .finally(async () => { + await redis.disconnect() + }) +} else { + module.exports = { + runExpireChunks, + } +} diff --git a/services/history-v1/storage/scripts/recover_doc_versions.js b/services/history-v1/storage/scripts/recover_doc_versions.js index f121c60afd..650fb20324 100644 --- a/services/history-v1/storage/scripts/recover_doc_versions.js +++ b/services/history-v1/storage/scripts/recover_doc_versions.js @@ -279,7 +279,7 @@ async function processProject(project, summary) { async function getHistoryDocVersions(project) { const historyId = project.overleaf.history.id - const chunk = await chunkStore.loadLatest(historyId) + const chunk = await chunkStore.loadLatest(historyId, { persistedOnly: true }) if (chunk == null) { return [] } diff --git a/services/history-v1/storage/scripts/show.mjs b/services/history-v1/storage/scripts/show.mjs index b4ae1664e3..51697dc38f 100644 --- a/services/history-v1/storage/scripts/show.mjs +++ b/services/history-v1/storage/scripts/show.mjs @@ -48,7 +48,16 @@ async function listChunks(historyId) { async function fetchChunkLocal(historyId, version) { const chunkRecord = await getChunkMetadataForVersion(historyId, version) const chunk = await loadAtVersion(historyId, version) - return { key: version, chunk, metadata: chunkRecord, source: 'local storage' } + const persistedChunk = await loadAtVersion(historyId, version, { + persistedOnly: true, + }) + return { + key: version, + chunk, + persistedChunk, + metadata: chunkRecord, + source: 'local storage', + } } async function fetchChunkRemote(historyId, version) { @@ -73,7 +82,7 @@ async function fetchChunkRemote(historyId, version) { } async function displayChunk(historyId, version, options) { - const { key, chunk, metadata, source } = await (options.remote + const { key, chunk, persistedChunk, metadata, source } = await (options.remote ? fetchChunkRemote(historyId, version) : fetchChunkLocal(historyId, version)) console.log('Source:', source) @@ -81,6 +90,18 @@ async function displayChunk(historyId, version, options) { console.log('Key', key) // console.log('Number of changes', chunk.getChanges().length) console.log(JSON.stringify(chunk)) + if ( + persistedChunk && + persistedChunk.getChanges().length !== chunk.getChanges().length + ) { + console.warn( + 'Warning: Local chunk and persisted chunk have different number of changes:', + chunk.getChanges().length, + 'local (including buffer) vs', + persistedChunk.getChanges().length, + 'persisted' + ) + } } async function fetchBlobRemote(historyId, blobHash) { diff --git a/services/history-v1/storage/tasks/fix_duplicate_versions.js b/services/history-v1/storage/tasks/fix_duplicate_versions.js index a7db4b2765..ae9dcb4965 100755 --- a/services/history-v1/storage/tasks/fix_duplicate_versions.js +++ b/services/history-v1/storage/tasks/fix_duplicate_versions.js @@ -34,7 +34,7 @@ async function main() { async function processProject(projectId, save) { console.log(`Project ${projectId}:`) - const chunk = await chunkStore.loadLatest(projectId) + const chunk = await chunkStore.loadLatest(projectId, { persistedOnly: true }) let numChanges = 0 numChanges += removeDuplicateProjectVersions(chunk) numChanges += removeDuplicateDocVersions(chunk) diff --git a/services/history-v1/test/acceptance/js/api/backupVerifier.test.mjs b/services/history-v1/test/acceptance/js/api/backupVerifier.test.mjs index 2b4001a9f0..0a1fa528ab 100644 --- a/services/history-v1/test/acceptance/js/api/backupVerifier.test.mjs +++ b/services/history-v1/test/acceptance/js/api/backupVerifier.test.mjs @@ -119,17 +119,17 @@ async function verifyBlobHTTP(historyId, hash) { } async function backupChunk(historyId) { - const newChunk = await chunkStore.loadLatestRaw(historyId) + const newChunkMetadata = await chunkStore.getLatestChunkMetadata(historyId) const { buffer: chunkBuffer } = await historyStore.loadRawWithBuffer( historyId, - newChunk.id + newChunkMetadata.id ) const md5 = Crypto.createHash('md5').update(chunkBuffer) await backupPersistor.sendStream( chunksBucket, path.join( projectKey.format(historyId), - projectKey.pad(newChunk.startVersion) + projectKey.pad(newChunkMetadata.startVersion) ), Stream.Readable.from([chunkBuffer]), { @@ -149,14 +149,14 @@ async function addFileInNewChunk( historyId, { creationDate = new Date() } ) { - const chunk = await chunkStore.loadLatest(historyId) + const chunk = await chunkStore.loadLatest(historyId, { persistedOnly: true }) const operation = Operation.addFile( `${historyId}.txt`, File.fromString(fileContents) ) const changes = [new Change([operation], creationDate, [])] chunk.pushChanges(changes) - await chunkStore.update(historyId, 0, chunk) + await chunkStore.update(historyId, chunk) } /** diff --git a/services/history-v1/test/acceptance/js/api/project_import.test.js b/services/history-v1/test/acceptance/js/api/project_import.test.js index 216fb527fa..fb173238f8 100644 --- a/services/history-v1/test/acceptance/js/api/project_import.test.js +++ b/services/history-v1/test/acceptance/js/api/project_import.test.js @@ -52,6 +52,6 @@ describe('project import', function () { }) expect(importResponse.status).to.equal(HTTPStatus.CREATED) - expect(importResponse.obj).to.deep.equal({}) + expect(importResponse.obj).to.deep.equal({ resyncNeeded: false }) }) }) diff --git a/services/history-v1/test/acceptance/js/api/project_updates.test.js b/services/history-v1/test/acceptance/js/api/project_updates.test.js index eb7b1703a7..f50f3677b5 100644 --- a/services/history-v1/test/acceptance/js/api/project_updates.test.js +++ b/services/history-v1/test/acceptance/js/api/project_updates.test.js @@ -22,7 +22,6 @@ const TextOperation = core.TextOperation const V2DocVersions = core.V2DocVersions const knex = require('../../../../storage').knex -const redis = require('../../../../storage/lib/chunk_store/redis') describe('history import', function () { beforeEach(cleanup.everything) @@ -595,10 +594,6 @@ describe('history import', function () { testFiles.NULL_CHARACTERS_TXT_BYTE_LENGTH ) }) - .then(() => { - // Now clear the cache because we have changed the string length in the database - return redis.clearCache(testProjectId) - }) .then(importChanges) .then(getLatestContent) .then(response => { diff --git a/services/history-v1/test/acceptance/js/api/projects.test.js b/services/history-v1/test/acceptance/js/api/projects.test.js index 3c333d8698..7654829516 100644 --- a/services/history-v1/test/acceptance/js/api/projects.test.js +++ b/services/history-v1/test/acceptance/js/api/projects.test.js @@ -10,7 +10,7 @@ const cleanup = require('../storage/support/cleanup') const fixtures = require('../storage/support/fixtures') const testFiles = require('../storage/support/test_files') -const { zipStore, persistChanges } = require('../../../../storage') +const { zipStore, BlobStore, persistChanges } = require('../../../../storage') const { expectHttpError } = require('./support/expect_response') const testServer = require('./support/test_server') @@ -155,12 +155,13 @@ describe('project controller', function () { project_id: projectId, }) expect(response.status).to.equal(HTTPStatus.OK) - const changes = response.obj + const { changes, hasMore } = response.obj expect(changes.length).to.equal(2) const filenames = changes .flatMap(change => change.operations) .map(operation => operation.pathname) expect(filenames).to.deep.equal(['test.tex', 'other.tex']) + expect(hasMore).to.be.false }) it('returns only requested changes', async function () { @@ -170,12 +171,13 @@ describe('project controller', function () { since: 1, }) expect(response.status).to.equal(HTTPStatus.OK) - const changes = response.obj + const { changes, hasMore } = response.obj expect(changes.length).to.equal(1) const filenames = changes .flatMap(change => change.operations) .map(operation => operation.pathname) expect(filenames).to.deep.equal(['other.tex']) + expect(hasMore).to.be.false }) it('rejects negative versions', async function () { @@ -196,68 +198,84 @@ describe('project controller', function () { ).to.be.rejectedWith('Bad Request') }) }) - }) - describe('project with many chunks', function () { - let projectId + describe('project with many chunks', function () { + let projectId, changes - beforeEach(async function () { - // used to provide a limit which forces us to persist all of the changes. - const farFuture = new Date() - farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) - const limits = { - minChangeTimestamp: farFuture, - maxChangeTimestamp: farFuture, - maxChunkChanges: 5, - } - const changes = [ - new Change( - [new AddFileOperation('test.tex', File.fromString(''))], - new Date(), - [] - ), - ] - - for (let i = 0; i < 20; i++) { - const textOperation = new TextOperation() - textOperation.retain(i) - textOperation.insert('x') - changes.push( + beforeEach(async function () { + // used to provide a limit which forces us to persist all of the changes. + const farFuture = new Date() + farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000) + const limits = { + minChangeTimestamp: farFuture, + maxChangeTimestamp: farFuture, + maxChunkChanges: 5, + } + projectId = await createEmptyProject() + const blobStore = new BlobStore(projectId) + const blob = await blobStore.putString('') + changes = [ new Change( - [new EditFileOperation('test.tex', textOperation)], + [new AddFileOperation('test.tex', File.createLazyFromBlobs(blob))], new Date(), [] + ), + ] + + for (let i = 0; i < 20; i++) { + const textOperation = new TextOperation() + textOperation.retain(i) + textOperation.insert('x') + changes.push( + new Change( + [new EditFileOperation('test.tex', textOperation)], + new Date(), + [] + ) ) - ) - } - - projectId = await createEmptyProject() - await persistChanges(projectId, changes, limits, 0) - }) - - it('returns all changes when not given a limit', async function () { - const response = await testServer.basicAuthClient.apis.Project.getChanges( - { - project_id: projectId, } - ) - expect(response.status).to.equal(HTTPStatus.OK) - const changes = response.obj - expect(changes.length).to.equal(21) - expect(changes[10].operations[0].textOperation).to.deep.equal([9, 'x']) - }) - it('returns only requested changes', async function () { - const response = await testServer.basicAuthClient.apis.Project.getChanges( - { - project_id: projectId, - since: 10, - } - ) - expect(response.status).to.equal(HTTPStatus.OK) - const changes = response.obj - expect(changes.length).to.equal(11) - expect(changes[2].operations[0].textOperation).to.deep.equal([11, 'x']) + await persistChanges(projectId, changes, limits, 0) + }) + + it('returns the first chunk when not given a limit', async function () { + const response = + await testServer.basicAuthClient.apis.Project.getChanges({ + project_id: projectId, + }) + + expect(response.status).to.equal(HTTPStatus.OK) + expect(response.obj).to.deep.equal({ + changes: changes.slice(0, 5).map(c => c.toRaw()), + hasMore: true, + }) + }) + + it('returns only requested changes', async function () { + const response = + await testServer.basicAuthClient.apis.Project.getChanges({ + project_id: projectId, + since: 12, + }) + expect(response.status).to.equal(HTTPStatus.OK) + expect(response.obj).to.deep.equal({ + changes: changes.slice(12, 15).map(c => c.toRaw()), + hasMore: true, + }) + }) + + it('returns changes in the latest chunk', async function () { + const response = + await testServer.basicAuthClient.apis.Project.getChanges({ + project_id: projectId, + since: 20, + }) + expect(response.status).to.equal(HTTPStatus.OK) + expect(response.obj).to.deep.equal({ + changes: changes.slice(20).map(c => c.toRaw()), + hasMore: false, + }) + }) }) }) diff --git a/services/history-v1/test/acceptance/js/storage/backup.test.mjs b/services/history-v1/test/acceptance/js/storage/backup.test.mjs index 83087a1384..fdca1ce294 100644 --- a/services/history-v1/test/acceptance/js/storage/backup.test.mjs +++ b/services/history-v1/test/acceptance/js/storage/backup.test.mjs @@ -169,8 +169,8 @@ describe('backup script', function () { makeChunkKey(historyId, 0) ) const chunkContent = await text(chunkStream.pipe(createGunzip())) - const chunk = await ChunkStore.loadLatestRaw(historyId) - const rawHistory = await historyStore.loadRaw(historyId, chunk.id) + const chunkMetadata = await ChunkStore.getLatestChunkMetadata(historyId) + const rawHistory = await historyStore.loadRaw(historyId, chunkMetadata.id) expect(JSON.parse(chunkContent)).to.deep.equal(rawHistory) // Unrelated entries from backedUpBlobs should be not cleared @@ -299,8 +299,8 @@ describe('backup script', function () { makeChunkKey(historyId, 0) ) const chunkContent = await text(chunkStream.pipe(createGunzip())) - const chunk = await ChunkStore.loadLatestRaw(historyId) - const rawHistory = await historyStore.loadRaw(historyId, chunk.id) + const chunkMetadata = await ChunkStore.getLatestChunkMetadata(historyId) + const rawHistory = await historyStore.loadRaw(historyId, chunkMetadata.id) expect(JSON.parse(chunkContent)).to.deep.equal(rawHistory) // Unrelated entries from backedUpBlobs should be not cleared @@ -399,8 +399,8 @@ describe('backup script', function () { makeChunkKey(historyId, 0) ) const chunkContent = await text(chunkStream.pipe(createGunzip())) - const chunk = await ChunkStore.loadLatestRaw(historyId) - const rawHistory = await historyStore.loadRaw(historyId, chunk.id) + const chunkMetadata = await ChunkStore.getLatestChunkMetadata(historyId) + const rawHistory = await historyStore.loadRaw(historyId, chunkMetadata.id) expect(JSON.parse(chunkContent)).to.deep.equal(rawHistory) // Verify that the demoted global blob was backed up diff --git a/services/history-v1/test/acceptance/js/storage/chunk_buffer.test.js b/services/history-v1/test/acceptance/js/storage/chunk_buffer.test.js deleted file mode 100644 index 841282a8e4..0000000000 --- a/services/history-v1/test/acceptance/js/storage/chunk_buffer.test.js +++ /dev/null @@ -1,351 +0,0 @@ -'use strict' - -const { expect } = require('chai') -const sinon = require('sinon') -const { - Chunk, - Snapshot, - History, - File, - AddFileOperation, - EditFileOperation, - AddCommentOperation, - TextOperation, - Range, - TrackingProps, - Change, -} = require('overleaf-editor-core') -const cleanup = require('./support/cleanup') -const fixtures = require('./support/fixtures') -const chunkBuffer = require('../../../../storage/lib/chunk_buffer') -const chunkStore = require('../../../../storage/lib/chunk_store') -const redisBackend = require('../../../../storage/lib/chunk_store/redis') -const metrics = require('@overleaf/metrics') - -describe('chunk buffer', function () { - beforeEach(cleanup.everything) - beforeEach(fixtures.create) - beforeEach(function () { - sinon.spy(metrics, 'inc') - }) - afterEach(function () { - metrics.inc.restore() - }) - - const projectId = '123456' - - describe('loadLatest', function () { - // Initialize project and create a test chunk - beforeEach(async function () { - // Initialize project in chunk store - await chunkStore.initializeProject(projectId) - }) - - describe('with an existing chunk', function () { - beforeEach(async function () { - // Create a sample chunk with some content - const snapshot = new Snapshot() - const changes = [ - new Change( - [new AddFileOperation('test.tex', File.fromString('Hello World'))], - new Date(), - [] - ), - ] - const history = new History(snapshot, changes) - const chunk = new Chunk(history, 1) // startVersion 1 - - // Store the chunk directly in the chunk store using create method - // which internally calls uploadChunk - await chunkStore.create(projectId, chunk) - - // Clear any existing cache - await redisBackend.clearCache(projectId) - }) - - it('should load from chunk store and update cache on first access (cache miss)', async function () { - // Load the underlying chunk from the chunk store for verification - const storedChunk = await chunkStore.loadLatest(projectId) - - // First access should load from chunk store and populate cache - const firstResult = await chunkBuffer.loadLatest(projectId) - - // Verify the chunk is correct - expect(firstResult).to.not.be.null - expect(firstResult.getStartVersion()).to.equal(1) - expect(firstResult.getEndVersion()).to.equal(2) - - // Verify the chunk is the same as the one in the store - expect(firstResult).to.deep.equal(storedChunk) - - // Verify that we got a cache miss metric - expect( - metrics.inc.calledWith('chunk_buffer.loadLatest', 1, { - status: 'cache-miss', - }) - ).to.be.true - - // Reset the metrics spy - metrics.inc.resetHistory() - - // Second access should hit the cache - const secondResult = await chunkBuffer.loadLatest(projectId) - - // Verify we got the same chunk - expect(secondResult).to.not.be.null - expect(secondResult.getStartVersion()).to.equal(1) - expect(secondResult.getEndVersion()).to.equal(2) - - // Verify the chunk is the same as the one in the store - expect(secondResult).to.deep.equal(storedChunk) - - // Verify that we got a cache hit metric - expect( - metrics.inc.calledWith('chunk_buffer.loadLatest', 1, { - status: 'cache-hit', - }) - ).to.be.true - - // Verify both chunks are equivalent - expect(secondResult.getStartVersion()).to.equal( - firstResult.getStartVersion() - ) - expect(secondResult.getEndVersion()).to.equal( - firstResult.getEndVersion() - ) - expect(secondResult).to.deep.equal(firstResult) - }) - - it('should refresh the cache when chunk changes in the store', async function () { - // First access to load into cache - const firstResult = await chunkBuffer.loadLatest(projectId) - expect(firstResult.getStartVersion()).to.equal(1) - - // Reset metrics spy - metrics.inc.resetHistory() - - // Create a new chunk with different content - const newSnapshot = new Snapshot() - const newChanges = [ - new Change( - [ - new AddFileOperation( - 'updated.tex', - File.fromString('Updated content') - ), - ], - new Date(), - [] - ), - ] - const newHistory = new History(newSnapshot, newChanges) - const newChunk = new Chunk(newHistory, 2) // Different start version - - // Store the new chunk directly in the chunk store - await chunkStore.create(projectId, newChunk) - - // Load the underlying chunk from the chunk store for verification - const storedChunk = await chunkStore.loadLatest(projectId) - - // Access again - should detect the change and refresh cache - const secondResult = await chunkBuffer.loadLatest(projectId) - - // Verify we got the updated chunk - expect(secondResult.getStartVersion()).to.equal(2) - expect(secondResult.getEndVersion()).to.equal(3) - // Verify that the chunk content is the same - expect(secondResult).to.deep.equal(storedChunk) - - // Verify that we got a cache miss metric (since the cached chunk was invalidated) - expect( - metrics.inc.calledWith('chunk_buffer.loadLatest', 1, { - status: 'cache-miss', - }) - ).to.be.true - }) - - it('should continue using cache when chunk in store has not changed', async function () { - // Load the underlying chunk from the chunk store for verification - const storedChunk = await chunkStore.loadLatest(projectId) - - // First access to load into cache - await chunkBuffer.loadLatest(projectId) - - // Reset metrics spy - metrics.inc.resetHistory() - - // Access again without changing the underlying chunk - const result = await chunkBuffer.loadLatest(projectId) - - // Verify we got the same chunk - expect(result.getStartVersion()).to.equal(1) - expect(result.getEndVersion()).to.equal(2) - expect(result).to.deep.equal(storedChunk) - - // Verify that we got a cache hit metric - expect( - metrics.inc.calledWith('chunk_buffer.loadLatest', 1, { - status: 'cache-hit', - }) - ).to.be.true - }) - }) - - it('should handle a chunk with metadata, comments and tracked changes', async function () { - // Create a snapshot and initial file - const snapshot = new Snapshot() - const initialFileOp = new AddFileOperation( - 'test.tex', - File.fromString('Initial line.\\nSecond line.', { - meta1: 'abc', - meta2: 'def', - }) - ) - const initialChange = new Change([initialFileOp], new Date(), []) - - // Add a comment - const commentOp = new AddCommentOperation( - 'comment1', - [new Range(0, 7)] // Range for "Initial" - ) - const commentChange = new Change( - [new EditFileOperation('test.tex', commentOp)], - new Date(), - [] - ) - - // Tracked insert - const trackedInsertOp = new TextOperation() - .retain(14) - .insert('Hello', { - commentIds: ['comment1'], - tracking: TrackingProps.fromRaw({ - ts: '2024-01-01T00:00:00.000Z', - type: 'insert', - userId: 'user1', - }), - }) - .retain(12) - const insertChange = new Change( - [new EditFileOperation('test.tex', trackedInsertOp)], - new Date(), - [] - ) - - // Tracked delete - const trackedDeleteOp = new TextOperation().retain(14, { - tracking: TrackingProps.fromRaw({ - ts: '2024-01-01T00:00:00.000Z', - type: 'delete', - userId: 'user1', - }), - }) - const deleteChange = new Change( - [new EditFileOperation('test.tex', trackedDeleteOp)], - new Date(), - [] - ) - - // Combine changes into history and create chunk - const history = new History(snapshot, [ - initialChange, - commentChange, - insertChange, - deleteChange, - ]) - const chunk = new Chunk(history, 1) // Start version 0 - // Store the chunk - await chunkStore.create(projectId, chunk) - // Clear the cache - await redisBackend.clearCache(projectId) - metrics.inc.resetHistory() - - // Load the underlying chunk from the chunk store for verification - const storedChunk = await chunkStore.loadLatest(projectId) - - // Load the chunk via buffer (cache miss) - const firstResult = await chunkBuffer.loadLatest(projectId) - - // Verify chunk details - expect(firstResult.getStartVersion()).to.equal(1) - expect(firstResult.getEndVersion()).to.equal(5) // 4 changes - expect(firstResult.history.changes.length).to.equal(4) - expect(firstResult).to.deep.equal(storedChunk) - - // Verify cache miss metric - expect( - metrics.inc.calledWith('chunk_buffer.loadLatest', 1, { - status: 'cache-miss', - }) - ).to.be.true - - // Reset metrics - metrics.inc.resetHistory() - - // Second access should hit the cache - const secondResult = await chunkBuffer.loadLatest(projectId) - - // Verify we got the same chunk - expect(secondResult.getStartVersion()).to.equal(1) - expect(secondResult.getEndVersion()).to.equal(5) - expect(secondResult.history.changes.length).to.equal(4) - expect(secondResult).to.deep.equal(storedChunk) - - // Verify cache hit metric - expect( - metrics.inc.calledWith('chunk_buffer.loadLatest', 1, { - status: 'cache-hit', - }) - ).to.be.true - }) - - describe('with an empty project', function () { - it('should handle a case with empty chunks (no changes)', async function () { - // Clear the cache - await redisBackend.clearCache(projectId) - - // Load the underlying chunk from the chunk store for verification - const storedChunk = await chunkStore.loadLatest(projectId) - - // Load the initial empty chunk via buffer - const result = await chunkBuffer.loadLatest(projectId) - - // Verify we got the empty chunk - expect(result.getStartVersion()).to.equal(0) - expect(result.getEndVersion()).to.equal(0) // Start equals end for empty chunks - expect(result.history.changes.length).to.equal(0) - - // Verify that the chunk is the same as the one in the store - expect(result).to.deep.equal(storedChunk) - - // Verify cache miss metric - expect( - metrics.inc.calledWith('chunk_buffer.loadLatest', 1, { - status: 'cache-miss', - }) - ).to.be.true - - // Reset metrics - metrics.inc.resetHistory() - - // Second access should hit the cache - const secondResult = await chunkBuffer.loadLatest(projectId) - - // Verify we got the same empty chunk - expect(secondResult.getStartVersion()).to.equal(0) - expect(secondResult.getEndVersion()).to.equal(0) - expect(secondResult.history.changes.length).to.equal(0) - - // Verify that the chunk is the same as the one in the store - expect(secondResult).to.deep.equal(storedChunk) - - // Verify cache hit metric - expect( - metrics.inc.calledWith('chunk_buffer.loadLatest', 1, { - status: 'cache-hit', - }) - ).to.be.true - }) - }) - }) -}) diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store.test.js index 50341fdcb5..da70467934 100644 --- a/services/history-v1/test/acceptance/js/storage/chunk_store.test.js +++ b/services/history-v1/test/acceptance/js/storage/chunk_store.test.js @@ -6,6 +6,9 @@ const { expect } = require('chai') const sinon = require('sinon') const { ObjectId } = require('mongodb') const { projects } = require('../../../../storage/lib/mongodb') +const { + ChunkVersionConflictError, +} = require('../../../../storage/lib/chunk_store/errors') const { Chunk, @@ -18,7 +21,8 @@ const { EditFileOperation, TextOperation, } = require('overleaf-editor-core') -const { chunkStore, historyStore } = require('../../../../storage') +const { chunkStore, historyStore, BlobStore } = require('../../../../storage') +const redisBackend = require('../../../../storage/lib/chunk_store/redis') describe('chunkStore', function () { beforeEach(cleanup.everything) @@ -42,6 +46,7 @@ describe('chunkStore', function () { describe(scenario.description, function () { let projectId let projectRecord + let blobStore beforeEach(async function () { projectId = await scenario.createProject() @@ -49,6 +54,7 @@ describe('chunkStore', function () { projectRecord = await projects.insertOne({ overleaf: { history: { id: scenario.idMapping(projectId) } }, }) + blobStore = new BlobStore(projectId) }) it('loads empty latest chunk for a new project', async function () { @@ -62,17 +68,34 @@ describe('chunkStore', function () { const pendingChangeTimestamp = new Date('2014-01-01T00:00:00') const lastChangeTimestamp = new Date('2015-01-01T00:00:00') beforeEach(async function () { - const chunk = makeChunk( + const blob = await blobStore.putString('abc') + const firstChunk = makeChunk( [ makeChange( - Operation.addFile('main.tex', File.fromString('abc')), + Operation.addFile('main.tex', File.createLazyFromBlobs(blob)), + lastChangeTimestamp + ), + ], + 0 + ) + await chunkStore.update(projectId, firstChunk, pendingChangeTimestamp) + + const secondChunk = makeChunk( + [ + makeChange( + Operation.addFile('other.tex', File.createLazyFromBlobs(blob)), lastChangeTimestamp ), ], 1 ) - await chunkStore.create(projectId, chunk, pendingChangeTimestamp) + await chunkStore.create( + projectId, + secondChunk, + pendingChangeTimestamp + ) }) + it('creates a chunk and inserts the pending change timestamp', async function () { const project = await projects.findOne({ _id: new ObjectId(projectRecord.insertedId), @@ -97,24 +120,22 @@ describe('chunkStore', function () { beforeEach(async function () { const chunk = await chunkStore.loadLatest(projectId) - const oldEndVersion = chunk.getEndVersion() + const blob = await blobStore.putString('') const changes = [ - makeChange(Operation.addFile(testPathname, File.fromString(''))), + makeChange( + Operation.addFile(testPathname, File.createLazyFromBlobs(blob)) + ), makeChange(Operation.editFile(testPathname, testTextOperation)), ] lastChangeTimestamp = changes[1].getTimestamp() chunk.pushChanges(changes) - await chunkStore.update( - projectId, - oldEndVersion, - chunk, - pendingChangeTimestamp - ) + await chunkStore.update(projectId, chunk, pendingChangeTimestamp) }) it('records the correct metadata in db readOnly=false', async function () { - const raw = await chunkStore.loadLatestRaw(projectId) - expect(raw).to.deep.include({ + const chunkMetadata = + await chunkStore.getLatestChunkMetadata(projectId) + expect(chunkMetadata).to.deep.include({ startVersion: 0, endVersion: 2, endTimestamp: lastChangeTimestamp, @@ -122,10 +143,11 @@ describe('chunkStore', function () { }) it('records the correct metadata in db readOnly=true', async function () { - const raw = await chunkStore.loadLatestRaw(projectId, { - readOnly: true, - }) - expect(raw).to.deep.include({ + const chunkMetadata = await chunkStore.getLatestChunkMetadata( + projectId, + { readOnly: true } + ) + expect(chunkMetadata).to.deep.include({ startVersion: 0, endVersion: 2, endTimestamp: lastChangeTimestamp, @@ -181,35 +203,31 @@ describe('chunkStore', function () { let firstChunk, secondChunk, thirdChunk beforeEach(async function () { + const blob = await blobStore.putString('') firstChunk = makeChunk( [ makeChange( - Operation.addFile('foo.tex', File.fromString('')), + Operation.addFile('foo.tex', File.createLazyFromBlobs(blob)), new Date(firstChunkTimestamp - 5000) ), makeChange( - Operation.addFile('bar.tex', File.fromString('')), + Operation.addFile('bar.tex', File.createLazyFromBlobs(blob)), firstChunkTimestamp ), ], 0 ) - await chunkStore.update( - projectId, - 0, - firstChunk, - pendingChangeTimestamp - ) + await chunkStore.update(projectId, firstChunk, pendingChangeTimestamp) firstChunk = await chunkStore.loadLatest(projectId) secondChunk = makeChunk( [ makeChange( - Operation.addFile('baz.tex', File.fromString('')), + Operation.addFile('baz.tex', File.createLazyFromBlobs(blob)), new Date(secondChunkTimestamp - 5000) ), makeChange( - Operation.addFile('qux.tex', File.fromString('')), + Operation.addFile('qux.tex', File.createLazyFromBlobs(blob)), secondChunkTimestamp ), ], @@ -221,7 +239,11 @@ describe('chunkStore', function () { thirdChunk = makeChunk( [ makeChange( - Operation.addFile('quux.tex', File.fromString('')), + Operation.addFile('quux.tex', File.createLazyFromBlobs(blob)), + thirdChunkTimestamp + ), + makeChange( + Operation.addFile('barbar.tex', File.createLazyFromBlobs(blob)), thirdChunkTimestamp ), ], @@ -298,7 +320,7 @@ describe('chunkStore', function () { const project = await projects.findOne({ _id: new ObjectId(projectRecord.insertedId), }) - expect(project.overleaf.history.currentEndVersion).to.equal(5) + expect(project.overleaf.history.currentEndVersion).to.equal(6) expect(project.overleaf.history.currentEndTimestamp).to.deep.equal( thirdChunkTimestamp ) @@ -313,26 +335,104 @@ describe('chunkStore', function () { ) }) - describe('after updating the last chunk', function () { - let newChunk + describe('chunk update', function () { + it('rejects a chunk that removes changes', async function () { + const newChunk = makeChunk([thirdChunk.getChanges()[0]], 4) + await expect( + chunkStore.update(projectId, newChunk) + ).to.be.rejectedWith(ChunkVersionConflictError) + const latestChunk = await chunkStore.loadLatest(projectId) + expect(latestChunk.toRaw()).to.deep.equal(thirdChunk.toRaw()) + }) - beforeEach(async function () { - newChunk = makeChunk( + it('accepts the same chunk', async function () { + await chunkStore.update(projectId, thirdChunk) + const latestChunk = await chunkStore.loadLatest(projectId) + expect(latestChunk.toRaw()).to.deep.equal(thirdChunk.toRaw()) + }) + + it('accepts a larger chunk', async function () { + const blob = await blobStore.putString('foobar') + const newChunk = makeChunk( [ ...thirdChunk.getChanges(), makeChange( - Operation.addFile('onemore.tex', File.fromString('')), + Operation.addFile( + 'onemore.tex', + File.createLazyFromBlobs(blob) + ), thirdChunkTimestamp ), ], 4 ) - await chunkStore.update(projectId, 5, newChunk) + await chunkStore.update(projectId, newChunk) + const latestChunk = await chunkStore.loadLatest(projectId) + expect(latestChunk.toRaw()).to.deep.equal(newChunk.toRaw()) + }) + }) + + describe('chunk create', function () { + let change + + beforeEach(async function () { + const blob = await blobStore.putString('foobar') + change = makeChange( + Operation.addFile('onemore.tex', File.createLazyFromBlobs(blob)), + thirdChunkTimestamp + ) + }) + + it('rejects a base version that is too low', async function () { + const newChunk = makeChunk([change], 5) + await expect( + chunkStore.create(projectId, newChunk) + ).to.be.rejectedWith(ChunkVersionConflictError) + const latestChunk = await chunkStore.loadLatest(projectId) + expect(latestChunk.toRaw()).to.deep.equal(thirdChunk.toRaw()) + }) + + it('rejects a base version that is too high', async function () { + const newChunk = makeChunk([change], 7) + await expect( + chunkStore.create(projectId, newChunk) + ).to.be.rejectedWith(ChunkVersionConflictError) + const latestChunk = await chunkStore.loadLatest(projectId) + expect(latestChunk.toRaw()).to.deep.equal(thirdChunk.toRaw()) + }) + + it('accepts the right base version', async function () { + const newChunk = makeChunk([change], 6) + await chunkStore.create(projectId, newChunk) + const latestChunk = await chunkStore.loadLatest(projectId) + expect(latestChunk.toRaw()).to.deep.equal(newChunk.toRaw()) + }) + }) + + describe('after updating the last chunk', function () { + let newChunk + + beforeEach(async function () { + const blob = await blobStore.putString('') + newChunk = makeChunk( + [ + ...thirdChunk.getChanges(), + makeChange( + Operation.addFile( + 'onemore.tex', + File.createLazyFromBlobs(blob) + ), + thirdChunkTimestamp + ), + ], + 4 + ) + await chunkStore.update(projectId, newChunk) newChunk = await chunkStore.loadLatest(projectId) }) it('replaces the latest chunk', function () { - expect(newChunk.getChanges()).to.have.length(2) + expect(newChunk.getChanges()).to.have.length(3) }) it('returns the right chunk when querying by version', async function () { @@ -352,7 +452,7 @@ describe('chunkStore', function () { const project = await projects.findOne({ _id: new ObjectId(projectRecord.insertedId), }) - expect(project.overleaf.history.currentEndVersion).to.equal(6) + expect(project.overleaf.history.currentEndVersion).to.equal(7) expect(project.overleaf.history.currentEndTimestamp).to.deep.equal( thirdChunkTimestamp ) @@ -368,6 +468,79 @@ describe('chunkStore', function () { }) }) + describe('with changes queued in the Redis buffer', function () { + let queuedChanges + + beforeEach(async function () { + const snapshot = thirdChunk.getSnapshot() + snapshot.applyAll(thirdChunk.getChanges()) + const blob = await blobStore.putString('zzz') + queuedChanges = [ + makeChange( + Operation.addFile( + 'in-redis.tex', + File.createLazyFromBlobs(blob) + ), + new Date() + ), + ] + await redisBackend.queueChanges( + projectId, + snapshot, + thirdChunk.getEndVersion(), + queuedChanges + ) + }) + + it('includes the queued changes when getting the latest chunk', async function () { + const chunk = await chunkStore.loadLatest(projectId) + const expectedChanges = thirdChunk + .getChanges() + .concat(queuedChanges) + expect(chunk.getChanges()).to.deep.equal(expectedChanges) + }) + + it('includes the queued changes when getting the latest chunk by timestamp', async function () { + const chunk = await chunkStore.loadAtTimestamp( + projectId, + thirdChunkTimestamp + ) + const expectedChanges = thirdChunk + .getChanges() + .concat(queuedChanges) + expect(chunk.getChanges()).to.deep.equal(expectedChanges) + }) + + it("doesn't include the queued changes when getting another chunk by timestamp", async function () { + const chunk = await chunkStore.loadAtTimestamp( + projectId, + secondChunkTimestamp + ) + const expectedChanges = secondChunk.getChanges() + expect(chunk.getChanges()).to.deep.equal(expectedChanges) + }) + + it('includes the queued changes when getting the latest chunk by version', async function () { + const chunk = await chunkStore.loadAtVersion( + projectId, + thirdChunk.getEndVersion() + ) + const expectedChanges = thirdChunk + .getChanges() + .concat(queuedChanges) + expect(chunk.getChanges()).to.deep.equal(expectedChanges) + }) + + it("doesn't include the queued changes when getting another chunk by version", async function () { + const chunk = await chunkStore.loadAtVersion( + projectId, + secondChunk.getEndVersion() + ) + const expectedChanges = secondChunk.getChanges() + expect(chunk.getChanges()).to.deep.equal(expectedChanges) + }) + }) + describe('when iterating the chunks with getProjectChunksFromVersion', function () { // The first chunk has startVersion:0 and endVersion:2 for (let startVersion = 0; startVersion <= 2; startVersion++) { @@ -442,7 +615,7 @@ describe('chunkStore', function () { const chunkRecords = [] for await (const chunk of chunkStore.getProjectChunksFromVersion( projectId, - 6 + 7 )) { chunkRecords.push(chunk) } @@ -470,15 +643,18 @@ describe('chunkStore', function () { let chunk = await chunkStore.loadLatest(projectId) expect(chunk.getEndVersion()).to.equal(oldEndVersion) + const blob = await blobStore.putString('') const changes = [ - makeChange(Operation.addFile(testPathname, File.fromString(''))), + makeChange( + Operation.addFile(testPathname, File.createLazyFromBlobs(blob)) + ), makeChange(Operation.editFile(testPathname, testTextOperation)), ] chunk.pushChanges(changes) - await expect( - chunkStore.update(projectId, oldEndVersion, chunk) - ).to.be.rejectedWith('S3 Error') + await expect(chunkStore.update(projectId, chunk)).to.be.rejectedWith( + 'S3 Error' + ) chunk = await chunkStore.loadLatest(projectId) expect(chunk.getEndVersion()).to.equal(oldEndVersion) }) @@ -487,9 +663,12 @@ describe('chunkStore', function () { describe('version checks', function () { beforeEach(async function () { // Create a chunk with start version 0, end version 3 + const blob = await blobStore.putString('abc') const chunk = makeChunk( [ - makeChange(Operation.addFile('main.tex', File.fromString('abc'))), + makeChange( + Operation.addFile('main.tex', File.createLazyFromBlobs(blob)) + ), makeChange( Operation.editFile( 'main.tex', @@ -505,12 +684,17 @@ describe('chunkStore', function () { ], 0 ) - await chunkStore.update(projectId, 0, chunk) + await chunkStore.update(projectId, chunk) }) it('refuses to create a chunk with the same start version', async function () { + const blob = await blobStore.putString('abc') const chunk = makeChunk( - [makeChange(Operation.addFile('main.tex', File.fromString('abc')))], + [ + makeChange( + Operation.addFile('main.tex', File.createLazyFromBlobs(blob)) + ), + ], 0 ) await expect(chunkStore.create(projectId, chunk)).to.be.rejectedWith( @@ -519,8 +703,13 @@ describe('chunkStore', function () { }) it("allows creating chunks that don't have version conflicts", async function () { + const blob = await blobStore.putString('abc') const chunk = makeChunk( - [makeChange(Operation.addFile('main.tex', File.fromString('abc')))], + [ + makeChange( + Operation.addFile('main.tex', File.createLazyFromBlobs(blob)) + ), + ], 3 ) await chunkStore.create(projectId, chunk) diff --git a/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js b/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js index 612e802ff1..2b13343fc4 100644 --- a/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js +++ b/services/history-v1/test/acceptance/js/storage/chunk_store_redis_backend.test.js @@ -2,923 +2,1189 @@ const { expect } = require('chai') const { - Chunk, Snapshot, - History, - File, - AddFileOperation, - Origin, Change, - V2DocVersions, + AddFileOperation, + File, } = require('overleaf-editor-core') const cleanup = require('./support/cleanup') const redisBackend = require('../../../../storage/lib/chunk_store/redis') +const { + JobNotReadyError, + JobNotFoundError, + VersionOutOfBoundsError, +} = require('../../../../storage/lib/chunk_store/errors') +const redis = require('../../../../storage/lib/redis') +const rclient = redis.rclientHistory +const keySchema = redisBackend.keySchema -describe('chunk store Redis backend', function () { +describe('chunk buffer Redis backend', function () { beforeEach(cleanup.everything) - const projectId = '123456' + const projectId = 'project123' - describe('getCurrentChunk', function () { + describe('getHeadSnapshot', function () { it('should return null on cache miss', async function () { - const chunk = await redisBackend.getCurrentChunk(projectId) - expect(chunk).to.be.null + const result = await redisBackend.getHeadSnapshot(projectId) + expect(result).to.be.null }) - it('should return the cached chunk', async function () { - // Create a sample chunk + it('should return the cached head snapshot and version', async function () { + // Create a sample snapshot and version const snapshot = new Snapshot() - const changes = [ - new Change( - [new AddFileOperation('test.tex', File.fromString('Hello World'))], - new Date(), - [] - ), - ] - const history = new History(snapshot, changes) - const chunk = new Chunk(history, 5) // startVersion 5 + const version = 42 + const rawSnapshot = JSON.stringify(snapshot.toRaw()) - // Cache the chunk - await redisBackend.setCurrentChunk(projectId, chunk) + // Manually set the data in Redis + await rclient.set(keySchema.head({ projectId }), rawSnapshot) + await rclient.set( + keySchema.headVersion({ projectId }), + version.toString() + ) - // Retrieve the cached chunk - const cachedChunk = await redisBackend.getCurrentChunk(projectId) + // Retrieve the cached snapshot + const result = await redisBackend.getHeadSnapshot(projectId) - expect(cachedChunk).to.not.be.null - expect(cachedChunk.getStartVersion()).to.equal(5) - expect(cachedChunk.getEndVersion()).to.equal(6) - expect(cachedChunk).to.deep.equal(chunk) + expect(result).to.not.be.null + expect(result.version).to.equal(version) + expect(result.snapshot).to.deep.equal(snapshot) // Use deep equal for object comparison + }) + + it('should return null if the version is missing', async function () { + // Create a sample snapshot + const snapshot = new Snapshot() + const rawSnapshot = JSON.stringify(snapshot.toRaw()) + + // Manually set only the snapshot data in Redis + await rclient.set(keySchema.head({ projectId }), rawSnapshot) + + // Attempt to retrieve the snapshot + const result = await redisBackend.getHeadSnapshot(projectId) + + expect(result).to.be.null }) }) - describe('setCurrentChunk', function () { - it('should successfully cache a chunk', async function () { - // Create a sample chunk - const snapshot = new Snapshot() - const changes = [ - new Change( - [new AddFileOperation('test.tex', File.fromString('Hello World'))], - new Date(), - [] - ), - ] - const history = new History(snapshot, changes) - const chunk = new Chunk(history, 5) // startVersion 5 + describe('queueChanges', function () { + it('should queue changes when the base version matches head version', async function () { + // Create base version + const baseVersion = 0 - // Cache the chunk - await redisBackend.setCurrentChunk(projectId, chunk) + // Create a new head snapshot that will be set after changes + const headSnapshot = new Snapshot() - // Verify the chunk was cached correctly by retrieving it - const cachedChunk = await redisBackend.getCurrentChunk(projectId) - expect(cachedChunk).to.not.be.null - expect(cachedChunk.getStartVersion()).to.equal(5) - expect(cachedChunk.getEndVersion()).to.equal(6) - expect(cachedChunk).to.deep.equal(chunk) + // Create changes + const timestamp = new Date() + const change = new Change([], timestamp, []) - // Verify that the chunk was stored correctly using the chunk metadata - const chunkMetadata = - await redisBackend.getCurrentChunkMetadata(projectId) - expect(chunkMetadata).to.not.be.null - expect(chunkMetadata.startVersion).to.equal(5) - expect(chunkMetadata.changesCount).to.equal(1) + // Set times + const now = Date.now() + const persistTime = now + 30 * 1000 // 30 seconds from now + const expireTime = now + 60 * 60 * 1000 // 1 hour from now + + // Queue the changes + await redisBackend.queueChanges( + projectId, + headSnapshot, + baseVersion, + [change], + { persistTime, expireTime } + ) + + // Get the state to verify the changes + const state = await redisBackend.getState(projectId) + + // Verify the result + expect(state).to.exist + expect(state.headVersion).to.equal(baseVersion + 1) + expect(state.headSnapshot).to.deep.equal(headSnapshot.toRaw()) + expect(state.persistTime).to.equal(persistTime) + expect(state.expireTime).to.equal(expireTime) }) - it('should correctly handle a chunk with zero changes', async function () { - // Create a sample chunk with no changes - const snapshot = new Snapshot() - const changes = [] - const history = new History(snapshot, changes) - const chunk = new Chunk(history, 10) // startVersion 10 + it('should throw BaseVersionConflictError when base version does not match head version', async function () { + // Create a mismatch scenario + const headSnapshot = new Snapshot() + const baseVersion = 0 - // Cache the chunk - await redisBackend.setCurrentChunk(projectId, chunk) + // Manually set a different head version in Redis + await rclient.set(keySchema.headVersion({ projectId }), '5') - // Retrieve the cached chunk - const cachedChunk = await redisBackend.getCurrentChunk(projectId) + // Create changes + const timestamp = new Date() + const change = new Change([], timestamp, []) - expect(cachedChunk).to.not.be.null - expect(cachedChunk.getStartVersion()).to.equal(10) - expect(cachedChunk.getEndVersion()).to.equal(10) // End version should equal start version with no changes - expect(cachedChunk.history.changes.length).to.equal(0) - expect(cachedChunk).to.deep.equal(chunk) + // Set times + const now = Date.now() + const persistTime = now + 30 * 1000 + const expireTime = now + 60 * 60 * 1000 + + // Attempt to queue the changes with a mismatched base version + // This should throw a BaseVersionConflictError + try { + await redisBackend.queueChanges( + projectId, + headSnapshot, + baseVersion, + [change], + { persistTime, expireTime } + ) + // If we get here, the test should fail + expect.fail('Expected BaseVersionConflictError but no error was thrown') + } catch (err) { + expect(err.name).to.equal('BaseVersionConflictError') + expect(err.info).to.deep.include({ + projectId, + baseVersion, + }) + } + }) + + it('should throw error when given an empty changes array', async function () { + // Create a valid scenario but with empty changes + const headSnapshot = new Snapshot() + const baseVersion = 0 + + // Set times + const now = Date.now() + const persistTime = now + 30 * 1000 + const expireTime = now + 60 * 60 * 1000 + + // Attempt to queue with empty changes array + try { + await redisBackend.queueChanges( + projectId, + headSnapshot, + baseVersion, + [], // Empty changes array + { persistTime, expireTime } + ) + // If we get here, the test should fail + expect.fail('Expected Error but no error was thrown') + } catch (err) { + expect(err.message).to.equal('Cannot queue empty changes array') + } + }) + + it('should queue multiple changes and increment version correctly', async function () { + // Create base version + const baseVersion = 0 + + // Create a new head snapshot + const headSnapshot = new Snapshot() + + // Create multiple changes + const timestamp = new Date() + const change1 = new Change([], timestamp) + const change2 = new Change([], timestamp) + const change3 = new Change([], timestamp) + + // Set times + const now = Date.now() + const persistTime = now + 30 * 1000 + const expireTime = now + 60 * 60 * 1000 + + // Queue the changes + await redisBackend.queueChanges( + projectId, + headSnapshot, + baseVersion, + [change1, change2, change3], // Multiple changes + { persistTime, expireTime } + ) + + // Get the state to verify the changes + const state = await redisBackend.getState(projectId) + + // Verify that version was incremented by the number of changes + expect(state.headVersion).to.equal(baseVersion + 3) + expect(state.headSnapshot).to.deep.equal(headSnapshot.toRaw()) + }) + + it('should use the provided persistTime only if it is sooner than existing time', async function () { + // Create base version + const baseVersion = 0 + + // Create a new head snapshot + const headSnapshot = new Snapshot() + + // Create changes + const timestamp = new Date() + const change = new Change([], timestamp) + + // Set times + const now = Date.now() + const earlierPersistTime = now + 15 * 1000 // 15 seconds from now + const laterPersistTime = now + 30 * 1000 // 30 seconds from now + const expireTime = now + 60 * 60 * 1000 // 1 hour from now + + // First queue changes with the later persist time + await redisBackend.queueChanges( + projectId, + headSnapshot, + baseVersion, + [change], + { persistTime: laterPersistTime, expireTime } + ) + + // Get the state to verify the first persist time was set + let state = await redisBackend.getState(projectId) + expect(state.persistTime).to.equal(laterPersistTime) + + // Queue more changes with an earlier persist time + const newerHeadSnapshot = new Snapshot() + await redisBackend.queueChanges( + projectId, + newerHeadSnapshot, + baseVersion + 1, // Updated base version + [change], + { + persistTime: earlierPersistTime, // Earlier time should replace the later one + expireTime, + } + ) + + // Get the state to verify the persist time was updated to the earlier time + state = await redisBackend.getState(projectId) + expect(state.persistTime).to.equal(earlierPersistTime) + + // Queue more changes with another later persist time + const evenNewerHeadSnapshot = new Snapshot() + await redisBackend.queueChanges( + projectId, + evenNewerHeadSnapshot, + baseVersion + 2, // Updated base version + [change], + { + persistTime: laterPersistTime, // Later time should not replace the earlier one + expireTime, + } + ) + + // Get the state to verify the persist time remains at the earlier time + state = await redisBackend.getState(projectId) + expect(state.persistTime).to.equal(earlierPersistTime) // Should still be the earlier time + }) + + it('should ignore changes when onlyIfExists is true and project does not exist', async function () { + // Create base version + const baseVersion = 10 + + // Create a new head snapshot + const headSnapshot = new Snapshot() + + // Create changes + const timestamp = new Date() + const change = new Change([], timestamp) + + // Set times + const now = Date.now() + const persistTime = now + 30 * 1000 + const expireTime = now + 60 * 60 * 1000 + + // Queue changes with onlyIfExists set to true + const result = await redisBackend.queueChanges( + projectId, + headSnapshot, + baseVersion, + [change], + { persistTime, expireTime, onlyIfExists: true } + ) + + // Should return 'ignore' status + expect(result).to.equal('ignore') + + // Get the state - should be empty/null + const state = await redisBackend.getState(projectId) + expect(state.headVersion).to.be.null + expect(state.headSnapshot).to.be.null + }) + + it('should queue changes when onlyIfExists is true and project exists', async function () { + // First create the project + const headSnapshot = new Snapshot() + const baseVersion = 10 + const timestamp = new Date() + const change1 = new Change([], timestamp) + + // Set times + const now = Date.now() + const persistTime = now + 30 * 1000 + const expireTime = now + 60 * 60 * 1000 + + // Create the project first + await redisBackend.queueChanges( + projectId, + headSnapshot, + baseVersion, + [change1], + { persistTime, expireTime } + ) + + // Now create another change with onlyIfExists set to true + const newerSnapshot = new Snapshot() + const change2 = new Change([], timestamp) + + // Queue changes with onlyIfExists set to true + const result = await redisBackend.queueChanges( + projectId, + newerSnapshot, + baseVersion + 1, // Version should be 1 after the first change + [change2], + { persistTime, expireTime, onlyIfExists: true } + ) + + // Should return 'ok' status + expect(result).to.equal('ok') + + // Get the state to verify the changes were applied + const state = await redisBackend.getState(projectId) + expect(state.headVersion).to.equal(baseVersion + 2) // Should be 2 after both changes + expect(state.headSnapshot).to.deep.equal(newerSnapshot.toRaw()) + }) + + it('should queue changes when onlyIfExists is false and project does not exist', async function () { + // Create base version + const baseVersion = 10 + + // Create a new head snapshot + const headSnapshot = new Snapshot() + + // Create changes + const timestamp = new Date() + const change = new Change([], timestamp) + + // Set times + const now = Date.now() + const persistTime = now + 30 * 1000 + const expireTime = now + 60 * 60 * 1000 + + // Queue changes with onlyIfExists explicitly set to false + const result = await redisBackend.queueChanges( + projectId, + headSnapshot, + baseVersion, + [change], + { persistTime, expireTime, onlyIfExists: false } + ) + + // Should return 'ok' status + expect(result).to.equal('ok') + + // Get the state to verify the project was created + const state = await redisBackend.getState(projectId) + expect(state.headVersion).to.equal(baseVersion + 1) + expect(state.headSnapshot).to.deep.equal(headSnapshot.toRaw()) }) }) - describe('updating already cached chunks', function () { - it('should replace a chunk with a longer chunk', async function () { - // Set initial chunk with one change - const snapshotA = new Snapshot() - const changesA = [ - new Change( - [ - new AddFileOperation( - 'test.tex', - File.fromString('Initial content') - ), - ], - new Date(), - [] - ), - ] - const historyA = new History(snapshotA, changesA) - const chunkA = new Chunk(historyA, 10) - - await redisBackend.setCurrentChunk(projectId, chunkA) - - // Verify the initial chunk was cached - const cachedChunkA = await redisBackend.getCurrentChunk(projectId) - expect(cachedChunkA.getStartVersion()).to.equal(10) - expect(cachedChunkA.getEndVersion()).to.equal(11) - expect(cachedChunkA.history.changes.length).to.equal(1) - - // Create a longer chunk (with more changes) - const snapshotB = new Snapshot() - const changesB = [ - new Change( - [new AddFileOperation('test1.tex', File.fromString('Content 1'))], - new Date(), - [] - ), - new Change( - [new AddFileOperation('test2.tex', File.fromString('Content 2'))], - new Date(), - [] - ), - new Change( - [new AddFileOperation('test3.tex', File.fromString('Content 3'))], - new Date(), - [] - ), - ] - const historyB = new History(snapshotB, changesB) - const chunkB = new Chunk(historyB, 15) - - // Replace the cached chunk - await redisBackend.setCurrentChunk(projectId, chunkB) - - // Verify the new chunk replaced the old one - const cachedChunkB = await redisBackend.getCurrentChunk(projectId) - expect(cachedChunkB).to.not.be.null - expect(cachedChunkB.getStartVersion()).to.equal(15) - expect(cachedChunkB.getEndVersion()).to.equal(18) - expect(cachedChunkB.history.changes.length).to.equal(3) - expect(cachedChunkB).to.deep.equal(chunkB) - - // Verify the metadata was updated - const updatedMetadata = - await redisBackend.getCurrentChunkMetadata(projectId) - expect(updatedMetadata.startVersion).to.equal(15) - expect(updatedMetadata.changesCount).to.equal(3) + describe('getChangesSinceVersion', function () { + it('should return not_found when project does not exist', async function () { + const result = await redisBackend.getChangesSinceVersion(projectId, 1) + expect(result.status).to.equal('not_found') }) - it('should replace a chunk with a shorter chunk', async function () { - // Set initial chunk with three changes - const snapshotA = new Snapshot() - const changesA = [ - new Change( - [new AddFileOperation('file1.tex', File.fromString('Content 1'))], - new Date(), - [] - ), - new Change( - [new AddFileOperation('file2.tex', File.fromString('Content 2'))], - new Date(), - [] - ), - new Change( - [new AddFileOperation('file3.tex', File.fromString('Content 3'))], - new Date(), - [] - ), - ] - const historyA = new History(snapshotA, changesA) - const chunkA = new Chunk(historyA, 20) + it('should return empty array when requested version equals head version', async function () { + // Set head version + const headVersion = 5 + await rclient.set( + keySchema.headVersion({ projectId }), + headVersion.toString() + ) - await redisBackend.setCurrentChunk(projectId, chunkA) + // Request changes since the current head version + const result = await redisBackend.getChangesSinceVersion( + projectId, + headVersion + ) - // Verify the initial chunk was cached - const cachedChunkA = await redisBackend.getCurrentChunk(projectId) - expect(cachedChunkA.getStartVersion()).to.equal(20) - expect(cachedChunkA.getEndVersion()).to.equal(23) - expect(cachedChunkA.history.changes.length).to.equal(3) - - // Create a shorter chunk (with fewer changes) - const snapshotB = new Snapshot() - const changesB = [ - new Change( - [new AddFileOperation('new.tex', File.fromString('New content'))], - new Date(), - [] - ), - ] - const historyB = new History(snapshotB, changesB) - const chunkB = new Chunk(historyB, 30) - - // Replace the cached chunk - await redisBackend.setCurrentChunk(projectId, chunkB) - - // Verify the new chunk replaced the old one - const cachedChunkB = await redisBackend.getCurrentChunk(projectId) - expect(cachedChunkB).to.not.be.null - expect(cachedChunkB.getStartVersion()).to.equal(30) - expect(cachedChunkB.getEndVersion()).to.equal(31) - expect(cachedChunkB.history.changes.length).to.equal(1) - expect(cachedChunkB).to.deep.equal(chunkB) - - // Verify the metadata was updated - const updatedMetadata = - await redisBackend.getCurrentChunkMetadata(projectId) - expect(updatedMetadata.startVersion).to.equal(30) - expect(updatedMetadata.changesCount).to.equal(1) + expect(result.status).to.equal('ok') + expect(result.changes).to.be.an('array').that.is.empty }) - it('should replace a chunk with a zero-length chunk', async function () { - // Set initial chunk with changes - const snapshotA = new Snapshot() - const changesA = [ - new Change( - [new AddFileOperation('file1.tex', File.fromString('Content 1'))], - new Date(), - [] - ), - new Change( - [new AddFileOperation('file2.tex', File.fromString('Content 2'))], - new Date(), - [] - ), - ] - const historyA = new History(snapshotA, changesA) - const chunkA = new Chunk(historyA, 25) + it('should return out_of_bounds when requested version is greater than head version', async function () { + // Set head version + const headVersion = 5 + await rclient.set( + keySchema.headVersion({ projectId }), + headVersion.toString() + ) - await redisBackend.setCurrentChunk(projectId, chunkA) + // Request changes with version larger than head + const result = await redisBackend.getChangesSinceVersion( + projectId, + headVersion + 1 + ) - // Verify the initial chunk was cached - const cachedChunkA = await redisBackend.getCurrentChunk(projectId) - expect(cachedChunkA.getStartVersion()).to.equal(25) - expect(cachedChunkA.getEndVersion()).to.equal(27) - expect(cachedChunkA.history.changes.length).to.equal(2) - - // Create a zero-length chunk (with no changes) - const snapshotB = new Snapshot() - const changesB = [] - const historyB = new History(snapshotB, changesB) - const chunkB = new Chunk(historyB, 40) - - // Replace the cached chunk - await redisBackend.setCurrentChunk(projectId, chunkB) - - // Verify the new chunk replaced the old one - const cachedChunkB = await redisBackend.getCurrentChunk(projectId) - expect(cachedChunkB).to.not.be.null - expect(cachedChunkB.getStartVersion()).to.equal(40) - expect(cachedChunkB.getEndVersion()).to.equal(40) // Start version equals end version with no changes - expect(cachedChunkB.history.changes.length).to.equal(0) - expect(cachedChunkB).to.deep.equal(chunkB) - - // Verify the metadata was updated - const updatedMetadata = - await redisBackend.getCurrentChunkMetadata(projectId) - expect(updatedMetadata.startVersion).to.equal(40) - expect(updatedMetadata.changesCount).to.equal(0) + expect(result.status).to.equal('out_of_bounds') }) - it('should replace a zero-length chunk with a non-empty chunk', async function () { - // Set initial empty chunk - const snapshotA = new Snapshot() - const changesA = [] - const historyA = new History(snapshotA, changesA) - const chunkA = new Chunk(historyA, 50) + it('should return out_of_bounds when requested version is too old', async function () { + // Set head version + const headVersion = 10 + await rclient.set( + keySchema.headVersion({ projectId }), + headVersion.toString() + ) - await redisBackend.setCurrentChunk(projectId, chunkA) + // Create a few changes but less than what we'd need to reach requested version + const timestamp = new Date() + const change1 = new Change([], timestamp) + const change2 = new Change([], timestamp) + await rclient.rpush( + keySchema.changes({ projectId }), + JSON.stringify(change1.toRaw()), + JSON.stringify(change2.toRaw()) + ) - // Verify the initial chunk was cached - const cachedChunkA = await redisBackend.getCurrentChunk(projectId) - expect(cachedChunkA.getStartVersion()).to.equal(50) - expect(cachedChunkA.getEndVersion()).to.equal(50) - expect(cachedChunkA.history.changes.length).to.equal(0) + // Request changes from version 5, which is too old (headVersion - changesCount = 10 - 2 = 8) + const result = await redisBackend.getChangesSinceVersion(projectId, 5) - // Create a non-empty chunk - const snapshotB = new Snapshot() - const changesB = [ - new Change( - [new AddFileOperation('newfile.tex', File.fromString('New content'))], - new Date(), - [] - ), - new Change( - [ - new AddFileOperation( - 'another.tex', - File.fromString('Another file') - ), - ], - new Date(), - [] - ), - ] - const historyB = new History(snapshotB, changesB) - const chunkB = new Chunk(historyB, 60) + expect(result.status).to.equal('out_of_bounds') + }) - // Replace the cached chunk - await redisBackend.setCurrentChunk(projectId, chunkB) + it('should return changes since requested version', async function () { + // Set head version + const headVersion = 5 + await rclient.set( + keySchema.headVersion({ projectId }), + headVersion.toString() + ) - // Verify the new chunk replaced the old one - const cachedChunkB = await redisBackend.getCurrentChunk(projectId) - expect(cachedChunkB).to.not.be.null - expect(cachedChunkB.getStartVersion()).to.equal(60) - expect(cachedChunkB.getEndVersion()).to.equal(62) - expect(cachedChunkB.history.changes.length).to.equal(2) - expect(cachedChunkB).to.deep.equal(chunkB) + // Create changes + const timestamp = new Date() + const change1 = new Change([], timestamp) + const change2 = new Change([], timestamp) + const change3 = new Change([], timestamp) - // Verify the metadata was updated - const updatedMetadata = - await redisBackend.getCurrentChunkMetadata(projectId) - expect(updatedMetadata.startVersion).to.equal(60) - expect(updatedMetadata.changesCount).to.equal(2) + // Push changes to Redis (representing versions 3, 4, and 5) + await rclient.rpush( + keySchema.changes({ projectId }), + JSON.stringify(change1.toRaw()), + JSON.stringify(change2.toRaw()), + JSON.stringify(change3.toRaw()) + ) + + // Request changes since version 3 (should return changes for versions 4 and 5) + const result = await redisBackend.getChangesSinceVersion(projectId, 3) + + expect(result.status).to.equal('ok') + expect(result.changes).to.be.an('array').with.lengthOf(2) + + // The changes array should contain the raw changes + // Note: We're comparing raw objects, not the Change instances + expect(result.changes[0]).to.deep.equal(change2.toRaw()) + expect(result.changes[1]).to.deep.equal(change3.toRaw()) + }) + + it('should return all changes when requested version is earliest available', async function () { + // Set head version to 5 + const headVersion = 5 + await rclient.set( + keySchema.headVersion({ projectId }), + headVersion.toString() + ) + + // Create changes + const timestamp = new Date() + const change1 = new Change([], timestamp) + const change2 = new Change([], timestamp) + const change3 = new Change([], timestamp) + + // Push changes to Redis (representing versions 3, 4, and 5) + await rclient.rpush( + keySchema.changes({ projectId }), + JSON.stringify(change1.toRaw()), + JSON.stringify(change2.toRaw()), + JSON.stringify(change3.toRaw()) + ) + + // Request changes since version 2 (should return all 3 changes) + const result = await redisBackend.getChangesSinceVersion(projectId, 2) + + expect(result.status).to.equal('ok') + expect(result.changes).to.be.an('array').with.lengthOf(3) + expect(result.changes[0]).to.deep.equal(change1.toRaw()) + expect(result.changes[1]).to.deep.equal(change2.toRaw()) + expect(result.changes[2]).to.deep.equal(change3.toRaw()) }) }) - describe('checkCacheValidity', function () { - it('should return true when versions match', function () { - const snapshotA = new Snapshot() - const historyA = new History(snapshotA, []) - const chunkA = new Chunk(historyA, 10) - chunkA.pushChanges([ - new Change( - [new AddFileOperation('test.tex', File.fromString('Hello'))], - new Date(), - [] - ), - ]) + describe('getNonPersistedChanges', function () { + describe('project not loaded', function () { + it('should return empty array', async function () { + const changes = await redisBackend.getNonPersistedChanges(projectId, 0) + expect(changes).to.be.an('array').that.is.empty + }) - const snapshotB = new Snapshot() - const historyB = new History(snapshotB, []) - const chunkB = new Chunk(historyB, 10) - chunkB.pushChanges([ - new Change( - [new AddFileOperation('test.tex', File.fromString('Hello'))], - new Date(), - [] - ), - ]) - - const isValid = redisBackend.checkCacheValidity(chunkA, chunkB) - expect(isValid).to.be.true + it('should handle any base version', async function () { + const changes = await redisBackend.getNonPersistedChanges(projectId, 2) + expect(changes).to.be.an('array').that.is.empty + }) }) - it('should return false when start versions differ', function () { - const snapshotA = new Snapshot() - const historyA = new History(snapshotA, []) - const chunkA = new Chunk(historyA, 10) + describe('project never persisted', function () { + let changes - const snapshotB = new Snapshot() - const historyB = new History(snapshotB, []) - const chunkB = new Chunk(historyB, 11) + beforeEach(async function () { + changes = await setupState(projectId, { + headVersion: 5, + persistedVersion: null, + changes: 3, + }) + }) - const isValid = redisBackend.checkCacheValidity(chunkA, chunkB) - expect(isValid).to.be.false + it('should return all changes if requested', async function () { + const nonPersistedChanges = await redisBackend.getNonPersistedChanges( + projectId, + 2 + ) + expect(nonPersistedChanges).to.deep.equal(changes) + }) + + it('should return part of the changes if requested', async function () { + const nonPersistedChanges = await redisBackend.getNonPersistedChanges( + projectId, + 3 + ) + expect(nonPersistedChanges).to.deep.equal(changes.slice(1)) + }) + + it('should error if the base version requested is too low', async function () { + await expect( + redisBackend.getNonPersistedChanges(projectId, 0) + ).to.be.rejectedWith(VersionOutOfBoundsError) + }) + + it('should return an empty array if the base version is the head version', async function () { + const nonPersistedChanges = await redisBackend.getNonPersistedChanges( + projectId, + 5 + ) + expect(nonPersistedChanges).to.deep.equal([]) + }) + + it('should error if the base version requested is too high', async function () { + await expect( + redisBackend.getNonPersistedChanges(projectId, 6) + ).to.be.rejectedWith(VersionOutOfBoundsError) + }) }) - it('should return false when end versions differ', function () { - const snapshotA = new Snapshot() - const historyA = new History(snapshotA, []) - const chunkA = new Chunk(historyA, 10) - chunkA.pushChanges([ - new Change( - [new AddFileOperation('test.tex', File.fromString('Hello'))], - new Date(), - [] - ), - ]) + describe('fully persisted changes', function () { + beforeEach(async function () { + await setupState(projectId, { + headVersion: 5, + persistedVersion: 5, + changes: 3, + }) + }) - const snapshotB = new Snapshot() - const historyB = new History(snapshotB, []) - const chunkB = new Chunk(historyB, 10) - chunkB.pushChanges([ - new Change( - [new AddFileOperation('test.tex', File.fromString('Hello'))], - new Date(), - [] - ), - new Change( - [new AddFileOperation('other.tex', File.fromString('World'))], - new Date(), - [] - ), - ]) + it('should return an empty array when asked for the head version', async function () { + const nonPersistedChanges = await redisBackend.getNonPersistedChanges( + projectId, + 5 + ) + expect(nonPersistedChanges).to.deep.equal([]) + }) - const isValid = redisBackend.checkCacheValidity(chunkA, chunkB) - expect(isValid).to.be.false + it('should throw an error when asked for an older version', async function () { + await expect( + redisBackend.getNonPersistedChanges(projectId, 4) + ).to.be.rejectedWith(VersionOutOfBoundsError) + }) + + it('should throw an error when asked for a newer version', async function () { + await expect( + redisBackend.getNonPersistedChanges(projectId, 6) + ).to.be.rejectedWith(VersionOutOfBoundsError) + }) }) - it('should return false when cached chunk is null', function () { - const snapshotB = new Snapshot() - const historyB = new History(snapshotB, []) - const chunkB = new Chunk(historyB, 10) + describe('partially persisted project', async function () { + let changes - const isValid = redisBackend.checkCacheValidity(null, chunkB) - expect(isValid).to.be.false + beforeEach(async function () { + changes = await setupState(projectId, { + headVersion: 10, + persistedVersion: 7, + changes: 6, + }) + }) + + it('should return all non-persisted changes if requested', async function () { + const nonPersistedChanges = await redisBackend.getNonPersistedChanges( + projectId, + 7 + ) + expect(nonPersistedChanges).to.deep.equal(changes.slice(3)) + }) + + it('should return part of the changes if requested', async function () { + const nonPersistedChanges = await redisBackend.getNonPersistedChanges( + projectId, + 8 + ) + expect(nonPersistedChanges).to.deep.equal(changes.slice(4)) + }) + + it('should error if the base version requested is too low', async function () { + await expect( + redisBackend.getNonPersistedChanges(projectId, 5) + ).to.be.rejectedWith(VersionOutOfBoundsError) + }) + + it('should return an empty array if the base version is the head version', async function () { + const nonPersistedChanges = await redisBackend.getNonPersistedChanges( + projectId, + 10 + ) + expect(nonPersistedChanges).to.deep.equal([]) + }) + + it('should error if the base version requested is too high', async function () { + await expect( + redisBackend.getNonPersistedChanges(projectId, 12) + ).to.be.rejectedWith(VersionOutOfBoundsError) + }) + }) + + // This case should never happen, but we'll handle it anyway + describe('persisted version before start of changes list', async function () { + let changes + + beforeEach(async function () { + changes = await setupState(projectId, { + headVersion: 5, + persistedVersion: 1, + changes: 3, + }) + }) + + it('should return all non-persisted changes if requested', async function () { + const nonPersistedChanges = await redisBackend.getNonPersistedChanges( + projectId, + 2 + ) + expect(nonPersistedChanges).to.deep.equal(changes) + }) + + it('should return part of the changes if requested', async function () { + const nonPersistedChanges = await redisBackend.getNonPersistedChanges( + projectId, + 3 + ) + expect(nonPersistedChanges).to.deep.equal(changes.slice(1)) + }) + + it('should error if the base version requested is too low', async function () { + await expect( + redisBackend.getNonPersistedChanges(projectId, 1) + ).to.be.rejectedWith(VersionOutOfBoundsError) + }) + + it('should return an empty array if the base version is the head version', async function () { + const nonPersistedChanges = await redisBackend.getNonPersistedChanges( + projectId, + 5 + ) + expect(nonPersistedChanges).to.deep.equal([]) + }) + + it('should error if the base version requested is too high', async function () { + await expect( + redisBackend.getNonPersistedChanges(projectId, 6) + ).to.be.rejectedWith(VersionOutOfBoundsError) + }) }) }) - describe('compareChunks', function () { - it('should return true when chunks are identical', function () { - // Create two identical chunks - const snapshot = new Snapshot() - const changes = [ - new Change( - [new AddFileOperation('test.tex', File.fromString('Hello World'))], - new Date('2025-04-10T12:00:00Z'), // Using fixed date for consistent comparison - [] - ), - ] - const history1 = new History(snapshot, changes) - const chunk1 = new Chunk(history1, 5) - - // Create a separate but identical chunk - const snapshot2 = new Snapshot() - const changes2 = [ - new Change( - [new AddFileOperation('test.tex', File.fromString('Hello World'))], - new Date('2025-04-10T12:00:00Z'), // Using same fixed date - [] - ), - ] - const history2 = new History(snapshot2, changes2) - const chunk2 = new Chunk(history2, 5) - - const result = redisBackend.compareChunks(projectId, chunk1, chunk2) - expect(result).to.be.true + describe('setPersistedVersion', function () { + it('should return not_found when project does not exist', async function () { + const result = await redisBackend.setPersistedVersion(projectId, 5) + expect(result).to.equal('not_found') }) - it('should return false when chunks differ', function () { - // Create first chunk - const snapshot1 = new Snapshot() - const changes1 = [ - new Change( - [new AddFileOperation('test.tex', File.fromString('Hello World'))], - new Date('2025-04-10T12:00:00Z'), - [] - ), - ] - const history1 = new History(snapshot1, changes1) - const chunk1 = new Chunk(history1, 5) + describe('when the persisted version is not set', function () { + beforeEach(async function () { + await setupState(projectId, { + headVersion: 5, + persistedVersion: null, + changes: 5, + }) + }) - // Create a different chunk (different content) - const snapshot2 = new Snapshot() - const changes2 = [ - new Change( - [ - new AddFileOperation( - 'test.tex', - File.fromString('Different content') - ), - ], - new Date('2025-04-10T12:00:00Z'), - [] - ), - ] - const history2 = new History(snapshot2, changes2) - const chunk2 = new Chunk(history2, 5) - - const result = redisBackend.compareChunks(projectId, chunk1, chunk2) - expect(result).to.be.false + it('should set the persisted version', async function () { + await redisBackend.setPersistedVersion(projectId, 3) + const state = await redisBackend.getState(projectId) + expect(state.persistedVersion).to.equal(3) + }) }) - it('should return false when one chunk is null', function () { - // Create a chunk - const snapshot = new Snapshot() - const changes = [ - new Change( - [new AddFileOperation('test.tex', File.fromString('Hello World'))], - new Date('2025-04-10T12:00:00Z'), - [] - ), - ] - const history = new History(snapshot, changes) - const chunk = new Chunk(history, 5) + describe('when the persisted version is set', function () { + beforeEach(async function () { + await setupState(projectId, { + headVersion: 5, + persistedVersion: 3, + changes: 5, + }) + }) - const resultWithNullCached = redisBackend.compareChunks( - projectId, - null, - chunk + it('should set the persisted version', async function () { + await redisBackend.setPersistedVersion(projectId, 5) + const state = await redisBackend.getState(projectId) + expect(state.persistedVersion).to.equal(5) + }) + + it('should not decrease the persisted version', async function () { + await redisBackend.setPersistedVersion(projectId, 2) + const state = await redisBackend.getState(projectId) + expect(state.persistedVersion).to.equal(3) + }) + }) + + it('should trim the changes list to keep only MAX_PERSISTED_CHANGES beyond persisted version', async function () { + // Get MAX_PERSISTED_CHANGES to ensure our test data is larger + const maxPersistedChanges = redisBackend.MAX_PERSISTED_CHANGES + + // Create a larger number of changes for the test + // Using MAX_PERSISTED_CHANGES + 10 to ensure we have enough changes to trigger trimming + const totalChanges = maxPersistedChanges + 10 + + // Set head version to match total number of changes + const headVersion = totalChanges + await rclient.set( + keySchema.headVersion({ projectId }), + headVersion.toString() ) - expect(resultWithNullCached).to.be.false - const resultWithNullCurrent = redisBackend.compareChunks( - projectId, - chunk, - null + // Create changes for versions 1 through totalChanges + const timestamp = new Date() + const changes = Array.from( + { length: totalChanges }, + (_, idx) => + new Change( + [new AddFileOperation(`file${idx}.tex`, File.fromString('hello'))], + timestamp + ) + ) + + // Push changes to Redis + await rclient.rpush( + keySchema.changes({ projectId }), + ...changes.map(change => JSON.stringify(change.toRaw())) + ) + + // Set persisted version to somewhere near the head version + const persistedVersion = headVersion - 5 + + // Set the persisted version + const result = await redisBackend.setPersistedVersion( + projectId, + persistedVersion + ) + expect(result).to.equal('ok') + + // Get all changes that remain in Redis + const remainingChanges = await rclient.lrange( + keySchema.changes({ projectId }), + 0, + -1 + ) + + // Calculate the expected number of changes to remain + expect(remainingChanges).to.have.lengthOf( + maxPersistedChanges + (headVersion - persistedVersion) + ) + + // Check that remaining changes are the expected ones + const expectedChanges = changes.slice( + persistedVersion - maxPersistedChanges, + totalChanges + ) + expect(remainingChanges).to.deep.equal( + expectedChanges.map(change => JSON.stringify(change.toRaw())) ) - expect(resultWithNullCurrent).to.be.false }) - it('should return false when chunks have different start versions', function () { - // Create first chunk with start version 5 - const snapshot1 = new Snapshot() - const changes1 = [ - new Change( - [new AddFileOperation('test.tex', File.fromString('Hello World'))], - new Date('2025-04-10T12:00:00Z'), - [] - ), - ] - const history1 = new History(snapshot1, changes1) - const chunk1 = new Chunk(history1, 5) + it('should keep all changes when there are fewer than MAX_PERSISTED_CHANGES', async function () { + // Set head version to 5 + const headVersion = 5 + await rclient.set( + keySchema.headVersion({ projectId }), + headVersion.toString() + ) - // Create second chunk with identical content but different start version (10) - const snapshot2 = new Snapshot() - const changes2 = [ - new Change( - [new AddFileOperation('test.tex', File.fromString('Hello World'))], - new Date('2025-04-10T12:00:00Z'), - [] - ), - ] - const history2 = new History(snapshot2, changes2) - const chunk2 = new Chunk(history2, 10) + // Create changes for versions 1 through 5 + const timestamp = new Date() + const changes = Array.from({ length: 5 }, () => new Change([], timestamp)) - const result = redisBackend.compareChunks(projectId, chunk1, chunk2) - expect(result).to.be.false + // Push changes to Redis + await rclient.rpush( + keySchema.changes({ projectId }), + ...changes.map(change => JSON.stringify(change.toRaw())) + ) + + // Set persisted version to 3 + // All changes should remain since total count is small + const persistedVersion = 3 + + // Ensure MAX_PERSISTED_CHANGES is larger than our test dataset + expect(redisBackend.MAX_PERSISTED_CHANGES).to.be.greaterThan( + 5, + 'MAX_PERSISTED_CHANGES should be greater than 5 for this test' + ) + + // Set the persisted version + const result = await redisBackend.setPersistedVersion( + projectId, + persistedVersion + ) + expect(result).to.equal('ok') + + // Get all changes that remain in Redis + const remainingChanges = await rclient.lrange( + keySchema.changes({ projectId }), + 0, + -1 + ) + + // All changes should remain + expect(remainingChanges).to.have.lengthOf(5) }) }) - describe('integration with redis', function () { - it('should store and retrieve complex chunks correctly', async function () { - // Create a more complex chunk + describe('getState', function () { + it('should return complete project state from Redis', async function () { + // Set up the test data in Redis const snapshot = new Snapshot() - const changes = [ - new Change( - [new AddFileOperation('file1.tex', File.fromString('Content 1'))], - new Date(), - [1234] - ), - new Change( - [new AddFileOperation('file2.tex', File.fromString('Content 2'))], - new Date(), - null, - new Origin('test-origin'), - ['5a296963ad5e82432674c839', null], - '123.4', - new V2DocVersions({ - 'random-doc-id': { pathname: 'file2.tex', v: 123 }, - }) - ), - new Change( - [new AddFileOperation('file3.tex', File.fromString('Content 3'))], - new Date(), - [] - ), - ] - const history = new History(snapshot, changes) - const chunk = new Chunk(history, 20) + const rawSnapshot = JSON.stringify(snapshot.toRaw()) + const headVersion = 42 + const persistedVersion = 40 + const now = Date.now() + const expireTime = now + 60 * 60 * 1000 // 1 hour from now + const persistTime = now + 30 * 1000 // 30 seconds from now - // Cache the chunk - await redisBackend.setCurrentChunk(projectId, chunk) + // Create a change + const timestamp = new Date() + const change = new Change([], timestamp) + const serializedChange = JSON.stringify(change.toRaw()) - // Retrieve the cached chunk - const cachedChunk = await redisBackend.getCurrentChunk(projectId) - - expect(cachedChunk.getStartVersion()).to.equal(20) - expect(cachedChunk.getEndVersion()).to.equal(23) - expect(cachedChunk).to.deep.equal(chunk) - expect(cachedChunk.history.changes.length).to.equal(3) - - // Check that the operations were preserved correctly - const retrievedChanges = cachedChunk.history.changes - expect(retrievedChanges[0].getOperations()[0].getPathname()).to.equal( - 'file1.tex' + // Set everything in Redis + await rclient.set(keySchema.head({ projectId }), rawSnapshot) + await rclient.set( + keySchema.headVersion({ projectId }), + headVersion.toString() ) - expect(retrievedChanges[1].getOperations()[0].getPathname()).to.equal( - 'file2.tex' + await rclient.set( + keySchema.persistedVersion({ projectId }), + persistedVersion.toString() ) - expect(retrievedChanges[2].getOperations()[0].getPathname()).to.equal( - 'file3.tex' + await rclient.set( + keySchema.expireTime({ projectId }), + expireTime.toString() + ) + await rclient.set( + keySchema.persistTime({ projectId }), + persistTime.toString() + ) + await rclient.rpush(keySchema.changes({ projectId }), serializedChange) + + // Get the state + const state = await redisBackend.getState(projectId) + + // Verify everything matches + expect(state).to.exist + expect(state.headSnapshot).to.deep.equal(snapshot.toRaw()) + expect(state.headVersion).to.equal(headVersion) + expect(state.persistedVersion).to.equal(persistedVersion) + expect(state.expireTime).to.equal(expireTime) + expect(state.persistTime).to.equal(persistTime) + }) + + it('should return proper defaults for missing fields', async function () { + // Only set the head snapshot and version, leave others unset + const snapshot = new Snapshot() + const rawSnapshot = JSON.stringify(snapshot.toRaw()) + const headVersion = 42 + + await rclient.set(keySchema.head({ projectId }), rawSnapshot) + await rclient.set( + keySchema.headVersion({ projectId }), + headVersion.toString() ) - // Check that the chunk was stored correctly using the chunk metadata - const chunkMetadata = - await redisBackend.getCurrentChunkMetadata(projectId) - expect(chunkMetadata).to.not.be.null - expect(chunkMetadata.startVersion).to.equal(20) - expect(chunkMetadata.changesCount).to.equal(3) + // Get the state + const state = await redisBackend.getState(projectId) + + // Verify only what we set exists, and other fields have correct defaults + expect(state).to.exist + expect(state.headSnapshot).to.deep.equal(snapshot.toRaw()) + expect(state.headVersion).to.equal(headVersion) + expect(state.persistedVersion).to.be.null + expect(state.expireTime).to.be.null + expect(state.persistTime).to.be.null }) }) - describe('getCurrentChunkIfValid', function () { - it('should return the chunk when versions and changes count match', async function () { - // Create and cache a sample chunk - const snapshot = new Snapshot() - const changes = [ - new Change( - [new AddFileOperation('test.tex', File.fromString('Valid content'))], - new Date(), - [] - ), - ] - const history = new History(snapshot, changes) - const chunk = new Chunk(history, 7) // startVersion 7, endVersion 8 - await redisBackend.setCurrentChunk(projectId, chunk) + describe('setExpireTime', function () { + it('should set the expire time on an active project', async function () { + // Load a fake project in Redis + const change = makeChange() + await queueChanges(projectId, [change], { expireTime: 123 }) - // Prepare chunkRecord matching the cached chunk - const chunkRecord = { startVersion: 7, endVersion: 8 } + // Check that the right expire time was recorded + let state = await redisBackend.getState(projectId) + expect(state.expireTime).to.equal(123) - // Retrieve using getCurrentChunkIfValid - const validChunk = await redisBackend.getCurrentChunkIfValid( - projectId, - chunkRecord - ) - - expect(validChunk).to.not.be.null - expect(validChunk.getStartVersion()).to.equal(7) - expect(validChunk.getEndVersion()).to.equal(8) - expect(validChunk).to.deep.equal(chunk) + // Set the expire time to something else + await redisBackend.setExpireTime(projectId, 456) + state = await redisBackend.getState(projectId) + expect(state.expireTime).to.equal(456) }) - it('should return null when no chunk is cached', async function () { - // No chunk is cached for this projectId yet - const chunkRecord = { startVersion: 1, endVersion: 2 } - const validChunk = await redisBackend.getCurrentChunkIfValid( - projectId, - chunkRecord - ) - expect(validChunk).to.be.null - }) + it('should not set an expire time on an inactive project', async function () { + let state = await redisBackend.getState(projectId) + expect(state.expireTime).to.be.null - it('should return null when start version mismatches', async function () { - // Cache a chunk with startVersion 10 - const snapshot = new Snapshot() - const changes = [ - new Change( - [new AddFileOperation('test.tex', File.fromString('Content'))], - new Date(), - [] - ), - ] - const history = new History(snapshot, changes) - const chunk = new Chunk(history, 10) // startVersion 10, endVersion 11 - await redisBackend.setCurrentChunk(projectId, chunk) - - // Attempt to retrieve with a different startVersion - const chunkRecord = { startVersion: 9, endVersion: 10 } // Incorrect startVersion - const validChunk = await redisBackend.getCurrentChunkIfValid( - projectId, - chunkRecord - ) - expect(validChunk).to.be.null - }) - - it('should return null when changes count mismatches', async function () { - // Cache a chunk with one change (startVersion 15, endVersion 16) - const snapshot = new Snapshot() - const changes = [ - new Change( - [new AddFileOperation('test.tex', File.fromString('Content'))], - new Date(), - [] - ), - ] - const history = new History(snapshot, changes) - const chunk = new Chunk(history, 15) - await redisBackend.setCurrentChunk(projectId, chunk) - - // Attempt to retrieve with correct startVersion but incorrect endVersion (implying wrong changes count) - const chunkRecord = { startVersion: 15, endVersion: 17 } // Incorrect endVersion (implies 2 changes) - const validChunk = await redisBackend.getCurrentChunkIfValid( - projectId, - chunkRecord - ) - expect(validChunk).to.be.null - }) - - it('should return the chunk when versions and changes count match for a zero-change chunk', async function () { - // Cache a chunk with zero changes - const snapshot = new Snapshot() - const changes = [] - const history = new History(snapshot, changes) - const chunk = new Chunk(history, 20) // startVersion 20, endVersion 20 - await redisBackend.setCurrentChunk(projectId, chunk) - - // Prepare chunkRecord matching the zero-change chunk - const chunkRecord = { startVersion: 20, endVersion: 20 } - - // Retrieve using getCurrentChunkIfValid - const validChunk = await redisBackend.getCurrentChunkIfValid( - projectId, - chunkRecord - ) - - expect(validChunk).to.not.be.null - expect(validChunk.getStartVersion()).to.equal(20) - expect(validChunk.getEndVersion()).to.equal(20) - expect(validChunk.history.changes.length).to.equal(0) - expect(validChunk).to.deep.equal(chunk) - }) - - it('should return null when start version matches but changes count is wrong for zero-change chunk', async function () { - // Cache a chunk with zero changes - const snapshot = new Snapshot() - const changes = [] - const history = new History(snapshot, changes) - const chunk = new Chunk(history, 25) // startVersion 25, endVersion 25 - await redisBackend.setCurrentChunk(projectId, chunk) - - // Attempt to retrieve with correct startVersion but incorrect endVersion - const chunkRecord = { startVersion: 25, endVersion: 26 } // Incorrect endVersion (implies 1 change) - const validChunk = await redisBackend.getCurrentChunkIfValid( - projectId, - chunkRecord - ) - expect(validChunk).to.be.null + await redisBackend.setExpireTime(projectId, 456) + state = await redisBackend.getState(projectId) + expect(state.expireTime).to.be.null }) }) - describe('getCurrentChunkMetadata', function () { - it('should return metadata for a cached chunk', async function () { - // Cache a chunk - const snapshot = new Snapshot() - const history = new History(snapshot, [ - new Change( - [new AddFileOperation('test.tex', File.fromString('Hello'))], - new Date(), - [] - ), - new Change( - [new AddFileOperation('other.tex', File.fromString('Bonjour'))], - new Date(), - [] - ), - ]) - const chunk = new Chunk(history, 10) - await redisBackend.setCurrentChunk(projectId, chunk) + describe('expireProject', function () { + it('should expire a persisted project', async function () { + // Load and persist a project in Redis + const change = makeChange() + await queueChanges(projectId, [change]) + await redisBackend.setPersistedVersion(projectId, 1) - const metadata = await redisBackend.getCurrentChunkMetadata(projectId) - expect(metadata).to.deep.equal({ startVersion: 10, changesCount: 2 }) + // Check that the project is loaded + let state = await redisBackend.getState(projectId) + expect(state.headVersion).to.equal(1) + expect(state.persistedVersion).to.equal(1) + + // Expire the project + await redisBackend.expireProject(projectId) + state = await redisBackend.getState(projectId) + expect(state.headVersion).to.be.null }) - it('should return null if no chunk is cached for the project', async function () { - const metadata = await redisBackend.getCurrentChunkMetadata( - 'non-existent-project-id' - ) - expect(metadata).to.be.null + it('should not expire a non-persisted project', async function () { + // Load a project in Redis + const change = makeChange() + await queueChanges(projectId, [change]) + + // Check that the project is loaded + let state = await redisBackend.getState(projectId) + expect(state.headVersion).to.equal(1) + expect(state.persistedVersion).to.equal(null) + + // Expire the project + await redisBackend.expireProject(projectId) + state = await redisBackend.getState(projectId) + expect(state.headVersion).to.equal(1) }) - it('should return metadata with zero changes for a zero-change chunk', async function () { - // Cache a chunk with no changes - const snapshot = new Snapshot() - const history = new History(snapshot, []) - const chunk = new Chunk(history, 5) - await redisBackend.setCurrentChunk(projectId, chunk) + it('should not expire a partially persisted project', async function () { + // Load a fake project in Redis + const change1 = makeChange() + const change2 = makeChange() + await queueChanges(projectId, [change1, change2]) - const metadata = await redisBackend.getCurrentChunkMetadata(projectId) - expect(metadata).to.deep.equal({ startVersion: 5, changesCount: 0 }) + // Persist the first change + await redisBackend.setPersistedVersion(projectId, 1) + + // Check that the project is loaded + let state = await redisBackend.getState(projectId) + expect(state.headVersion).to.equal(2) + expect(state.persistedVersion).to.equal(1) + + // Expire the project + await redisBackend.expireProject(projectId) + state = await redisBackend.getState(projectId) + expect(state.headVersion).to.equal(2) + }) + + it('should handle a project that is not loaded', async function () { + // Check that the project is not loaded + let state = await redisBackend.getState(projectId) + expect(state.headVersion).to.be.null + + // Expire the project + await redisBackend.expireProject(projectId) + state = await redisBackend.getState(projectId) + expect(state.headVersion).to.be.null }) }) - describe('expireCurrentChunk', function () { - const TEMPORARY_CACHE_LIFETIME_MS = 300 * 1000 // Match the value in redis.js + describe('claimExpireJob', function () { + it("should claim the expire job when it's ready", async function () { + // Load a project in Redis + const change = makeChange() + const now = Date.now() + const expireTime = now - 1000 + await queueChanges(projectId, [change], { expireTime }) - it('should return false and not expire a non-expired chunk', async function () { - // Cache a chunk - const snapshot = new Snapshot() - const history = new History(snapshot, []) - const chunk = new Chunk(history, 10) - await redisBackend.setCurrentChunk(projectId, chunk) + // Check that the expire time has been set correctly + let state = await redisBackend.getState(projectId) + expect(state.expireTime).to.equal(expireTime) - // Attempt to expire immediately (should not be expired yet) - const expired = await redisBackend.expireCurrentChunk(projectId) - expect(expired).to.be.false + // Claim the job + await redisBackend.claimExpireJob(projectId) - // Verify the chunk still exists - const cachedChunk = await redisBackend.getCurrentChunk(projectId) - expect(cachedChunk).to.not.be.null - expect(cachedChunk.getStartVersion()).to.equal(10) + // Check the job expires in the future + state = await redisBackend.getState(projectId) + expect(state.expireTime).to.satisfy(time => time > now) }) - it('should return true and expire an expired chunk using currentTime', async function () { - // Cache a chunk - const snapshot = new Snapshot() - const history = new History(snapshot, []) - const chunk = new Chunk(history, 10) - await redisBackend.setCurrentChunk(projectId, chunk) + it('should throw an error when the job is not ready', async function () { + // Load a project in Redis + const change = makeChange() + const now = Date.now() + const expireTime = now + 100_000 + await queueChanges(projectId, [change], { expireTime }) - // Calculate a time far enough in the future to ensure expiry - const futureTime = Date.now() + TEMPORARY_CACHE_LIFETIME_MS + 5000 // 5 seconds past expiry - - // Attempt to expire using the future time - const expired = await redisBackend.expireCurrentChunk( - projectId, - futureTime + // Claim the job + await expect(redisBackend.claimExpireJob(projectId)).to.be.rejectedWith( + JobNotReadyError ) - expect(expired).to.be.true - - // Verify the chunk is gone - const cachedChunk = await redisBackend.getCurrentChunk(projectId) - expect(cachedChunk).to.be.null - - // Verify metadata is also gone - const metadata = await redisBackend.getCurrentChunkMetadata(projectId) - expect(metadata).to.be.null }) - it('should return false if no chunk is cached for the project', async function () { - const expired = await redisBackend.expireCurrentChunk( - 'non-existent-project' + it('should throw an error when the job is not found', async function () { + // Claim a job on a project that is not loaded + await expect(redisBackend.claimExpireJob(projectId)).to.be.rejectedWith( + JobNotFoundError ) - expect(expired).to.be.false - }) - - it('should return false if called with a currentTime before the expiry time', async function () { - // Cache a chunk - const snapshot = new Snapshot() - const history = new History(snapshot, []) - const chunk = new Chunk(history, 10) - await redisBackend.setCurrentChunk(projectId, chunk) - - // Use a time *before* the cache would normally expire - const pastTime = Date.now() - 10000 // 10 seconds ago - - // Attempt to expire using the past time - const expired = await redisBackend.expireCurrentChunk(projectId, pastTime) - expect(expired).to.be.false - - // Verify the chunk still exists - const cachedChunk = await redisBackend.getCurrentChunk(projectId) - expect(cachedChunk).to.not.be.null }) }) - describe('with a persist-time timestamp', function () { - const persistTimestamp = Date.now() + 1000 * 60 * 60 // 1 hour in the future + describe('claimPersistJob', function () { + it("should claim the persist job when it's ready", async function () { + // Load a project in Redis + const change = makeChange() + const now = Date.now() + const persistTime = now - 1000 + await queueChanges(projectId, [change], { persistTime }) + + // Check that the persist time has been set correctly + let state = await redisBackend.getState(projectId) + expect(state.persistTime).to.equal(persistTime) + + // Claim the job + await redisBackend.claimPersistJob(projectId) + + // Check the job is not ready + state = await redisBackend.getState(projectId) + expect(state.persistTime).to.satisfy(time => time > now) + }) + + it('should throw an error when the job is not ready', async function () { + // Load a project in Redis + const change = makeChange() + const now = Date.now() + const persistTime = now + 100_000 + await queueChanges(projectId, [change], { persistTime }) + + // Claim the job + await expect(redisBackend.claimPersistJob(projectId)).to.be.rejectedWith( + JobNotReadyError + ) + }) + + it('should throw an error when the job is not found', async function () { + // Claim a job on a project that is not loaded + await expect(redisBackend.claimExpireJob(projectId)).to.be.rejectedWith( + JobNotFoundError + ) + }) + }) + + describe('closing a job', function () { + let job beforeEach(async function () { - // Ensure a chunk exists before each test in this block - const snapshot = new Snapshot() - const changes = [ - new Change( - [new AddFileOperation('test.tex', File.fromString('Persist Test'))], - new Date(), - [] - ), - ] - const history = new History(snapshot, changes) - const chunk = new Chunk(history, 100) - await redisBackend.setCurrentChunk(projectId, chunk) + // Load a project in Redis + const change = makeChange() + const now = Date.now() + const expireTime = now - 1000 + await queueChanges(projectId, [change], { expireTime }) + + // Check that the expire time has been set correctly + const state = await redisBackend.getState(projectId) + expect(state.expireTime).to.equal(expireTime) + + // Claim the job + job = await redisBackend.claimExpireJob(projectId) }) - it('should not clear a chunk if persist-time is set', async function () { - // Set persist time - await redisBackend.setPersistTime(projectId, persistTimestamp) - - // Attempt to clear the cache - const cleared = await redisBackend.clearCache(projectId) - expect(cleared).to.be.false // Expect clearCache to return false - - // Verify the chunk still exists - const chunk = await redisBackend.getCurrentChunk(projectId) - expect(chunk).to.not.be.null - expect(chunk.getStartVersion()).to.equal(100) + it("should delete the key if it hasn't changed", async function () { + await job.close() + const state = await redisBackend.getState(projectId) + expect(state.expireTime).to.be.null }) - it('should not expire a chunk if persist-time is set, even if expire-time has passed', async function () { - // Set persist time - await redisBackend.setPersistTime(projectId, persistTimestamp) - - // Attempt to expire the chunk with a time far in the future - const farFutureTime = Date.now() + 1000 * 60 * 60 * 24 // 24 hours in the future - const expired = await redisBackend.expireCurrentChunk( - projectId, - farFutureTime - ) - expect(expired).to.be.false // Expect expireCurrentChunk to return false - - // Verify the chunk still exists - const chunk = await redisBackend.getCurrentChunk(projectId) - expect(chunk).to.not.be.null - expect(chunk.getStartVersion()).to.equal(100) - }) - - it('getCurrentChunkStatus should return persist-time when set', async function () { - // Set persist time - await redisBackend.setPersistTime(projectId, persistTimestamp) - - const status = await redisBackend.getCurrentChunkStatus(projectId) - expect(status.persistTime).to.equal(persistTimestamp) - expect(status.expireTime).to.be.a('number') // expireTime is set by setCurrentChunk - }) - - it('getCurrentChunkStatus should return null for persist-time when not set', async function () { - const status = await redisBackend.getCurrentChunkStatus(projectId) - expect(status.persistTime).to.be.null - expect(status.expireTime).to.be.a('number') - }) - - it('getCurrentChunkStatus should return nulls after cache is cleared (without persist-time)', async function () { - // Clear cache (persistTime is not set here) - await redisBackend.clearCache(projectId) - - const status = await redisBackend.getCurrentChunkStatus(projectId) - expect(status.persistTime).to.be.null - expect(status.expireTime).to.be.null + it('should keep the key if it has changed', async function () { + const newTimestamp = job.claimTimestamp + 1000 + await redisBackend.setExpireTime(projectId, newTimestamp) + await job.close() + const state = await redisBackend.getState(projectId) + expect(state.expireTime).to.equal(newTimestamp) }) }) }) + +async function queueChanges(projectId, changes, opts = {}) { + const baseVersion = 0 + const headSnapshot = new Snapshot() + + await redisBackend.queueChanges( + projectId, + headSnapshot, + baseVersion, + changes, + { + persistTime: opts.persistTime, + expireTime: opts.expireTime, + } + ) +} + +function makeChange() { + const timestamp = new Date() + return new Change([], timestamp) +} + +/** + * Setup Redis buffer state for tests + * + * @param {string} projectId + * @param {object} params + * @param {number} params.headVersion + * @param {number | null} params.persistedVersion + * @param {number} params.changes - number of changes to create + * @return {Promise} dummy changes that have been created + */ +async function setupState(projectId, params) { + await rclient.set(keySchema.headVersion({ projectId }), params.headVersion) + if (params.persistedVersion) { + await rclient.set( + keySchema.persistedVersion({ projectId }), + params.persistedVersion + ) + } + + const changes = [] + for (let i = 1; i <= params.changes; i++) { + const change = new Change( + [new AddFileOperation(`file${i}.tex`, File.createHollow(i, i))], + new Date() + ) + changes.push(change) + } + await rclient.rpush( + keySchema.changes({ projectId }), + changes.map(change => JSON.stringify(change.toRaw())) + ) + return changes +} diff --git a/services/history-v1/test/acceptance/js/storage/expire_redis_chunks.test.js b/services/history-v1/test/acceptance/js/storage/expire_redis_chunks.test.js new file mode 100644 index 0000000000..b657991dda --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/expire_redis_chunks.test.js @@ -0,0 +1,209 @@ +'use strict' + +const { expect } = require('chai') +const { promisify } = require('node:util') +const { execFile } = require('node:child_process') +const { Snapshot, Author, Change } = require('overleaf-editor-core') +const cleanup = require('./support/cleanup') +const redisBackend = require('../../../../storage/lib/chunk_store/redis') +const redis = require('../../../../storage/lib/redis') +const rclient = redis.rclientHistory +const keySchema = redisBackend.keySchema + +const SCRIPT_PATH = 'storage/scripts/expire_redis_chunks.js' + +async function runExpireScript() { + const TIMEOUT = 10 * 1000 // 10 seconds + let result + try { + result = await promisify(execFile)('node', [SCRIPT_PATH], { + encoding: 'utf-8', + timeout: TIMEOUT, + env: { + ...process.env, + LOG_LEVEL: 'debug', // Override LOG_LEVEL for script output + }, + }) + result.status = 0 + } catch (err) { + const { stdout, stderr, code } = err + if (typeof code !== 'number') { + console.error('Error running expire script:', err) + throw err + } + result = { stdout, stderr, status: code } + } + // The script might exit with status 1 if it finds no keys to process, which is ok + if (result.status !== 0 && result.status !== 1) { + console.error('Expire script failed:', result.stderr) + throw new Error(`expire script failed with status ${result.status}`) + } + return result +} + +// Helper to set up a basic project state in Redis +async function setupProjectState( + projectId, + { + headVersion = 0, + persistedVersion = null, + expireTime = null, + persistTime = null, + changes = [], + } +) { + const headSnapshot = new Snapshot() + await rclient.set( + keySchema.head({ projectId }), + JSON.stringify(headSnapshot.toRaw()) + ) + await rclient.set( + keySchema.headVersion({ projectId }), + headVersion.toString() + ) + + if (persistedVersion !== null) { + await rclient.set( + keySchema.persistedVersion({ projectId }), + persistedVersion.toString() + ) + } + if (expireTime !== null) { + await rclient.set( + keySchema.expireTime({ projectId }), + expireTime.toString() + ) + } + if (persistTime !== null) { + await rclient.set( + keySchema.persistTime({ projectId }), + persistTime.toString() + ) + } + if (changes.length > 0) { + const rawChanges = changes.map(c => JSON.stringify(c.toRaw())) + await rclient.rpush(keySchema.changes({ projectId }), ...rawChanges) + } +} + +function makeChange() { + const timestamp = new Date() + const author = new Author(123, 'test@example.com', 'Test User') + return new Change([], timestamp, [author]) +} + +describe('expire_redis_chunks script', function () { + beforeEach(cleanup.everything) + + let now, past, future + + // Setup all projects and run the script once before tests + beforeEach(async function () { + now = Date.now() + past = now - 10000 // 10 seconds ago + future = now + 60000 // 1 minute in the future + + // Setup all project states explicitly + await setupProjectState('expired_persisted', { + headVersion: 2, + persistedVersion: 2, + expireTime: past, + }) + await setupProjectState('expired_initial_state', { + headVersion: 0, + persistedVersion: 0, + expireTime: past, + }) + await setupProjectState('expired_persisted_with_job', { + headVersion: 2, + persistedVersion: 2, + expireTime: past, + persistTime: future, + }) + await setupProjectState('expired_not_persisted', { + headVersion: 3, + persistedVersion: 2, + expireTime: past, + changes: [makeChange()], + }) + await setupProjectState('expired_no_persisted_version', { + headVersion: 1, + persistedVersion: null, + expireTime: past, + changes: [makeChange()], + }) + await setupProjectState('future_expired_persisted', { + headVersion: 2, + persistedVersion: 2, + expireTime: future, + }) + await setupProjectState('future_expired_not_persisted', { + headVersion: 3, + persistedVersion: 2, + expireTime: future, + changes: [makeChange()], + }) + await setupProjectState('no_expire_time', { + headVersion: 1, + persistedVersion: 1, + expireTime: null, + }) + + // Run the expire script once after all projects are set up + await runExpireScript() + }) + + async function checkProjectStatus(projectId) { + const exists = + (await rclient.exists(keySchema.headVersion({ projectId }))) === 1 + return exists ? 'exists' : 'deleted' + } + + it('should expire a project when expireTime is past and it is fully persisted', async function () { + const projectId = 'expired_persisted' + const status = await checkProjectStatus(projectId) + expect(status).to.equal('deleted') + }) + + it('should expire a project when expireTime is past and it has no changes (initial state)', async function () { + const projectId = 'expired_initial_state' + const status = await checkProjectStatus(projectId) + expect(status).to.equal('deleted') + }) + + it('should expire a project when expireTime is past and it is fully persisted even if persistTime is set', async function () { + const projectId = 'expired_persisted_with_job' + const status = await checkProjectStatus(projectId) + expect(status).to.equal('deleted') + }) + + it('should not expire a project when expireTime is past but it is not fully persisted', async function () { + const projectId = 'expired_not_persisted' + const status = await checkProjectStatus(projectId) + expect(status).to.equal('exists') + }) + + it('should not expire a project when expireTime is past but persistedVersion is not set', async function () { + const projectId = 'expired_no_persisted_version' + const status = await checkProjectStatus(projectId) + expect(status).to.equal('exists') + }) + + it('should not expire a project when expireTime is in the future (even if fully persisted)', async function () { + const projectId = 'future_expired_persisted' + const status = await checkProjectStatus(projectId) + expect(status).to.equal('exists') + }) + + it('should not expire a project when expireTime is in the future (if not fully persisted)', async function () { + const projectId = 'future_expired_not_persisted' + const status = await checkProjectStatus(projectId) + expect(status).to.equal('exists') + }) + + it('should not expire a project when expireTime is not set', async function () { + const projectId = 'no_expire_time' + const status = await checkProjectStatus(projectId) + expect(status).to.equal('exists') + }) +}) diff --git a/services/history-v1/test/acceptance/js/storage/persist_changes.test.js b/services/history-v1/test/acceptance/js/storage/persist_changes.test.js index aa56dc8c2a..0bb8836cc1 100644 --- a/services/history-v1/test/acceptance/js/storage/persist_changes.test.js +++ b/services/history-v1/test/acceptance/js/storage/persist_changes.test.js @@ -58,6 +58,7 @@ describe('persistChanges', function () { numberOfChangesPersisted: 1, originalEndVersion: 0, currentChunk, + resyncNeeded: false, }) const chunk = await chunkStore.loadLatest(projectId) @@ -106,6 +107,7 @@ describe('persistChanges', function () { numberOfChangesPersisted: 2, originalEndVersion: 0, currentChunk, + resyncNeeded: false, }) const chunk = await chunkStore.loadLatest(projectId) @@ -147,6 +149,7 @@ describe('persistChanges', function () { numberOfChangesPersisted: 2, originalEndVersion: 0, currentChunk, + resyncNeeded: false, }) const chunk = await chunkStore.loadLatest(projectId) @@ -213,7 +216,7 @@ describe('persistChanges', function () { expect(result.numberOfChangesPersisted).to.equal(1) }) - it('rejects a change with an invalid hash', async function () { + it('turns on the resyncNeeded flag if content hash validation fails', async function () { const limitsToPersistImmediately = { minChangeTimestamp: farFuture, maxChangeTimestamp: farFuture, @@ -235,9 +238,13 @@ describe('persistChanges', function () { ) const changes = [change] - await expect( - persistChanges(projectId, changes, limitsToPersistImmediately, 0) - ).to.be.rejectedWith(storage.InvalidChangeError) + const result = await persistChanges( + projectId, + changes, + limitsToPersistImmediately, + 0 + ) + expect(result.resyncNeeded).to.be.true }) }) }) diff --git a/services/history-v1/test/acceptance/js/storage/support/cleanup.js b/services/history-v1/test/acceptance/js/storage/support/cleanup.js index 55829bef13..632cc96c04 100644 --- a/services/history-v1/test/acceptance/js/storage/support/cleanup.js +++ b/services/history-v1/test/acceptance/js/storage/support/cleanup.js @@ -64,6 +64,10 @@ async function clearBucket(name) { let s3PersistorForBackupCleanup async function cleanupBackup() { + if (!config.has('backupStore')) { + return + } + // The backupPersistor refuses to delete short prefixes. Use a low-level S3 persistor. if (!s3PersistorForBackupCleanup) { const { backupPersistor } = await import( diff --git a/services/notifications/.gitignore b/services/notifications/.gitignore deleted file mode 100644 index 8a030e9aff..0000000000 --- a/services/notifications/.gitignore +++ /dev/null @@ -1,54 +0,0 @@ -Compiled source # -################### -*.com -*.class -*.dll -*.exe -*.o -*.so - -# Packages # -############ -# it's better to unpack these files and commit the raw source -# git has its own built in compression methods -*.7z -*.dmg -*.gz -*.iso -*.jar -*.rar -*.tar -*.zip - -# Logs and databases # -###################### -*.log -*.sql -*.sqlite - -# OS generated files # -###################### -.DS_Store? -ehthumbs.db -Icon? -Thumbs.db - -node_modules/* -data/* - -cookies.txt -UserAndProjectPopulator.coffee - -public/stylesheets/style.css - -Gemfile.lock - -*.swp -.DS_Store - -app/views/external - -/modules/ - -# managed by dev-environment$ bin/update_build_scripts -.npmrc diff --git a/services/notifications/.nvmrc b/services/notifications/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/services/notifications/.nvmrc +++ b/services/notifications/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/services/notifications/Dockerfile b/services/notifications/Dockerfile index 16a5c44fbf..3733d6df6a 100644 --- a/services/notifications/Dockerfile +++ b/services/notifications/Dockerfile @@ -2,7 +2,7 @@ # Instead run bin/update_build_scripts from # https://github.com/overleaf/internal/ -FROM node:20.18.2 AS base +FROM node:22.15.1 AS base WORKDIR /overleaf/services/notifications diff --git a/services/notifications/Makefile b/services/notifications/Makefile index 8ca3f983ff..51d588bd86 100644 --- a/services/notifications/Makefile +++ b/services/notifications/Makefile @@ -32,12 +32,12 @@ HERE=$(shell pwd) MONOREPO=$(shell cd ../../ && pwd) # Run the linting commands in the scope of the monorepo. # Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:20.18.2 npm run --silent +RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:22.15.1 npm run --silent RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) npm run --silent # Same but from the top of the monorepo -RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:20.18.2 npm run --silent +RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:22.15.1 npm run --silent SHELLCHECK_OPTS = \ --shell=bash \ diff --git a/services/notifications/buildscript.txt b/services/notifications/buildscript.txt index c52e316ffe..bb7e1ec953 100644 --- a/services/notifications/buildscript.txt +++ b/services/notifications/buildscript.txt @@ -4,6 +4,6 @@ notifications --env-add= --env-pass-through= --esmock-loader=False ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=True --script-version=4.7.0 diff --git a/services/notifications/docker-compose.ci.yml b/services/notifications/docker-compose.ci.yml index 51eb64d126..8fd86c1fbb 100644 --- a/services/notifications/docker-compose.ci.yml +++ b/services/notifications/docker-compose.ci.yml @@ -39,7 +39,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root mongo: - image: mongo:6.0.13 + image: mongo:7.0.20 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/notifications/docker-compose.yml b/services/notifications/docker-compose.yml index c0902fee2d..090742ff6d 100644 --- a/services/notifications/docker-compose.yml +++ b/services/notifications/docker-compose.yml @@ -6,7 +6,7 @@ version: "2.3" services: test_unit: - image: node:20.18.2 + image: node:22.15.1 volumes: - .:/overleaf/services/notifications - ../../node_modules:/overleaf/node_modules @@ -21,7 +21,7 @@ services: user: node test_acceptance: - image: node:20.18.2 + image: node:22.15.1 volumes: - .:/overleaf/services/notifications - ../../node_modules:/overleaf/node_modules @@ -42,7 +42,7 @@ services: command: npm run --silent test:acceptance mongo: - image: mongo:6.0.13 + image: mongo:7.0.20 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/project-history/.gitignore b/services/project-history/.gitignore deleted file mode 100644 index 25328fed2e..0000000000 --- a/services/project-history/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -**.swp -node_modules/ -forever/ -.config -.npm - -# managed by dev-environment$ bin/update_build_scripts -.npmrc diff --git a/services/project-history/.nvmrc b/services/project-history/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/services/project-history/.nvmrc +++ b/services/project-history/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/services/project-history/Dockerfile b/services/project-history/Dockerfile index 1bf4e5680b..5216b7fcb9 100644 --- a/services/project-history/Dockerfile +++ b/services/project-history/Dockerfile @@ -2,7 +2,7 @@ # Instead run bin/update_build_scripts from # https://github.com/overleaf/internal/ -FROM node:20.18.2 AS base +FROM node:22.15.1 AS base WORKDIR /overleaf/services/project-history diff --git a/services/project-history/Makefile b/services/project-history/Makefile index 5cde05ea46..27b339a97a 100644 --- a/services/project-history/Makefile +++ b/services/project-history/Makefile @@ -32,12 +32,12 @@ HERE=$(shell pwd) MONOREPO=$(shell cd ../../ && pwd) # Run the linting commands in the scope of the monorepo. # Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:20.18.2 npm run --silent +RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:22.15.1 npm run --silent RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/app/js/types.ts:/overleaf/services/document-updater/app/js/types.ts ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) npm run --silent # Same but from the top of the monorepo -RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:20.18.2 npm run --silent +RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:22.15.1 npm run --silent SHELLCHECK_OPTS = \ --shell=bash \ diff --git a/services/project-history/app/js/HistoryStoreManager.js b/services/project-history/app/js/HistoryStoreManager.js index fe9c9e3d2d..bb41dfb3c0 100644 --- a/services/project-history/app/js/HistoryStoreManager.js +++ b/services/project-history/app/js/HistoryStoreManager.js @@ -249,7 +249,7 @@ export function sendChanges( method: 'POST', json: changes, }, - error => { + (error, response) => { if (error) { OError.tag(error, 'failed to send changes to v1', { projectId, @@ -261,7 +261,7 @@ export function sendChanges( }) return callback(error) } - callback() + callback(null, { resyncNeeded: response?.resyncNeeded ?? false }) } ) } diff --git a/services/project-history/app/js/UpdateCompressor.js b/services/project-history/app/js/UpdateCompressor.js index b548b7529e..471fc791ab 100644 --- a/services/project-history/app/js/UpdateCompressor.js +++ b/services/project-history/app/js/UpdateCompressor.js @@ -2,6 +2,7 @@ import OError from '@overleaf/o-error' import DMP from 'diff-match-patch' +import { EditOperationBuilder } from 'overleaf-editor-core' /** * @import { DeleteOp, InsertOp, Op, Update } from './types' @@ -230,6 +231,13 @@ function _concatTwoUpdates(firstUpdate, secondUpdate) { return [firstUpdate, secondUpdate] } + const firstUpdateIsHistoryOT = EditOperationBuilder.isValid(firstUpdate.op) + const secondUpdateIsHistoryOT = EditOperationBuilder.isValid(secondUpdate.op) + if (firstUpdateIsHistoryOT !== secondUpdateIsHistoryOT) { + // cannot merge mix of sharejs-text-op and history-ot, should not happen. + return [firstUpdate, secondUpdate] + } + if ( firstUpdate.doc !== secondUpdate.doc || firstUpdate.pathname !== secondUpdate.pathname @@ -276,6 +284,15 @@ function _concatTwoUpdates(firstUpdate, secondUpdate) { return [firstUpdate, secondUpdate] } + if (firstUpdateIsHistoryOT && secondUpdateIsHistoryOT) { + const op1 = EditOperationBuilder.fromJSON(firstUpdate.op) + const op2 = EditOperationBuilder.fromJSON(secondUpdate.op) + if (!op1.canBeComposedWith(op2)) return [firstUpdate, secondUpdate] + return [ + mergeUpdatesWithOp(firstUpdate, secondUpdate, op1.compose(op2).toJSON()), + ] + } + if ( firstUpdate.op.trackedDeleteRejection || secondUpdate.op.trackedDeleteRejection @@ -440,8 +457,7 @@ export function diffAsShareJsOps(before, after) { const ops = [] let position = 0 for (const diff of diffs) { - const type = diff[0] - const content = diff[1] + const [type, content] = diff if (type === ADDED) { ops.push({ i: content, diff --git a/services/project-history/app/js/UpdateTranslator.js b/services/project-history/app/js/UpdateTranslator.js index 38e65f6968..43b9f48270 100644 --- a/services/project-history/app/js/UpdateTranslator.js +++ b/services/project-history/app/js/UpdateTranslator.js @@ -7,7 +7,7 @@ import * as OperationsCompressor from './OperationsCompressor.js' import { isInsert, isRetain, isDelete, isComment } from './Utils.js' /** - * @import { AddDocUpdate, AddFileUpdate, DeleteCommentUpdate, Op, RawScanOp } from './types' + * @import { AddDocUpdate, AddFileUpdate, DeleteCommentUpdate, HistoryOTEditOperationUpdate, Op, RawScanOp } from './types' * @import { RenameUpdate, TextUpdate, TrackingDirective, TrackingProps } from './types' * @import { SetCommentStateUpdate, SetFileMetadataOperation, Update, UpdateWithBlob } from './types' */ @@ -60,6 +60,16 @@ function _convertToChange(projectId, updateWithBlob) { } operations = [op] projectVersion = update.version + } else if (isHistoryOTEditOperationUpdate(update)) { + let { pathname } = update.meta + pathname = _convertPathname(pathname) + if (update.v != null) { + v2DocVersions[update.doc] = { pathname, v: update.v } + } + operations = update.op.map(op => { + // Turn EditOperation into EditFileOperation by adding the pathname field. + return { pathname, ...op } + }) } else if (isTextUpdate(update)) { const docLength = update.meta.history_doc_length ?? update.meta.doc_length let pathname = update.meta.pathname @@ -194,6 +204,22 @@ export function isTextUpdate(update) { ) } +/** + * @param {Update} update + * @returns {update is HistoryOTEditOperationUpdate} + */ +export function isHistoryOTEditOperationUpdate(update) { + return ( + 'doc' in update && + update.doc != null && + 'op' in update && + update.op != null && + 'pathname' in update.meta && + update.meta.pathname != null && + Core.EditOperationBuilder.isValid(update.op[0]) + ) +} + export function isProjectStructureUpdate(update) { return isAddUpdate(update) || _isRenameUpdate(update) } diff --git a/services/project-history/app/js/UpdatesProcessor.js b/services/project-history/app/js/UpdatesProcessor.js index b52fac7af6..a76241d7ca 100644 --- a/services/project-history/app/js/UpdatesProcessor.js +++ b/services/project-history/app/js/UpdatesProcessor.js @@ -85,7 +85,7 @@ export function startResyncAndProcessUpdatesUnderLock( }) }) }, - (flushError, queueSize) => { + (flushError, { queueSize } = {}) => { if (flushError) { OError.tag(flushError) ErrorRecorder.record(projectId, queueSize, flushError, recordError => { @@ -132,7 +132,7 @@ export function processUpdatesForProject(projectId, callback) { releaseLock ) }, - (flushError, queueSize) => { + (flushError, { queueSize, resyncNeeded } = {}) => { if (flushError) { OError.tag(flushError) ErrorRecorder.record( @@ -167,7 +167,15 @@ export function processUpdatesForProject(projectId, callback) { 'failed to clear error' ) } - callback() + if (resyncNeeded) { + logger.warn( + { projectId }, + 'Resyncing project as requested by full project history' + ) + resyncProject(projectId, callback) + } else { + callback() + } }) } if (queueSize > 0) { @@ -198,7 +206,7 @@ export function resyncProject(projectId, callback) { releaseLock ) }, - (flushError, queueSize) => { + (flushError, { queueSize } = {}) => { if (flushError) { ErrorRecorder.record( projectId, @@ -247,7 +255,7 @@ export function processUpdatesForProjectUsingBisect( releaseLock ) }, - (flushError, queueSize) => { + (flushError, { queueSize } = {}) => { if (amountToProcess === 0 || queueSize === 0) { // no further processing possible if (flushError != null) { @@ -298,7 +306,7 @@ export function processSingleUpdateForProject(projectId, callback) { ) => { _countAndProcessUpdates(projectId, extendLock, 1, releaseLock) }, - (flushError, queueSize) => { + (flushError, { queueSize } = {}) => { // no need to clear the flush marker when single stepping // it will be cleared up on the next background flush if // the queue is empty @@ -339,18 +347,34 @@ _mocks._countAndProcessUpdates = ( } if (queueSize > 0) { logger.debug({ projectId, queueSize }, 'processing uncompressed updates') + + let resyncNeeded = false RedisManager.getUpdatesInBatches( projectId, batchSize, (updates, cb) => { - _processUpdatesBatch(projectId, updates, extendLock, cb) + _processUpdatesBatch( + projectId, + updates, + extendLock, + (err, flushResponse) => { + if (err) { + return cb(err) + } + + if (flushResponse.resyncNeeded) { + resyncNeeded = true + } + cb() + } + ) }, error => { // Unconventional callback signature. The caller needs the queue size // even when an error is thrown in order to record the queue size in // the projectHistoryFailures collection. We'll have to find another // way to achieve this when we promisify. - callback(error, queueSize) + callback(error, { queueSize, resyncNeeded }) } ) } else { @@ -376,15 +400,21 @@ function _processUpdatesBatch(projectId, updates, extendLock, callback) { { projectId }, 'discarding updates as project does not use history' ) - return callback() + return callback(null, {}) } - _processUpdates(projectId, historyId, updates, extendLock, error => { - if (error != null) { - return callback(OError.tag(error)) + _processUpdates( + projectId, + historyId, + updates, + extendLock, + (error, flushResponse) => { + if (error != null) { + return callback(OError.tag(error)) + } + callback(null, flushResponse) } - callback() - }) + ) }) } @@ -536,6 +566,8 @@ export function _processUpdates( if (error != null) { return callback(error) } + + let resyncNeeded = false async.waterfall( [ cb => { @@ -646,7 +678,13 @@ export function _processUpdates( projectHistoryId, changes, baseVersion, - cb + (err, response) => { + if (err) { + return cb(err) + } + resyncNeeded = response.resyncNeeded + cb() + } ) }) }, @@ -657,7 +695,11 @@ export function _processUpdates( ], error => { profile.end() - callback(error) + if (error) { + callback(error) + } else { + callback(null, { resyncNeeded }) + } } ) } diff --git a/services/project-history/app/js/types.ts b/services/project-history/app/js/types.ts index c2b0d83728..96701e587f 100644 --- a/services/project-history/app/js/types.ts +++ b/services/project-history/app/js/types.ts @@ -1,5 +1,9 @@ import { HistoryRanges } from '../../../document-updater/app/js/types' -import { LinkedFileData, RawOrigin } from 'overleaf-editor-core/lib/types' +import { + LinkedFileData, + RawEditOperation, + RawOrigin, +} from 'overleaf-editor-core/lib/types' export type Update = | TextUpdate @@ -40,6 +44,15 @@ export type TextUpdate = { } } +export type HistoryOTEditOperationUpdate = { + doc: string + op: RawEditOperation[] + v: number + meta: UpdateMeta & { + pathname: string + } +} + export type SetCommentStateUpdate = { pathname: string commentId: string diff --git a/services/project-history/buildscript.txt b/services/project-history/buildscript.txt index be5e751759..bcb29f215b 100644 --- a/services/project-history/buildscript.txt +++ b/services/project-history/buildscript.txt @@ -4,6 +4,6 @@ project-history --env-add= --env-pass-through= --esmock-loader=True ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 diff --git a/services/project-history/docker-compose.ci.yml b/services/project-history/docker-compose.ci.yml index 6deaad433d..2fe97bd9b3 100644 --- a/services/project-history/docker-compose.ci.yml +++ b/services/project-history/docker-compose.ci.yml @@ -52,7 +52,7 @@ services: retries: 20 mongo: - image: mongo:6.0.13 + image: mongo:7.0.20 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/project-history/docker-compose.yml b/services/project-history/docker-compose.yml index deed9c5033..68360baf44 100644 --- a/services/project-history/docker-compose.yml +++ b/services/project-history/docker-compose.yml @@ -6,7 +6,7 @@ version: "2.3" services: test_unit: - image: node:20.18.2 + image: node:22.15.1 volumes: - .:/overleaf/services/project-history - ../../node_modules:/overleaf/node_modules @@ -21,7 +21,7 @@ services: user: node test_acceptance: - image: node:20.18.2 + image: node:22.15.1 volumes: - .:/overleaf/services/project-history - ../../node_modules:/overleaf/node_modules @@ -55,7 +55,7 @@ services: retries: 20 mongo: - image: mongo:6.0.13 + image: mongo:7.0.20 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/project-history/test/unit/js/UpdatesManager/UpdatesProcessorTests.js b/services/project-history/test/unit/js/UpdatesManager/UpdatesProcessorTests.js index 137169bfcf..6f148e5a8d 100644 --- a/services/project-history/test/unit/js/UpdatesManager/UpdatesProcessorTests.js +++ b/services/project-history/test/unit/js/UpdatesManager/UpdatesProcessorTests.js @@ -13,7 +13,7 @@ describe('UpdatesProcessor', function () { } this.HistoryStoreManager = { getMostRecentVersion: sinon.stub(), - sendChanges: sinon.stub().yields(), + sendChanges: sinon.stub().yields(null, {}), } this.LockManager = { runWithLock: sinon.spy((key, runner, callback) => @@ -109,7 +109,7 @@ describe('UpdatesProcessor', function () { this.queueSize = 445 this.UpdatesProcessor._mocks._countAndProcessUpdates = sinon .stub() - .callsArgWith(3, this.error, this.queueSize) + .callsArgWith(3, this.error, { queueSize: this.queueSize }) }) describe('when there is no existing error', function () { diff --git a/services/real-time/.gitignore b/services/real-time/.gitignore deleted file mode 100644 index 80bac793a7..0000000000 --- a/services/real-time/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -forever - -# managed by dev-environment$ bin/update_build_scripts -.npmrc diff --git a/services/real-time/.nvmrc b/services/real-time/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/services/real-time/.nvmrc +++ b/services/real-time/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/services/real-time/Dockerfile b/services/real-time/Dockerfile index d1f2046895..f178233af9 100644 --- a/services/real-time/Dockerfile +++ b/services/real-time/Dockerfile @@ -2,7 +2,7 @@ # Instead run bin/update_build_scripts from # https://github.com/overleaf/internal/ -FROM node:20.18.2 AS base +FROM node:22.15.1 AS base WORKDIR /overleaf/services/real-time diff --git a/services/real-time/Makefile b/services/real-time/Makefile index e9e6a7a067..5604e9f5cc 100644 --- a/services/real-time/Makefile +++ b/services/real-time/Makefile @@ -32,12 +32,12 @@ HERE=$(shell pwd) MONOREPO=$(shell cd ../../ && pwd) # Run the linting commands in the scope of the monorepo. # Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:20.18.2 npm run --silent +RUN_LINTING = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(HERE) node:22.15.1 npm run --silent RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) npm run --silent # Same but from the top of the monorepo -RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:20.18.2 npm run --silent +RUN_LINTING_MONOREPO = docker run --rm -v $(MONOREPO):$(MONOREPO) -w $(MONOREPO) node:22.15.1 npm run --silent SHELLCHECK_OPTS = \ --shell=bash \ diff --git a/services/real-time/app.js b/services/real-time/app.js index 38cb3caec4..a66833d9d9 100644 --- a/services/real-time/app.js +++ b/services/real-time/app.js @@ -91,6 +91,11 @@ io.configure(function () { ) io.set('origins', function (origin, req) { + if (!origin) { + // There is no origin or referer header - this is likely a same-site request. + logger.warn({ req }, 'No origin or referer header') + return true + } const normalizedOrigin = URL.parse(origin).origin const originIsValid = allowedCorsOriginsRegex.test(normalizedOrigin) @@ -260,6 +265,7 @@ function drainAndShutdown(signal) { } Settings.shutDownInProgress = false +Settings.shutDownScheduled = false const shutdownDrainTimeWindow = parseInt(Settings.shutdownDrainTimeWindow, 10) if (Settings.shutdownDrainTimeWindow) { logger.info({ shutdownDrainTimeWindow }, 'shutdownDrainTimeWindow enabled') @@ -299,8 +305,16 @@ if (Settings.shutdownDrainTimeWindow) { ) } logger.error({ err: error }, 'uncaught exception') - if (Settings.errors && Settings.errors.shutdownOnUncaughtError) { - drainAndShutdown('SIGABRT') + if ( + Settings.errors?.shutdownOnUncaughtError && + !Settings.shutDownScheduled + ) { + Settings.shutDownScheduled = true + const delay = Math.ceil( + Math.random() * 60 * Math.max(io.sockets.clients().length, 1_000) + ) + logger.info({ delay }, 'delaying shutdown on uncaught error') + setTimeout(() => drainAndShutdown('SIGABRT'), delay) } }) } diff --git a/services/real-time/app/js/ConnectedUsersManager.js b/services/real-time/app/js/ConnectedUsersManager.js index 1421e8eeef..4ce3dcdcad 100644 --- a/services/real-time/app/js/ConnectedUsersManager.js +++ b/services/real-time/app/js/ConnectedUsersManager.js @@ -29,6 +29,10 @@ function recordProjectNotEmptySinceMetric(res, status) { } module.exports = { + countConnectedClients(projectId, callback) { + rclient.scard(Keys.clientsInProject({ project_id: projectId }), callback) + }, + // Use the same method for when a user connects, and when a user sends a cursor // update. This way we don't care if the connected_user key has expired when // we receive a cursor update. @@ -85,13 +89,14 @@ module.exports = { multi.exec(function (err, res) { if (err) { err = new OError('problem marking user as connected').withCause(err) + return callback(err) } const [, nConnectedClients] = res Metrics.inc('editing_session_mode', 1, { method: cursorData ? 'update' : 'connect', status: nConnectedClients === 1 ? 'single' : 'multi', }) - callback(err) + callback(null) }) }, @@ -132,6 +137,7 @@ module.exports = { multi.exec(function (err, res) { if (err) { err = new OError('problem marking user as disconnected').withCause(err) + return callback(err) } const [, nConnectedClients] = res const status = @@ -179,7 +185,7 @@ module.exports = { } }) } - callback(err) + callback(null) }) }, diff --git a/services/real-time/app/js/DocumentUpdaterManager.js b/services/real-time/app/js/DocumentUpdaterManager.js index 0a9a12c99d..51b71e8ec0 100644 --- a/services/real-time/app/js/DocumentUpdaterManager.js +++ b/services/real-time/app/js/DocumentUpdaterManager.js @@ -19,7 +19,7 @@ const Keys = settings.redis.documentupdater.key_schema const DocumentUpdaterManager = { getDocument(projectId, docId, fromVersion, callback) { const timer = new metrics.Timer('get-document') - const url = `${settings.apis.documentupdater.url}/project/${projectId}/doc/${docId}?fromVersion=${fromVersion}` + const url = `${settings.apis.documentupdater.url}/project/${projectId}/doc/${docId}?fromVersion=${fromVersion}&historyOTSupport=true` logger.debug( { projectId, docId, fromVersion }, 'getting doc from document updater' @@ -48,7 +48,8 @@ const DocumentUpdaterManager = { body.version, body.ranges, body.ops, - body.ttlInS + body.ttlInS, + body.type ) } else if (res.statusCode === 422 && body?.firstVersionInRedis) { callback(new ClientRequestedMissingOpsError(422, body)) diff --git a/services/real-time/app/js/HttpApiController.js b/services/real-time/app/js/HttpApiController.js index 122f1838be..5e75fe3601 100644 --- a/services/real-time/app/js/HttpApiController.js +++ b/services/real-time/app/js/HttpApiController.js @@ -1,8 +1,23 @@ const WebsocketLoadBalancer = require('./WebsocketLoadBalancer') const DrainManager = require('./DrainManager') +const ConnectedUsersManager = require('./ConnectedUsersManager') const logger = require('@overleaf/logger') module.exports = { + countConnectedClients(req, res) { + const { projectId } = req.params + ConnectedUsersManager.countConnectedClients( + projectId, + (err, nConnectedClients) => { + if (err) { + logger.err({ err, projectId }, 'count connected clients failed') + return res.sendStatus(500) + } + res.json({ nConnectedClients }) + } + ) + }, + sendMessage(req, res) { logger.debug({ message: req.params.message }, 'sending message') if (Array.isArray(req.body)) { diff --git a/services/real-time/app/js/Router.js b/services/real-time/app/js/Router.js index 238dc386a3..943453bc13 100644 --- a/services/real-time/app/js/Router.js +++ b/services/real-time/app/js/Router.js @@ -113,6 +113,10 @@ module.exports = Router = { bodyParser.json({ limit: '5mb' }), HttpApiController.sendMessage ) + app.get( + '/project/:projectId/count-connected-clients', + HttpApiController.countConnectedClients + ) app.post('/drain', HttpApiController.startDrain) app.post( diff --git a/services/real-time/app/js/WebsocketController.js b/services/real-time/app/js/WebsocketController.js index dec567709a..c0f465a490 100644 --- a/services/real-time/app/js/WebsocketController.js +++ b/services/real-time/app/js/WebsocketController.js @@ -8,6 +8,7 @@ const ConnectedUsersManager = require('./ConnectedUsersManager') const WebsocketLoadBalancer = require('./WebsocketLoadBalancer') const RoomManager = require('./RoomManager') const { + CodedError, JoinLeaveEpochMismatchError, NotAuthorizedError, NotJoinedError, @@ -283,7 +284,7 @@ module.exports = WebsocketController = { projectId, docId, fromVersion, - function (error, lines, version, ranges, ops, ttlInS) { + function (error, lines, version, ranges, ops, ttlInS, type) { if (error) { if (error instanceof ClientRequestedMissingOpsError) { emitJoinDocCatchUpMetrics('missing', error.info) @@ -307,36 +308,53 @@ module.exports = WebsocketController = { // See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html const encodeForWebsockets = text => unescape(encodeURIComponent(text)) - const escapedLines = [] - for (let line of lines) { - try { - line = encodeForWebsockets(line) - } catch (err) { - OError.tag(err, 'error encoding line uri component', { line }) - return callback(err) + metrics.inc('client_supports_history_v1_ot', 1, { + status: options.supportsHistoryOT ? 'success' : 'failure', + }) + let escapedLines + if (type === 'history-ot') { + if (!options.supportsHistoryOT) { + RoomManager.leaveDoc(client, docId) + // TODO(24596): ask the user to reload the editor page (via out-of-sync modal when there are pending ops). + return callback( + new CodedError('client does not support history-ot') + ) } - escapedLines.push(line) - } - if (options.encodeRanges) { - try { - for (const comment of (ranges && ranges.comments) || []) { - if (comment.op.c) { - comment.op.c = encodeForWebsockets(comment.op.c) - } + escapedLines = lines + } else { + escapedLines = [] + for (let line of lines) { + try { + line = encodeForWebsockets(line) + } catch (err) { + OError.tag(err, 'error encoding line uri component', { + line, + }) + return callback(err) } - for (const change of (ranges && ranges.changes) || []) { - if (change.op.i) { - change.op.i = encodeForWebsockets(change.op.i) + escapedLines.push(line) + } + if (options.encodeRanges) { + try { + for (const comment of (ranges && ranges.comments) || []) { + if (comment.op.c) { + comment.op.c = encodeForWebsockets(comment.op.c) + } } - if (change.op.d) { - change.op.d = encodeForWebsockets(change.op.d) + for (const change of (ranges && ranges.changes) || []) { + if (change.op.i) { + change.op.i = encodeForWebsockets(change.op.i) + } + if (change.op.d) { + change.op.d = encodeForWebsockets(change.op.d) + } } + } catch (err) { + OError.tag(err, 'error encoding range uri component', { + ranges, + }) + return callback(err) } - } catch (err) { - OError.tag(err, 'error encoding range uri component', { - ranges, - }) - return callback(err) } } @@ -351,7 +369,7 @@ module.exports = WebsocketController = { }, 'client joined doc' ) - callback(null, escapedLines, version, ops, ranges) + callback(null, escapedLines, version, ops, ranges, type) } ) }) diff --git a/services/real-time/buildscript.txt b/services/real-time/buildscript.txt index 292fde8b4c..fdfd32e135 100644 --- a/services/real-time/buildscript.txt +++ b/services/real-time/buildscript.txt @@ -4,6 +4,6 @@ real-time --env-add= --env-pass-through= --esmock-loader=False ---node-version=20.18.2 +--node-version=22.15.1 --public-repo=False --script-version=4.7.0 diff --git a/services/real-time/docker-compose.yml b/services/real-time/docker-compose.yml index d40fada758..9333271dcf 100644 --- a/services/real-time/docker-compose.yml +++ b/services/real-time/docker-compose.yml @@ -6,7 +6,7 @@ version: "2.3" services: test_unit: - image: node:20.18.2 + image: node:22.15.1 volumes: - .:/overleaf/services/real-time - ../../node_modules:/overleaf/node_modules @@ -21,7 +21,7 @@ services: user: node test_acceptance: - image: node:20.18.2 + image: node:22.15.1 volumes: - .:/overleaf/services/real-time - ../../node_modules:/overleaf/node_modules diff --git a/services/real-time/package.json b/services/real-time/package.json index 2d5f87a109..a52e0dfcf9 100644 --- a/services/real-time/package.json +++ b/services/real-time/package.json @@ -34,7 +34,7 @@ "lodash": "^4.17.21", "proxy-addr": "^2.0.7", "request": "^2.88.2", - "socket.io": "github:overleaf/socket.io#0.9.19-overleaf-11", + "socket.io": "github:overleaf/socket.io#0.9.19-overleaf-12", "socket.io-client": "github:overleaf/socket.io-client#0.9.17-overleaf-5" }, "devDependencies": { diff --git a/services/real-time/test/acceptance/js/ClientTrackingTests.js b/services/real-time/test/acceptance/js/ClientTrackingTests.js index 415e9ad662..d4b484c0a8 100644 --- a/services/real-time/test/acceptance/js/ClientTrackingTests.js +++ b/services/real-time/test/acceptance/js/ClientTrackingTests.js @@ -19,6 +19,80 @@ const FixturesManager = require('./helpers/FixturesManager') const async = require('async') describe('clientTracking', function () { + describe('when another logged in user joins a project', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { name: 'Test Project' }, + }, + (error, { user_id: userId, project_id: projectId }) => { + if (error) return done(error) + this.user_id = userId + this.project_id = projectId + return cb() + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id: docId }) => { + this.doc_id = docId + return cb(e) + } + ) + }, + + cb => { + this.clientA = RealTimeClient.connect(this.project_id, cb) + }, + + cb => { + RealTimeClient.countConnectedClients( + this.project_id, + (err, body) => { + if (err) return cb(err) + expect(body).to.deep.equal({ nConnectedClients: 1 }) + cb() + } + ) + }, + + cb => { + this.clientB = RealTimeClient.connect(this.project_id, cb) + }, + ], + done + ) + }) + + it('should record the initial state in getConnectedUsers', function (done) { + this.clientA.emit('clientTracking.getConnectedUsers', (error, users) => { + if (error) return done(error) + for (const user of Array.from(users)) { + if (user.client_id === this.clientB.publicId) { + expect(user.cursorData).to.not.exist + return done() + } + } + throw new Error('other user was never found') + }) + }) + it('should list both clients via HTTP', function (done) { + RealTimeClient.countConnectedClients(this.project_id, (err, body) => { + if (err) return done(err) + expect(body).to.deep.equal({ nConnectedClients: 2 }) + done() + }) + }) + }) + describe('when a client updates its cursor location', function () { before(function (done) { return async.series( diff --git a/services/real-time/test/acceptance/js/JoinDocTests.js b/services/real-time/test/acceptance/js/JoinDocTests.js index 547691d358..3381526c59 100644 --- a/services/real-time/test/acceptance/js/JoinDocTests.js +++ b/services/real-time/test/acceptance/js/JoinDocTests.js @@ -89,6 +89,7 @@ describe('joinDoc', function () { this.version, this.ops, this.ranges, + 'sharejs-text-ot', ]) }) @@ -168,6 +169,7 @@ describe('joinDoc', function () { this.version, this.ops, this.ranges, + 'sharejs-text-ot', ]) }) @@ -247,6 +249,7 @@ describe('joinDoc', function () { this.version, this.ops, this.ranges, + 'sharejs-text-ot', ]) }) @@ -408,6 +411,7 @@ describe('joinDoc', function () { this.version, this.ops, this.ranges, + 'sharejs-text-ot', ]) }) @@ -489,6 +493,7 @@ describe('joinDoc', function () { this.version, this.ops, this.ranges, + 'sharejs-text-ot', ]) }) @@ -504,7 +509,7 @@ describe('joinDoc', function () { }) }) - return describe('with fromVersion and options', function () { + describe('with fromVersion and options', function () { before(function (done) { this.fromVersion = 36 this.options = { encodeRanges: true } @@ -572,6 +577,7 @@ describe('joinDoc', function () { this.version, this.ops, this.ranges, + 'sharejs-text-ot', ]) }) @@ -586,4 +592,139 @@ describe('joinDoc', function () { ) }) }) + + describe('with type=history-ot', function () { + before(function (done) { + async.series( + [ + cb => { + FixturesManager.setUpProject( + { privilegeLevel: 'owner' }, + (e, { project_id: projectId, user_id: userId }) => { + this.project_id = projectId + this.user_id = userId + cb(e) + } + ) + }, + + cb => { + FixturesManager.setUpDoc( + this.project_id, + { + lines: this.lines, + version: this.version, + ops: this.ops, + ranges: this.ranges, + type: 'history-ot', + }, + (e, { doc_id: docId }) => { + this.doc_id = docId + cb(e) + } + ) + }, + ], + done + ) + }) + + describe('when support is indicated', function () { + before(function (done) { + MockDocUpdaterServer.getDocument.resetHistory() + async.series( + [ + cb => { + this.client = RealTimeClient.connect(this.project_id, cb) + }, + cb => + this.client.emit( + 'joinDoc', + this.doc_id, + { supportsHistoryOT: true }, + (error, ...rest) => { + ;[...this.returnedArgs] = Array.from(rest) + cb(error) + } + ), + ], + done + ) + }) + + it('should get the doc from the doc updater', function () { + MockDocUpdaterServer.getDocument + .calledWith(this.project_id, this.doc_id, -1) + .should.equal(true) + }) + + it('should return the doc lines, version, ranges and ops', function () { + this.returnedArgs.should.deep.equal([ + this.lines, + this.version, + this.ops, + this.ranges, + 'history-ot', + ]) + }) + + it('should have joined the doc room', function (done) { + RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + if (error) return done(error) + expect(client.rooms).to.deep.equal([this.project_id, this.doc_id]) + done() + } + ) + }) + }) + + describe('when support is not indicated', function () { + before(function (done) { + MockDocUpdaterServer.getDocument.resetHistory() + async.series( + [ + cb => { + this.client = RealTimeClient.connect(this.project_id, cb) + }, + cb => + this.client.emit('joinDoc', this.doc_id, (error, ...rest) => { + this.error = error + ;[...this.returnedArgs] = Array.from(rest) + cb() + }), + ], + done + ) + }) + + it('should get the doc from the doc updater', function () { + MockDocUpdaterServer.getDocument + .calledWith(this.project_id, this.doc_id, -1) + .should.equal(true) + }) + + it('should return an error', function () { + expect(this.error).to.deep.equal({ + message: 'client does not support history-ot', + }) + }) + + it('should not return the doc lines, version, ranges and ops', function () { + this.returnedArgs.should.deep.equal([]) + }) + + it('should leave the doc room again', function (done) { + RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + if (error) return done(error) + expect(client.rooms).to.deep.equal([this.project_id]) + done() + } + ) + }) + }) + }) }) diff --git a/services/real-time/test/acceptance/js/helpers/FixturesManager.js b/services/real-time/test/acceptance/js/helpers/FixturesManager.js index 1db0c684c1..66e3072532 100644 --- a/services/real-time/test/acceptance/js/helpers/FixturesManager.js +++ b/services/real-time/test/acceptance/js/helpers/FixturesManager.js @@ -108,13 +108,17 @@ module.exports = FixturesManager = { if (!options.ops) { options.ops = ['mock', 'ops'] } - const { doc_id: docId, lines, version, ops, ranges } = options + if (!options.type) { + options.type = 'sharejs-text-ot' + } + const { doc_id: docId, lines, version, ops, ranges, type } = options MockDocUpdaterServer.createMockDoc(projectId, docId, { lines, version, ops, ranges, + type, }) return MockDocUpdaterServer.run(error => { if (error != null) { diff --git a/services/real-time/test/acceptance/js/helpers/RealTimeClient.js b/services/real-time/test/acceptance/js/helpers/RealTimeClient.js index 7b53f5d5c4..6cc7001896 100644 --- a/services/real-time/test/acceptance/js/helpers/RealTimeClient.js +++ b/services/real-time/test/acceptance/js/helpers/RealTimeClient.js @@ -123,6 +123,16 @@ module.exports = Client = { ) }, + countConnectedClients(projectId, callback) { + request.get( + { + url: `http://127.0.0.1:3026/project/${projectId}/count-connected-clients`, + json: true, + }, + (error, response, data) => callback(error, data) + ) + }, + getConnectedClient(clientId, callback) { if (callback == null) { callback = function () {} diff --git a/services/real-time/test/unit/js/DocumentUpdaterManagerTests.js b/services/real-time/test/unit/js/DocumentUpdaterManagerTests.js index 6dea5401f0..ecf45cd452 100644 --- a/services/real-time/test/unit/js/DocumentUpdaterManagerTests.js +++ b/services/real-time/test/unit/js/DocumentUpdaterManagerTests.js @@ -79,7 +79,7 @@ describe('DocumentUpdaterManager', function () { }) it('should get the document from the document updater', function () { - const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}?fromVersion=${this.fromVersion}` + const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}?fromVersion=${this.fromVersion}&historyOTSupport=true` return this.request.get.calledWith(url).should.equal(true) }) diff --git a/services/web/.gitignore b/services/web/.gitignore index 8bd23b7f0a..9946f23ae6 100644 --- a/services/web/.gitignore +++ b/services/web/.gitignore @@ -1,51 +1,6 @@ -# Compiled source # -################### -*.com -*.class -*.dll -*.exe -*.o -*.so - -# Packages # -############ -# it's better to unpack these files and commit the raw source -# git has its own built in compression methods -*.7z -*.dmg -*.gz -*.iso -*.jar -*.rar -*.tar -*.zip - -# Logs and databases # -###################### -*.log -*.sql -*.sqlite - -# OS generated files # -###################### -.DS_Store? -ehthumbs.db -Icon? -Thumbs.db - -# allow "icons" -![Ii]cons - -node_modules/* data/* coverage -cookies.txt -requestQueueWorker.js -TpdsWorker.js -BackgroundJobsWorker.js -UserAndProjectPopulator.coffee - public/manifest.json public/js @@ -54,22 +9,6 @@ public/stylesheets public/fonts public/images -Gemfile.lock - -*.swp -.DS_Store - -docker-shared.yml - -config/*.coffee -!config/settings.defaults.coffee -!config/settings.webpack.coffee -config/*.js -!config/settings.defaults.js -!config/settings.webpack.js -!config/settings.overrides.saas.js -!config/settings.overrides.server-pro.js - modules/**/Makefile # Precompiled pug files @@ -78,13 +17,6 @@ modules/**/Makefile # Sentry secrets file (injected by CI) .sentryclirc -# via dev-environment -.npmrc - -# Intellij -.idea -.run - # Cypress cypress/screenshots/ cypress/videos/ diff --git a/services/web/.nvmrc b/services/web/.nvmrc index 0254b1e633..8320a6d299 100644 --- a/services/web/.nvmrc +++ b/services/web/.nvmrc @@ -1 +1 @@ -20.18.2 +22.15.1 diff --git a/services/web/Dockerfile b/services/web/Dockerfile index 4f5d2e5ff1..469a7af2d7 100644 --- a/services/web/Dockerfile +++ b/services/web/Dockerfile @@ -1,6 +1,6 @@ # the base image is suitable for running web with /overleaf/services/web bind # mounted -FROM node:20.18.2 AS base +FROM node:22.15.1 AS base WORKDIR /overleaf/services/web diff --git a/services/web/Dockerfile.frontend b/services/web/Dockerfile.frontend index 0bdfd8c1f9..b22e376391 100644 --- a/services/web/Dockerfile.frontend +++ b/services/web/Dockerfile.frontend @@ -1,4 +1,4 @@ -FROM node:20.18.2 +FROM node:22.15.1 # Install Google Chrome RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - diff --git a/services/web/app.mjs b/services/web/app.mjs index 5ece02cf32..b7c723da3d 100644 --- a/services/web/app.mjs +++ b/services/web/app.mjs @@ -1,5 +1,5 @@ // Metrics must be initialized before importing anything else -import '@overleaf/metrics/initialize.js' +import { metricsModuleImportStartTime } from '@overleaf/metrics/initialize.js' import Modules from './app/src/infrastructure/Modules.js' import metrics from '@overleaf/metrics' @@ -20,6 +20,13 @@ import FileWriter from './app/src/infrastructure/FileWriter.js' import { fileURLToPath } from 'node:url' import Features from './app/src/infrastructure/Features.js' +metrics.gauge( + 'web_startup', + performance.now() - metricsModuleImportStartTime, + 1, + { path: 'imports' } +) + logger.initialize(process.env.METRICS_APP_NAME || 'web') logger.logger.serializers.user = Serializers.user logger.logger.serializers.docs = Serializers.docs @@ -58,6 +65,29 @@ if ( ) } +// handle SIGTERM for graceful shutdown in kubernetes +process.on('SIGTERM', function (signal) { + triggerGracefulShutdown(Server.server, signal) +}) + +const beforeWaitForMongoAndGlobalBlobs = performance.now() +try { + await Promise.all([ + mongodb.connectionPromise, + mongoose.connectionPromise, + HistoryManager.loadGlobalBlobsPromise, + ]) +} catch (err) { + logger.fatal({ err }, 'Cannot connect to mongo. Exiting.') + process.exit(1) +} +metrics.gauge( + 'web_startup', + performance.now() - beforeWaitForMongoAndGlobalBlobs, + 1, + { path: 'waitForMongoAndGlobalBlobs' } +) + const port = Settings.port || Settings.internal.web.port || 3000 const host = Settings.internal.web.host || '127.0.0.1' if (process.argv[1] === fileURLToPath(import.meta.url)) { @@ -69,42 +99,33 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { PlansLocator.ensurePlansAreSetupCorrectly() - Promise.all([ - mongodb.connectionPromise, - mongoose.connectionPromise, - HistoryManager.promises.loadGlobalBlobs(), - ]) - .then(async () => { - Server.server.listen(port, host, function () { - logger.debug(`web starting up, listening on ${host}:${port}`) - logger.debug(`${http.globalAgent.maxSockets} sockets enabled`) - // wait until the process is ready before monitoring the event loop - metrics.event_loop.monitor(logger) - }) - QueueWorkers.start() - await Modules.start() - }) - .catch(err => { - logger.fatal({ err }, 'Cannot connect to mongo. Exiting.') - process.exit(1) - }) + Server.server.listen(port, host, function () { + logger.debug(`web starting up, listening on ${host}:${port}`) + logger.debug(`${http.globalAgent.maxSockets} sockets enabled`) + // wait until the process is ready before monitoring the event loop + metrics.event_loop.monitor(logger) + + // Record metrics for the total startup time before listening on HTTP. + metrics.gauge( + 'web_startup', + performance.now() - metricsModuleImportStartTime, + 1, + { path: 'metricsModuleImportToHTTPListen' } + ) + }) + try { + QueueWorkers.start() + } catch (err) { + logger.fatal({ err }, 'failed to start queue processing') + } + try { + await Modules.start() + } catch (err) { + logger.fatal({ err }, 'failed to start web module background jobs') + } } // initialise site admin tasks -Promise.all([ - mongodb.connectionPromise, - mongoose.connectionPromise, - HistoryManager.promises.loadGlobalBlobs(), -]) - .then(() => SiteAdminHandler.initialise()) - .catch(err => { - logger.fatal({ err }, 'Cannot connect to mongo. Exiting.') - process.exit(1) - }) - -// handle SIGTERM for graceful shutdown in kubernetes -process.on('SIGTERM', function (signal) { - triggerGracefulShutdown(Server.server, signal) -}) +SiteAdminHandler.initialise() export default Server.server diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js index 77fb7ab2d3..caa6ef159d 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js +++ b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.js @@ -385,34 +385,32 @@ function _getMemberIdsWithPrivilegeLevelsFromFields( } async function _loadMembers(members) { - const limit = pLimit(3) - const results = await Promise.all( - members.map(member => - limit(async () => { - const user = await UserGetter.promises.getUser(member.id, { - _id: 1, - email: 1, - features: 1, - first_name: 1, - last_name: 1, - signUpDate: 1, - }) - if (user != null) { - const record = { - user, - privilegeLevel: member.privilegeLevel, - } - if (member.pendingEditor) { - record.pendingEditor = true - } else if (member.pendingReviewer) { - record.pendingReviewer = true - } - return record - } else { - return null - } - }) - ) - ) - return results.filter(r => r != null) + const userIds = Array.from(new Set(members.map(m => m.id))) + const users = new Map() + for (const user of await UserGetter.promises.getUsers(userIds, { + _id: 1, + email: 1, + features: 1, + first_name: 1, + last_name: 1, + signUpDate: 1, + })) { + users.set(user._id.toString(), user) + } + return members + .map(member => { + const user = users.get(member.id) + if (!user) return null + const record = { + user, + privilegeLevel: member.privilegeLevel, + } + if (member.pendingEditor) { + record.pendingEditor = true + } else if (member.pendingReviewer) { + record.pendingReviewer = true + } + return record + }) + .filter(r => r != null) } diff --git a/services/web/app/src/Features/Compile/ClsiCacheController.js b/services/web/app/src/Features/Compile/ClsiCacheController.js index 9795fd3ef2..d76f0a02bd 100644 --- a/services/web/app/src/Features/Compile/ClsiCacheController.js +++ b/services/web/app/src/Features/Compile/ClsiCacheController.js @@ -1,8 +1,7 @@ -const { NotFoundError } = require('../Errors/Errors') +const { NotFoundError, ResourceGoneError } = require('../Errors/Errors') const { fetchStreamWithResponse, RequestFailedError, - fetchJson, } = require('@overleaf/fetch-utils') const Path = require('path') const { pipeline } = require('stream/promises') @@ -110,61 +109,14 @@ async function getLatestBuildFromCache(req, res) { const userId = CompileController._getUserIdForCompile(req) try { const { - internal: { location: metaLocation, zone }, - external: { isUpToDate, allFiles }, - } = await ClsiCacheManager.getLatestBuildFromCache( - projectId, - userId, - 'output.overleaf.json' - ) + zone, + outputFiles, + compileGroup, + clsiServerId, + clsiCacheShard, + options, + } = await ClsiCacheManager.getLatestCompileResult(projectId, userId) - if (!isUpToDate) return res.sendStatus(410) - - const meta = await fetchJson(metaLocation, { - signal: AbortSignal.timeout(5 * 1000), - }) - - const [, editorId, buildId] = metaLocation.match( - /\/build\/([a-f0-9-]+?)-([a-f0-9]+-[a-f0-9]+)\// - ) - - let baseURL = `/project/${projectId}` - if (userId) { - baseURL += `/user/${userId}` - } - - const { ranges, contentId, clsiServerId, compileGroup, size, options } = - meta - - const outputFiles = allFiles - .filter( - path => path !== 'output.overleaf.json' && path !== 'output.tar.gz' - ) - .map(path => { - const f = { - url: `${baseURL}/build/${editorId}-${buildId}/output/${path}`, - downloadURL: `/download/project/${projectId}/build/${editorId}-${buildId}/output/cached/${path}`, - build: buildId, - path, - type: path.split('.').pop(), - } - if (path === 'output.pdf') { - Object.assign(f, { - size, - editorId, - }) - if (clsiServerId !== 'cache') { - // Enable PDF caching and attempt to download from VM first. - // (clsi VMs do not have the editorId in the path on disk, omit it). - Object.assign(f, { - url: `${baseURL}/build/${buildId}/output/output.pdf`, - ranges, - contentId, - }) - } - } - return f - }) let { pdfCachingMinChunkSize, pdfDownloadDomain } = await CompileController._getSplitTestOptions(req, res) pdfDownloadDomain += `/zone/${zone}` @@ -174,6 +126,7 @@ async function getLatestBuildFromCache(req, res) { outputFiles, compileGroup, clsiServerId, + clsiCacheShard, pdfDownloadDomain, pdfCachingMinChunkSize, options, @@ -181,6 +134,8 @@ async function getLatestBuildFromCache(req, res) { } catch (err) { if (err instanceof NotFoundError) { res.sendStatus(404) + } else if (err instanceof ResourceGoneError) { + res.sendStatus(410) } else { throw err } diff --git a/services/web/app/src/Features/Compile/ClsiCacheHandler.js b/services/web/app/src/Features/Compile/ClsiCacheHandler.js index 54ebd9e259..bb0414bf03 100644 --- a/services/web/app/src/Features/Compile/ClsiCacheHandler.js +++ b/services/web/app/src/Features/Compile/ClsiCacheHandler.js @@ -9,7 +9,15 @@ const Settings = require('@overleaf/settings') const OError = require('@overleaf/o-error') const { NotFoundError, InvalidNameError } = require('../Errors/Errors') +/** + * Keep in sync with validateFilename in services/clsi-cache/app/js/utils.js + * + * @param {string} filename + */ function validateFilename(filename) { + if (filename.split('/').includes('..')) { + throw new InvalidNameError('path traversal') + } if ( !( [ @@ -41,7 +49,7 @@ async function clearCache(projectId, userId) { path += '/output' await Promise.all( - Settings.apis.clsiCache.instances.map(async ({ url, zone }) => { + Settings.apis.clsiCache.instances.map(async ({ url, shard }) => { const u = new URL(url) u.pathname = path try { @@ -50,7 +58,7 @@ async function clearCache(projectId, userId) { signal: AbortSignal.timeout(15_000), }) } catch (err) { - throw OError.tag(err, 'clear clsi-cache', { url, zone }) + throw OError.tag(err, 'clear clsi-cache', { url, shard }) } }) ) @@ -64,7 +72,7 @@ async function clearCache(projectId, userId) { * @param buildId * @param filename * @param signal - * @return {Promise<{size: number, zone: string, location: string, lastModified: Date, allFiles: string[]}>} + * @return {Promise<{size: number, zone: string, shard: string, location: string, lastModified: Date, allFiles: string[]}>} */ async function getOutputFile( projectId, @@ -93,7 +101,7 @@ async function getOutputFile( * @param userId * @param filename * @param signal - * @return {Promise<{size: number, zone: string, location: string, lastModified: Date, allFiles: string[]}>} + * @return {Promise<{size: number, zone: string, shard: string, location: string, lastModified: Date, allFiles: string[]}>} */ async function getLatestOutputFile( projectId, @@ -125,7 +133,7 @@ async function getLatestOutputFile( * @param userId * @param path * @param signal - * @return {Promise<{size: number, zone: string, location: string, lastModified: Date, allFiles: string[]}>} + * @return {Promise<{size: number, zone: string, shard: string, location: string, lastModified: Date, allFiles: string[]}>} */ async function getRedirectWithFallback( projectId, @@ -135,7 +143,7 @@ async function getRedirectWithFallback( ) { // Avoid hitting the same instance first all the time. const instances = _.shuffle(Settings.apis.clsiCache.instances) - for (const { url, zone } of instances) { + for (const { url, shard } of instances) { const u = new URL(url) u.pathname = path try { @@ -145,20 +153,25 @@ async function getRedirectWithFallback( } = await fetchRedirectWithResponse(u, { signal, }) + let allFilesRaw = headers.get('X-All-Files') + if (!allFilesRaw.startsWith('[')) { + allFilesRaw = Buffer.from(allFilesRaw, 'base64url').toString() + } // Success, return the cache entry. return { location, zone: headers.get('X-Zone'), + shard: headers.get('X-Shard') || 'cache', lastModified: new Date(headers.get('X-Last-Modified')), size: parseInt(headers.get('X-Content-Length'), 10), - allFiles: JSON.parse(headers.get('X-All-Files')), + allFiles: JSON.parse(allFilesRaw), } } catch (err) { if (err instanceof RequestFailedError && err.response.status === 404) { break // No clsi-cache instance has cached something for this project/user. } logger.warn( - { err, projectId, userId, url, zone }, + { err, projectId, userId, url, shard }, 'getLatestOutputFile from clsi-cache failed' ) // This clsi-cache instance is down, try the next backend. @@ -178,18 +191,18 @@ async function getRedirectWithFallback( * @param templateId * @param templateVersionId * @param lastUpdated - * @param zone + * @param shard * @param signal * @return {Promise} */ async function prepareCacheSource( projectId, userId, - { sourceProjectId, templateId, templateVersionId, lastUpdated, zone, signal } + { sourceProjectId, templateId, templateVersionId, lastUpdated, shard, signal } ) { const url = new URL( `/project/${projectId}/user/${userId}/import-from`, - Settings.apis.clsiCache.instances.find(i => i.zone === zone).url + Settings.apis.clsiCache.instances.find(i => i.shard === shard).url ) try { await fetchNothing(url, { diff --git a/services/web/app/src/Features/Compile/ClsiCacheManager.js b/services/web/app/src/Features/Compile/ClsiCacheManager.js index 3fe4b987c5..b1ef2a46ac 100644 --- a/services/web/app/src/Features/Compile/ClsiCacheManager.js +++ b/services/web/app/src/Features/Compile/ClsiCacheManager.js @@ -1,9 +1,12 @@ -const { NotFoundError } = require('../Errors/Errors') +const _ = require('lodash') +const { NotFoundError, ResourceGoneError } = require('../Errors/Errors') const ClsiCacheHandler = require('./ClsiCacheHandler') const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler') const ProjectGetter = require('../Project/ProjectGetter') const SplitTestHandler = require('../SplitTests/SplitTestHandler') const UserGetter = require('../User/UserGetter') +const Settings = require('@overleaf/settings') +const { fetchJson, RequestFailedError } = require('@overleaf/fetch-utils') /** * Get the most recent build and metadata @@ -14,11 +17,11 @@ const UserGetter = require('../User/UserGetter') * @param userId * @param filename * @param signal - * @return {Promise<{internal: {zone: string, location: string}, external: {isUpToDate: boolean, lastUpdated: Date, size: number, allFiles: string[]}}>} + * @return {Promise<{internal: {location: string}, external: {zone: string, shard: string, isUpToDate: boolean, lastUpdated: Date, size: number, allFiles: string[]}}>} */ async function getLatestBuildFromCache(projectId, userId, filename, signal) { const [ - { location, lastModified: lastCompiled, zone, size, allFiles }, + { location, lastModified: lastCompiled, zone, shard, size, allFiles }, lastUpdatedInRedis, { lastUpdated: lastUpdatedInMongo }, ] = await Promise.all([ @@ -36,17 +39,110 @@ async function getLatestBuildFromCache(projectId, userId, filename, signal) { return { internal: { location, - zone, }, external: { isUpToDate, lastUpdated, size, allFiles, + shard, + zone, }, } } +class MetaFileExpiredError extends NotFoundError {} + +async function getLatestCompileResult(projectId, userId) { + const signal = AbortSignal.timeout(15_000) + for (let attempt = 0; attempt < 3; attempt++) { + try { + return await tryGetLatestCompileResult(projectId, userId, signal) + } catch (err) { + if (err instanceof MetaFileExpiredError) { + continue + } + throw err + } + } + throw new NotFoundError() +} + +async function tryGetLatestCompileResult(projectId, userId, signal) { + const { + internal: { location: metaLocation }, + external: { isUpToDate, allFiles, zone, shard: clsiCacheShard }, + } = await getLatestBuildFromCache( + projectId, + userId, + 'output.overleaf.json', + signal + ) + if (!isUpToDate) throw new ResourceGoneError() + + let meta + try { + meta = await fetchJson(metaLocation, { + signal: AbortSignal.timeout(5 * 1000), + }) + } catch (err) { + if (err instanceof RequestFailedError && err.response.status === 404) { + throw new MetaFileExpiredError( + 'build expired between listing and reading' + ) + } + throw err + } + + const [, editorId, buildId] = metaLocation.match( + /\/build\/([a-f0-9-]+?)-([a-f0-9]+-[a-f0-9]+)\// + ) + const { ranges, contentId, clsiServerId, compileGroup, size, options } = meta + + let baseURL = `/project/${projectId}` + if (userId) { + baseURL += `/user/${userId}` + } + + const outputFiles = allFiles + .filter(path => path !== 'output.overleaf.json' && path !== 'output.tar.gz') + .map(path => { + const f = { + url: `${baseURL}/build/${editorId}-${buildId}/output/${path}`, + downloadURL: `/download/project/${projectId}/build/${editorId}-${buildId}/output/cached/${path}`, + build: buildId, + path, + type: path.split('.').pop(), + } + if (path === 'output.pdf') { + Object.assign(f, { + size, + editorId, + }) + if (clsiServerId !== clsiCacheShard) { + // Enable PDF caching and attempt to download from VM first. + // (clsi VMs do not have the editorId in the path on disk, omit it). + Object.assign(f, { + url: `${baseURL}/build/${buildId}/output/output.pdf`, + ranges, + contentId, + }) + } + } + return f + }) + + return { + allFiles, + zone, + outputFiles, + compileGroup, + clsiServerId, + clsiCacheShard, + options, + } +} + /** * Collect metadata and prepare the clsi-cache for the given project. * @@ -64,7 +160,7 @@ async function prepareClsiCache( ) { const { variant } = await SplitTestHandler.promises.getAssignmentForUser( userId, - 'copy-clsi-cache' + 'populate-clsi-cache' ) if (variant !== 'enabled') return @@ -73,12 +169,11 @@ async function prepareClsiCache( const signal = AbortSignal.timeout(5_000) let lastUpdated - let zone = 'b' // populate template data on zone b + let shard = _.shuffle(Settings.apis.clsiCache.instances)[0].shard if (sourceProjectId) { try { ;({ - internal: { zone }, - external: { lastUpdated }, + external: { lastUpdated, shard }, } = await getLatestBuildFromCache( sourceProjectId, userId, @@ -95,7 +190,7 @@ async function prepareClsiCache( sourceProjectId, templateId, templateVersionId, - zone, + shard, lastUpdated, signal, }) @@ -107,5 +202,6 @@ async function prepareClsiCache( module.exports = { getLatestBuildFromCache, + getLatestCompileResult, prepareClsiCache, } diff --git a/services/web/app/src/Features/Compile/ClsiCookieManager.js b/services/web/app/src/Features/Compile/ClsiCookieManager.js index fc542fefaf..a1ac0741b9 100644 --- a/services/web/app/src/Features/Compile/ClsiCookieManager.js +++ b/services/web/app/src/Features/Compile/ClsiCookieManager.js @@ -1,12 +1,15 @@ const { URL, URLSearchParams } = require('url') const OError = require('@overleaf/o-error') const Settings = require('@overleaf/settings') -const request = require('request').defaults({ timeout: 30 * 1000 }) +const { + fetchNothing, + fetchStringWithResponse, + RequestFailedError, +} = require('@overleaf/fetch-utils') const RedisWrapper = require('../../infrastructure/RedisWrapper') const Cookie = require('cookie') const logger = require('@overleaf/logger') const Metrics = require('@overleaf/metrics') -const { promisifyAll } = require('@overleaf/promise-utils') const clsiCookiesEnabled = (Settings.clsiCookie?.key ?? '') !== '' @@ -16,235 +19,204 @@ if (Settings.redis.clsi_cookie_secondary != null) { rclientSecondary = RedisWrapper.client('clsi_cookie_secondary') } -module.exports = function (backendGroup) { - const cookieManager = { - buildKey(projectId, userId) { - if (backendGroup != null) { - return `clsiserver:${backendGroup}:${projectId}:${userId}` - } else { - return `clsiserver:${projectId}:${userId}` - } - }, +const ClsiCookieManagerFactory = function (backendGroup) { + function buildKey(projectId, userId) { + if (backendGroup != null) { + return `clsiserver:${backendGroup}:${projectId}:${userId}` + } else { + return `clsiserver:${projectId}:${userId}` + } + } - getServerId( - projectId, - userId, - compileGroup, - compileBackendClass, - callback - ) { - if (!clsiCookiesEnabled) { - return callback() - } - rclient.get(this.buildKey(projectId, userId), (err, serverId) => { - if (err) { - return callback(err) - } - if (serverId == null || serverId === '') { - this._populateServerIdViaRequest( - projectId, - userId, - compileGroup, - compileBackendClass, - callback - ) - } else { - callback(null, serverId) - } - }) - }, + async function getServerId( + projectId, + userId, + compileGroup, + compileBackendClass + ) { + if (!clsiCookiesEnabled) { + return + } + const serverId = await rclient.get(buildKey(projectId, userId)) - _populateServerIdViaRequest( - projectId, - userId, - compileGroup, - compileBackendClass, - callback - ) { - const u = new URL(`${Settings.apis.clsi.url}/project/${projectId}/status`) - u.search = new URLSearchParams({ + if (!serverId) { + return await cookieManager.promises._populateServerIdViaRequest( + projectId, + userId, compileGroup, - compileBackendClass, - }).toString() - request.post(u.href, (err, res, body) => { - if (err) { - OError.tag(err, 'error getting initial server id for project', { - project_id: projectId, - }) - return callback(err) - } - if (!clsiCookiesEnabled) { - return callback() - } - const serverId = this._parseServerIdFromResponse(res) - this.setServerId( - projectId, - userId, - compileGroup, - compileBackendClass, - serverId, - null, - function (err) { - if (err) { - logger.warn( - { err, projectId }, - 'error setting server id via populate request' - ) - } - callback(err, serverId) - } - ) - }) - }, - - _parseServerIdFromResponse(response) { - const cookies = Cookie.parse(response.headers['set-cookie']?.[0] || '') - return cookies?.[Settings.clsiCookie.key] - }, - - checkIsLoadSheddingEvent(clsiserverid, compileGroup, compileBackendClass) { - request.get( - { - url: `${Settings.apis.clsi.url}/instance-state`, - qs: { clsiserverid, compileGroup, compileBackendClass }, - }, - (err, res, body) => { - if (err) { - Metrics.inc('clsi-lb-switch-backend', 1, { - status: 'error', - }) - logger.warn({ err, clsiserverid }, 'cannot probe clsi VM') - return - } - const isStillRunning = - res.statusCode === 200 && body === `${clsiserverid},UP\n` - Metrics.inc('clsi-lb-switch-backend', 1, { - status: isStillRunning ? 'load-shedding' : 'cycle', - }) - } + compileBackendClass ) - }, + } else { + return serverId + } + } - _getTTLInSeconds(clsiServerId) { - return (clsiServerId || '').includes('-reg-') - ? Settings.clsiCookie.ttlInSecondsRegular - : Settings.clsiCookie.ttlInSeconds - }, - - setServerId( - projectId, - userId, + async function _populateServerIdViaRequest( + projectId, + userId, + compileGroup, + compileBackendClass + ) { + const u = new URL(`${Settings.apis.clsi.url}/project/${projectId}/status`) + u.search = new URLSearchParams({ compileGroup, compileBackendClass, - serverId, - previous, - callback - ) { - if (!clsiCookiesEnabled) { - return callback() - } - if (serverId == null) { - // We don't get a cookie back if it hasn't changed - return rclient.expire( - this.buildKey(projectId, userId), - this._getTTLInSeconds(previous), - err => callback(err) - ) - } - if (!previous) { - // Initial assignment of a user+project or after clearing cache. - Metrics.inc('clsi-lb-assign-initial-backend') - } else { - this.checkIsLoadSheddingEvent( - previous, - compileGroup, - compileBackendClass - ) - } - if (rclientSecondary != null) { - this._setServerIdInRedis( - rclientSecondary, - projectId, - userId, - serverId, - () => {} - ) - } - this._setServerIdInRedis(rclient, projectId, userId, serverId, err => - callback(err) - ) - }, - - _setServerIdInRedis(rclient, projectId, userId, serverId, callback) { - rclient.setex( - this.buildKey(projectId, userId), - this._getTTLInSeconds(serverId), - serverId, - callback - ) - }, - - clearServerId(projectId, userId, callback) { - if (!clsiCookiesEnabled) { - return callback() - } - rclient.del(this.buildKey(projectId, userId), err => { - if (err) { - // redis errors need wrapping as the instance may be shared - return callback( - new OError( - 'Failed to clear clsi persistence', - { projectId, userId }, - err - ) - ) - } else { - return callback() - } + }).toString() + let res + try { + res = await fetchNothing(u.href, { + method: 'POST', + signal: AbortSignal.timeout(30_000), }) - }, + } catch (err) { + OError.tag(err, 'error getting initial server id for project', { + project_id: projectId, + }) + throw err + } - getCookieJar( - projectId, - userId, - compileGroup, - compileBackendClass, - callback - ) { - if (!clsiCookiesEnabled) { - return callback(null, request.jar(), undefined) - } - this.getServerId( + if (!clsiCookiesEnabled) { + return + } + const serverId = cookieManager._parseServerIdFromResponse(res) + try { + await cookieManager.promises.setServerId( projectId, userId, compileGroup, compileBackendClass, - (err, serverId) => { - if (err != null) { - OError.tag(err, 'error getting server id', { - project_id: projectId, - }) - return callback(err) - } - const serverCookie = request.cookie( - `${Settings.clsiCookie.key}=${serverId}` - ) - const jar = request.jar() - jar.setCookie(serverCookie, Settings.apis.clsi.url) - callback(null, jar, serverId) + serverId, + null + ) + return serverId + } catch (err) { + logger.warn( + { err, projectId }, + 'error setting server id via populate request' + ) + throw err + } + } + + function _parseServerIdFromResponse(response) { + const cookies = Cookie.parse(response.headers['set-cookie']?.[0] || '') + return cookies?.[Settings.clsiCookie.key] + } + + async function checkIsLoadSheddingEvent( + clsiserverid, + compileGroup, + compileBackendClass + ) { + let status + try { + const params = new URLSearchParams({ + clsiserverid, + compileGroup, + compileBackendClass, + }).toString() + const { response, body } = await fetchStringWithResponse( + `${Settings.apis.clsi.url}/instance-state?${params}`, + { + method: 'GET', + signal: AbortSignal.timeout(30_000), } ) + status = + response.status === 200 && body === `${clsiserverid},UP\n` + ? 'load-shedding' + : 'cycle' + } catch (err) { + if (err instanceof RequestFailedError && err.response.status === 404) { + status = 'cycle' + } else { + status = 'error' + logger.warn({ err, clsiserverid }, 'cannot probe clsi VM') + } + } + Metrics.inc('clsi-lb-switch-backend', 1, { status }) + } + + function _getTTLInSeconds(clsiServerId) { + return (clsiServerId || '').includes('-reg-') + ? Settings.clsiCookie.ttlInSecondsRegular + : Settings.clsiCookie.ttlInSeconds + } + + async function setServerId( + projectId, + userId, + compileGroup, + compileBackendClass, + serverId, + previous + ) { + if (!clsiCookiesEnabled) { + return + } + if (serverId == null) { + // We don't get a cookie back if it hasn't changed + return await rclient.expire( + buildKey(projectId, userId), + _getTTLInSeconds(previous) + ) + } + if (!previous) { + // Initial assignment of a user+project or after clearing cache. + Metrics.inc('clsi-lb-assign-initial-backend') + } else { + await checkIsLoadSheddingEvent( + previous, + compileGroup, + compileBackendClass + ) + } + if (rclientSecondary != null) { + await _setServerIdInRedis( + rclientSecondary, + projectId, + userId, + serverId + ).catch(() => {}) + } + await _setServerIdInRedis(rclient, projectId, userId, serverId) + } + + async function _setServerIdInRedis(rclient, projectId, userId, serverId) { + await rclient.setex( + buildKey(projectId, userId), + _getTTLInSeconds(serverId), + serverId + ) + } + + async function clearServerId(projectId, userId) { + if (!clsiCookiesEnabled) { + return + } + try { + await rclient.del(buildKey(projectId, userId)) + } catch (err) { + // redis errors need wrapping as the instance may be shared + throw new OError( + 'Failed to clear clsi persistence', + { projectId, userId }, + err + ) + } + } + + const cookieManager = { + _parseServerIdFromResponse, + promises: { + getServerId, + clearServerId, + _populateServerIdViaRequest, + setServerId, }, } - cookieManager.promises = promisifyAll(cookieManager, { - without: [ - '_parseServerIdFromResponse', - 'checkIsLoadSheddingEvent', - '_getTTLInSeconds', - ], - multiResult: { - getCookieJar: ['jar', 'clsiServerId'], - }, - }) + return cookieManager } + +module.exports = ClsiCookieManagerFactory diff --git a/services/web/app/src/Features/Compile/ClsiManager.js b/services/web/app/src/Features/Compile/ClsiManager.js index 2e32aa9622..6f11297248 100644 --- a/services/web/app/src/Features/Compile/ClsiManager.js +++ b/services/web/app/src/Features/Compile/ClsiManager.js @@ -207,6 +207,7 @@ async function _sendBuiltRequest(projectId, userId, req, options, callback) { stats: compile.stats, timings: compile.timings, outputUrlPrefix: compile.outputUrlPrefix, + clsiCacheShard: compile.clsiCacheShard, } } @@ -853,6 +854,7 @@ module.exports = { 'timings', 'outputUrlPrefix', 'buildId', + 'clsiCacheShard', ]), sendExternalRequest: callbackifyMultiResult(sendExternalRequest, [ 'status', diff --git a/services/web/app/src/Features/Compile/CompileController.js b/services/web/app/src/Features/Compile/CompileController.js index 5d2bbcda3e..9d5cbf63b9 100644 --- a/services/web/app/src/Features/Compile/CompileController.js +++ b/services/web/app/src/Features/Compile/CompileController.js @@ -1,4 +1,3 @@ -let CompileController const { URL, URLSearchParams } = require('url') const { pipeline } = require('stream/promises') const { Cookie } = require('tough-cookie') @@ -17,7 +16,7 @@ const ClsiCookieManager = require('./ClsiCookieManager')( const Path = require('path') const AnalyticsManager = require('../Analytics/AnalyticsManager') const SplitTestHandler = require('../SplitTests/SplitTestHandler') -const { callbackify } = require('@overleaf/promise-utils') +const { expressify } = require('@overleaf/promise-utils') const { fetchStreamWithResponse, RequestFailedError, @@ -34,17 +33,19 @@ function getOutputFilesArchiveSpecification(projectId, userId, buildId) { const fileName = 'output.zip' return { path: fileName, - url: CompileController._getFileUrl(projectId, userId, buildId, fileName), + url: _CompileController._getFileUrl(projectId, userId, buildId, fileName), type: 'zip', } } -function getImageNameForProject(projectId, callback) { - ProjectGetter.getProject(projectId, { imageName: 1 }, (err, project) => { - if (err) return callback(err) - if (!project) return callback(new Error('project not found')) - callback(null, project.imageName) +async function getImageNameForProject(projectId) { + const project = await ProjectGetter.promises.getProject(projectId, { + imageName: 1, }) + if (!project) { + throw new Error('project not found') + } + return project.imageName } async function getPdfCachingMinChunkSize(req, res) { @@ -53,7 +54,9 @@ async function getPdfCachingMinChunkSize(req, res) { res, 'pdf-caching-min-chunk-size' ) - if (variant === 'default') return 1_000_000 + if (variant === 'default') { + return 1_000_000 + } return parseInt(variant, 10) } @@ -69,13 +72,6 @@ async function _getSplitTestOptions(req, res) { // Lookup the clsi-cache flag in the backend. // We may need to turn off the feature on a short notice, without requiring // all users to reload their editor page to disable the feature. - const { variant: compileFromClsiCacheVariant } = - await SplitTestHandler.promises.getAssignment( - editorReq, - res, - 'compile-from-clsi-cache' - ) - const compileFromClsiCache = compileFromClsiCacheVariant === 'enabled' const { variant: populateClsiCacheVariant } = await SplitTestHandler.promises.getAssignment( editorReq, @@ -83,6 +79,7 @@ async function _getSplitTestOptions(req, res) { 'populate-clsi-cache' ) const populateClsiCache = populateClsiCacheVariant === 'enabled' + const compileFromClsiCache = populateClsiCache // use same split-test const pdfDownloadDomain = Settings.pdfDownloadDomain @@ -123,10 +120,9 @@ async function _getSplitTestOptions(req, res) { pdfCachingMinChunkSize, } } -const getSplitTestOptionsCb = callbackify(_getSplitTestOptions) -module.exports = CompileController = { - compile(req, res, next) { +const _CompileController = { + async compile(req, res) { res.setTimeout(COMPILE_TIMEOUT_MS) const projectId = req.params.Project_id const isAutoCompile = !!req.query.auto_compile @@ -162,105 +158,95 @@ module.exports = CompileController = { options.incrementalCompilesEnabled = true } - getSplitTestOptionsCb(req, res, (err, splitTestOptions) => { - if (err) return next(err) - let { - compileFromClsiCache, - populateClsiCache, - enablePdfCaching, - pdfCachingMinChunkSize, - pdfDownloadDomain, - } = splitTestOptions - options.compileFromClsiCache = compileFromClsiCache - options.populateClsiCache = populateClsiCache - options.enablePdfCaching = enablePdfCaching - if (enablePdfCaching) { - options.pdfCachingMinChunkSize = pdfCachingMinChunkSize - } + let { + compileFromClsiCache, + populateClsiCache, + enablePdfCaching, + pdfCachingMinChunkSize, + pdfDownloadDomain, + } = await _getSplitTestOptions(req, res) + options.compileFromClsiCache = compileFromClsiCache + options.populateClsiCache = populateClsiCache + options.enablePdfCaching = enablePdfCaching + if (enablePdfCaching) { + options.pdfCachingMinChunkSize = pdfCachingMinChunkSize + } - CompileManager.compile( - projectId, - userId, - options, - ( - error, + const { + status, + outputFiles, + clsiServerId, + limits, + validationProblems, + stats, + timings, + outputUrlPrefix, + buildId, + clsiCacheShard, + } = await CompileManager.promises + .compile(projectId, userId, options) + .catch(error => { + Metrics.inc('compile-error') + throw error + }) + + Metrics.inc('compile-status', 1, { status }) + if (pdfDownloadDomain && outputUrlPrefix) { + pdfDownloadDomain += outputUrlPrefix + } + + if (limits) { + // For a compile request to be sent to clsi we need limits. + // If we get here without having the limits object populated, it is + // a reasonable assumption to make that nothing was compiled. + // We need to know the limits in order to make use of the events. + AnalyticsManager.recordEventForSession( + req.session, + 'compile-result-backend', + { + projectId, + ownerAnalyticsId: limits.ownerAnalyticsId, status, - outputFiles, - clsiServerId, - limits, - validationProblems, - stats, - timings, - outputUrlPrefix, - buildId - ) => { - if (error) { - Metrics.inc('compile-error') - return next(error) - } - Metrics.inc('compile-status', 1, { status }) - if (pdfDownloadDomain && outputUrlPrefix) { - pdfDownloadDomain += outputUrlPrefix - } - - if (limits) { - // For a compile request to be sent to clsi we need limits. - // If we get here without having the limits object populated, it is - // a reasonable assumption to make that nothing was compiled. - // We need to know the limits in order to make use of the events. - AnalyticsManager.recordEventForSession( - req.session, - 'compile-result-backend', - { - projectId, - ownerAnalyticsId: limits.ownerAnalyticsId, - status, - compileTime: timings?.compileE2E, - timeout: limits.timeout === 60 ? 'short' : 'long', - server: clsiServerId?.includes('-c2d-') ? 'faster' : 'normal', - isAutoCompile, - isInitialCompile: stats?.isInitialCompile === 1, - restoredClsiCache: stats?.restoredClsiCache === 1, - stopOnFirstError, - } - ) - } - - const outputFilesArchive = buildId - ? getOutputFilesArchiveSpecification(projectId, userId, buildId) - : null - - res.json({ - status, - outputFiles, - outputFilesArchive, - compileGroup: limits?.compileGroup, - clsiServerId, - validationProblems, - stats, - timings, - outputUrlPrefix, - pdfDownloadDomain, - pdfCachingMinChunkSize, - }) + compileTime: timings?.compileE2E, + timeout: limits.timeout, + server: clsiServerId?.includes('-c2d-') ? 'faster' : 'normal', + isAutoCompile, + isInitialCompile: stats?.isInitialCompile === 1, + restoredClsiCache: stats?.restoredClsiCache === 1, + stopOnFirstError, } ) + } + + const outputFilesArchive = buildId + ? getOutputFilesArchiveSpecification(projectId, userId, buildId) + : null + + res.json({ + status, + outputFiles, + outputFilesArchive, + compileGroup: limits?.compileGroup, + clsiServerId, + clsiCacheShard, + validationProblems, + stats, + timings, + outputUrlPrefix, + pdfDownloadDomain, + pdfCachingMinChunkSize, }) }, - stopCompile(req, res, next) { + async stopCompile(req, res) { const projectId = req.params.Project_id const userId = SessionManager.getLoggedInUserId(req.session) - CompileManager.stopCompile(projectId, userId, function (error) { - if (error) { - return next(error) - } - res.sendStatus(200) - }) + await CompileManager.promises.stopCompile(projectId, userId) + res.sendStatus(200) }, // Used for submissions through the public API - compileSubmission(req, res, next) { + async compileSubmission(req, res) { res.setTimeout(COMPILE_TIMEOUT_MS) const submissionId = req.params.submission_id const options = {} @@ -281,195 +267,163 @@ module.exports = CompileController = { options.compileBackendClass = Settings.apis.clsi.submissionBackendClass options.timeout = req.body?.timeout || Settings.defaultFeatures.compileTimeout - ClsiManager.sendExternalRequest( - submissionId, - req.body, - options, - function (error, status, outputFiles, clsiServerId, validationProblems) { - if (error) { - return next(error) - } - res.json({ - status, - outputFiles, - clsiServerId, - validationProblems, - }) - } - ) + const { status, outputFiles, clsiServerId, validationProblems } = + await ClsiManager.promises.sendExternalRequest( + submissionId, + req.body, + options + ) + res.json({ + status, + outputFiles, + clsiServerId, + validationProblems, + }) }, - _getSplitTestOptions, - _getUserIdForCompile(req) { if (!Settings.disablePerUserCompiles) { return SessionManager.getLoggedInUserId(req.session) } return null }, - _compileAsUser(req, callback) { - callback(null, CompileController._getUserIdForCompile(req)) - }, - _downloadAsUser(req, callback) { - callback(null, CompileController._getUserIdForCompile(req)) - }, - downloadPdf(req, res, next) { + async downloadPdf(req, res) { Metrics.inc('pdf-downloads') const projectId = req.params.Project_id - const rateLimit = function (callback) { + const rateLimit = () => pdfDownloadRateLimiter .consume(req.ip, 1, { method: 'ip' }) - .then(() => { - callback(null, true) - }) + .then(() => true) .catch(err => { if (err instanceof Error) { - callback(err) - } else { - callback(null, false) + throw err } + return false }) + + const project = await ProjectGetter.promises.getProject(projectId, { + name: 1, + }) + + res.contentType('application/pdf') + const filename = `${_CompileController._getSafeProjectName(project)}.pdf` + + if (req.query.popupDownload) { + res.setContentDisposition('attachment', { filename }) + } else { + res.setContentDisposition('inline', { filename }) } - ProjectGetter.getProject(projectId, { name: 1 }, function (err, project) { - if (err) { - return next(err) - } - res.contentType('application/pdf') - const filename = `${CompileController._getSafeProjectName(project)}.pdf` + let canContinue + try { + canContinue = await rateLimit() + } catch (err) { + logger.err({ err }, 'error checking rate limit for pdf download') + res.sendStatus(500) + return + } - if (req.query.popupDownload) { - res.setContentDisposition('attachment', { filename }) - } else { - res.setContentDisposition('inline', { filename }) - } + if (!canContinue) { + logger.debug({ projectId, ip: req.ip }, 'rate limit hit downloading pdf') + res.sendStatus(500) // should it be 429? + } else { + const userId = CompileController._getUserIdForCompile(req) - rateLimit(function (err, canContinue) { - if (err) { - logger.err({ err }, 'error checking rate limit for pdf download') - res.sendStatus(500) - } else if (!canContinue) { - logger.debug( - { projectId, ip: req.ip }, - 'rate limit hit downloading pdf' - ) - res.sendStatus(500) - } else { - CompileController._downloadAsUser(req, function (error, userId) { - if (error) { - return next(error) - } - const url = CompileController._getFileUrl( - projectId, - userId, - req.params.build_id, - 'output.pdf' - ) - CompileController.proxyToClsi( - projectId, - 'output-file', - url, - {}, - req, - res, - next - ) - }) - } - }) - }) + const url = _CompileController._getFileUrl( + projectId, + userId, + req.params.build_id, + 'output.pdf' + ) + await CompileController._proxyToClsi( + projectId, + 'output-file', + url, + {}, + req, + res + ) + } }, _getSafeProjectName(project) { return project.name.replace(/[^\p{L}\p{Nd}]/gu, '_') }, - deleteAuxFiles(req, res, next) { + async deleteAuxFiles(req, res) { const projectId = req.params.Project_id const { clsiserverid } = req.query - CompileController._compileAsUser(req, function (error, userId) { - if (error) { - return next(error) - } - CompileManager.deleteAuxFiles( - projectId, - userId, - clsiserverid, - function (error) { - if (error) { - return next(error) - } - res.sendStatus(200) - } - ) - }) + const userId = await CompileController._getUserIdForCompile(req) + await CompileManager.promises.deleteAuxFiles( + projectId, + userId, + clsiserverid + ) + res.sendStatus(200) }, // this is only used by templates, so is not called with a userId - compileAndDownloadPdf(req, res, next) { + async compileAndDownloadPdf(req, res) { const projectId = req.params.project_id - // pass userId as null, since templates are an "anonymous" compile - CompileManager.compile(projectId, null, {}, (err, _status, outputFiles) => { - if (err) { - logger.err( - { err, projectId }, - 'something went wrong compile and downloading pdf' - ) - res.sendStatus(500) - return - } - const pdf = outputFiles.find(f => f.path === 'output.pdf') - if (!pdf) { - logger.warn( - { projectId }, - 'something went wrong compile and downloading pdf: no pdf' - ) - res.sendStatus(500) - return - } - CompileController.proxyToClsi( - projectId, - 'output-file', - pdf.url, - {}, - req, - res, - next + + let outputFiles + try { + ;({ outputFiles } = await CompileManager.promises + // pass userId as null, since templates are an "anonymous" compile + .compile(projectId, null, {})) + } catch (err) { + logger.err( + { err, projectId }, + 'something went wrong compile and downloading pdf' ) - }) + res.sendStatus(500) + return + } + const pdf = outputFiles.find(f => f.path === 'output.pdf') + if (!pdf) { + logger.warn( + { projectId }, + 'something went wrong compile and downloading pdf: no pdf' + ) + res.sendStatus(500) + return + } + await CompileController._proxyToClsi( + projectId, + 'output-file', + pdf.url, + {}, + req, + res + ) }, - getFileFromClsi(req, res, next) { + async getFileFromClsi(req, res) { const projectId = req.params.Project_id - CompileController._downloadAsUser(req, function (error, userId) { - if (error) { - return next(error) - } + const userId = CompileController._getUserIdForCompile(req) - const qs = {} + const qs = {} - const url = CompileController._getFileUrl( - projectId, - userId, - req.params.build_id, - req.params.file - ) - CompileController.proxyToClsi( - projectId, - 'output-file', - url, - qs, - req, - res, - next - ) - }) + const url = _CompileController._getFileUrl( + projectId, + userId, + req.params.build_id, + req.params.file + ) + await CompileController._proxyToClsi( + projectId, + 'output-file', + url, + qs, + req, + res + ) }, - getFileFromClsiWithoutUser(req, res, next) { + async getFileFromClsiWithoutUser(req, res) { const submissionId = req.params.submission_id - const url = CompileController._getFileUrl( + const url = _CompileController._getFileUrl( submissionId, null, req.params.build_id, @@ -482,15 +436,14 @@ module.exports = CompileController = { Settings.defaultFeatures.compileGroup, compileBackendClass: Settings.apis.clsi.submissionBackendClass, } - CompileController.proxyToClsiWithLimits( + await CompileController._proxyToClsiWithLimits( submissionId, 'output-file', url, {}, limits, req, - res, - next + res ) }, @@ -518,51 +471,42 @@ module.exports = CompileController = { return `${path}/${action}` }, - proxySyncPdf(req, res, next) { + async proxySyncPdf(req, res) { const projectId = req.params.Project_id const { page, h, v, editorId, buildId } = req.query if (!page?.match(/^\d+$/)) { - return next(new Error('invalid page parameter')) + throw new Error('invalid page parameter') } if (!h?.match(/^-?\d+\.\d+$/)) { - return next(new Error('invalid h parameter')) + throw new Error('invalid h parameter') } if (!v?.match(/^-?\d+\.\d+$/)) { - return next(new Error('invalid v parameter')) + throw new Error('invalid v parameter') } // whether this request is going to a per-user container - CompileController._compileAsUser(req, function (error, userId) { - if (error) { - return next(error) - } - getImageNameForProject(projectId, (error, imageName) => { - if (error) return next(error) + const userId = CompileController._getUserIdForCompile(req) - getSplitTestOptionsCb(req, res, (error, splitTestOptions) => { - if (error) return next(error) - const { compileFromClsiCache } = splitTestOptions + const imageName = await getImageNameForProject(projectId) - const url = CompileController._getUrl(projectId, userId, 'sync/pdf') + const { compileFromClsiCache } = await _getSplitTestOptions(req, res) - CompileController.proxyToClsi( - projectId, - 'sync-to-pdf', - url, - { page, h, v, imageName, editorId, buildId, compileFromClsiCache }, - req, - res, - next - ) - }) - }) - }) + const url = _CompileController._getUrl(projectId, userId, 'sync/pdf') + + await CompileController._proxyToClsi( + projectId, + 'sync-to-pdf', + url, + { page, h, v, imageName, editorId, buildId, compileFromClsiCache }, + req, + res + ) }, - proxySyncCode(req, res, next) { + async proxySyncCode(req, res) { const projectId = req.params.Project_id const { file, line, column, editorId, buildId } = req.query if (file == null) { - return next(new Error('missing file parameter')) + throw new Error('missing file parameter') } // Check that we are dealing with a simple file path (this is not // strictly needed because synctex uses this parameter as a label @@ -571,225 +515,226 @@ module.exports = CompileController = { // allow those by replacing /./ with / const testPath = file.replace('/./', '/') if (Path.resolve('/', testPath) !== `/${testPath}`) { - return next(new Error('invalid file parameter')) + throw new Error('invalid file parameter') } if (!line?.match(/^\d+$/)) { - return next(new Error('invalid line parameter')) + throw new Error('invalid line parameter') } if (!column?.match(/^\d+$/)) { - return next(new Error('invalid column parameter')) + throw new Error('invalid column parameter') } - CompileController._compileAsUser(req, function (error, userId) { - if (error) { - return next(error) - } - getImageNameForProject(projectId, (error, imageName) => { - if (error) return next(error) + const userId = CompileController._getUserIdForCompile(req) - getSplitTestOptionsCb(req, res, (error, splitTestOptions) => { - if (error) return next(error) - const { compileFromClsiCache } = splitTestOptions + const imageName = await getImageNameForProject(projectId) - const url = CompileController._getUrl(projectId, userId, 'sync/code') - CompileController.proxyToClsi( - projectId, - 'sync-to-code', - url, - { - file, - line, - column, - imageName, - editorId, - buildId, - compileFromClsiCache, - }, - req, - res, - next - ) - }) - }) - }) - }, + const { compileFromClsiCache } = await _getSplitTestOptions(req, res) - proxyToClsi(projectId, action, url, qs, req, res, next) { - CompileManager.getProjectCompileLimits(projectId, function (error, limits) { - if (error) { - return next(error) - } - CompileController.proxyToClsiWithLimits( - projectId, - action, - url, - qs, - limits, - req, - res, - next - ) - }) - }, - - proxyToClsiWithLimits(projectId, action, url, qs, limits, req, res, next) { - _getPersistenceOptions( - req, + const url = _CompileController._getUrl(projectId, userId, 'sync/code') + await CompileController._proxyToClsi( projectId, - limits.compileGroup, - limits.compileBackendClass, - (err, persistenceOptions) => { - if (err) { - OError.tag(err, 'error getting cookie jar for clsi request') - return next(err) - } - url = new URL(`${Settings.apis.clsi.url}${url}`) - url.search = new URLSearchParams({ - ...persistenceOptions.qs, - ...qs, - }).toString() - const timer = new Metrics.Timer( - 'proxy_to_clsi', - 1, - { path: action }, - [0, 100, 1000, 2000, 5000, 10000, 15000, 20000, 30000, 45000, 60000] - ) - Metrics.inc('proxy_to_clsi', 1, { path: action, status: 'start' }) - fetchStreamWithResponse(url.href, { - method: req.method, - signal: AbortSignal.timeout(60 * 1000), - headers: persistenceOptions.headers, - }) - .then(({ stream, response }) => { - if (req.destroyed) { - // The client has disconnected already, avoid trying to write into the broken connection. - Metrics.inc('proxy_to_clsi', 1, { - path: action, - status: 'req-aborted', - }) - return - } - Metrics.inc('proxy_to_clsi', 1, { - path: action, - status: response.status, - }) - - for (const key of ['Content-Length', 'Content-Type']) { - if (response.headers.has(key)) { - res.setHeader(key, response.headers.get(key)) - } - } - res.writeHead(response.status) - return pipeline(stream, res) - }) - .then(() => { - timer.labels.status = 'success' - timer.done() - }) - .catch(err => { - const reqAborted = Boolean(req.destroyed) - const status = reqAborted ? 'req-aborted-late' : 'error' - timer.labels.status = status - const duration = timer.done() - Metrics.inc('proxy_to_clsi', 1, { path: action, status }) - const streamingStarted = Boolean(res.headersSent) - if (!streamingStarted) { - if (err instanceof RequestFailedError) { - res.sendStatus(err.response.status) - } else { - res.sendStatus(500) - } - } - if ( - streamingStarted && - reqAborted && - err.code === 'ERR_STREAM_PREMATURE_CLOSE' - ) { - // Ignore noisy spurious error - return - } - if ( - err instanceof RequestFailedError && - ['sync-to-code', 'sync-to-pdf', 'output-file'].includes(action) - ) { - // Ignore noisy error - // https://github.com/overleaf/internal/issues/15201 - return - } - logger.warn( - { - err, - projectId, - url, - action, - reqAborted, - streamingStarted, - duration, - }, - 'CLSI proxy error' - ) - }) - } + 'sync-to-code', + url, + { + file, + line, + column, + imageName, + editorId, + buildId, + compileFromClsiCache, + }, + req, + res ) }, - wordCount(req, res, next) { + async _proxyToClsi(projectId, action, url, qs, req, res) { + const limits = + await CompileManager.promises.getProjectCompileLimits(projectId) + if ( + qs?.compileFromClsiCache && + !['alpha', 'priority'].includes(limits.compileGroup) + ) { + qs.compileFromClsiCache = false + } + return CompileController._proxyToClsiWithLimits( + projectId, + action, + url, + qs, + limits, + req, + res + ) + }, + + async _proxyToClsiWithLimits(projectId, action, url, qs, limits, req, res) { + const persistenceOptions = await _getPersistenceOptions( + req, + projectId, + limits.compileGroup, + limits.compileBackendClass + ).catch(err => { + OError.tag(err, 'error getting cookie jar for clsi request') + throw err + }) + + url = new URL(`${Settings.apis.clsi.url}${url}`) + url.search = new URLSearchParams({ + ...persistenceOptions.qs, + ...qs, + }).toString() + const timer = new Metrics.Timer( + 'proxy_to_clsi', + 1, + { path: action }, + [0, 100, 1000, 2000, 5000, 10000, 15000, 20000, 30000, 45000, 60000] + ) + Metrics.inc('proxy_to_clsi', 1, { path: action, status: 'start' }) + try { + const { stream, response } = await fetchStreamWithResponse(url.href, { + method: req.method, + signal: AbortSignal.timeout(60 * 1000), + headers: persistenceOptions.headers, + }) + if (req.destroyed) { + // The client has disconnected already, avoid trying to write into the broken connection. + Metrics.inc('proxy_to_clsi', 1, { + path: action, + status: 'req-aborted', + }) + return + } + Metrics.inc('proxy_to_clsi', 1, { + path: action, + status: response.status, + }) + + for (const key of ['Content-Length', 'Content-Type']) { + if (response.headers.has(key)) { + res.setHeader(key, response.headers.get(key)) + } + } + res.writeHead(response.status) + await pipeline(stream, res) + timer.labels.status = 'success' + timer.done() + } catch (err) { + const reqAborted = Boolean(req.destroyed) + const status = reqAborted ? 'req-aborted-late' : 'error' + timer.labels.status = status + const duration = timer.done() + Metrics.inc('proxy_to_clsi', 1, { path: action, status }) + const streamingStarted = Boolean(res.headersSent) + if (!streamingStarted) { + if (err instanceof RequestFailedError) { + res.sendStatus(err.response.status) + } else { + res.sendStatus(500) + } + } + if ( + streamingStarted && + reqAborted && + err.code === 'ERR_STREAM_PREMATURE_CLOSE' + ) { + // Ignore noisy spurious error + return + } + if ( + err instanceof RequestFailedError && + ['sync-to-code', 'sync-to-pdf', 'output-file'].includes(action) + ) { + // Ignore noisy error + // https://github.com/overleaf/internal/issues/15201 + return + } + logger.warn( + { + err, + projectId, + url, + action, + reqAborted, + streamingStarted, + duration, + }, + 'CLSI proxy error' + ) + } + }, + + async wordCount(req, res) { const projectId = req.params.Project_id const file = req.query.file || false const { clsiserverid } = req.query - CompileController._compileAsUser(req, function (error, userId) { - if (error) { - return next(error) - } - CompileManager.wordCount( - projectId, - userId, - file, - clsiserverid, - function (error, body) { - if (error) { - return next(error) - } - res.json(body) - } - ) - }) + const userId = CompileController._getUserIdForCompile(req) + + const body = await CompileManager.promises.wordCount( + projectId, + userId, + file, + clsiserverid + ) + res.json(body) }, } -function _getPersistenceOptions( +async function _getPersistenceOptions( req, projectId, compileGroup, - compileBackendClass, - callback + compileBackendClass ) { const { clsiserverid } = req.query const userId = SessionManager.getLoggedInUserId(req) if (clsiserverid && typeof clsiserverid === 'string') { - callback(null, { + return { qs: { clsiserverid, compileGroup, compileBackendClass }, headers: {}, - }) + } } else { - ClsiCookieManager.getServerId( + const clsiServerId = await ClsiCookieManager.promises.getServerId( projectId, userId, compileGroup, - compileBackendClass, - (err, clsiServerId) => { - if (err) return callback(err) - callback(null, { - qs: { compileGroup, compileBackendClass }, - headers: clsiServerId - ? { - Cookie: new Cookie({ - key: Settings.clsiCookie.key, - value: clsiServerId, - }).cookieString(), - } - : {}, - }) - } + compileBackendClass ) + return { + qs: { compileGroup, compileBackendClass }, + headers: clsiServerId + ? { + Cookie: new Cookie({ + key: Settings.clsiCookie.key, + value: clsiServerId, + }).cookieString(), + } + : {}, + } } } + +const CompileController = { + COMPILE_TIMEOUT_MS, + compile: expressify(_CompileController.compile), + stopCompile: expressify(_CompileController.stopCompile), + compileSubmission: expressify(_CompileController.compileSubmission), + downloadPdf: expressify(_CompileController.downloadPdf), // + compileAndDownloadPdf: expressify(_CompileController.compileAndDownloadPdf), + deleteAuxFiles: expressify(_CompileController.deleteAuxFiles), + getFileFromClsi: expressify(_CompileController.getFileFromClsi), + getFileFromClsiWithoutUser: expressify( + _CompileController.getFileFromClsiWithoutUser + ), + proxySyncPdf: expressify(_CompileController.proxySyncPdf), + proxySyncCode: expressify(_CompileController.proxySyncCode), + wordCount: expressify(_CompileController.wordCount), + + _getSafeProjectName: _CompileController._getSafeProjectName, + _getSplitTestOptions, + _getUserIdForCompile: _CompileController._getUserIdForCompile, + _proxyToClsi: _CompileController._proxyToClsi, + _proxyToClsiWithLimits: _CompileController._proxyToClsiWithLimits, +} + +module.exports = CompileController diff --git a/services/web/app/src/Features/Compile/CompileManager.js b/services/web/app/src/Features/Compile/CompileManager.js index 9b5404865a..974f573815 100644 --- a/services/web/app/src/Features/Compile/CompileManager.js +++ b/services/web/app/src/Features/Compile/CompileManager.js @@ -86,6 +86,7 @@ async function compile(projectId, userId, options = {}) { timings, outputUrlPrefix, buildId, + clsiCacheShard, } = await ClsiManager.promises.sendRequest(projectId, compileAsUser, options) return { @@ -98,6 +99,7 @@ async function compile(projectId, userId, options = {}) { timings, outputUrlPrefix, buildId, + clsiCacheShard, } } @@ -184,6 +186,7 @@ module.exports = CompileManager = { 'timings', 'outputUrlPrefix', 'buildId', + 'clsiCacheShard', ]), stopCompile: callbackify(stopCompile), diff --git a/services/web/app/src/Features/Cooldown/CooldownManager.js b/services/web/app/src/Features/Cooldown/CooldownManager.js index 67bdc98838..8c6b708c07 100644 --- a/services/web/app/src/Features/Cooldown/CooldownManager.js +++ b/services/web/app/src/Features/Cooldown/CooldownManager.js @@ -1,24 +1,11 @@ -/* eslint-disable - n/handle-callback-err, - max-len, -*/ -// 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 - * DS207: Consider shorter variations of null checks - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -let CooldownManager const RedisWrapper = require('../../infrastructure/RedisWrapper') const rclient = RedisWrapper.client('cooldown') const logger = require('@overleaf/logger') -const { promisifyAll } = require('@overleaf/promise-utils') +const { promisify } = require('@overleaf/promise-utils') const COOLDOWN_IN_SECONDS = 60 * 10 -module.exports = CooldownManager = { +const CooldownManager = { _buildKey(projectId) { return `Cooldown:{${projectId}}` }, @@ -31,7 +18,7 @@ module.exports = CooldownManager = { { projectId }, `[Cooldown] putting project on cooldown for ${COOLDOWN_IN_SECONDS} seconds` ) - return rclient.set( + rclient.set( CooldownManager._buildKey(projectId), '1', 'EX', @@ -44,18 +31,18 @@ module.exports = CooldownManager = { if (callback == null) { callback = function () {} } - return rclient.get( - CooldownManager._buildKey(projectId), - function (err, result) { - if (err != null) { - return callback(err) - } - return callback(null, result === '1') + rclient.get(CooldownManager._buildKey(projectId), function (err, result) { + if (err != null) { + return callback(err) } - ) + return callback(null, result === '1') + }) }, } -module.exports.promises = promisifyAll(module.exports, { - without: ['_buildKey'], -}) +CooldownManager.promises = { + putProjectOnCooldown: promisify(CooldownManager.putProjectOnCooldown), + isProjectOnCooldown: promisify(CooldownManager.isProjectOnCooldown), +} + +module.exports = CooldownManager diff --git a/services/web/app/src/Features/Documents/DocumentController.mjs b/services/web/app/src/Features/Documents/DocumentController.mjs index 6886414291..6998c0b36a 100644 --- a/services/web/app/src/Features/Documents/DocumentController.mjs +++ b/services/web/app/src/Features/Documents/DocumentController.mjs @@ -52,6 +52,11 @@ async function getDocument(req, res) { 'overleaf.history.rangesSupportEnabled', false ) + const otMigrationStage = _.get( + project, + 'overleaf.history.otMigrationStage', + 0 + ) // all projects are now migrated to Full Project History, keeping the field // for API compatibility @@ -65,6 +70,7 @@ async function getDocument(req, res) { projectHistoryId, projectHistoryType, historyRangesSupport, + otMigrationStage, resolvedCommentIds, }) } diff --git a/services/web/app/src/Features/Editor/EditorHttpController.js b/services/web/app/src/Features/Editor/EditorHttpController.js index 45c9b2427a..8128a95b26 100644 --- a/services/web/app/src/Features/Editor/EditorHttpController.js +++ b/services/web/app/src/Features/Editor/EditorHttpController.js @@ -10,8 +10,6 @@ const CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler') const PrivilegeLevels = require('../Authorization/PrivilegeLevels') const SessionManager = require('../Authentication/SessionManager') const Errors = require('../Errors/Errors') -const DocstoreManager = require('../Docstore/DocstoreManager') -const logger = require('@overleaf/logger') const { expressify } = require('@overleaf/promise-utils') const Settings = require('@overleaf/settings') @@ -77,20 +75,6 @@ async function _buildJoinProjectView(req, projectId, userId) { if (project == null) { throw new Errors.NotFoundError('project not found') } - let deletedDocsFromDocstore = [] - try { - deletedDocsFromDocstore = - await DocstoreManager.promises.getAllDeletedDocs(projectId) - } catch (err) { - // The query in docstore is not optimized at this time and fails for - // projects with many very large, deleted documents. - // Not serving the user with deletedDocs from docstore may cause a minor - // UI issue with deleted files that are no longer available for restore. - logger.warn( - { err, projectId }, - 'soft-failure when fetching deletedDocs from docstore' - ) - } const members = await CollaboratorsGetter.promises.getInvitedMembersWithPrivilegeLevels( projectId @@ -126,8 +110,7 @@ async function _buildJoinProjectView(req, projectId, userId) { project: ProjectEditorHandler.buildProjectModelView( project, members, - invites, - deletedDocsFromDocstore + invites ), privilegeLevel, isTokenMember, diff --git a/services/web/app/src/Features/Email/EmailBuilder.js b/services/web/app/src/Features/Email/EmailBuilder.js index 0e46b1b8ea..01565201ac 100644 --- a/services/web/app/src/Features/Email/EmailBuilder.js +++ b/services/web/app/src/Features/Email/EmailBuilder.js @@ -222,10 +222,10 @@ templates.passwordResetRequested = ctaTemplate({ templates.confirmEmail = ctaTemplate({ subject() { - return `Confirm Email - ${settings.appName}` + return `Confirm email - ${settings.appName}` }, title() { - return 'Confirm Email' + return 'Confirm email' }, message(opts) { return [ @@ -239,7 +239,7 @@ templates.confirmEmail = ctaTemplate({ ] }, ctaText() { - return 'Confirm Email' + return 'Confirm email' }, ctaURL(opts) { return opts.confirmEmailUrl @@ -861,7 +861,7 @@ templates.SAMLDataCleared = ctaTemplate({ ] }, ctaText(opts) { - return 'Update my Emails and Affiliations' + return 'Update my Emails and affiliations' }, ctaURL(opts) { return `${settings.siteUrl}/user/settings` @@ -907,7 +907,7 @@ templates.welcome = ctaTemplate({ ] }, ctaText() { - return 'Confirm Email' + return 'Confirm email' }, ctaURL(opts) { return opts.confirmEmailUrl diff --git a/services/web/app/src/Features/Errors/Errors.js b/services/web/app/src/Features/Errors/Errors.js index 8a21b6042a..4b1b7dd064 100644 --- a/services/web/app/src/Features/Errors/Errors.js +++ b/services/web/app/src/Features/Errors/Errors.js @@ -41,6 +41,8 @@ class ServiceNotConfiguredError extends BackwardCompatibleError {} class TooManyRequestsError extends BackwardCompatibleError {} +class ResourceGoneError extends BackwardCompatibleError {} + class DuplicateNameError extends OError {} class InvalidNameError extends BackwardCompatibleError {} @@ -300,6 +302,18 @@ class NonDeletableEntityError extends OError { } } +class FoundConnectedClientsError extends OError { + constructor(nConnectedClients) { + super(`found ${nConnectedClients} remaining connected clients`) + } +} + +class ConcurrentLoadingOfDocsDetectedError extends OError { + constructor() { + super('concurrent loading of docs detected') + } +} + module.exports = { OError, BackwardCompatibleError, @@ -307,6 +321,7 @@ module.exports = { ForbiddenError, ServiceNotConfiguredError, TooManyRequestsError, + ResourceGoneError, DuplicateNameError, InvalidNameError, UnsupportedFileTypeError, @@ -356,4 +371,6 @@ module.exports = { InvalidEmailError, InvalidInstitutionalEmailError, NonDeletableEntityError, + FoundConnectedClientsError, + ConcurrentLoadingOfDocsDetectedError, } diff --git a/services/web/app/src/Features/History/HistoryController.js b/services/web/app/src/Features/History/HistoryController.js index a0f0183f44..be2a44c39e 100644 --- a/services/web/app/src/Features/History/HistoryController.js +++ b/services/web/app/src/Features/History/HistoryController.js @@ -465,9 +465,37 @@ async function getLatestHistory(req, res, next) { async function getChanges(req, res, next) { const projectId = req.params.project_id - const since = req.query.since - const changes = await HistoryManager.promises.getChanges(projectId, { since }) - res.json(changes) + let since = req.query.since + // TODO: Transition flag; remove after a while + const paginated = req.query.paginated === 'true' + + if (paginated) { + const changes = await HistoryManager.promises.getChanges(projectId, { + since, + }) + return res.json(changes) + } else { + // TODO: Remove this code path after a while + let hasMore = true + const allChanges = [] + while (hasMore) { + const response = await HistoryManager.promises.getChanges(projectId, { + since, + }) + + let changes + if (Array.isArray(response)) { + changes = response + hasMore = false + } else { + changes = response.changes + hasMore = response.hasMore + since += changes.length + } + allChanges.push(...changes) + } + return res.json(allChanges) + } } function isPrematureClose(err) { diff --git a/services/web/app/src/Features/History/HistoryManager.js b/services/web/app/src/Features/History/HistoryManager.js index 6e40907d1b..fe9e6e86a7 100644 --- a/services/web/app/src/Features/History/HistoryManager.js +++ b/services/web/app/src/Features/History/HistoryManager.js @@ -417,8 +417,11 @@ function _userView(user) { return { first_name: firstName, last_name: lastName, email, id: _id } } +const loadGlobalBlobsPromise = loadGlobalBlobs() + module.exports = { getBlobLocation, + loadGlobalBlobsPromise, initializeProject: callbackify(initializeProject), flushProject: callbackify(flushProject), resyncProject: callbackify(resyncProject), @@ -432,7 +435,6 @@ module.exports = { getLatestHistory: callbackify(getLatestHistory), getChanges: callbackify(getChanges), promises: { - loadGlobalBlobs, initializeProject, flushProject, resyncProject, diff --git a/services/web/app/src/Features/History/HistoryOTMigration.mjs b/services/web/app/src/Features/History/HistoryOTMigration.mjs new file mode 100644 index 0000000000..a55fb3bbfd --- /dev/null +++ b/services/web/app/src/Features/History/HistoryOTMigration.mjs @@ -0,0 +1,56 @@ +import ProjectGetter from '../Project/ProjectGetter.js' +import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js' +import HistoryManager from '../History/HistoryManager.js' +import * as RealTimeHandler from '../References/RealTime/RealTimeHandler.mjs' +import ProjectOptionsHandler from '../Project/ProjectOptionsHandler.js' +import { + NotFoundError, + FoundConnectedClientsError, + ConcurrentLoadingOfDocsDetectedError, +} from '../Errors/Errors.js' + +async function ensureNoConnectedClients(projectId) { + const n = await RealTimeHandler.countConnectedClients(projectId) + if (n > 0) throw new FoundConnectedClientsError(n) +} + +/** + * @param {string} projectId + * @param {number} nextStage + * @return {Promise<{otMigrationStage: number}>} + */ +export async function advanceOTMigrationStage(projectId, nextStage) { + const project = await ProjectGetter.promises.getProject(projectId, { + overleaf: true, + }) + if (!project) throw new NotFoundError() + const { otMigrationStage } = project?.overleaf?.history || {} + if (otMigrationStage >= nextStage) return { otMigrationStage } + + // NOTE: For the single connected client case, we could emit a pub/sub event here asking any (inactive) client without pending edits to disconnect briefly. + // e.g. EditorRealTimeController.emitToRoom(projectId, 'attempt-history-ot-migration') + + // Ensure we can perform the hard migration + await ensureNoConnectedClients(projectId) + + // Flush ahead of migrating to keep the time under lock down. + await DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete(projectId) + // Avoid mixing update types + await HistoryManager.promises.flushProject(projectId) + + // Obtain lock + if (!(await DocumentUpdaterHandler.promises.blockProject(projectId))) { + throw new ConcurrentLoadingOfDocsDetectedError() + } + + try { + // Perform the mongo update and tell caller about the latest stage. + return await ProjectOptionsHandler.promises.setOTMigrationStage( + projectId, + nextStage + ) + } finally { + // Unlock again (The lock will expire after 30s otherwise) + await DocumentUpdaterHandler.promises.unblockProject(projectId) + } +} diff --git a/services/web/app/src/Features/InactiveData/InactiveProjectManager.js b/services/web/app/src/Features/InactiveData/InactiveProjectManager.js index 818fe70c08..54bd81a500 100644 --- a/services/web/app/src/Features/InactiveData/InactiveProjectManager.js +++ b/services/web/app/src/Features/InactiveData/InactiveProjectManager.js @@ -11,6 +11,27 @@ const { callbackifyAll } = require('@overleaf/promise-utils') const Metrics = require('@overleaf/metrics') const MILISECONDS_IN_DAY = 86400000 + +function findInactiveProjects(limit, daysOld) { + const oldProjectDate = new Date() - MILISECONDS_IN_DAY * daysOld + try { + // use $not $gt to catch non-opened projects where lastOpened is null + // return a cursor instead of executing the query + return Project.find({ + lastOpened: { $not: { $gt: oldProjectDate } }, + }) + .where('active') + .equals(true) + .select(['_id', 'lastOpened']) + .limit(limit) + .read(READ_PREFERENCE_SECONDARY) + .cursor() + } catch (err) { + logger.err({ err }, 'could not get projects for deactivating') + throw err // Re-throw the error to be handled by the caller + } +} + const InactiveProjectManager = { async reactivateProjectIfRequired(projectId) { let project @@ -53,30 +74,13 @@ const InactiveProjectManager = { if (daysOld == null) { daysOld = 360 } - const oldProjectDate = new Date() - MILISECONDS_IN_DAY * daysOld - let projects - try { - // use $not $gt to catch non-opened projects where lastOpened is null - projects = await Project.find({ - lastOpened: { $not: { $gt: oldProjectDate } }, - }) - .where('active') - .equals(true) - .select('_id') - .limit(limit) - .read(READ_PREFERENCE_SECONDARY) - .exec() - } catch (err) { - logger.err({ err }, 'could not get projects for deactivating') - } + logger.debug('deactivating projects') - logger.debug( - { numberOfProjects: projects && projects.length }, - 'deactivating projects' - ) + const processedProjects = [] - for (const project of projects) { + for await (const project of findInactiveProjects(limit, daysOld)) { + processedProjects.push(project) try { await InactiveProjectManager.deactivateProject(project._id) } catch (err) { @@ -87,7 +91,12 @@ const InactiveProjectManager = { } } - return projects + logger.debug( + { numberOfProjects: processedProjects.length }, + 'finished deactivating projects' + ) + + return processedProjects }, async deactivateProject(projectId) { @@ -126,4 +135,5 @@ const InactiveProjectManager = { module.exports = { ...callbackifyAll(InactiveProjectManager), promises: InactiveProjectManager, + findInactiveProjects, } diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index f033436fdd..160914db81 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -15,7 +15,6 @@ const metrics = require('@overleaf/metrics') const { User } = require('../../models/User') const SubscriptionLocator = require('../Subscription/SubscriptionLocator') const LimitationsManager = require('../Subscription/LimitationsManager') -const FeaturesHelper = require('../Subscription/FeaturesHelper') const Settings = require('@overleaf/settings') const AuthorizationManager = require('../Authorization/AuthorizationManager') const InactiveProjectManager = require('../InactiveData/InactiveProjectManager') @@ -336,10 +335,8 @@ const _ProjectController = { const splitTests = [ 'compile-log-events', 'external-socket-heartbeat', - 'full-project-search', 'null-test-share-modal', - 'fall-back-to-clsi-cache', - 'initial-compile-from-clsi-cache', + 'populate-clsi-cache', 'pdf-caching-cached-url-lookup', 'pdf-caching-mode', 'pdf-caching-prefetch-large', @@ -354,7 +351,6 @@ const _ProjectController = { 'editor-redesign', 'paywall-change-compile-timeout', 'overleaf-assist-bundle', - 'wf-feature-rebrand', 'word-count-client', 'editor-popup-ux-survey', ].filter(Boolean) @@ -755,16 +751,7 @@ const _ProjectController = { let fullFeatureSet = user?.features if (!anonymous) { - // generate users feature set including features added, or overriden via modules - const moduleFeatures = - (await Modules.promises.hooks.fire( - 'getModuleProvidedFeatures', - userId - )) || [] - fullFeatureSet = FeaturesHelper.computeFeatureSet([ - user.features, - ...moduleFeatures, - ]) + fullFeatureSet = await UserGetter.promises.getUserFeatures(userId) } const isPaywallChangeCompileTimeoutEnabled = @@ -951,9 +938,17 @@ const _ProjectController = { const annualPrice = Settings.localizedAddOnsPricing[currency][plan].annual const monthlyPrice = Settings.localizedAddOnsPricing[currency][plan].monthly + const annualDividedByTwelve = + Settings.localizedAddOnsPricing[currency][plan].annualDividedByTwelve plansData[plan] = { annual: formatCurrency(annualPrice, currency, locale, true), + annualDividedByTwelve: formatCurrency( + annualDividedByTwelve, + currency, + locale, + true + ), monthly: formatCurrency(monthlyPrice, currency, locale, true), } }) diff --git a/services/web/app/src/Features/Project/ProjectEditorHandler.js b/services/web/app/src/Features/Project/ProjectEditorHandler.js index a85e8b5764..05e5beba09 100644 --- a/services/web/app/src/Features/Project/ProjectEditorHandler.js +++ b/services/web/app/src/Features/Project/ProjectEditorHandler.js @@ -3,23 +3,11 @@ const _ = require('lodash') const Path = require('path') const Features = require('../../infrastructure/Features') -function mergeDeletedDocs(a, b) { - const docIdsInA = new Set(a.map(doc => doc._id.toString())) - return a.concat(b.filter(doc => !docIdsInA.has(doc._id.toString()))) -} - module.exports = ProjectEditorHandler = { trackChangesAvailable: false, - buildProjectModelView(project, members, invites, deletedDocsFromDocstore) { + buildProjectModelView(project, members, invites) { let owner, ownerFeatures - if (!Array.isArray(project.deletedDocs)) { - project.deletedDocs = [] - } - project.deletedDocs.forEach(doc => { - // The frontend does not use this field. - delete doc.deletedAt - }) const result = { _id: project._id, name: project.name, @@ -32,10 +20,6 @@ module.exports = ProjectEditorHandler = { description: project.description, spellCheckLanguage: project.spellCheckLanguage, deletedByExternalDataSource: project.deletedByExternalDataSource || false, - deletedDocs: mergeDeletedDocs( - project.deletedDocs, - deletedDocsFromDocstore - ), members: [], invites: this.buildInvitesView(invites), imageName: diff --git a/services/web/app/src/Features/Project/ProjectListController.mjs b/services/web/app/src/Features/Project/ProjectListController.mjs index 61131ec617..c62396e153 100644 --- a/services/web/app/src/Features/Project/ProjectListController.mjs +++ b/services/web/app/src/Features/Project/ProjectListController.mjs @@ -21,12 +21,10 @@ import { OError, V1ConnectionError } from '../Errors/Errors.js' import { User } from '../../models/User.js' import UserPrimaryEmailCheckHandler from '../User/UserPrimaryEmailCheckHandler.js' import UserController from '../User/UserController.js' -import LimitationsManager from '../Subscription/LimitationsManager.js' 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 SubscriptionLocator from '../Subscription/SubscriptionLocator.js' import TutorialHandler from '../Tutorial/TutorialHandler.js' /** @@ -102,6 +100,8 @@ async function projectListPage(req, res, next) { // - undefined - when there's no "saas" feature or couldn't get subscription data // - object - the subscription data object let usersBestSubscription + let usersIndividualSubscription + let usersGroupSubscriptions = [] let survey let userIsMemberOfGroupSubscription = false let groupSubscriptionsPendingEnrollment = [] @@ -132,10 +132,13 @@ async function projectListPage(req, res, next) { await SplitTestSessionHandler.promises.sessionMaintenance(req, user) try { - usersBestSubscription = - await SubscriptionViewModelBuilder.promises.getBestSubscription({ - _id: userId, - }) + ;({ + bestSubscription: usersBestSubscription, + individualSubscription: usersIndividualSubscription, + memberGroupSubscriptions: usersGroupSubscriptions, + } = await SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + { _id: userId } + )) } catch (error) { logger.err( { err: error, userId }, @@ -143,14 +146,11 @@ async function projectListPage(req, res, next) { ) } try { - const { isMember, subscriptions } = - await LimitationsManager.promises.userIsMemberOfGroupSubscription(user) - - userIsMemberOfGroupSubscription = isMember + userIsMemberOfGroupSubscription = usersGroupSubscriptions?.length > 0 // TODO use helper function if (!user.enrollment?.managedBy) { - groupSubscriptionsPendingEnrollment = subscriptions.filter( + groupSubscriptionsPendingEnrollment = usersGroupSubscriptions.filter( subscription => subscription.groupPlan && subscription.managedUsersEnabled ) @@ -314,18 +314,9 @@ async function projectListPage(req, res, next) { delete req.session.saml } - function fakeDelay() { - return new Promise(resolve => { - setTimeout(() => resolve(undefined), 0) - }) - } - - const prefetchedProjectsBlob = await Promise.race([ - projectsBlobPending, - fakeDelay(), - ]) + const prefetchedProjectsBlob = await projectsBlobPending Metrics.inc('project-list-prefetch-projects', 1, { - status: prefetchedProjectsBlob ? 'success' : 'too-slow', + status: prefetchedProjectsBlob ? 'success' : 'error', }) // in v2 add notifications for matching university IPs @@ -400,13 +391,10 @@ async function projectListPage(req, res, next) { let hasIndividualRecurlySubscription = false try { - const individualSubscription = - await SubscriptionLocator.promises.getUsersSubscription(userId) - hasIndividualRecurlySubscription = - individualSubscription?.groupPlan === false && - individualSubscription?.recurlyStatus?.state !== 'canceled' && - individualSubscription?.recurlySubscription_id !== '' + usersIndividualSubscription?.groupPlan === false && + usersIndividualSubscription?.recurlyStatus?.state !== 'canceled' && + usersIndividualSubscription?.recurlySubscription_id !== '' } catch (error) { logger.error({ err: error }, 'Failed to get individual subscription') } diff --git a/services/web/app/src/Features/Project/ProjectOptionsHandler.js b/services/web/app/src/Features/Project/ProjectOptionsHandler.js index 5ca89ce145..c0c11c396c 100644 --- a/services/web/app/src/Features/Project/ProjectOptionsHandler.js +++ b/services/web/app/src/Features/Project/ProjectOptionsHandler.js @@ -2,6 +2,8 @@ const { Project } = require('../../models/Project') const settings = require('@overleaf/settings') const { callbackify } = require('util') const { db, ObjectId } = require('../../infrastructure/mongodb') +const Errors = require('../Errors/Errors') +const { ReturnDocument } = require('mongodb-legacy') const safeCompilers = ['xelatex', 'pdflatex', 'latex', 'lualatex'] const ProjectOptionsHandler = { @@ -73,6 +75,21 @@ const ProjectOptionsHandler = { // because rangesSupportEnabled is not part of the schema? return db.projects.updateOne(conditions, update) }, + + async setOTMigrationStage(projectId, nextStage) { + const project = await db.projects.findOneAndUpdate( + { _id: new ObjectId(projectId) }, + // Use $max to ensure that we never downgrade the migration stage. + { $max: { 'overleaf.history.otMigrationStage': nextStage } }, + { + returnDocument: ReturnDocument.AFTER, + projection: { 'overleaf.history.otMigrationStage': 1 }, + } + ) + if (!project) throw new Errors.NotFoundError('project does not exist') + const { otMigrationStage } = project.overleaf.history + return { otMigrationStage } + }, } module.exports = { diff --git a/services/web/app/src/Features/References/RealTime/RealTimeHandler.mjs b/services/web/app/src/Features/References/RealTime/RealTimeHandler.mjs new file mode 100644 index 0000000000..3c797a9003 --- /dev/null +++ b/services/web/app/src/Features/References/RealTime/RealTimeHandler.mjs @@ -0,0 +1,9 @@ +import Settings from '@overleaf/settings' +import { fetchJson } from '@overleaf/fetch-utils' + +export async function countConnectedClients(projectId) { + const url = new URL(Settings.apis.realTime.url) + url.pathname = `/project/${projectId}/count-connected-clients` + const { nConnectedClients } = await fetchJson(url) + return nConnectedClients +} diff --git a/services/web/app/src/Features/StaticPages/StaticPagesRouter.mjs b/services/web/app/src/Features/StaticPages/StaticPagesRouter.mjs index b81e25c7cd..60350505a6 100644 --- a/services/web/app/src/Features/StaticPages/StaticPagesRouter.mjs +++ b/services/web/app/src/Features/StaticPages/StaticPagesRouter.mjs @@ -22,11 +22,6 @@ export default { HomeController.externalPage('planned_maintenance', 'Planned Maintenance') ) - webRouter.get( - '/track-changes-and-comments-in-latex', - HomeController.externalPage('review-features-page', 'Review features') - ) - webRouter.get('/university', UniversityController.getIndexPage) return webRouter.get('/university/*', UniversityController.getPage) }, diff --git a/services/web/app/src/Features/Subscription/Errors.js b/services/web/app/src/Features/Subscription/Errors.js index 53ecf7ba12..cbcd0014f7 100644 --- a/services/web/app/src/Features/Subscription/Errors.js +++ b/services/web/app/src/Features/Subscription/Errors.js @@ -24,6 +24,8 @@ class InactiveError extends OError {} class SubtotalLimitExceededError extends OError {} +class HasPastDueInvoiceError extends OError {} + module.exports = { RecurlyTransactionError, DuplicateAddOnError, @@ -33,4 +35,5 @@ module.exports = { PendingChangeError, InactiveError, SubtotalLimitExceededError, + HasPastDueInvoiceError, } diff --git a/services/web/app/src/Features/Subscription/FeaturesUpdater.js b/services/web/app/src/Features/Subscription/FeaturesUpdater.js index eff88d45fb..a8c27f705f 100644 --- a/services/web/app/src/Features/Subscription/FeaturesUpdater.js +++ b/services/web/app/src/Features/Subscription/FeaturesUpdater.js @@ -197,14 +197,6 @@ async function doSyncFromV1(v1UserId) { return refreshFeatures(user._id, 'sync-v1') } -async function hasFeaturesViaWritefull(userId) { - const user = await UserGetter.promises.getUser(userId, { - _id: 1, - writefull: 1, - }) - return Boolean(user?.writefull?.isPremium) -} - module.exports = { featuresEpochIsCurrent, computeFeatures: callbackify(computeFeatures), @@ -217,12 +209,10 @@ module.exports = { 'featuresChanged', ]), scheduleRefreshFeatures: callbackify(scheduleRefreshFeatures), - hasFeaturesViaWritefull: callbackify(hasFeaturesViaWritefull), promises: { computeFeatures, refreshFeatures, scheduleRefreshFeatures, doSyncFromV1, - hasFeaturesViaWritefull, }, } diff --git a/services/web/app/src/Features/Subscription/PaymentProviderEntities.js b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js index 8cc15f6f2e..f6a8af4aa5 100644 --- a/services/web/app/src/Features/Subscription/PaymentProviderEntities.js +++ b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js @@ -30,6 +30,7 @@ class PaymentProviderSubscription { * @param {Date} props.periodStart * @param {Date} props.periodEnd * @param {string} props.collectionMethod + * @param {number} [props.netTerms] * @param {string} [props.poNumber] * @param {string} [props.termsAndConditions] * @param {PaymentProviderSubscriptionChange} [props.pendingChange] @@ -55,6 +56,7 @@ class PaymentProviderSubscription { this.periodStart = props.periodStart this.periodEnd = props.periodEnd this.collectionMethod = props.collectionMethod + this.netTerms = props.netTerms ?? 0 this.poNumber = props.poNumber ?? '' this.termsAndConditions = props.termsAndConditions ?? '' this.pendingChange = props.pendingChange ?? null @@ -85,6 +87,15 @@ class PaymentProviderSubscription { return isStandaloneAiAddOnPlanCode(this.planCode) } + /** + * Returns whether this subscription is a group subscription + * + * @return {boolean} + */ + isGroupSubscription() { + return isGroupPlanCode(this.planCode) + } + /** * Returns whether this subcription will have the given add-on next billing * period. @@ -541,6 +552,15 @@ function isStandaloneAiAddOnPlanCode(planCode) { return STANDALONE_AI_ADD_ON_CODES.includes(planCode) } +/** + * Returns whether the given plan code is a group plan + * + * @param {string} planCode + */ +function isGroupPlanCode(planCode) { + return planCode.includes('group') +} + /** * Returns whether subscription change will have have the ai bundle once the change is processed * @@ -573,6 +593,7 @@ module.exports = { PaymentProviderPlan, PaymentProviderCoupon, PaymentProviderAccount, + isGroupPlanCode, isStandaloneAiAddOnPlanCode, subscriptionChangeIsAiAssistUpgrade, PaymentProviderImmediateCharge, diff --git a/services/web/app/src/Features/Subscription/PlansLocator.js b/services/web/app/src/Features/Subscription/PlansLocator.js index 937d2d3ccb..24343e1109 100644 --- a/services/web/app/src/Features/Subscription/PlansLocator.js +++ b/services/web/app/src/Features/Subscription/PlansLocator.js @@ -34,6 +34,12 @@ const recurlyPlanCodeToStripeLookupKey = { 'student-annual': 'student_annual', student: 'student_monthly', student_free_trial_7_days: 'student_monthly', + group_professional: 'group_professional_enterprise', + group_professional_educational: 'group_professional_educational', + group_collaborator: 'group_standard_enterprise', + group_collaborator_educational: 'group_standard_educational', + assistant_annual: 'error_assist_annual', + assistant: 'error_assist_monthly', } /** @@ -46,24 +52,28 @@ function mapRecurlyPlanCodeToStripeLookupKey(recurlyPlanCode) { } const recurlyPlanCodeToPlanTypeAndPeriod = { - collaborator: { planType: 'standard', period: 'monthly' }, - collaborator_free_trial_7_days: { planType: 'standard', period: 'monthly' }, - 'collaborator-annual': { planType: 'standard', period: 'annual' }, - professional: { planType: 'professional', period: 'monthly' }, + collaborator: { planType: 'individual', period: 'monthly' }, + collaborator_free_trial_7_days: { planType: 'individual', period: 'monthly' }, + 'collaborator-annual': { planType: 'individual', period: 'annual' }, + professional: { planType: 'individual', period: 'monthly' }, professional_free_trial_7_days: { - planType: 'professional', + planType: 'individual', period: 'monthly', }, - 'professional-annual': { planType: 'professional', period: 'annual' }, + 'professional-annual': { planType: 'individual', period: 'annual' }, student: { planType: 'student', period: 'monthly' }, student_free_trial_7_days: { planType: 'student', period: 'monthly' }, 'student-annual': { planType: 'student', period: 'annual' }, + group_professional: { planType: 'group', period: 'annual' }, + group_professional_educational: { planType: 'group', period: 'annual' }, + group_collaborator: { planType: 'group', period: 'annual' }, + group_collaborator_educational: { planType: 'group', period: 'annual' }, } /** * * @param {RecurlyPlanCode} recurlyPlanCode - * @returns {{ planType: 'standard' | 'professional' | 'student', period: 'annual' | 'monthly'}} + * @returns {{ planType: 'individual' | 'group' | 'student', period: 'annual' | 'monthly'}} */ function getPlanTypeAndPeriodFromRecurlyPlanCode(recurlyPlanCode) { return recurlyPlanCodeToPlanTypeAndPeriod[recurlyPlanCode] diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index f5f2e5f31f..fdb3b023e6 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -384,25 +384,6 @@ async function getPlan(planCode) { return planFromApi(plan) } -/** - * Get the country code for given user - * - * @param {string} userId - * @return {Promise} - */ -async function getCountryCode(userId) { - const account = await client.getAccount(`code-${userId}`) - const countryCode = account.address?.country - - if (!countryCode) { - throw new OError('Country code not found', { - userId, - }) - } - - return countryCode -} - function subscriptionIsCanceledOrExpired(subscription) { const state = subscription?.recurlyStatus?.state return state === 'canceled' || state === 'expired' @@ -467,6 +448,7 @@ function subscriptionFromApi(apiSubscription) { apiSubscription.currentPeriodStartedAt == null || apiSubscription.currentPeriodEndsAt == null || apiSubscription.collectionMethod == null || + apiSubscription.netTerms == null || // The values below could be null initially if the subscription has never updated !('poNumber' in apiSubscription) || !('termsAndConditions' in apiSubscription) @@ -491,6 +473,7 @@ function subscriptionFromApi(apiSubscription) { periodStart: apiSubscription.currentPeriodStartedAt, periodEnd: apiSubscription.currentPeriodEndsAt, collectionMethod: apiSubscription.collectionMethod, + netTerms: apiSubscription.netTerms ?? 0, poNumber: apiSubscription.poNumber ?? '', termsAndConditions: apiSubscription.termsAndConditions ?? '', service: 'recurly', @@ -720,7 +703,6 @@ module.exports = { getPaymentMethod: callbackify(getPaymentMethod), getAddOn: callbackify(getAddOn), getPlan: callbackify(getPlan), - getCountryCode: callbackify(getCountryCode), subscriptionIsCanceledOrExpired, pauseSubscriptionByUuid: callbackify(pauseSubscriptionByUuid), resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid), @@ -744,6 +726,5 @@ module.exports = { getPaymentMethod, getAddOn, getPlan, - getCountryCode, }, } diff --git a/services/web/app/src/Features/Subscription/RecurlyEventHandler.js b/services/web/app/src/Features/Subscription/RecurlyEventHandler.js index d97d57ecba..e0d2531239 100644 --- a/services/web/app/src/Features/Subscription/RecurlyEventHandler.js +++ b/services/web/app/src/Features/Subscription/RecurlyEventHandler.js @@ -66,6 +66,7 @@ async function _sendSubscriptionResumedEvent(userId, eventData) { { plan_code: planCode, subscriptionId, + payment_provider: 'recurly', } ) AnalyticsManager.setUserPropertyForUserInBackground( @@ -87,6 +88,7 @@ async function _sendSubscriptionPausedEvent(userId, eventData) { pause_length: pauseLength, plan_code: planCode, subscriptionId, + payment_provider: 'recurly', } ) AnalyticsManager.setUserPropertyForUserInBackground( @@ -108,6 +110,7 @@ async function _sendSubscriptionStartedEvent(userId, eventData) { is_trial: isTrial, has_ai_add_on: hasAiAddOn, subscriptionId, + payment_provider: 'recurly', } ) AnalyticsManager.setUserPropertyForUserInBackground( @@ -154,6 +157,7 @@ async function _sendSubscriptionUpdatedEvent(userId, eventData) { is_trial: isTrial, has_ai_add_on: hasAiAddOn, subscriptionId, + payment_provider: 'recurly', } ) AnalyticsManager.setUserPropertyForUserInBackground( @@ -185,6 +189,7 @@ async function _sendSubscriptionCancelledEvent(userId, eventData) { is_trial: isTrial, has_ai_add_on: hasAiAddOn, subscriptionId, + payment_provider: 'recurly', } ) AnalyticsManager.setUserPropertyForUserInBackground( @@ -211,6 +216,7 @@ async function _sendSubscriptionExpiredEvent(userId, eventData) { is_trial: isTrial, has_ai_add_on: hasAiAddOn, subscriptionId, + payment_provider: 'recurly', } ) AnalyticsManager.setUserPropertyForUserInBackground( @@ -242,6 +248,7 @@ async function _sendSubscriptionRenewedEvent(userId, eventData) { is_trial: isTrial, has_ai_add_on: hasAiAddOn, subscriptionId, + payment_provider: 'recurly', } ) AnalyticsManager.setUserPropertyForUserInBackground( @@ -272,6 +279,7 @@ async function _sendSubscriptionReactivatedEvent(userId, eventData) { quantity, has_ai_add_on: hasAiAddOn, subscriptionId, + payment_provider: 'recurly', } ) AnalyticsManager.setUserPropertyForUserInBackground( @@ -318,6 +326,7 @@ async function _sendInvoicePaidEvent(userId, eventData) { taxInCents, country, collectionMethod, + payment_provider: 'recurly', ...subscriptionIds, } ) diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 885784d10d..db278b23c0 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -25,6 +25,8 @@ const RecurlyClient = require('./RecurlyClient') const { AI_ADD_ON_CODE } = require('./PaymentProviderEntities') const PlansLocator = require('./PlansLocator') const PaymentProviderEntities = require('./PaymentProviderEntities') +const { User } = require('../../models/User') +const UserGetter = require('../User/UserGetter') /** * @import { SubscriptionChangeDescription } from '../../../../types/subscription/subscription-change-preview' @@ -85,7 +87,7 @@ async function userSubscriptionPage(req, res) { const groupPlansDataForDash = formatGroupPlansDataForDash() - // display the Group Settings button only to admins of group subscriptions with either/or the Managed Users or Group SSO feature available + // display the Group settings button only to admins of group subscriptions with either/or the Managed Users or Group SSO feature available let groupSettingsEnabledFor try { const managedGroups = await async.filter( @@ -153,9 +155,10 @@ async function userSubscriptionPage(req, res) { 'Failed to list groups with group settings enabled for advertising' ) } - - const hasAiAssistViaWritefull = - await FeaturesUpdater.promises.hasFeaturesViaWritefull(user._id) + const { + isPremium: hasAiAssistViaWritefull, + premiumSource: aiAssistViaWritefullSource, + } = await UserGetter.promises.getWritefullData(user._id) const data = { title: 'your_subscription', @@ -180,13 +183,16 @@ async function userSubscriptionPage(req, res) { isManagedAccount: !!req.managedBy, userRestrictions: Array.from(req.userRestrictions || []), hasAiAssistViaWritefull, + aiAssistViaWritefullSource, } res.render('subscriptions/dashboard-react', data) } async function successfulSubscription(req, res) { const user = SessionManager.getSessionUser(req.session) - + if (!user) { + throw new Error('User is not logged in') + } const { personalSubscription } = await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( user, @@ -198,11 +204,23 @@ async function successfulSubscription(req, res) { if (!personalSubscription) { res.redirect('/user/subscription/plans') } else { + const userInDb = await User.findById(user._id, { + _id: 1, + features: 1, + }) + + if (!userInDb) { + throw new Error('User not found') + } + res.render('subscriptions/successful-subscription-react', { title: 'thank_you', personalSubscription, postCheckoutRedirect, - user, + user: { + _id: user._id, + features: userInDb.features, + }, }) } } @@ -320,15 +338,19 @@ async function previewAddonPurchase(req, res) { return HttpErrorHandler.notFound(req, res, `Unknown add-on: ${addOnCode}`) } - const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId) + /** @type {PaymentMethod[]} */ + const paymentMethod = await Modules.promises.hooks.fire( + 'getPaymentMethod', + userId + ) let subscriptionChange try { subscriptionChange = await SubscriptionHandler.promises.previewAddonPurchase(userId, addOnCode) - const hasAiAssistViaWritefull = - await FeaturesUpdater.promises.hasFeaturesViaWritefull(userId) + const { isPremium: hasAiAssistViaWritefull } = + await UserGetter.promises.getWritefullData(userId) const isAiUpgrade = PaymentProviderEntities.subscriptionChangeIsAiAssistUpgrade( subscriptionChange @@ -361,7 +383,13 @@ async function previewAddonPurchase(req, res) { }, }, subscriptionChange, - paymentMethod + paymentMethod[0] + ) + + await SplitTestHandler.promises.getAssignment( + req, + res, + 'overleaf-assist-bundle' ) res.render('subscriptions/preview-change', { @@ -387,7 +415,6 @@ async function purchaseAddon(req, res, next) { addOnCode, quantity ) - return res.sendStatus(200) } catch (err) { if (err instanceof DuplicateAddOnError) { HttpErrorHandler.badRequest( @@ -406,6 +433,14 @@ async function purchaseAddon(req, res, next) { return next(err) } } + + try { + await FeaturesUpdater.promises.refreshFeatures(user._id, 'add-on-purchase') + } catch (err) { + logger.error({ err }, 'Failed to refresh features after add-on purchase') + } + + return res.sendStatus(200) } async function removeAddon(req, res, next) { @@ -446,6 +481,7 @@ async function previewSubscription(req, res, next) { if (!planCode) { return HttpErrorHandler.notFound(req, res, 'Missing plan code') } + // TODO: use PaymentService to fetch plan information const plan = await RecurlyClient.promises.getPlan(planCode) const userId = SessionManager.getLoggedInUserId(req.session) const subscriptionChange = @@ -453,14 +489,18 @@ async function previewSubscription(req, res, next) { userId, planCode ) - const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId) + /** @type {PaymentMethod[]} */ + const paymentMethod = await Modules.promises.hooks.fire( + 'getPaymentMethod', + userId + ) const changePreview = makeChangePreview( { type: 'premium-subscription', plan: { code: plan.code, name: plan.name }, }, subscriptionChange, - paymentMethod + paymentMethod[0] ) res.render('subscriptions/preview-change', { changePreview }) @@ -740,6 +780,7 @@ function makeChangePreview( currency: subscription.currency, immediateCharge: { ...subscriptionChange.immediateCharge }, paymentMethod: paymentMethod?.toString(), + netTerms: subscription.netTerms, nextPlan: { annual: nextPlan.annual ?? false, }, diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs index 6ce552ec75..ce1207cded 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs @@ -18,6 +18,7 @@ import { PendingChangeError, InactiveError, SubtotalLimitExceededError, + HasPastDueInvoiceError, } from './Errors.js' import RecurlyClient from './RecurlyClient.js' @@ -142,6 +143,9 @@ async function addSeatsToGroupSubscription(req, res) { await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive( subscription ) + await SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice( + subscription + ) const { variant: flexibleLicensingForManuallyBilledSubscriptionsVariant } = await SplitTestHandler.promises.getAssignment( @@ -183,7 +187,11 @@ async function addSeatsToGroupSubscription(req, res) { ) } - if (error instanceof PendingChangeError || error instanceof InactiveError) { + if ( + error instanceof PendingChangeError || + error instanceof InactiveError || + error instanceof HasPastDueInvoiceError + ) { return res.redirect('/user/subscription') } @@ -216,7 +224,8 @@ async function previewAddSeatsSubscriptionChange(req, res) { error instanceof MissingBillingInfoError || error instanceof ManuallyCollectedError || error instanceof PendingChangeError || - error instanceof InactiveError + error instanceof InactiveError || + error instanceof HasPastDueInvoiceError ) { return res.status(422).end() } @@ -258,7 +267,8 @@ async function createAddSeatsSubscriptionChange(req, res) { error instanceof MissingBillingInfoError || error instanceof ManuallyCollectedError || error instanceof PendingChangeError || - error instanceof InactiveError + error instanceof InactiveError || + error instanceof HasPastDueInvoiceError ) { return res.status(422).end() } @@ -395,6 +405,12 @@ async function manuallyCollectedSubscription(req, res) { const subscription = await SubscriptionLocator.promises.getUsersSubscription(userId) + await SplitTestHandler.promises.getAssignment( + req, + res, + 'flexible-group-licensing-for-manually-billed-subscriptions' + ) + res.render('subscriptions/manually-collected-subscription', { groupName: subscription.teamName, }) diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js index b92ce807f6..5772946b8a 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js @@ -17,6 +17,7 @@ const { ManuallyCollectedError, PendingChangeError, InactiveError, + HasPastDueInvoiceError, } = require('./Errors') const EmailHelper = require('../Helpers/EmailHelper') const { InvalidEmailError } = require('../Errors/Errors') @@ -103,6 +104,22 @@ async function ensureSubscriptionHasNoPendingChanges(recurlySubscription) { } } +async function ensureSubscriptionHasNoPastDueInvoice(subscription) { + const [paymentRecord] = await Modules.promises.hooks.fire( + 'getPaymentFromRecord', + subscription + ) + + if (paymentRecord.account.hasPastDueInvoice) { + throw new HasPastDueInvoiceError( + 'This subscription has a past due invoice', + { + subscriptionId: subscription._id.toString(), + } + ) + } +} + async function getUsersGroupSubscriptionDetails(userId) { const subscription = await SubscriptionLocator.promises.getUsersSubscription(userId) @@ -144,6 +161,7 @@ async function _addSeatsSubscriptionChange(userId, adding) { await ensureSubscriptionIsActive(subscription) await ensureSubscriptionHasNoPendingChanges(recurlySubscription) await checkBillingInfoExistence(recurlySubscription, userId) + await ensureSubscriptionHasNoPastDueInvoice(subscription) const currentAddonQuantity = recurlySubscription.addOns.find( @@ -259,10 +277,9 @@ async function updateSubscriptionPaymentTerms( recurlySubscription, poNumber ) { - const countryCode = await RecurlyClient.promises.getCountryCode(userId) const [termsAndConditions] = await Modules.promises.hooks.fire( 'generateTermsAndConditions', - { countryCode, poNumber } + { currency: recurlySubscription.currency, poNumber } ) const updateRequest = poNumber @@ -464,6 +481,9 @@ module.exports = { ensureSubscriptionHasNoPendingChanges: callbackify( ensureSubscriptionHasNoPendingChanges ), + ensureSubscriptionHasNoPastDueInvoice: callbackify( + ensureSubscriptionHasNoPastDueInvoice + ), getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup), isUserPartOfGroup: callbackify(isUserPartOfGroup), getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview), @@ -477,6 +497,7 @@ module.exports = { ensureSubscriptionIsActive, ensureSubscriptionCollectionMethodIsNotManual, ensureSubscriptionHasNoPendingChanges, + ensureSubscriptionHasNoPastDueInvoice, getTotalConfirmedUsersInGroup, isUserPartOfGroup, getUsersGroupSubscriptionDetails, diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js index 9cff487aec..39a44f305f 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js @@ -73,19 +73,19 @@ async function createSubscription(user, subscriptionDetails, recurlyTokenIds) { * @return {Promise} */ async function previewSubscriptionChange(userId, planCode) { - const subscription = await getSubscriptionForUser(userId) - const changeRequest = subscription?.getRequestForPlanChange(planCode) - const change = - await RecurlyClient.promises.previewSubscriptionChange(changeRequest) - return change + const change = await Modules.promises.hooks.fire( + 'previewSubscriptionChange', + userId, + planCode + ) + return change[0] } /** * @param user * @param planCode - * @param couponCode */ -async function updateSubscription(user, planCode, couponCode) { +async function updateSubscription(user, planCode) { let hasSubscription = false let subscription @@ -102,30 +102,18 @@ async function updateSubscription(user, planCode, couponCode) { if ( !hasSubscription || subscription == null || - subscription.recurlySubscription_id == null + (subscription.recurlySubscription_id == null && + subscription.paymentProvider?.subscriptionId == null) ) { return } - const recurlySubscriptionId = subscription.recurlySubscription_id - if (couponCode) { - const usersSubscription = await RecurlyWrapper.promises.getSubscription( - recurlySubscriptionId, - { includeAccount: true } - ) - - await RecurlyWrapper.promises.redeemCoupon( - usersSubscription.account.account_code, - couponCode - ) - } - - const recurlySubscription = await RecurlyClient.promises.getSubscription( - recurlySubscriptionId + await Modules.promises.hooks.fire( + 'updatePaidSubscription', + subscription, + planCode, + user._id ) - const changeRequest = recurlySubscription.getRequestForPlanChange(planCode) - await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest) - await syncSubscription({ uuid: recurlySubscriptionId }, user._id) } /** @@ -136,8 +124,9 @@ async function cancelPendingSubscriptionChange(user) { await LimitationsManager.promises.userHasSubscription(user) if (hasSubscription && subscription != null) { - await RecurlyClient.promises.removeSubscriptionChangeByUuid( - subscription.recurlySubscription_id + await Modules.promises.hooks.fire( + 'cancelPendingPaidSubscriptionChange', + subscription ) } } @@ -273,24 +262,12 @@ async function extendTrial(subscription, daysToExend) { * @return {Promise} */ async function previewAddonPurchase(userId, addOnCode) { - const subscription = await getSubscriptionForUser(userId) - - try { - await RecurlyClient.promises.getAddOn(subscription.planCode, addOnCode) - } catch (err) { - if (err instanceof recurly.errors.NotFoundError) { - throw new NotFoundError({ - message: 'Add-on not found', - info: { addOnCode }, - }) - } - throw err - } - - const changeRequest = subscription.getRequestForAddOnPurchase(addOnCode) - const change = - await RecurlyClient.promises.previewSubscriptionChange(changeRequest) - return change + const change = await Modules.promises.hooks.fire( + 'previewAddOnPurchase', + userId, + addOnCode + ) + return change[0] } /** diff --git a/services/web/app/src/Features/Subscription/SubscriptionLocator.js b/services/web/app/src/Features/Subscription/SubscriptionLocator.js index ac0fa5918a..8526ad0fb2 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionLocator.js +++ b/services/web/app/src/Features/Subscription/SubscriptionLocator.js @@ -121,9 +121,11 @@ const SubscriptionLocator = { async hasAiAssist(userOrId) { const userId = SubscriptionLocator._getUserId(userOrId) const subscription = await Subscription.findOne({ admin_id: userId }).exec() + // todo: as opposed to recurlyEntities which use addon.code, subscription model uses addon.addOnCode + // which we hope to align via https://github.com/overleaf/internal/issues/25494 return Boolean( isStandaloneAiAddOnPlanCode(subscription?.planCode) || - subscription?.addOns?.some(addOn => addOn.code === AI_ADD_ON_CODE) + subscription?.addOns?.some(addOn => addOn.addOnCode === AI_ADD_ON_CODE) ) }, diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs index 54523b0004..154a1882b2 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs @@ -19,11 +19,12 @@ const subscriptionRateLimiter = new RateLimiter('subscription', { }) const MAX_NUMBER_OF_USERS = 20 +const MAX_NUMBER_OF_PO_NUMBER_CHARACTERS = 50 const addSeatsValidateSchema = { body: Joi.object({ adding: Joi.number().integer().min(1).max(MAX_NUMBER_OF_USERS).required(), - poNumber: Joi.string(), + poNumber: Joi.string().max(MAX_NUMBER_OF_PO_NUMBER_CHARACTERS), }), } diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js index 129463dcf0..441d9c2c9b 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js +++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js @@ -24,6 +24,7 @@ const Modules = require('../../infrastructure/Modules') /** * @import { Subscription } from "../../../../types/project/dashboard/subscription" + * @import { Subscription as DBSubscription } from "../../models/Subscription" */ function buildHostedLink(type) { @@ -282,6 +283,18 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { isEligibleForGroupPlan: paymentRecord.subscription.service === 'recurly' && !isInTrial, } + + const isMonthlyCollaboratorPlan = + personalSubscription.planCode.includes('collaborator') && + !personalSubscription.planCode.includes('ann') && + !personalSubscription.plan.groupPlan + personalSubscription.payment.isEligibleForDowngradeUpsell = + !personalSubscription.payment.pausedAt && + !personalSubscription.payment.remainingPauseCycles && + isMonthlyCollaboratorPlan && + !isInTrial && + paymentRecord.subscription.service === 'recurly' + if (paymentRecord.subscription.pendingChange) { const pendingPlanCode = paymentRecord.subscription.pendingChange.nextPlanCode @@ -366,6 +379,15 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { * @returns {Promise} */ async function getBestSubscription(user) { + const { bestSubscription } = await getUsersSubscriptionDetails(user) + return bestSubscription +} + +/** + * @param {{_id: string}} user + * @returns {Promise<{bestSubscription:Subscription,individualSubscription:DBSubscription|null,memberGroupSubscriptions:DBSubscription[]}>} + */ +async function getUsersSubscriptionDetails(user) { let [ individualSubscription, memberGroupSubscriptions, @@ -452,7 +474,7 @@ async function getBestSubscription(user) { } } } - return bestSubscription + return { bestSubscription, individualSubscription, memberGroupSubscriptions } } function buildPlansList(currentPlan) { @@ -587,5 +609,6 @@ module.exports = { promises: { buildUsersSubscriptionViewModel, getBestSubscription, + getUsersSubscriptionDetails, }, } diff --git a/services/web/app/src/Features/TokenAccess/TokenAccessController.mjs b/services/web/app/src/Features/TokenAccess/TokenAccessController.mjs index ff4b93e88c..276524dd02 100644 --- a/services/web/app/src/Features/TokenAccess/TokenAccessController.mjs +++ b/services/web/app/src/Features/TokenAccess/TokenAccessController.mjs @@ -66,7 +66,7 @@ async function _handleV1Project(token, userId) { userId ) // This should not happen anymore, but it does show - // a nice "contact support" message, so it can stay + // a nice "contact Support" message, so it can stay if (!docInfo) { return { v1Import: { status: 'cannotImport' } } } diff --git a/services/web/app/src/Features/Tutorial/TutorialController.mjs b/services/web/app/src/Features/Tutorial/TutorialController.mjs index a5cdf8d478..e5fc940b34 100644 --- a/services/web/app/src/Features/Tutorial/TutorialController.mjs +++ b/services/web/app/src/Features/Tutorial/TutorialController.mjs @@ -13,6 +13,8 @@ const VALID_KEYS = [ 'us-gov-banner-fedramp', 'full-project-search-promo', 'editor-popup-ux-survey', + 'wf-features-moved', + 'review-mode', ] async function completeTutorial(req, res, next) { diff --git a/services/web/app/src/Features/User/UserGetter.js b/services/web/app/src/Features/User/UserGetter.js index 34d758add6..bce4568880 100644 --- a/services/web/app/src/Features/User/UserGetter.js +++ b/services/web/app/src/Features/User/UserGetter.js @@ -11,6 +11,8 @@ const Errors = require('../Errors/Errors') const Features = require('../../infrastructure/Features') const { User } = require('../../models/User') const { normalizeQuery, normalizeMultiQuery } = require('../Helpers/Mongo') +const Modules = require('../../infrastructure/Modules') +const FeaturesHelper = require('../Subscription/FeaturesHelper') function _lastDayToReconfirm(emailData, institutionData) { const globalReconfirmPeriod = settings.reconfirmNotificationDays @@ -95,6 +97,21 @@ async function getUserFullEmails(userId) { ) } +async function getUserFeatures(userId) { + const user = await UserGetter.promises.getUser(userId, { + features: 1, + }) + if (!user) { + throw new Error('User not Found') + } + + const moduleFeatures = + (await Modules.promises.hooks.fire('getModuleProvidedFeatures', userId)) || + [] + + return FeaturesHelper.computeFeatureSet([user.features, ...moduleFeatures]) +} + async function getUserConfirmedEmails(userId) { const user = await UserGetter.promises.getUser(userId, { emails: 1, @@ -120,6 +137,19 @@ async function getSsoUsersAtInstitution(institutionId, projection) { ).exec() } +async function getWritefullData(userId) { + const user = await UserGetter.promises.getUser(userId, { + writefull: 1, + }) + if (!user) { + throw new Error('user not found') + } + return { + isPremium: Boolean(user?.writefull?.isPremium), + premiumSource: user?.writefull?.premiumSource || null, + } +} + const UserGetter = { getSsoUsersAtInstitution: callbackify(getSsoUsersAtInstitution), @@ -136,13 +166,7 @@ const UserGetter = { } }, - getUserFeatures(userId, callback) { - this.getUser(userId, { features: 1 }, (error, user) => { - if (error) return callback(error) - if (!user) return callback(new Errors.NotFoundError('user not found')) - callback(null, user.features) - }) - }, + getUserFeatures: callbackify(getUserFeatures), getUserEmail(userId, callback) { this.getUser(userId, { email: 1 }, (error, user) => @@ -260,6 +284,7 @@ const UserGetter = { callback(error) }) }, + getWritefullData: callbackify(getWritefullData), } const decorateFullEmails = ( @@ -335,9 +360,16 @@ const decorateFullEmails = ( } UserGetter.promises = promisifyAll(UserGetter, { - without: ['getSsoUsersAtInstitution', 'getUserFullEmails'], + without: [ + 'getSsoUsersAtInstitution', + 'getUserFullEmails', + 'getUserFeatures', + 'getWritefullData', + ], }) UserGetter.promises.getUserFullEmails = getUserFullEmails UserGetter.promises.getSsoUsersAtInstitution = getSsoUsersAtInstitution +UserGetter.promises.getUserFeatures = getUserFeatures +UserGetter.promises.getWritefullData = getWritefullData module.exports = UserGetter diff --git a/services/web/app/src/Features/User/UserInfoController.js b/services/web/app/src/Features/User/UserInfoController.js index 4eeea4f5e9..c95bc45af5 100644 --- a/services/web/app/src/Features/User/UserInfoController.js +++ b/services/web/app/src/Features/User/UserInfoController.js @@ -1,6 +1,7 @@ const UserGetter = require('./UserGetter') const SessionManager = require('../Authentication/SessionManager') const { ObjectId } = require('mongodb-legacy') +const { expressify } = require('@overleaf/promise-utils') function getLoggedInUsersPersonalInfo(req, res, next) { const userId = SessionManager.getLoggedInUserId(req.session) @@ -78,9 +79,19 @@ function formatPersonalInfo(user) { return formattedUser } +async function getUserFeatures(req, res, next) { + const userId = SessionManager.getLoggedInUserId(req.session) + if (!userId) { + throw new Error('User is not logged in') + } + const features = await UserGetter.promises.getUserFeatures(userId) + return res.json(features) +} + module.exports = { getLoggedInUsersPersonalInfo, getPersonalInfo, sendFormattedPersonalInfo, formatPersonalInfo, + getUserFeatures: expressify(getUserFeatures), } diff --git a/services/web/app/src/infrastructure/FileWriter.js b/services/web/app/src/infrastructure/FileWriter.js index 2c98028f37..1a56f5fa26 100644 --- a/services/web/app/src/infrastructure/FileWriter.js +++ b/services/web/app/src/infrastructure/FileWriter.js @@ -1,9 +1,4 @@ -/* eslint-disable - n/handle-callback-err, - max-len, -*/ // 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 @@ -122,6 +117,16 @@ const FileWriter = { }).withCause(err || {}) } if (err) { + stream.destroy() + writeStream.destroy() + fs.unlink(fsPath, error => { + if (error && error.code !== 'ENOENT') { + logger.warn( + { error, fsPath }, + 'Failed to delete partial file after error' + ) + } + }) OError.tag( err, '[writeStreamToDisk] something went wrong writing the stream to disk', @@ -153,14 +158,16 @@ const FileWriter = { callback = _.once(callback) const stream = request.get(url) - stream.on('error', function (err) { + const errorHandler = function (err) { logger.warn( { err, identifier, url }, '[writeUrlToDisk] something went wrong with writing to disk' ) callback(err) - }) + } + stream.on('error', errorHandler) stream.on('response', function (response) { + stream.removeListener('error', errorHandler) if (response.statusCode >= 200 && response.statusCode < 300) { FileWriter.writeStreamToDisk(identifier, stream, options, callback) } else { diff --git a/services/web/app/src/infrastructure/GracefulShutdown.js b/services/web/app/src/infrastructure/GracefulShutdown.js index 2446397b1d..b4b345fb95 100644 --- a/services/web/app/src/infrastructure/GracefulShutdown.js +++ b/services/web/app/src/infrastructure/GracefulShutdown.js @@ -65,20 +65,22 @@ async function gracefulShutdown(server, signal) { true ) - await sleep(Settings.gracefulShutdownDelayInMs) - try { - await new Promise((resolve, reject) => { - logger.warn({}, 'graceful shutdown: closing http server') - server.close(err => { - if (err) { - reject(OError.tag(err, 'http.Server.close failed')) - } else { - resolve() - } + if (server) { + await sleep(Settings.gracefulShutdownDelayInMs) + try { + await new Promise((resolve, reject) => { + logger.warn({}, 'graceful shutdown: closing http server') + server.close(err => { + if (err) { + reject(OError.tag(err, 'http.Server.close failed')) + } else { + resolve() + } + }) }) - }) - } catch (err) { - throw OError.tag(err, 'stop traffic') + } catch (err) { + throw OError.tag(err, 'stop traffic') + } } await runHandlers( diff --git a/services/web/app/src/infrastructure/Modules.js b/services/web/app/src/infrastructure/Modules.js index a21be431c4..f746519612 100644 --- a/services/web/app/src/infrastructure/Modules.js +++ b/services/web/app/src/infrastructure/Modules.js @@ -4,6 +4,7 @@ const { promisify, callbackify } = require('util') const Settings = require('@overleaf/settings') const Views = require('./Views') const _ = require('lodash') +const Metrics = require('@overleaf/metrics') const MODULE_BASE_PATH = Path.join(__dirname, '/../../../modules') @@ -15,7 +16,11 @@ let _viewIncludes = {} async function modules() { if (!_modulesLoaded) { + const beforeLoadModules = performance.now() await loadModules() + Metrics.gauge('web_startup', performance.now() - beforeLoadModules, 1, { + path: 'loadModules', + }) } return _modules } diff --git a/services/web/app/src/infrastructure/Server.mjs b/services/web/app/src/infrastructure/Server.mjs index 3c7fd752d6..9e548bdc9e 100644 --- a/services/web/app/src/infrastructure/Server.mjs +++ b/services/web/app/src/infrastructure/Server.mjs @@ -372,6 +372,10 @@ if (Settings.enabledServices.includes('web')) { metrics.injectMetricsRoute(webRouter) metrics.injectMetricsRoute(privateApiRouter) +const beforeRouterInitialize = performance.now() await Router.initialize(webRouter, privateApiRouter, publicApiRouter) +metrics.gauge('web_startup', performance.now() - beforeRouterInitialize, 1, { + path: 'Router.initialize', +}) export default { app, server } diff --git a/services/web/app/src/infrastructure/mongodb.js b/services/web/app/src/infrastructure/mongodb.js index aa7aa4ac44..7fc1039140 100644 --- a/services/web/app/src/infrastructure/mongodb.js +++ b/services/web/app/src/infrastructure/mongodb.js @@ -49,6 +49,7 @@ const db = { githubSyncUserCredentials: internalDb.collection('githubSyncUserCredentials'), globalMetrics: internalDb.collection('globalMetrics'), grouppolicies: internalDb.collection('grouppolicies'), + groupAuditLogEntries: internalDb.collection('groupAuditLogEntries'), institutions: internalDb.collection('institutions'), messages: internalDb.collection('messages'), migrations: internalDb.collection('migrations'), diff --git a/services/web/app/src/models/GroupAuditLogEntry.js b/services/web/app/src/models/GroupAuditLogEntry.js new file mode 100644 index 0000000000..3bda4ebf95 --- /dev/null +++ b/services/web/app/src/models/GroupAuditLogEntry.js @@ -0,0 +1,23 @@ +const mongoose = require('../infrastructure/Mongoose') +const { Schema } = mongoose + +const GroupAuditLogEntrySchema = new Schema( + { + groupId: { type: Schema.Types.ObjectId, index: true }, + info: { type: Object }, + initiatorId: { type: Schema.Types.ObjectId }, + ipAddress: { type: String }, + operation: { type: String }, + timestamp: { type: Date, default: Date.now }, + }, + { + collection: 'groupAuditLogEntries', + minimize: false, + } +) + +exports.GroupAuditLogEntry = mongoose.model( + 'GroupAuditLogEntry', + GroupAuditLogEntrySchema +) +exports.GroupAuditLogEntrySchema = GroupAuditLogEntrySchema diff --git a/services/web/app/src/models/OauthApplication.js b/services/web/app/src/models/OauthApplication.js index f02a9850db..d7bd181fd6 100644 --- a/services/web/app/src/models/OauthApplication.js +++ b/services/web/app/src/models/OauthApplication.js @@ -10,6 +10,7 @@ const OauthApplicationSchema = new Schema( name: String, redirectUris: [String], scopes: [String], + pkceEnabled: Boolean, }, { collection: 'oauthApplications', diff --git a/services/web/app/src/models/Project.js b/services/web/app/src/models/Project.js index 8da4b888d3..145c8f9023 100644 --- a/services/web/app/src/models/Project.js +++ b/services/web/app/src/models/Project.js @@ -99,6 +99,7 @@ const ProjectSchema = new Schema( allowDowngrade: { type: Boolean }, zipFileArchivedInProject: { type: Boolean }, rangesSupportEnabled: { type: Boolean }, + otMigrationStage: { type: Number }, }, }, collabratecUsers: [ diff --git a/services/web/app/src/models/User.js b/services/web/app/src/models/User.js index c63647e914..d228c46b82 100644 --- a/services/web/app/src/models/User.js +++ b/services/web/app/src/models/User.js @@ -196,6 +196,7 @@ const UserSchema = new Schema( enabled: { type: Boolean, default: null }, autoCreatedAccount: { type: Boolean, default: false }, isPremium: { type: Boolean, default: false }, + premiumSource: { type: String, default: null }, }, aiErrorAssistant: { enabled: { type: Boolean, default: true }, diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 5e1a21c063..f87297c35c 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -484,6 +484,11 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { AuthenticationController.requirePrivateApiAuth(), UserInfoController.getPersonalInfo ) + webRouter.get( + '/user/features', + AuthenticationController.requireLogin(), + UserInfoController.getUserFeatures + ) webRouter.get( '/user/reconfirm', @@ -1187,7 +1192,9 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { const sendRes = _.once(function (statusCode, message) { res.status(statusCode) plainTextResponse(res, message) - ClsiCookieManager.clearServerId(projectId, testUserId, () => {}) + ClsiCookieManager.promises + .clearServerId(projectId, testUserId) + .catch(() => {}) }) // force every compile to a new server // set a timeout let handler = setTimeout(function () { diff --git a/services/web/app/templates/project_files/example-project/main.tex b/services/web/app/templates/project_files/example-project/main.tex index 5199b66d9d..45d982a2f6 100644 --- a/services/web/app/templates/project_files/example-project/main.tex +++ b/services/web/app/templates/project_files/example-project/main.tex @@ -49,7 +49,7 @@ Note that your figure will automatically be placed in the most appropriate place \subsection{How to add Tables} -Use the table and tabular environments for basic tables --- see Table~\ref{tab:widgets}, for example. For more information, please see this help article on \href{https://www.overleaf.com/learn/latex/tables}{tables}. +Use the table and tabular environments for basic tables --- see Table~\ref{tab:widgets}, for example. For more information, please see this help article on \href{https://www.overleaf.com/learn/latex/tables}{tables}. \begin{table} \centering @@ -65,7 +65,7 @@ Gadgets & 13 Comments can be added to your project by highlighting some text and clicking ``Add comment'' in the top right of the editor pane. To view existing comments, click on the Review menu in the toolbar above. To reply to a comment, click on the Reply button in the lower right corner of the comment. You can close the Review pane by clicking its name on the toolbar when you're done reviewing for the time being. -Track changes are available on all our \href{https://www.overleaf.com/user/subscription/plans}{premium plans}, and can be toggled on or off using the option at the top of the Review pane. Track changes allow you to keep track of every change made to the document, along with the person making the change. +Track changes are available on all our \href{https://www.overleaf.com/user/subscription/plans}{premium plans}, and can be toggled on or off using the option at the top of the Review pane. Track changes allow you to keep track of every change made to the document, along with the person making the change. \subsection{How to add Lists} @@ -97,7 +97,7 @@ If however you're using a more general template, such as this one, and would lik \subsection{How to change the document language and spell check settings} -Overleaf supports many different languages, including multiple different languages within one document. +Overleaf supports many different languages, including multiple different languages within one document. To configure the document language, simply edit the option provided to the babel package in the preamble at the top of this example project. To learn more about the different options, please visit this help article on \href{https://www.overleaf.com/learn/latex/International_language_support}{international language support}. @@ -111,9 +111,9 @@ If you have an \href{https://www.overleaf.com/user/subscription/plans}{upgraded \subsection{Good luck!} -We hope you find Overleaf useful, and do take a look at our \href{https://www.overleaf.com/learn}{help library} for more tutorials and user guides! Please also let us know if you have any feedback using the Contact Us link at the bottom of the Overleaf menu --- or use the contact form at \url{https://www.overleaf.com/contact}. +We hope you find Overleaf useful, and do take a look at our \href{https://www.overleaf.com/learn}{help library} for more tutorials and user guides! Please also let us know if you have any feedback using the \textbf{Contact us} link at the bottom of the Overleaf menu --- or use the contact form at \url{https://www.overleaf.com/contact}. \bibliographystyle{alpha} \bibliography{sample} -\end{document} \ No newline at end of file +\end{document} diff --git a/services/web/app/views/_mixins/back_to_btns.pug b/services/web/app/views/_mixins/back_to_btns.pug index da1c9c09db..570237b5bc 100644 --- a/services/web/app/views/_mixins/back_to_btns.pug +++ b/services/web/app/views/_mixins/back_to_btns.pug @@ -1,4 +1,4 @@ mixin back-to-btns(settingsAnchor) - a.btn.btn-secondary.text-capitalize(href=`/user/settings${settingsAnchor ? '#' + settingsAnchor : '' }`) #{translate('back_to_account_settings')} + a.btn.btn-secondary(href=`/user/settings${settingsAnchor ? '#' + settingsAnchor : '' }`) #{translate('back_to_account_settings')} | - a.btn.btn-secondary.text-capitalize(href='/project') #{translate('back_to_your_projects')} \ No newline at end of file + a.btn.btn-secondary(href='/project') #{translate('back_to_your_projects')} diff --git a/services/web/app/views/_mixins/faq_search-marketing.pug b/services/web/app/views/_mixins/faq_search-marketing.pug index 8ec136e08e..aa41d00f9b 100644 --- a/services/web/app/views/_mixins/faq_search-marketing.pug +++ b/services/web/app/views/_mixins/faq_search-marketing.pug @@ -6,7 +6,7 @@ mixin faq_search-marketing(headerText, headerClass) form.project-search.form-horizontal(role="search" data-ol-faq-search) .form-group.has-feedback.has-feedback-left .col-sm-12 - input.form-control(type='text', placeholder="Search help library…") + input.form-control(type='search', placeholder="Search help library…" aria-label="Search help library…") i.fa.fa-search.form-control-feedback-left(aria-hidden="true") i.fa.fa-times.form-control-feedback( style="cursor: pointer;", diff --git a/services/web/app/views/_mixins/quote.pug b/services/web/app/views/_mixins/quote.pug index b8065dbdab..573e0b6b0c 100644 --- a/services/web/app/views/_mixins/quote.pug +++ b/services/web/app/views/_mixins/quote.pug @@ -1,9 +1,10 @@ -mixin quoteLargeTextCentered(quote, person, position, affiliation, link, pictureUrl, pictureAltAttr) +mixin quoteLargeTextCentered(quote, person, position, affiliation, link, pictureUrl) blockquote.quote-large-text-centered .quote !{quote} if pictureUrl .quote-img - img(src=pictureUrl alt=pictureAltAttr) + -var pictureAlt=`Photo of ${person}` + img(src=pictureUrl alt=pictureAlt) footer div.quote-person strong #{person} @@ -33,7 +34,7 @@ mixin collinsQuote1 -var quotePersonPosition = 'Associate Professor and Lab Director, Ontario Tech University' -var quotePersonImg = buildImgPath("advocates/collins.jpg") .card-body - +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg, quotePerson) + +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg) mixin collinsQuote2 .card.card-dark-green-bg @@ -42,7 +43,7 @@ mixin collinsQuote2 -var quotePersonPosition = 'Associate Professor and Lab Director, Ontario Tech University' -var quotePersonImg = buildImgPath("advocates/collins.jpg") .card-body - +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg, quotePerson) + +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg) mixin bennettQuote1 .card.card-dark-green-bg @@ -51,4 +52,4 @@ mixin bennettQuote1 -var quotePersonPosition = 'Software Architect, Symplectic' -var quotePersonImg = buildImgPath("advocates/bennett.jpg") .card-body - +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg, quotePerson) \ No newline at end of file + +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg) diff --git a/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug b/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug index ee94394bc4..92e2d4301d 100644 --- a/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug +++ b/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug @@ -178,7 +178,7 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={ +dropdown-menu-item div.disabled.dropdown-item #{getSessionUser().email} +dropdown-menu-divider - +dropdown-menu-link-item()(href="/user/settings") #{translate('Account Settings')} + +dropdown-menu-link-item()(href="/user/settings") #{translate('account_settings')} if nav.showSubscriptionLink +dropdown-menu-link-item()(href="/user/subscription") #{translate('subscription')} +dropdown-menu-divider diff --git a/services/web/app/views/layout/navbar-marketing.pug b/services/web/app/views/layout/navbar-marketing.pug index e0f36004b8..c5e9f2e0bf 100644 --- a/services/web/app/views/layout/navbar-marketing.pug +++ b/services/web/app/views/layout/navbar-marketing.pug @@ -186,7 +186,7 @@ nav.navbar.navbar-default.navbar-main(class={ div.subdued #{getSessionUser().email} li.divider.hidden-xs.hidden-sm li - a(href="/user/settings") #{translate('Account Settings')} + a(href="/user/settings") #{translate('account_settings')} if nav.showSubscriptionLink li a(href="/user/subscription") #{translate('subscription')} diff --git a/services/web/app/views/layout/navbar-website-redesign.pug b/services/web/app/views/layout/navbar-website-redesign.pug index c4b712e955..8ea71861c0 100644 --- a/services/web/app/views/layout/navbar-website-redesign.pug +++ b/services/web/app/views/layout/navbar-website-redesign.pug @@ -184,7 +184,7 @@ nav.navbar.navbar-default.navbar-main.website-redesign-navbar div.subdued #{getSessionUser().email} li.divider.hidden-xs.hidden-sm li - a(href="/user/settings") #{translate('Account Settings')} + a(href="/user/settings") #{translate('account_settings')} if nav.showSubscriptionLink li a(href="/user/subscription") #{translate('subscription')} diff --git a/services/web/app/views/subscriptions/dashboard-react.pug b/services/web/app/views/subscriptions/dashboard-react.pug index dab505e4e5..d6a1bff49c 100644 --- a/services/web/app/views/subscriptions/dashboard-react.pug +++ b/services/web/app/views/subscriptions/dashboard-react.pug @@ -23,6 +23,7 @@ block append meta meta(name="ol-showGroupDiscount" data-type="boolean", content=showGroupDiscount) meta(name="ol-groupSettingsEnabledFor" data-type="json" content=groupSettingsEnabledFor) meta(name="ol-hasAiAssistViaWritefull" data-type="boolean", content=hasAiAssistViaWritefull) + meta(name="ol-aiAssistViaWritefullSource" data-type="string", content=aiAssistViaWritefullSource) meta(name="ol-user" data-type="json" content=user) if (personalSubscription && personalSubscription.payment) meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey) diff --git a/services/web/app/views/user/reconfirm.pug b/services/web/app/views/user/reconfirm.pug index 5db1d811c7..7c17423d5a 100644 --- a/services/web/app/views/user/reconfirm.pug +++ b/services/web/app/views/user/reconfirm.pug @@ -23,7 +23,7 @@ block content .row .col-sm-12.col-md-6.col-md-offset-3 .card - h1.card-header.text-capitalize #{translate("reconfirm")} #{translate("Account")} + h1.card-header #{translate("reconfirm")} #{translate("Account")} p #{translate('reconfirm_explained')}  a(href=`mailto:${settings.adminEmail}`) #{settings.adminEmail} | . diff --git a/services/web/app/views/user/restricted.pug b/services/web/app/views/user/restricted.pug index 949bd9b4b6..eba1d2ab05 100644 --- a/services/web/app/views/user/restricted.pug +++ b/services/web/app/views/user/restricted.pug @@ -1,16 +1,13 @@ extends ../layout-marketing -block vars - - bootstrap5PageStatus = 'disabled' - block content main.content#main-content .container .row - .col-md-8.col-md-offset-2.text-center + .col-md-8.offset-md-2.text-center .page-header h2 #{translate("restricted_no_permission")} p - a(href="/") - i.fa.fa-arrow-circle-o-left(aria-hidden="true") - | #{translate("take_me_home")} + span.inline-material-symbols + a(href="/").material-symbols(aria-hidden="true") arrow_left_alt + a(href="/") #{translate("take_me_home")} diff --git a/services/web/app/views/user/sessions.pug b/services/web/app/views/user/sessions.pug index 99905a960d..187c1dae75 100644 --- a/services/web/app/views/user/sessions.pug +++ b/services/web/app/views/user/sessions.pug @@ -67,6 +67,6 @@ block content p.text-success.text-center | #{translate('clear_sessions_success')} .page-separator - a.btn.btn-secondary.text-capitalize(href='/user/settings') #{translate('back_to_account_settings')} + a.btn.btn-secondary(href='/user/settings') #{translate('back_to_account_settings')} | - a.btn.btn-secondary.text-capitalize(href='/project') #{translate('back_to_your_projects')} + a.btn.btn-secondary(href='/project') #{translate('back_to_your_projects')} diff --git a/services/web/app/views/user_membership/new.pug b/services/web/app/views/user_membership/new.pug index 6a88249ca6..c59837b107 100644 --- a/services/web/app/views/user_membership/new.pug +++ b/services/web/app/views/user_membership/new.pug @@ -15,7 +15,7 @@ block content action='' ) input(name="_csrf", type="hidden", value=csrfToken) - button.btn.btn-primary.text-capitalize( + button.btn.btn-primary( type="submit", data-ol-disabled-inflight ) diff --git a/services/web/cloudbuild-storybook.yaml b/services/web/cloudbuild-storybook.yaml index ac9cfeba7d..c50513ff7b 100644 --- a/services/web/cloudbuild-storybook.yaml +++ b/services/web/cloudbuild-storybook.yaml @@ -1,13 +1,13 @@ steps: - id: npm_ci - name: "node:20.18.2" + name: "node:22.15.1" entrypoint: /bin/bash args: - '-c' - 'bin/npm_install_subset . libraries/* services/web' - id: build-storybook - name: 'node:20.18.2' + name: 'node:22.15.1' env: - 'BRANCH_NAME=$BRANCH_NAME' - 'BUILD_ID=$BUILD_ID' @@ -49,7 +49,7 @@ steps: - deploy-storybook - id: create-storybook-index - name: 'node:20.18.2' + name: 'node:22.15.1' dir: services/web env: - 'BRANCH_NAME=$BRANCH_NAME' diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index be567bf13e..a7ff970ef0 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1,4 +1,4 @@ -const Path = require('path') +const Path = require('node:path') const { merge } = require('@overleaf/settings/merge') let defaultFeatures, siteUrl @@ -968,6 +968,7 @@ module.exports = { sourceEditorComponents: [], pdfLogEntryComponents: [], pdfLogEntriesComponents: [], + pdfPreviewPromotions: [], diagnosticActions: [], sourceEditorCompletionSources: [], sourceEditorSymbolPalette: [], diff --git a/services/web/cypress/fixtures/build/mock-writefull-api.js b/services/web/cypress/fixtures/build/mock-writefull-api.js index 4ba52ba2c8..a70a28653e 100644 --- a/services/web/cypress/fixtures/build/mock-writefull-api.js +++ b/services/web/cypress/fixtures/build/mock-writefull-api.js @@ -1 +1,4 @@ -module.exports = {} +module.exports = { + addEventListener: () => {}, + removeEventListener: () => {}, +} diff --git a/services/web/cypress/support/shared/commands/compile.ts b/services/web/cypress/support/shared/commands/compile.ts index 9f7273c403..44ee9c0805 100644 --- a/services/web/cypress/support/shared/commands/compile.ts +++ b/services/web/cypress/support/shared/commands/compile.ts @@ -48,6 +48,7 @@ const compileFromCacheResponse = () => { fromCache: true, status: 'success', clsiServerId: 'foo', + clsiCacheShard: 'clsi-cache-zone-b-shard-1', compileGroup: 'priority', pdfDownloadDomain: 'https://clsi.test-overleaf.com', outputFiles: outputFiles(), @@ -166,10 +167,10 @@ export const waitForCompileOutput = ({ } = {}) => { cy.wait(`@${prefix}-log`) .its('request.query.clsiserverid') - .should('eq', cached ? 'cache' : 'foo') // straight from cache if cached + .should('eq', cached ? 'clsi-cache-zone-b-shard-1' : 'foo') // straight from cache if cached cy.wait(`@${prefix}-blg`) .its('request.query.clsiserverid') - .should('eq', cached ? 'cache' : 'foo') // straight from cache if cached + .should('eq', cached ? 'clsi-cache-zone-b-shard-1' : 'foo') // straight from cache if cached if (pdf) { cy.wait(`@${prefix}-pdf`) .its('request.query.clsiserverid') diff --git a/services/web/docker-compose.ci.yml b/services/web/docker-compose.ci.yml index c277cc0e97..164cc22c5a 100644 --- a/services/web/docker-compose.ci.yml +++ b/services/web/docker-compose.ci.yml @@ -88,7 +88,7 @@ services: image: redis mongo: - image: mongo:6.0.13 + image: mongo:7.0.20 logging: driver: none command: --replSet overleaf diff --git a/services/web/docker-compose.common.env b/services/web/docker-compose.common.env index 7f94f5714a..0642e1ee98 100644 --- a/services/web/docker-compose.common.env +++ b/services/web/docker-compose.common.env @@ -41,6 +41,6 @@ OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN=true OVERLEAF_SAML_CERT=MIIDXTCCAkWgAwIBAgIJAOvOeQ4xFTzsMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkdCMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMTE1MTQxMjU5WhcNMjYxMTE1MTQxMjU5WjBFMQswCQYDVQQGEwJHQjETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxCT6MBe5G9VoLU8MfztOEbUhnwLp17ak8eFUqxqeXkkqtWB0b/cmIBU3xoQoO3dIF8PBzfqehqfYVhrNt/TFgcmDfmJnPJRL1RJWMW3VmiP5odJ3LwlkKbZpkeT3wZ8HEJIR1+zbpxiBNkbd2GbdR1iumcsHzMYX1A2CBj+ZMV5VijC+K4P0e9c05VsDEUtLmfeAasJAiumQoVVgAe/BpiXjICGGewa6EPFI7mKkifIRKOGxdRESwZZjxP30bI31oDN0cgKqIgSJtJ9nfCn9jgBMBkQHu42WMuaWD4jrGd7+vYdX+oIfArs9aKgAH5kUGhGdew2R9SpBefrhbNxG8QIDAQABo1AwTjAdBgNVHQ4EFgQU+aSojSyyLChP/IpZcafvSdhj7KkwHwYDVR0jBBgwFoAU+aSojSyyLChP/IpZcafvSdhj7KkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEABl3+OOVLBWMKs6PjA8lPuloWDNzSr3v76oUcHqAb+cfbucjXrOVsS9RJ0X9yxvCQyfM9FfY43DbspnN3izYhdvbJD8kKLNf0LA5st+ZxLfy0ACyL2iyAwICaqndqxAjQYplFAHmpUiu1DiHckyBPekokDJd+ze95urHMOsaGS5RWPoKJVE0bkaAeZCmEu0NNpXRSBiuxXSTeSAJfv6kyE/rkdhzUKyUl/cGQFrsVYfAFQVA+W6CKOh74ErSEzSHQQYndl7nD33snD/YqdU1ROxV6aJzLKCg+sdj+wRXSP2u/UHnM4jW9TGJfhO42jzL6WVuEvr9q4l7zWzUQKKKhtQ== # DEVICE_HISTORY_SECRET has been generated using: # NOTE: crypto.generateKeySync was added in v15, v16 is the next LTS release. -# $ docker run --rm node:20.18.2 --print 'require("crypto").generateKeySync("aes", { length: 256 }).export().toString("hex")' +# $ docker run --rm node:22.15.1 --print 'require("crypto").generateKeySync("aes", { length: 256 }).export().toString("hex")' DEVICE_HISTORY_SECRET=1b46e6cdf72db02845da06c9517c9cfbbfa0d87357479f4e1df3ce160bd54807 QUEUE_PROCESSING_ENABLED=true diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml index c6a5aa7482..5314e94ed3 100644 --- a/services/web/docker-compose.yml +++ b/services/web/docker-compose.yml @@ -6,7 +6,7 @@ volumes: services: test_unit: - image: node:20.18.2 + image: node:22.15.1 volumes: - .:/overleaf/services/web - ../../node_modules:/overleaf/node_modules @@ -26,7 +26,7 @@ services: - mongo test_acceptance: - image: node:20.18.2 + image: node:22.15.1 volumes: - .:/overleaf/services/web - ../../node_modules:/overleaf/node_modules @@ -87,7 +87,7 @@ services: image: redis mongo: - image: mongo:6.0.13 + image: mongo:7.0.20 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 7df8553f55..c64817b94c 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -5,7 +5,6 @@ "3_4_width": "", "About": "", "Account": "", - "Account Settings": "", "Documentation": "", "Get Involved": "", "Help": "", @@ -31,13 +30,12 @@ "about_to_leave_project": "", "about_to_leave_projects": "", "about_to_trash_projects": "", - "about_writefull": "", + "abstract": "", "accept_and_continue": "", "accept_change": "", "accept_change_error_description": "", "accept_change_error_title": "", "accept_invitation": "", - "accept_or_reject_each_changes_individually": "", "accept_or_reject_individual_edits": "", "accept_selected_changes": "", "accept_terms_and_conditions": "", @@ -62,6 +60,9 @@ "add_additional_certificate": "", "add_affiliation": "", "add_ai_assist": "", + "add_ai_assist_annual_and_get_unlimited_access": "", + "add_ai_assist_monthly_and_get_unlimited_access": "", + "add_ai_assist_to_your_plan": "", "add_another_address_line": "", "add_another_email": "", "add_another_token": "", @@ -82,12 +83,11 @@ "add_on": "", "add_ons": "", "add_or_remove_project_from_tag": "", - "add_overleaf_assist_to_your_group_subscription": "", - "add_overleaf_assist_to_your_institution": "", "add_people": "", "add_role_and_department": "", "add_to_dictionary": "", "add_to_tag": "", + "add_unlimited_ai_to_overleaf": "", "add_unlimited_ai_to_your_overleaf_plan": "", "add_your_comment_here": "", "add_your_first_group_member_now": "", @@ -104,7 +104,8 @@ "aggregate_to": "", "agree": "", "agree_with_the_terms": "", - "ai_assist_in_overleaf_is_included_via_writefull": "", + "ai_assist_in_overleaf_is_included_via_writefull_groups": "", + "ai_assist_in_overleaf_is_included_via_writefull_individual": "", "ai_assistance_to_help_you": "", "ai_based_language_tools": "", "ai_can_make_mistakes": "", @@ -126,7 +127,6 @@ "all_these_experiments_are_available_exclusively": "", "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "", "already_have_a_papers_account": "", - "already_subscribed_try_refreshing_the_page": "", "an_email_has_already_been_sent_to": "", "an_error_occured_while_restoring_project": "", "an_error_occurred_when_verifying_the_coupon_code": "", @@ -185,12 +185,12 @@ "blocked_filename": "", "blog": "", "bold": "", + "booktabs": "", "browser": "", "bullet_list": "", "buy_licenses": "", "buy_more_licenses": "", "by_subscribing_you_agree_to_our_terms_of_service": "", - "can_edit_content": "", "can_link_institution_email_acct_to_institution_acct": "", "can_link_your_institution_acct_2": "", "can_now_relink_dropbox": "", @@ -209,6 +209,7 @@ "cant_see_what_youre_looking_for_question": "", "caption_above": "", "caption_below": "", + "captions": "", "card_details": "", "card_details_are_not_valid": "", "card_must_be_authenticated_by_3dsecure": "", @@ -222,6 +223,7 @@ "center": "", "change": "", "change_currency": "", + "change_email": "", "change_language": "", "change_or_cancel-cancel": "", "change_or_cancel-change": "", @@ -397,6 +399,7 @@ "disable_equation_preview": "", "disable_equation_preview_confirm": "", "disable_equation_preview_enable": "", + "disable_equation_preview_enable_in_settings": "", "disable_single_sign_on": "", "disable_sso": "", "disable_stop_on_first_error": "", @@ -429,6 +432,7 @@ "done": "", "dont_forget_you_currently_have": "", "dont_reload_or_close_this_tab": "", + "double_clicking_on_the_pdf_shows": "", "download": "", "download_all": "", "download_as_pdf": "", @@ -465,6 +469,7 @@ "edit": "", "edit_comment_error_message": "", "edit_comment_error_title": "", + "edit_content_directly": "", "edit_dictionary": "", "edit_dictionary_empty": "", "edit_dictionary_remove": "", @@ -587,6 +592,7 @@ "footer_about_us": "", "footer_contact_us": "", "footer_navigation": "", + "footnotes": "", "for_enterprise": "", "for_government": "", "for_individuals_and_groups": "", @@ -612,6 +618,7 @@ "full_width": "", "future_payments": "", "generate_from_text_or_image": "", + "generate_tables_and_equations": "", "generate_token": "", "generic_if_problem_continues_contact_us": "", "generic_linked_file_compile_error": "", @@ -621,7 +628,9 @@ "get_error_assist": "", "get_exclusive_access_to_labs": "", "get_in_touch": "", - "get_most_subscription_discover_premium_features": "", + "get_most_subscription_by_checking_ai_writefull": "", + "get_most_subscription_by_checking_overleaf": "", + "get_most_subscription_by_checking_overleaf_ai_writefull": "", "get_real_time_track_changes": "", "git": "", "git_authentication_token": "", @@ -665,6 +674,7 @@ "github_workflow_files_delete_github_repo": "", "github_workflow_files_error": "", "give_feedback": "", + "give_feedback_about": "", "give_your_feedback": "", "go_next_page": "", "go_page": "", @@ -848,6 +858,7 @@ "issued_on": "", "it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "", "it_looks_like_your_account_is_billed_manually": "", + "it_looks_like_your_account_is_billed_manually_upgrading_subscription": "", "it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "", "italics": "", "join_beta_program": "", @@ -884,6 +895,7 @@ "last_used": "", "latam_discount_modal_info": "", "latam_discount_modal_title": "", + "latest_updates": "", "latex_in_thirty_minutes": "", "latex_places_figures_according_to_a_special_algorithm": "", "latex_places_tables_according_to_a_special_algorithm": "", @@ -891,6 +903,7 @@ "layout_options": "", "layout_processing": "", "learn_more": "", + "learn_more_about": "", "learn_more_about_account": "", "learn_more_about_compile_timeouts": "", "learn_more_about_link_sharing": "", @@ -914,10 +927,6 @@ "limited_offer": "", "limited_to_n_collaborators_per_project": "", "limited_to_n_collaborators_per_project_plural": "", - "limited_to_n_editors": "", - "limited_to_n_editors_per_project": "", - "limited_to_n_editors_per_project_plural": "", - "limited_to_n_editors_plural": "", "line": "", "line_height": "", "line_width_is_the_width_of_the_line_in_the_current_environment": "", @@ -1024,17 +1033,17 @@ "month_plural": "", "more": "", "more_actions": "", - "more_changes_based_on_your_feedback": "", "more_collabs_per_project": "", "more_comments": "", "more_compile_time": "", "more_editor_toolbar_item": "", "more_info": "", "more_options": "", - "more_options_for_border_settings_coming_soon": "", "my_library": "", "n_items": "", "n_items_plural": "", + "n_more_collaborators": "", + "n_more_collaborators_plural": "", "n_more_updates_above": "", "n_more_updates_above_plural": "", "n_more_updates_below": "", @@ -1051,6 +1060,7 @@ "need_to_leave": "", "neither_agree_nor_disagree": "", "new_compile_domain_notice": "", + "new_create_tables_and_equations": "", "new_file": "", "new_folder": "", "new_font_open_dyslexic": "", @@ -1061,6 +1071,7 @@ "new_overleaf_editor": "", "new_password": "", "new_project": "", + "new_project_name": "", "new_subscription_will_be_billed_immediately": "", "new_tag": "", "new_tag_name": "", @@ -1200,7 +1211,7 @@ "pending_invite": "", "per_license": "", "per_month": "", - "per_month_billed_annually": "", + "per_month_x_annually": "", "percent_is_the_percentage_of_the_line_width": "", "permanently_disables_the_preview": "", "personal_library": "", @@ -1236,6 +1247,9 @@ "plus_more": "", "plus_x_additional_licenses_for_a_total_of_y_licenses": "", "po_number": "", + "po_number_can_include_digits_and_letters_only": "", + "po_number_must_not_exceed_x_characters": "", + "po_number_must_not_exceed_x_characters_plural": "", "postal_code": "", "premium": "", "premium_feature": "", @@ -1418,7 +1432,6 @@ "review": "", "review_panel": "", "review_panel_and_error_logs_moved_to_the_left": "", - "review_your_peers_work": "", "reviewer": "", "reviewer_dropbox_sync_message": "", "reviewing": "", @@ -1437,7 +1450,6 @@ "saml_missing_signature_error": "", "saml_response": "", "save": "", - "save_20_percent_when_you_switch_to_annual": "", "save_or_cancel-cancel": "", "save_or_cancel-or": "", "save_or_cancel-save": "", @@ -1473,7 +1485,6 @@ "search_within_selection": "", "searched_path_for_lines_containing": "", "security": "", - "see_changes_in_your_documents_live": "", "see_suggestions_from_collaborators": "", "select_a_column_or_a_merged_cell_to_align": "", "select_a_column_to_adjust_column_width": "", @@ -1672,7 +1683,6 @@ "sure_you_want_to_change_plan": "", "sure_you_want_to_delete": "", "sure_you_want_to_leave_group": "", - "switch_back_to_monthly_pay_20_more": "", "switch_compile_mode_for_faster_draft_compilation": "", "switch_to_editor": "", "switch_to_new_editor": "", @@ -1698,8 +1708,6 @@ "tags": "", "take_short_survey": "", "take_survey": "", - "tc_everyone": "", - "tc_guests": "", "tell_the_project_owner_and_ask_them_to_upgrade": "", "template": "", "template_description": "", @@ -1712,6 +1720,7 @@ "test_configuration_successful": "", "tex_live_version": "", "texgpt": "", + "text": "", "thank_you": "", "thank_you_exclamation": "", "thank_you_for_your_feedback": "", @@ -1849,19 +1858,15 @@ "tooltip_show_filetree": "", "tooltip_show_panel": "", "tooltip_show_pdf": "", + "total": "", + "total_due_in_x_days": "", "total_due_today": "", "total_per_month": "", "total_per_year": "", "total_today": "", "total_with_subtotal_and_tax": "", "total_words": "", - "track_any_change_in_real_time": "", "track_changes": "", - "track_changes_for_everyone": "", - "track_changes_for_guests": "", - "track_changes_for_x": "", - "track_changes_is_off": "", - "track_changes_is_on": "", "tracked_change_added": "", "tracked_change_deleted": "", "transfer_management_of_your_account": "", @@ -1953,7 +1958,6 @@ "upgrade_to_add_more_collaborators_and_access_collaboration_features": "", "upgrade_to_get_feature": "", "upgrade_to_review": "", - "upgrade_to_track_changes": "", "upgrade_to_unlock_more_time": "", "upgrade_your_subscription": "", "upload": "", @@ -2033,6 +2037,7 @@ "we_sent_new_code": "", "we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months": "", "we_will_charge_you_now_for_your_new_plan_based_on_the_remaining_months_of_your_current_subscription": "", + "we_will_invoice_you_now_for_the_additional_licenses_based_on_remaining_months": "", "we_will_use_your_existing_payment_method": "", "webinars": "", "website_status": "", @@ -2049,8 +2054,7 @@ "what_does_this_mean_for_you": "", "what_happens_when_sso_is_enabled": "", "what_should_we_call_you": "", - "whats_new": "", - "whats_next": "", + "whats_different_in_the_new_editor": "", "when_you_tick_the_include_caption_box": "", "why_latex": "", "why_might_this_happen": "", @@ -2059,6 +2063,7 @@ "will_lose_edit_access_on_date": "", "with_premium_subscription_you_also_get": "", "word_count": "", + "word_count_lower": "", "work_in_vim_or_emacs_emulation_mode": "", "work_offline": "", "work_offline_pull_to_overleaf": "", @@ -2087,7 +2092,6 @@ "you_are_a_manager_of_publisher_x": "", "you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "", "you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z_you": "", - "you_are_now_saving_20_percent": "", "you_are_on_a_paid_plan_contact_support_to_find_out_more": "", "you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "", "you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "", diff --git a/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 b/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 index 14a5ef1e2b..df942df176 100644 Binary files a/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 and b/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 differ diff --git a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs index eca63fa035..baefac05aa 100644 --- a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs +++ b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs @@ -8,6 +8,7 @@ export default /** @type {const} */ ([ 'brush', 'code', 'create_new_folder', + 'delete', 'description', 'experiment', 'forum', diff --git a/services/web/frontend/js/features/chat/context/chat-context.tsx b/services/web/frontend/js/features/chat/context/chat-context.tsx index d86171a451..9feca60579 100644 --- a/services/web/frontend/js/features/chat/context/chat-context.tsx +++ b/services/web/frontend/js/features/chat/context/chat-context.tsx @@ -20,6 +20,8 @@ import { useIdeContext } from '@/shared/context/ide-context' import getMeta from '@/utils/meta' import { debugConsole } from '@/utils/debugging' import { User } from '../../../../../types/user' +import { useRailContext } from '@/features/ide-redesign/contexts/rail-context' +import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils' const PAGE_SIZE = 50 @@ -200,7 +202,12 @@ export const ChatProvider: FC = ({ children }) => { const user = useUserContext() const { _id: projectId } = useProjectContext() - const { chatIsOpen } = useLayoutContext() + const { chatIsOpen: chatIsOpenOldEditor } = useLayoutContext() + const { selectedTab: selectedRailTab, isOpen: railIsOpen } = useRailContext() + const newEditor = useIsNewEditorEnabled() + const chatIsOpen = newEditor + ? selectedRailTab === 'chat' && railIsOpen + : chatIsOpenOldEditor const { hasFocus: windowHasFocus, diff --git a/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.jsx b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.tsx similarity index 82% rename from services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.jsx rename to services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.tsx index 84d8151f0f..5754522360 100644 --- a/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.jsx +++ b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.tsx @@ -1,6 +1,5 @@ /* eslint-disable jsx-a11y/no-autofocus */ -import PropTypes from 'prop-types' -import { useCallback, useMemo, useState } from 'react' +import { FormEvent, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { postJSON } from '../../../infrastructure/fetch-json' import { CloneProjectTag } from './clone-project-tag' @@ -16,6 +15,7 @@ import OLFormGroup from '@/features/ui/components/ol/ol-form-group' import OLFormControl from '@/features/ui/components/ol/ol-form-control' import OLFormLabel from '@/features/ui/components/ol/ol-form-label' import OLButton from '@/features/ui/components/ol/ol-button' +import { Tag } from '../../../../../app/src/Features/Tags/types' export default function CloneProjectModalContent({ handleHide, @@ -25,10 +25,18 @@ export default function CloneProjectModalContent({ projectId, projectName, projectTags, +}: { + handleHide: () => void + inFlight: boolean + setInFlight: (inFlight: boolean) => void + handleAfterCloned: (clonedProject: any, tags: Tag[]) => void + projectId: string + projectName: string + projectTags: Tag[] }) { const { t } = useTranslation() - const [error, setError] = useState() + const [error, setError] = useState() const [clonedProjectName, setClonedProjectName] = useState( `${projectName} (Copy)` ) @@ -42,7 +50,7 @@ export default function CloneProjectModalContent({ ) // form submission: clone the project if the name is valid - const handleSubmit = event => { + const handleSubmit = (event: FormEvent) => { event.preventDefault() if (!valid) { @@ -75,7 +83,7 @@ export default function CloneProjectModalContent({ }) } - const removeTag = useCallback(tag => { + const removeTag = useCallback((tag: Tag) => { setClonedProjectTags(value => value.filter(item => item._id !== tag._id)) }, []) @@ -91,7 +99,7 @@ export default function CloneProjectModalContent({ {t('new_name')} setClonedProjectName(event.target.value)} @@ -120,7 +128,11 @@ export default function CloneProjectModalContent({ {error && ( )} @@ -142,18 +154,3 @@ export default function CloneProjectModalContent({ ) } -CloneProjectModalContent.propTypes = { - handleHide: PropTypes.func.isRequired, - inFlight: PropTypes.bool, - setInFlight: PropTypes.func.isRequired, - handleAfterCloned: PropTypes.func.isRequired, - projectId: PropTypes.string, - projectName: PropTypes.string, - projectTags: PropTypes.arrayOf( - PropTypes.shape({ - _id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - color: PropTypes.string, - }) - ), -} diff --git a/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.jsx b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.tsx similarity index 71% rename from services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.jsx rename to services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.tsx index 4ebc22545d..8c5793b87e 100644 --- a/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.jsx +++ b/services/web/frontend/js/features/clone-project-modal/components/clone-project-modal.tsx @@ -1,7 +1,8 @@ import React, { memo, useCallback, useState } from 'react' -import PropTypes from 'prop-types' import CloneProjectModalContent from './clone-project-modal-content' import OLModal from '@/features/ui/components/ol/ol-modal' +import { ClonedProject } from '../../../../../types/project/dashboard/api' +import { Tag } from '../../../../../app/src/Features/Tags/types' function CloneProjectModal({ show, @@ -10,6 +11,13 @@ function CloneProjectModal({ projectId, projectName, projectTags, +}: { + show: boolean + handleHide: () => void + handleAfterCloned: (clonedProject: ClonedProject, tags: Tag[]) => void + projectId: string + projectName: string + projectTags: Tag[] }) { const [inFlight, setInFlight] = useState(false) @@ -42,19 +50,4 @@ function CloneProjectModal({ ) } -CloneProjectModal.propTypes = { - handleHide: PropTypes.func.isRequired, - show: PropTypes.bool.isRequired, - handleAfterCloned: PropTypes.func.isRequired, - projectId: PropTypes.string, - projectName: PropTypes.string, - projectTags: PropTypes.arrayOf( - PropTypes.shape({ - _id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - color: PropTypes.string, - }) - ), -} - export default memo(CloneProjectModal) diff --git a/services/web/frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper.jsx b/services/web/frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper.tsx similarity index 74% rename from services/web/frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper.jsx rename to services/web/frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper.tsx index 2ebc6edece..aba9a87fbc 100644 --- a/services/web/frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper.jsx +++ b/services/web/frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper.tsx @@ -1,11 +1,18 @@ import React from 'react' -import PropTypes from 'prop-types' import { useProjectContext } from '../../../shared/context/project-context' import withErrorBoundary from '../../../infrastructure/error-boundary' import CloneProjectModal from './clone-project-modal' const EditorCloneProjectModalWrapper = React.memo( - function EditorCloneProjectModalWrapper({ show, handleHide, openProject }) { + function EditorCloneProjectModalWrapper({ + show, + handleHide, + openProject, + }: { + show: boolean + handleHide: () => void + openProject: ({ project_id }: { project_id: string }) => void + }) { const { _id: projectId, name: projectName, @@ -30,10 +37,4 @@ const EditorCloneProjectModalWrapper = React.memo( } ) -EditorCloneProjectModalWrapper.propTypes = { - handleHide: PropTypes.func.isRequired, - show: PropTypes.bool.isRequired, - openProject: PropTypes.func.isRequired, -} - export default withErrorBoundary(EditorCloneProjectModalWrapper) diff --git a/services/web/frontend/js/features/contact-form/search.js b/services/web/frontend/js/features/contact-form/search.js index dddba6781f..10e2ab2f63 100644 --- a/services/web/frontend/js/features/contact-form/search.js +++ b/services/web/frontend/js/features/contact-form/search.js @@ -46,7 +46,8 @@ export function setupSearch(formEl) { linkEl.append(contentEl) const iconEl = document.createElement('i') - iconEl.className = 'fa fa-angle-right' + iconEl.className = 'material-symbols dropdown-item-trailing-icon' + iconEl.innerText = 'open_in_new' iconEl.setAttribute('aria-hidden', 'true') linkEl.append(iconEl) diff --git a/services/web/frontend/js/features/editor-left-menu/components/editor-left-menu.tsx b/services/web/frontend/js/features/editor-left-menu/components/editor-left-menu.tsx index 392793ec9c..40eed9e77e 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/editor-left-menu.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/editor-left-menu.tsx @@ -3,7 +3,7 @@ import LeftMenuMask from './left-menu-mask' import classNames from 'classnames' import { lazy, memo, Suspense } from 'react' import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner' -import { Offcanvas } from 'react-bootstrap-5' +import { Offcanvas } from 'react-bootstrap' import { EditorLeftMenuProvider } from './editor-left-menu-context' import withErrorBoundary from '@/infrastructure/error-boundary' import OLNotification from '@/features/ui/components/ol/ol-notification' diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-menu-select.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-menu-select.tsx index a6a68cd5f9..6b1f06ec36 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-menu-select.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-menu-select.tsx @@ -2,7 +2,7 @@ import OLFormGroup from '@/features/ui/components/ol/ol-form-group' import OLFormLabel from '@/features/ui/components/ol/ol-form-label' import OLFormSelect from '@/features/ui/components/ol/ol-form-select' import { ChangeEventHandler, useCallback, useEffect, useRef } from 'react' -import { Spinner } from 'react-bootstrap-5' +import { Spinner } from 'react-bootstrap' import { useEditorLeftMenuContext } from '@/features/editor-left-menu/components/editor-left-menu-context' type PossibleValue = string | number | boolean diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/chat-toggle-button.jsx b/services/web/frontend/js/features/editor-navigation-toolbar/components/chat-toggle-button.tsx similarity index 76% rename from services/web/frontend/js/features/editor-navigation-toolbar/components/chat-toggle-button.jsx rename to services/web/frontend/js/features/editor-navigation-toolbar/components/chat-toggle-button.tsx index 78afcdde42..3336d59aa6 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/chat-toggle-button.jsx +++ b/services/web/frontend/js/features/editor-navigation-toolbar/components/chat-toggle-button.tsx @@ -1,10 +1,17 @@ -import PropTypes from 'prop-types' import classNames from 'classnames' import { useTranslation } from 'react-i18next' import MaterialIcon from '@/shared/components/material-icon' import OLBadge from '@/features/ui/components/ol/ol-badge' -function ChatToggleButton({ chatIsOpen, unreadMessageCount, onClick }) { +function ChatToggleButton({ + chatIsOpen, + unreadMessageCount, + onClick, +}: { + chatIsOpen: boolean + unreadMessageCount: number + onClick: () => void +}) { const { t } = useTranslation() const classes = classNames('btn', 'btn-full-height', { active: chatIsOpen }) @@ -26,10 +33,4 @@ function ChatToggleButton({ chatIsOpen, unreadMessageCount, onClick }) { ) } -ChatToggleButton.propTypes = { - chatIsOpen: PropTypes.bool, - unreadMessageCount: PropTypes.number.isRequired, - onClick: PropTypes.func.isRequired, -} - export default ChatToggleButton diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/cobranding-logo.jsx b/services/web/frontend/js/features/editor-navigation-toolbar/components/cobranding-logo.tsx similarity index 67% rename from services/web/frontend/js/features/editor-navigation-toolbar/components/cobranding-logo.jsx rename to services/web/frontend/js/features/editor-navigation-toolbar/components/cobranding-logo.tsx index 80f3bc12b8..56d9f7b6ab 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/cobranding-logo.jsx +++ b/services/web/frontend/js/features/editor-navigation-toolbar/components/cobranding-logo.tsx @@ -1,9 +1,11 @@ -import PropTypes from 'prop-types' - function CobrandingLogo({ brandVariationHomeUrl, brandVariationName, logoImgUrl, +}: { + brandVariationHomeUrl: string + brandVariationName: string + logoImgUrl: string }) { return ( void openShareProjectModal: () => void }) { @@ -93,7 +94,7 @@ const EditorNavigationToolbarRoot = React.memo( }, [setLeftMenuShown]) const goToUser = useCallback( - (user: any) => { + (user: OnlineUser) => { if (user.doc && typeof user.row === 'number') { openDoc(user.doc, { gotoLine: user.row + 1 }) } @@ -103,7 +104,6 @@ const EditorNavigationToolbarRoot = React.memo( return ( void }) { const { t } = useTranslation() return ( @@ -16,8 +15,4 @@ function HistoryToggleButton({ onClick }) { ) } -HistoryToggleButton.propTypes = { - onClick: PropTypes.func.isRequired, -} - export default HistoryToggleButton diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.tsx b/services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.tsx index 748dd565c8..ff0fbda5f1 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.tsx +++ b/services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, forwardRef } from 'react' -import { Spinner } from 'react-bootstrap-5' +import { Spinner } from 'react-bootstrap' import { Dropdown, DropdownItem, diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/menu-button.jsx b/services/web/frontend/js/features/editor-navigation-toolbar/components/menu-button.tsx similarity index 77% rename from services/web/frontend/js/features/editor-navigation-toolbar/components/menu-button.jsx rename to services/web/frontend/js/features/editor-navigation-toolbar/components/menu-button.tsx index fa3d8fa46c..fb059737dd 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/menu-button.jsx +++ b/services/web/frontend/js/features/editor-navigation-toolbar/components/menu-button.tsx @@ -1,8 +1,7 @@ -import PropTypes from 'prop-types' import { useTranslation } from 'react-i18next' import MaterialIcon from '@/shared/components/material-icon' -function MenuButton({ onClick }) { +function MenuButton({ onClick }: { onClick: () => void }) { const { t } = useTranslation() return ( @@ -15,8 +14,4 @@ function MenuButton({ onClick }) { ) } -MenuButton.propTypes = { - onClick: PropTypes.func.isRequired, -} - export default MenuButton diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/online-users-widget.jsx b/services/web/frontend/js/features/editor-navigation-toolbar/components/online-users-widget.tsx similarity index 77% rename from services/web/frontend/js/features/editor-navigation-toolbar/components/online-users-widget.jsx rename to services/web/frontend/js/features/editor-navigation-toolbar/components/online-users-widget.tsx index 700bc5eb05..6188d5e8ea 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/online-users-widget.jsx +++ b/services/web/frontend/js/features/editor-navigation-toolbar/components/online-users-widget.tsx @@ -1,5 +1,4 @@ import React from 'react' -import PropTypes from 'prop-types' import { useTranslation } from 'react-i18next' import { Dropdown, @@ -11,17 +10,27 @@ import { import { getBackgroundColorForUserId } from '@/shared/utils/colors' import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import MaterialIcon from '@/shared/components/material-icon' +import { OnlineUser } from '@/features/ide-react/context/online-users-context' -function OnlineUsersWidget({ onlineUsers, goToUser }) { +function OnlineUsersWidget({ + onlineUsers, + goToUser, +}: { + onlineUsers: OnlineUser[] + goToUser: (user: OnlineUser) => void +}) { const { t } = useTranslation() const shouldDisplayDropdown = onlineUsers.length >= 4 if (shouldDisplayDropdown) { return ( - + @@ -63,17 +72,15 @@ function OnlineUsersWidget({ onlineUsers, goToUser }) { } } -OnlineUsersWidget.propTypes = { - onlineUsers: PropTypes.arrayOf( - PropTypes.shape({ - user_id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - }) - ).isRequired, - goToUser: PropTypes.func.isRequired, -} - -function UserIcon({ user, showName, onClick }) { +function UserIcon({ + user, + showName, + onClick, +}: { + user: OnlineUser + showName?: boolean + onClick?: (user: OnlineUser) => void +}) { const backgroundColor = getBackgroundColorForUserId(user.user_id) function handleOnClick() { @@ -93,16 +100,10 @@ function UserIcon({ user, showName, onClick }) { ) } -UserIcon.propTypes = { - user: PropTypes.shape({ - user_id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - }), - showName: PropTypes.bool, - onClick: PropTypes.func, -} - -const DropDownToggleButton = React.forwardRef((props, ref) => { +const DropDownToggleButton = React.forwardRef< + HTMLButtonElement, + { onlineUserCount: number; onClick: React.MouseEventHandler } +>((props, ref) => { const { t } = useTranslation() return ( { DropDownToggleButton.displayName = 'DropDownToggleButton' -DropDownToggleButton.propTypes = { - onlineUserCount: PropTypes.number.isRequired, - onClick: PropTypes.func, -} - export default OnlineUsersWidget diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/share-project-button.jsx b/services/web/frontend/js/features/editor-navigation-toolbar/components/share-project-button.tsx similarity index 77% rename from services/web/frontend/js/features/editor-navigation-toolbar/components/share-project-button.jsx rename to services/web/frontend/js/features/editor-navigation-toolbar/components/share-project-button.tsx index 6c4123f03d..359ce92ffb 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/share-project-button.jsx +++ b/services/web/frontend/js/features/editor-navigation-toolbar/components/share-project-button.tsx @@ -1,8 +1,7 @@ -import PropTypes from 'prop-types' import { useTranslation } from 'react-i18next' import MaterialIcon from '@/shared/components/material-icon' -function ShareProjectButton({ onClick }) { +function ShareProjectButton({ onClick }: { onClick: () => void }) { const { t } = useTranslation() return ( @@ -16,8 +15,4 @@ function ShareProjectButton({ onClick }) { ) } -ShareProjectButton.propTypes = { - onClick: PropTypes.func.isRequired, -} - export default ShareProjectButton diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.jsx b/services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.tsx similarity index 82% rename from services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.jsx rename to services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.tsx index 97bfa763cc..4304768c48 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.jsx +++ b/services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.tsx @@ -1,5 +1,4 @@ -import React from 'react' -import PropTypes from 'prop-types' +import React, { ElementType } from 'react' import { useTranslation } from 'react-i18next' import MenuButton from './menu-button' import CobrandingLogo from './cobranding-logo' @@ -18,13 +17,22 @@ import getMeta from '@/utils/meta' import { isSplitTestEnabled } from '@/utils/splitTestUtils' import { canUseNewEditor } from '@/features/ide-redesign/utils/new-editor-utils' import TryNewEditorButton from '../try-new-editor-button' +import { OnlineUser } from '@/features/ide-react/context/online-users-context' +import { Cobranding } from '../../../../../types/cobranding' -const [publishModalModules] = importOverleafModules('publishModal') +const [publishModalModules] = importOverleafModules('publishModal') as { + import: { default: ElementType } + path: string +}[] const PublishButton = publishModalModules?.import.default const offlineModeToolbarButtons = importOverleafModules( 'offlineModeToolbarButtons' -) +) as { + import: { default: ElementType } + path: string +}[] + // double opt-in const enableROMirrorOnClient = isSplitTestEnabled('ro-mirror-on-client') && @@ -51,6 +59,26 @@ const ToolbarHeader = React.memo(function ToolbarHeader({ hasRenamePermissions, openShareModal, trackChangesVisible, +}: { + cobranding: Cobranding | undefined + onShowLeftMenuClick: () => void + chatIsOpen: boolean + toggleChatOpen: () => void + reviewPanelOpen: boolean + toggleReviewPanelOpen: (e: React.MouseEvent) => void + historyIsOpen: boolean + toggleHistoryOpen: () => void + unreadMessageCount: number + onlineUsers: OnlineUser[] + goToUser: (user: OnlineUser) => void + isRestrictedTokenMember: boolean | undefined + hasPublishPermissions: boolean + chatVisible: boolean + projectName: string + renameProject: (name: string) => void + hasRenamePermissions: boolean + openShareModal: () => void + trackChangesVisible: boolean | undefined }) { const chatEnabled = getMeta('ol-chatEnabled') @@ -132,26 +160,4 @@ const ToolbarHeader = React.memo(function ToolbarHeader({ ) }) -ToolbarHeader.propTypes = { - onShowLeftMenuClick: PropTypes.func.isRequired, - cobranding: PropTypes.object, - chatIsOpen: PropTypes.bool, - toggleChatOpen: PropTypes.func.isRequired, - reviewPanelOpen: PropTypes.bool, - toggleReviewPanelOpen: PropTypes.func.isRequired, - historyIsOpen: PropTypes.bool, - toggleHistoryOpen: PropTypes.func.isRequired, - unreadMessageCount: PropTypes.number.isRequired, - onlineUsers: PropTypes.array.isRequired, - goToUser: PropTypes.func.isRequired, - isRestrictedTokenMember: PropTypes.bool, - hasPublishPermissions: PropTypes.bool, - chatVisible: PropTypes.bool, - projectName: PropTypes.string.isRequired, - renameProject: PropTypes.func.isRequired, - hasRenamePermissions: PropTypes.bool, - openShareModal: PropTypes.func.isRequired, - trackChangesVisible: PropTypes.bool, -} - export default ToolbarHeader diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/track-changes-toggle-button.jsx b/services/web/frontend/js/features/editor-navigation-toolbar/components/track-changes-toggle-button.tsx similarity index 81% rename from services/web/frontend/js/features/editor-navigation-toolbar/components/track-changes-toggle-button.jsx rename to services/web/frontend/js/features/editor-navigation-toolbar/components/track-changes-toggle-button.tsx index 469dc7cd29..927e6ec2c5 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/track-changes-toggle-button.jsx +++ b/services/web/frontend/js/features/editor-navigation-toolbar/components/track-changes-toggle-button.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types' import classNames from 'classnames' import { useTranslation } from 'react-i18next' import MaterialIcon from '@/shared/components/material-icon' @@ -7,6 +6,10 @@ function TrackChangesToggleButton({ trackChangesIsOpen, disabled, onMouseDown, +}: { + trackChangesIsOpen: boolean + disabled?: boolean + onMouseDown: (e: React.MouseEvent) => void }) { const { t } = useTranslation() const classes = classNames('btn', 'btn-full-height', { @@ -30,10 +33,4 @@ function TrackChangesToggleButton({ ) } -TrackChangesToggleButton.propTypes = { - trackChangesIsOpen: PropTypes.bool, - disabled: PropTypes.bool, - onMouseDown: PropTypes.func.isRequired, -} - export default TrackChangesToggleButton diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/upgrade-prompt.jsx b/services/web/frontend/js/features/editor-navigation-toolbar/components/upgrade-prompt.tsx similarity index 96% rename from services/web/frontend/js/features/editor-navigation-toolbar/components/upgrade-prompt.jsx rename to services/web/frontend/js/features/editor-navigation-toolbar/components/upgrade-prompt.tsx index 594dd9feec..b5bbc1a0ba 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/upgrade-prompt.jsx +++ b/services/web/frontend/js/features/editor-navigation-toolbar/components/upgrade-prompt.tsx @@ -5,7 +5,7 @@ import OLButton from '@/features/ui/components/ol/ol-button' function UpgradePrompt() { const { t } = useTranslation() - function handleClick(e) { + function handleClick() { eventTracking.send('subscription-funnel', 'code-editor', 'upgrade') eventTracking.sendMB('upgrade-button-click', { source: 'code-editor' }) } diff --git a/services/web/frontend/js/features/faq-search/index.js b/services/web/frontend/js/features/faq-search/index.js index bb0b11b7a4..8774ee235a 100644 --- a/services/web/frontend/js/features/faq-search/index.js +++ b/services/web/frontend/js/features/faq-search/index.js @@ -2,7 +2,7 @@ import _ from 'lodash' import { formatWikiHit, searchWiki } from '../algolia-search/search-wiki' function setupSearch(formEl) { - const inputEl = formEl.querySelector('input[type="text"]') + const inputEl = formEl.querySelector('input[type="search"]') const resultsEl = formEl.querySelector('[data-ol-search-results]') const wrapperEl = formEl.querySelector('[data-ol-search-results-wrapper]') const noResultsEl = formEl.querySelector('[data-ol-search-no-results]') diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/danger-message.jsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/danger-message.jsx deleted file mode 100644 index ceaafc6fa8..0000000000 --- a/services/web/frontend/js/features/file-tree/components/file-tree-create/danger-message.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import OLNotification from '@/features/ui/components/ol/ol-notification' -import PropTypes from 'prop-types' - -export default function DangerMessage({ children }) { - return -} -DangerMessage.propTypes = { - children: PropTypes.any.isRequired, -} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/danger-message.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/danger-message.tsx new file mode 100644 index 0000000000..d6b7173cb0 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/danger-message.tsx @@ -0,0 +1,9 @@ +import OLNotification from '@/features/ui/components/ol/ol-notification' + +export default function DangerMessage({ + children, +}: { + children: React.ReactNode +}) { + return +} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/error-message.jsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/error-message.tsx similarity index 94% rename from services/web/frontend/js/features/file-tree/components/file-tree-create/error-message.jsx rename to services/web/frontend/js/features/file-tree/components/file-tree-create/error-message.tsx index 0c9ab381c6..02cc083928 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-create/error-message.jsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/error-message.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types' import { useTranslation } from 'react-i18next' import { FetchError } from '../../../../infrastructure/fetch-json' import RedirectToLogin from './redirect-to-login' @@ -9,7 +8,12 @@ import { } from '../../errors' import DangerMessage from './danger-message' -export default function ErrorMessage({ error }) { +// TODO: Update the error type when we properly type FileTreeActionableContext +export default function ErrorMessage({ + error, +}: { + error: string | Record +}) { const { t } = useTranslation() const fileNameLimit = 150 @@ -119,6 +123,3 @@ export default function ErrorMessage({ error }) { return {t('generic_something_went_wrong')} } } -ErrorMessage.propTypes = { - error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, -} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-create-name-input.jsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-create-name-input.tsx similarity index 85% rename from services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-create-name-input.jsx rename to services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-create-name-input.tsx index a76768c4df..80c53f1b63 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-create-name-input.jsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-create-name-input.tsx @@ -1,7 +1,6 @@ import { useRef, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useFileTreeCreateName } from '../../contexts/file-tree-create-name' -import PropTypes from 'prop-types' import { BlockedFilenameError, DuplicateFilenameError, @@ -23,6 +22,15 @@ export default function FileTreeCreateNameInput({ placeholder, error, inFlight, +}: { + label?: string + focusName?: boolean + classes?: { + formGroup?: string + } + placeholder?: string + error?: string | Record + inFlight: boolean }) { const { t } = useTranslation() @@ -30,7 +38,7 @@ export default function FileTreeCreateNameInput({ const { name, setName, touchedName, validName } = useFileTreeCreateName() // focus the first part of the filename if needed - const inputRef = useRef(null) + const inputRef = useRef(null) useEffect(() => { if (inputRef.current && focusName) { @@ -73,18 +81,7 @@ export default function FileTreeCreateNameInput({ ) } -FileTreeCreateNameInput.propTypes = { - focusName: PropTypes.bool, - label: PropTypes.string, - classes: PropTypes.shape({ - formGroup: PropTypes.string, - }), - placeholder: PropTypes.string, - error: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), - inFlight: PropTypes.bool.isRequired, -} - -function ErrorMessage({ error }) { +function ErrorMessage({ error }: { error: string | Record }) { const { t } = useTranslation() // if (typeof error === 'string') { @@ -124,6 +121,3 @@ function ErrorMessage({ error }) { return null // other errors are displayed elsewhere } } -ErrorMessage.propTypes = { - error: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired, -} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-footer.jsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-footer.tsx similarity index 79% rename from services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-footer.jsx rename to services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-footer.tsx index 3432d7b1b8..d1966f1b5d 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-footer.jsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-footer.tsx @@ -2,7 +2,6 @@ import { useTranslation } from 'react-i18next' import { useFileTreeCreateForm } from '../../contexts/file-tree-create-form' import { useFileTreeActionable } from '../../contexts/file-tree-actionable' import { useFileTreeData } from '../../../../shared/context/file-tree-data-context' -import PropTypes from 'prop-types' import OLButton from '@/features/ui/components/ol/ol-button' import OLNotification from '@/features/ui/components/ol/ol-notification' @@ -26,21 +25,33 @@ export function FileTreeModalCreateFileFooterContent({ valid, fileCount, inFlight, - newFileCreateMode, cancel, + newFileCreateMode, +}: { + valid: boolean + fileCount: + | { + limit: number + status: string + value: number + } + | number + inFlight: boolean + cancel: () => void + newFileCreateMode?: string }) { const { t } = useTranslation() return ( <> - {fileCount.status === 'warning' && ( + {typeof fileCount !== 'number' && fileCount.status === 'warning' && (
{t('project_approaching_file_limit')} ({fileCount.value}/ {fileCount.limit})
)} - {fileCount.status === 'error' && ( + {typeof fileCount !== 'number' && fileCount.status === 'error' && ( ) } -FileTreeModalCreateFileFooterContent.propTypes = { - cancel: PropTypes.func.isRequired, - fileCount: PropTypes.shape({ - limit: PropTypes.number.isRequired, - status: PropTypes.string.isRequired, - value: PropTypes.number.isRequired, - }).isRequired, - inFlight: PropTypes.bool.isRequired, - newFileCreateMode: PropTypes.string, - valid: PropTypes.bool.isRequired, -} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-mode.jsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-mode.tsx similarity index 75% rename from services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-mode.jsx rename to services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-mode.tsx index 259dac3144..40a3d7ba9e 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-mode.jsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-mode.tsx @@ -1,11 +1,18 @@ import classnames from 'classnames' -import PropTypes from 'prop-types' import { useFileTreeActionable } from '../../contexts/file-tree-actionable' import * as eventTracking from '../../../../infrastructure/event-tracking' import OLButton from '@/features/ui/components/ol/ol-button' import MaterialIcon from '@/shared/components/material-icon' -export default function FileTreeModalCreateFileMode({ mode, icon, label }) { +export default function FileTreeModalCreateFileMode({ + mode, + icon, + label, +}: { + mode: string + icon: string + label: string +}) { const { newFileCreateMode, startCreatingFile } = useFileTreeActionable() const handleClick = () => { @@ -27,9 +34,3 @@ export default function FileTreeModalCreateFileMode({ mode, icon, label }) { ) } - -FileTreeModalCreateFileMode.propTypes = { - mode: PropTypes.string.isRequired, - icon: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, -} diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx index 7afdbbdd55..7e02183641 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-import-from-project.tsx @@ -24,7 +24,7 @@ import OLFormGroup from '@/features/ui/components/ol/ol-form-group' import OLFormLabel from '@/features/ui/components/ol/ol-form-label' import OLForm from '@/features/ui/components/ol/ol-form' import OLFormSelect from '@/features/ui/components/ol/ol-form-select' -import { Spinner } from 'react-bootstrap-5' +import { Spinner } from 'react-bootstrap' export default function FileTreeImportFromProject() { const { t } = useTranslation() diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-draggable-preview-layer.jsx b/services/web/frontend/js/features/file-tree/components/file-tree-draggable-preview-layer.tsx similarity index 74% rename from services/web/frontend/js/features/file-tree/components/file-tree-draggable-preview-layer.jsx rename to services/web/frontend/js/features/file-tree/components/file-tree-draggable-preview-layer.tsx index fffc4f5ae1..e9076cc3c3 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-draggable-preview-layer.jsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-draggable-preview-layer.tsx @@ -1,6 +1,6 @@ import { useRef } from 'react' -import PropTypes from 'prop-types' import classNames from 'classnames' +import { XYCoord } from 'react-dnd' // a custom component rendered on top of a draggable area that renders the // dragged item. See @@ -12,8 +12,13 @@ function FileTreeDraggablePreviewLayer({ isDragging, item, clientOffset, +}: { + isOver: boolean + isDragging: boolean + item: { title: string } + clientOffset: XYCoord | null }) { - const ref = useRef() + const ref = useRef(null) return (
{title}
} -DraggablePreviewItem.propTypes = { - title: PropTypes.string.isRequired, -} - // makes the preview item follow the cursor. // See https://react-dnd.github.io/react-dnd/docs/api/drag-layer-monitor -function getItemStyle(clientOffset, containerOffset) { +function getItemStyle( + clientOffset: XYCoord | null, + containerOffset: DOMRect | undefined +) { if (!containerOffset || !clientOffset) { return { display: 'none', diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-root.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-root.tsx index 561e1b5e1d..a6cde94e73 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-root.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-root.tsx @@ -6,7 +6,7 @@ import FileTreeContext from './file-tree-context' import FileTreeDraggablePreviewLayer from './file-tree-draggable-preview-layer' import FileTreeFolderList from './file-tree-folder-list' import FileTreeToolbar from './file-tree-toolbar' -import FileTreeToolbarNew from '@/features/ide-redesign/components/file-tree-toolbar' +import FileTreeToolbarNew from '@/features/ide-redesign/components/file-tree/file-tree-toolbar' import FileTreeModalDelete from './modals/file-tree-modal-delete' import FileTreeModalCreateFolder from './modals/file-tree-modal-create-folder' import FileTreeModalError from './modals/file-tree-modal-error' diff --git a/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.jsx b/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.tsx similarity index 87% rename from services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.jsx rename to services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.tsx index 850b603229..7342c1f752 100644 --- a/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.jsx +++ b/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.tsx @@ -1,5 +1,4 @@ -import { useEffect, useState } from 'react' -import PropTypes from 'prop-types' +import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus' import { useFileTreeActionable } from '../../contexts/file-tree-actionable' @@ -109,19 +108,25 @@ function InputName({ validName, setValidName, handleCreateFolder, +}: { + name: string + setName: (name: string) => void + validName: boolean + setValidName: (validName: boolean) => void + handleCreateFolder: () => void }) { - const { autoFocusedRef } = useRefWithAutoFocus() + const { autoFocusedRef } = useRefWithAutoFocus() - function handleFocus(ev) { + function handleFocus(ev: React.FocusEvent) { ev.target.setSelectionRange(0, -1) } - function handleChange(ev) { + function handleChange(ev: React.ChangeEvent) { setValidName(isCleanFilename(ev.target.value.trim())) setName(ev.target.value) } - function handleKeyDown(ev) { + function handleKeyDown(ev: React.KeyboardEvent) { if (ev.key === 'Enter' && validName) { handleCreateFolder() } @@ -140,12 +145,4 @@ function InputName({ ) } -InputName.propTypes = { - name: PropTypes.string.isRequired, - setName: PropTypes.func.isRequired, - validName: PropTypes.bool.isRequired, - setValidName: PropTypes.func.isRequired, - handleCreateFolder: PropTypes.func.isRequired, -} - export default FileTreeModalCreateFolder diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx index 6b26de3298..54723e3e0d 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx @@ -57,6 +57,7 @@ const FileTreeActionableContext = createContext< newFileCreateMode: any | null error: any | null canDelete: boolean + canBulkDelete: boolean canRename: boolean canCreate: boolean parentFolderId: string @@ -509,6 +510,8 @@ export const FileTreeActionableProvider: FC = ({ const value = useMemo( () => ({ canDelete: write && selectedEntityIds.size > 0 && !isRootFolderSelected, + canBulkDelete: + write && selectedEntityIds.size > 1 && !isRootFolderSelected, canRename: write && selectedEntityIds.size === 1 && !isRootFolderSelected, canCreate: write && selectedEntityIds.size < 2, ...state, @@ -545,8 +548,8 @@ export const FileTreeActionableProvider: FC = ({ isDuplicate, isRootFolderSelected, parentFolderId, - selectedEntityIds.size, selectedFileName, + selectedEntityIds.size, startCreatingDocOrFile, startCreatingFile, startCreatingFolder, diff --git a/services/web/frontend/js/features/form-helpers/hydrate-form.js b/services/web/frontend/js/features/form-helpers/hydrate-form.js index 9c00dd43ae..ed7b9fc26e 100644 --- a/services/web/frontend/js/features/form-helpers/hydrate-form.js +++ b/services/web/frontend/js/features/form-helpers/hydrate-form.js @@ -3,6 +3,7 @@ import { FetchError, postJSON } from '../../infrastructure/fetch-json' import { canSkipCaptcha, validateCaptchaV2 } from './captcha' import inputValidator from './input-validator' import { disableElement, enableElement } from '../utils/disableElement' +import { isBootstrap5 } from '@/features/utils/bootstrap-5' // Form helper(s) to handle: // - Attaching to the relevant form elements @@ -133,6 +134,66 @@ function hideFormElements(formEl) { } } +/** + * Creates a notification element from a message object, with BS5 classes. + * + * @param {Object} message + * @param {'error' | 'success' | 'warning' | 'info'} message.type + * @param {string} message.key + * @param {string} message.text + * @param {string[]} message.hints + * @returns {HTMLDivElement} + */ +function createNotificationFromMessageBS5(message) { + const messageEl = document.createElement('div') + messageEl.className = classNames('mb-3 notification', { + 'notification-type-error': message.type === 'error', + 'notification-type-success': message.type === 'success', + 'notification-type-warning': message.type === 'warning', + 'notification-type-info': message.type === 'info', + }) + messageEl.setAttribute('aria-live', 'assertive') + messageEl.setAttribute('role', message.type === 'error' ? 'alert' : 'status') + + const materialIcon = { + info: 'info', + success: 'check_circle', + error: 'error', + warning: 'warning', + }[message.type] + if (materialIcon) { + const iconEl = document.createElement('div') + iconEl.className = 'notification-icon' + const iconSpan = document.createElement('span') + iconSpan.className = 'material-symbols' + iconSpan.setAttribute('aria-hidden', 'true') + iconSpan.textContent = materialIcon + iconEl.append(iconSpan) + messageEl.append(iconEl) + } + + const contentAndCtaEl = document.createElement('div') + contentAndCtaEl.className = 'notification-content-and-cta' + + const contentEl = document.createElement('div') + contentEl.className = 'notification-content' + contentEl.append(message.text || `Error: ${message.key}`) + + if (message.hints && message.hints.length) { + const listEl = document.createElement('ul') + message.hints.forEach(hint => { + const listItemEl = document.createElement('li') + listItemEl.textContent = hint + listEl.append(listItemEl) + }) + contentEl.append(listEl) + } + contentAndCtaEl.append(contentEl) + messageEl.append(contentAndCtaEl) + + return messageEl +} + // TODO: remove the showMessages function after every form alerts are updated to use the new style // TODO: rename showMessagesNewStyle to showMessages after the above is done function showMessages(formEl, messageBag) { @@ -157,6 +218,9 @@ function showMessages(formEl, messageBag) { customErrorElements.forEach(el => { el.hidden = false }) + } else if (isBootstrap5()) { + const notification = createNotificationFromMessageBS5(message) + messagesEl.append(notification) } else { // No custom error element for key on page, append a new error message const messageEl = document.createElement('div') @@ -311,7 +375,7 @@ function formSentHelper(el) { } function formValidationHelper(el) { - el.querySelectorAll('input').forEach(inputEl => { + el.querySelectorAll('input, textarea').forEach(inputEl => { if ( inputEl.willValidate && !inputEl.hasAttribute('data-ol-no-custom-form-validation-messages') diff --git a/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx index 988876a4a0..ab9f96a975 100644 --- a/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx +++ b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx @@ -13,7 +13,7 @@ import { FormGroup, FormLabel, FormControl, -} from 'react-bootstrap-5' +} from 'react-bootstrap' import FormText from '@/features/ui/components/bootstrap-5/form/form-text' import Button from '@/features/ui/components/bootstrap-5/button' import PoNumber from '@/features/group-management/components/add-seats/po-number' @@ -33,6 +33,7 @@ import { sendMB } from '../../../../infrastructure/event-tracking' import { useFeatureFlag } from '@/shared/context/split-test-context' export const MAX_NUMBER_OF_USERS = 20 +export const MAX_NUMBER_OF_PO_NUMBER_CHARACTERS = 50 type CostSummaryData = MergeAndOverride< SubscriptionChangePreview, @@ -47,6 +48,7 @@ function AddSeats() { const isProfessional = getMeta('ol-isProfessional') const isCollectionMethodManual = getMeta('ol-isCollectionMethodManual') const [addSeatsInputError, setAddSeatsInputError] = useState() + const [poNumberInputError, setPoNumberInputError] = useState() const [shouldContactSales, setShouldContactSales] = useState(false) const isFlexibleGroupLicensingForManuallyBilledSubscriptions = useFeatureFlag( 'flexible-group-licensing-for-manually-billed-subscriptions' @@ -125,6 +127,38 @@ function AddSeats() { } } + const poNumberValidationSchema = useMemo(() => { + return yup + .string() + .matches( + /^[\p{L}\p{N}]*$/u, + t('po_number_can_include_digits_and_letters_only') + ) + .max( + MAX_NUMBER_OF_PO_NUMBER_CHARACTERS, + t('po_number_must_not_exceed_x_characters', { + count: MAX_NUMBER_OF_PO_NUMBER_CHARACTERS, + }) + ) + }, [t]) + + const validatePoNumber = async (value: string | undefined) => { + try { + await poNumberValidationSchema.validate(value) + setPoNumberInputError(undefined) + + return true + } catch (error) { + if (error instanceof yup.ValidationError) { + setPoNumberInputError(error.errors[0]) + } else { + debugConsole.error(error) + } + + return false + } + } + const handleSeatsChange = async (e: React.ChangeEvent) => { const value = e.target.value === '' ? undefined : e.target.value const isValidSeatsNumber = await validateSeats(value) @@ -161,7 +195,10 @@ function AddSeats() { ? undefined : (formData.get('po_number') as string) - if (!(await validateSeats(rawSeats))) { + if ( + !(await validateSeats(rawSeats)) || + !(await validatePoNumber(poNumber)) + ) { return } @@ -337,7 +374,12 @@ function AddSeats() { )} {isFlexibleGroupLicensingForManuallyBilledSubscriptions && - isCollectionMethodManual && } + isCollectionMethodManual && ( + + )} - {t('total_due_today')} + + {isCollectionMethodManual + ? t('total_due_in_x_days', { + days: subscriptionChange.netTerms, + }) + : t('total_due_today')} + {formatCurrency( subscriptionChange.immediateCharge.total, @@ -121,9 +129,14 @@ function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) {
- {t( - 'we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months' - )} + {isCollectionMethodManual + ? t( + 'we_will_invoice_you_now_for_the_additional_licenses_based_on_remaining_months', + { days: subscriptionChange.netTerms } + ) + : t( + 'we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months' + )}
{t( @@ -143,7 +156,8 @@ function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) { ), date: formatTime( subscriptionChange.nextInvoice.date, - 'MMMM D' + 'MMMM D', + true ), } )} diff --git a/services/web/frontend/js/features/group-management/components/add-seats/po-number.tsx b/services/web/frontend/js/features/group-management/components/add-seats/po-number.tsx index f72d7857a4..c6257553d6 100644 --- a/services/web/frontend/js/features/group-management/components/add-seats/po-number.tsx +++ b/services/web/frontend/js/features/group-management/components/add-seats/po-number.tsx @@ -1,9 +1,15 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { FormControl, FormGroup, FormLabel } from 'react-bootstrap-5' +import { FormControl, FormGroup, FormLabel } from 'react-bootstrap' +import FormText from '@/features/ui/components/bootstrap-5/form/form-text' import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox' -function PoNumber() { +type PoNumberProps = { + error: string | undefined + validate: (value: string | undefined) => Promise +} + +function PoNumber({ error, validate }: PoNumberProps) { const { t } = useTranslation() const [isPoNumberChecked, setIsPoNumberChecked] = useState(false) @@ -20,7 +26,15 @@ function PoNumber() { {isPoNumberChecked && ( {t('po_number')} - + await validate(e.target.value)} + isInvalid={Boolean(error)} + /> + {Boolean(error) && {error}} )} diff --git a/services/web/frontend/js/features/group-management/components/card.tsx b/services/web/frontend/js/features/group-management/components/card.tsx index 04b858ba7a..ff51231f5a 100644 --- a/services/web/frontend/js/features/group-management/components/card.tsx +++ b/services/web/frontend/js/features/group-management/components/card.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next' import getMeta from '@/utils/meta' import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n' -import { Card as BSCard, CardBody, Col, Row } from 'react-bootstrap-5' +import { Card as BSCard, CardBody, Col, Row } from 'react-bootstrap' import IconButton from '@/features/ui/components/bootstrap-5/icon-button' type CardProps = { diff --git a/services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx b/services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx index 2b2c111716..971d4fa791 100644 --- a/services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx +++ b/services/web/frontend/js/features/group-management/components/manually-collected-subscription.tsx @@ -1,9 +1,20 @@ import { Trans, useTranslation } from 'react-i18next' import OLNotification from '@/features/ui/components/ol/ol-notification' import Card from '@/features/group-management/components/card' +import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n' +import { useFeatureFlag } from '@/shared/context/split-test-context' function ManuallyCollectedSubscription() { const { t } = useTranslation() + const isFlexibleGroupLicensingForManuallyBilledSubscriptions = useFeatureFlag( + 'flexible-group-licensing-for-manually-billed-subscriptions' + ) + + const { isReady } = useWaitForI18n() + + if (!isReady) { + return null + } return ( @@ -11,13 +22,23 @@ function ManuallyCollectedSubscription() { type="error" title={t('account_billed_manually')} content={ - , - ]} - /> + isFlexibleGroupLicensingForManuallyBilledSubscriptions ? ( + , + ]} + /> + ) : ( + , + ]} + /> + ) } className="m-0" /> diff --git a/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx b/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx index a78f08d40c..bd3b5ee10e 100644 --- a/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx +++ b/services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx @@ -19,7 +19,7 @@ import { useGroupMembersContext } from '../../context/group-members-context' import getMeta from '@/utils/meta' import MaterialIcon from '@/shared/components/material-icon' import DropdownListItem from '@/features/ui/components/bootstrap-5/dropdown-list-item' -import { Spinner } from 'react-bootstrap-5' +import { Spinner } from 'react-bootstrap' type resendInviteResponse = { success: boolean diff --git a/services/web/frontend/js/features/group-management/components/request-status.tsx b/services/web/frontend/js/features/group-management/components/request-status.tsx index e7bf3d9cff..637380ac13 100644 --- a/services/web/frontend/js/features/group-management/components/request-status.tsx +++ b/services/web/frontend/js/features/group-management/components/request-status.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { Card, CardBody, Row, Col } from 'react-bootstrap-5' +import { Card, CardBody, Row, Col } from 'react-bootstrap' import Button from '@/features/ui/components/bootstrap-5/button' import MaterialIcon from '@/shared/components/material-icon' import getMeta from '@/utils/meta' diff --git a/services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription-plan-details.tsx b/services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription-plan-details.tsx index a0a95fc692..85088d74af 100644 --- a/services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription-plan-details.tsx +++ b/services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription-plan-details.tsx @@ -1,7 +1,7 @@ import getMeta from '@/utils/meta' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { Card, Row, Col } from 'react-bootstrap-5' +import { Card, Row, Col } from 'react-bootstrap' import MaterialIcon from '@/shared/components/material-icon' import { formatCurrency } from '@/shared/utils/currency' diff --git a/services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription-upgrade-summary.tsx b/services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription-upgrade-summary.tsx index d7546ccfa7..6d3f76e5a2 100644 --- a/services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription-upgrade-summary.tsx +++ b/services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription-upgrade-summary.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { Card, ListGroup } from 'react-bootstrap-5' +import { Card, ListGroup } from 'react-bootstrap' import { formatCurrency } from '@/shared/utils/currency' import { formatTime } from '@/features/utils/format-date' import { @@ -100,7 +100,11 @@ function UpgradeSummary({ subscriptionChange }: UpgradeSummaryProps) { subscriptionChange.nextInvoice.tax.amount, subscriptionChange.currency ), - date: formatTime(subscriptionChange.nextInvoice.date, 'MMMM D'), + date: formatTime( + subscriptionChange.nextInvoice.date, + 'MMMM D', + true + ), } )} {subscriptionChange.immediateCharge.discount !== 0 && diff --git a/services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription.tsx b/services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription.tsx index f5750ae349..467ce88dea 100644 --- a/services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription.tsx +++ b/services/web/frontend/js/features/group-management/components/upgrade-subscription/upgrade-subscription.tsx @@ -1,7 +1,7 @@ import getMeta from '@/utils/meta' import { postJSON } from '@/infrastructure/fetch-json' import { useTranslation, Trans } from 'react-i18next' -import { Card, Row, Col } from 'react-bootstrap-5' +import { Card, Row, Col } from 'react-bootstrap' import IconButton from '@/features/ui/components/bootstrap-5/icon-button' import Button from '@/features/ui/components/bootstrap-5/button' import UpgradeSubscriptionPlanDetails from './upgrade-subscription-plan-details' diff --git a/services/web/frontend/js/features/header-footer-react/index.tsx b/services/web/frontend/js/features/header-footer-react/index.tsx index ebbf4ddecf..94a43b65fa 100644 --- a/services/web/frontend/js/features/header-footer-react/index.tsx +++ b/services/web/frontend/js/features/header-footer-react/index.tsx @@ -1,6 +1,6 @@ import { createRoot } from 'react-dom/client' import getMeta from '@/utils/meta' -import DefaultNavbar from '@/features/ui/components/bootstrap-5/navbar/default-navbar' +import { DefaultNavbarRoot } from '@/features/ui/components/bootstrap-5/navbar/default-navbar' import Footer from '@/features/ui/components/bootstrap-5/footer/footer' import { SplitTestProvider } from '@/shared/context/split-test-context' @@ -10,7 +10,7 @@ if (navbarElement) { const root = createRoot(navbarElement) root.render( - + ) } diff --git a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx index f8b094f821..0eeb870a2b 100644 --- a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx +++ b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx @@ -23,6 +23,7 @@ import { ReferencesProvider } from '@/features/ide-react/context/references-cont import { SnapshotProvider } from '@/features/ide-react/context/snapshot-context' import { SplitTestProvider } from '@/shared/context/split-test-context' import { UserProvider } from '@/shared/context/user-context' +import { UserFeaturesProvider } from '@/shared/context/user-features-context' import { UserSettingsProvider } from '@/shared/context/user-settings-context' import { IdeRedesignSwitcherProvider } from './ide-redesign-switcher-context' import { CommandRegistryProvider } from './command-registry-context' @@ -60,6 +61,7 @@ export const ReactContextRoot: FC< UserSettingsProvider, IdeRedesignSwitcherProvider, CommandRegistryProvider, + UserFeaturesProvider, ...providers, } @@ -77,35 +79,37 @@ export const ReactContextRoot: FC< - - - - - - - - - - - - - - - {children} - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + {children} + + + + + + + + + + + + + + + diff --git a/services/web/frontend/js/features/ide-react/editor/document-container.ts b/services/web/frontend/js/features/ide-react/editor/document-container.ts index 9172ec9ff8..fee359f146 100644 --- a/services/web/frontend/js/features/ide-react/editor/document-container.ts +++ b/services/web/frontend/js/features/ide-react/editor/document-container.ts @@ -2,7 +2,7 @@ // Migrated from services/web/frontend/js/ide/editor/Document.js import RangesTracker from '@overleaf/ranges-tracker' -import { ShareJsDoc } from './share-js-doc' +import { OTType, ShareJsDoc } from './share-js-doc' import { debugConsole } from '@/utils/debugging' import { Socket } from '@/features/ide-react/connection/types/socket' import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter' @@ -28,6 +28,7 @@ import { } from '@/features/ide-react/editor/types/document' import { ThreadId } from '../../../../../types/review-panel/review-panel' import getMeta from '@/utils/meta' +import OError from '@overleaf/o-error' const MAX_PENDING_OP_SIZE = 64 @@ -447,16 +448,36 @@ export class DocumentContainer extends EventEmitter { 'joinDoc', this.doc_id, this.doc.getVersion(), - { encodeRanges: true, age: this.doc.getTimeSinceLastServerActivity() }, - (error, docLines, version, updates, ranges) => { + { + encodeRanges: true, + age: this.doc.getTimeSinceLastServerActivity(), + supportsHistoryOT: true, + }, + ( + error, + docLines, + version, + updates, + ranges, + type = 'sharejs-text-ot' + ) => { if (error) { callback?.(error) return } this.joined = true this.doc?.catchUp(updates) - this.decodeRanges(ranges) - this.catchUpRanges(ranges?.changes, ranges?.comments) + if (this.doc?.getType() !== type) { + // TODO(24596): page reload after checking for pending ops? + throw new OError('ot type mismatch', { + got: type, + want: this.doc?.getType(), + }) + } + if (type === 'sharejs-text-ot') { + this.decodeRanges(ranges) + this.catchUpRanges(ranges?.changes, ranges?.comments) + } callback?.() } ) @@ -464,8 +485,18 @@ export class DocumentContainer extends EventEmitter { this.socket.emit( 'joinDoc', this.doc_id, - { encodeRanges: true }, - (error, docLines, version, updates, ranges) => { + { + encodeRanges: true, + supportsHistoryOT: true, + }, + ( + error, + docLines, + version, + updates, + ranges, + type: OTType = 'sharejs-text-ot' + ) => { if (error) { callback?.(error) return @@ -477,9 +508,12 @@ export class DocumentContainer extends EventEmitter { version, this.socket, this.globalEditorWatchdogManager, - this.ideEventEmitter + this.ideEventEmitter, + type ) - this.decodeRanges(ranges) + if (type === 'sharejs-text-ot') { + this.decodeRanges(ranges) + } this.ranges = new RangesTracker(ranges?.changes, ranges?.comments) this.bindToShareJsDocEvents() callback?.() @@ -580,7 +614,9 @@ export class DocumentContainer extends EventEmitter { this.doc.on( 'change', (ops: AnyOperation[], oldSnapshot: any, msg: Message) => { - this.applyOpsToRanges(ops, msg) + if (this.getType() === 'sharejs-text-ot') { + this.applyOpsToRanges(ops, msg) + } if (docChangedTimeout) { window.clearTimeout(docChangedTimeout) } diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts index 7b4e3492f8..96e866afec 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts @@ -2,7 +2,7 @@ // Migrated from services/web/frontend/js/ide/editor/ShareJsDoc.js import EventEmitter from '../../../utils/EventEmitter' -import { Doc } from '@/vendor/libs/sharejs' +import sharejs, { Doc } from '@/vendor/libs/sharejs' import { Socket } from '@/features/ide-react/connection/types/socket' import { debugConsole } from '@/utils/debugging' import { decodeUtf8 } from '@/utils/decode-utf8' @@ -12,11 +12,18 @@ import { Message, ShareJsConnectionState, ShareJsOperation, + ShareJsTextType, TrackChangesIdSeeds, } from '@/features/ide-react/editor/types/document' import { EditorFacade } from '@/features/source-editor/extensions/realtime' import { recordDocumentFirstChangeEvent } from '@/features/event-tracking/document-first-change-event' import getMeta from '@/utils/meta' +import { HistoryOTType } from './share-js-history-ot-type' +import { StringFileData } from 'overleaf-editor-core/index' +import { + RawEditOperation, + StringFileRawData, +} from 'overleaf-editor-core/lib/types' // All times below are in milliseconds const SINGLE_USER_FLUSH_DELAY = 2000 @@ -27,6 +34,7 @@ const FATAL_OP_TIMEOUT = 45000 const RECENT_ACK_LIMIT = 2 * SINGLE_USER_FLUSH_DELAY type Update = Record +export type OTType = 'sharejs-text-ot' | 'history-ot' type Connection = { send: (update: Update) => void @@ -35,7 +43,6 @@ type Connection = { } export class ShareJsDoc extends EventEmitter { - type: string track_changes = false track_changes_id_seeds: TrackChangesIdSeeds | null = null connection: Connection @@ -57,12 +64,24 @@ export class ShareJsDoc extends EventEmitter { version: number, readonly socket: Socket, private readonly globalEditorWatchdogManager: EditorWatchdogManager, - private readonly eventEmitter: IdeEventEmitter + private readonly eventEmitter: IdeEventEmitter, + readonly type: OTType = 'sharejs-text-ot' ) { super() - this.type = 'text' + let sharejsType: ShareJsTextType = sharejs.types.text // Decode any binary bits of data - const snapshot = docLines.map(line => decodeUtf8(line)).join('\n') + let snapshot: string | StringFileData + if (this.type === 'history-ot') { + snapshot = StringFileData.fromRaw( + docLines as unknown as StringFileRawData + ) + sharejsType = new HistoryOTType(snapshot) as ShareJsTextType< + StringFileData, + RawEditOperation[] + > + } else { + snapshot = docLines.map(line => decodeUtf8(line)).join('\n') + } this.connection = { send: (update: Update) => { @@ -89,7 +108,7 @@ export class ShareJsDoc extends EventEmitter { } this._doc = new Doc(this.connection, this.doc_id, { - type: this.type, + type: sharejsType, }) this._doc.setFlushDelay(SINGLE_USER_FLUSH_DELAY) this._doc.on('change', (...args: any[]) => { diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts new file mode 100644 index 0000000000..cec1983037 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts @@ -0,0 +1,131 @@ +import EventEmitter from '@/utils/EventEmitter' +import { + EditOperationBuilder, + InsertOp, + RemoveOp, + RetainOp, + StringFileData, + TextOperation, +} from 'overleaf-editor-core' +import { RawEditOperation } from 'overleaf-editor-core/lib/types' + +function loadTextOperation(raw: RawEditOperation): TextOperation { + const operation = EditOperationBuilder.fromJSON(raw) + if (!(operation instanceof TextOperation)) { + throw new Error(`operation not supported: ${operation.constructor.name}`) + } + return operation +} + +export class HistoryOTType extends EventEmitter { + // stub interface, these are actually on the Doc + api: HistoryOTType + snapshot: StringFileData + + constructor(snapshot: StringFileData) { + super() + this.api = this + this.snapshot = snapshot + } + + transformX(raw1: RawEditOperation[], raw2: RawEditOperation[]) { + const [a, b] = TextOperation.transform( + loadTextOperation(raw1[0]), + loadTextOperation(raw2[0]) + ) + return [[a.toJSON()], [b.toJSON()]] + } + + apply(snapshot: StringFileData, rawEditOperation: RawEditOperation[]) { + const operation = loadTextOperation(rawEditOperation[0]) + const afterFile = StringFileData.fromRaw(snapshot.toRaw()) + afterFile.edit(operation) + this.snapshot = afterFile + return afterFile + } + + compose(op1: RawEditOperation[], op2: RawEditOperation[]) { + return [ + loadTextOperation(op1[0]).compose(loadTextOperation(op2[0])).toJSON(), + ] + } + + // Do not provide normalize, used by submitOp to fixup bad input. + // normalize(op: TextOperation) {} + + // Do not provide invert, only needed for reverting a rejected update. + // We are displaying an out-of-sync modal when an op is rejected. + // invert(op: TextOperation) {} + + // API + insert(pos: number, text: string, fromUndo: boolean) { + const old = this.getText() + const op = new TextOperation() + op.retain(pos) + op.insert(text) + op.retain(old.length - pos) + this.submitOp([op.toJSON()]) + } + + del(pos: number, length: number, fromUndo: boolean) { + const old = this.getText() + const op = new TextOperation() + op.retain(pos) + op.remove(length) + op.retain(old.length - pos - length) + this.submitOp([op.toJSON()]) + } + + getText() { + return this.snapshot.getContent({ filterTrackedDeletes: true }) + } + + getLength() { + return this.getText().length + } + + _register() { + this.on( + 'remoteop', + (rawEditOperation: RawEditOperation[], oldSnapshot: StringFileData) => { + const operation = loadTextOperation(rawEditOperation[0]) + const str = oldSnapshot.getContent() + if (str.length !== operation.baseLength) + throw new TextOperation.ApplyError( + "The operation's base length must be equal to the string's length.", + operation, + str + ) + + let outputCursor = 0 + let inputCursor = 0 + for (const op of operation.ops) { + if (op instanceof RetainOp) { + inputCursor += op.length + outputCursor += op.length + } else if (op instanceof InsertOp) { + this.emit('insert', outputCursor, op.insertion, op.insertion.length) + outputCursor += op.insertion.length + } else if (op instanceof RemoveOp) { + this.emit( + 'delete', + outputCursor, + str.slice(inputCursor, inputCursor + op.length) + ) + inputCursor += op.length + } + } + + if (inputCursor !== str.length) + throw new TextOperation.ApplyError( + "The operation didn't operate on the whole string.", + operation, + str + ) + } + ) + } + + // stub-interface, provided by sharejs.Doc + submitOp(op: RawEditOperation[]) {} +} diff --git a/services/web/frontend/js/features/ide-react/editor/types/document.ts b/services/web/frontend/js/features/ide-react/editor/types/document.ts index 44d36c0e48..fbed3ab8f1 100644 --- a/services/web/frontend/js/features/ide-react/editor/types/document.ts +++ b/services/web/frontend/js/features/ide-react/editor/types/document.ts @@ -1,3 +1,4 @@ +import { StringFileData } from 'overleaf-editor-core' import { AnyOperation } from '../../../../../../types/change' export type Version = number @@ -8,6 +9,23 @@ export type ShareJsOperation = AnyOperation[] export type TrackChangesIdSeeds = { inflight: string; pending: string } +export interface ShareJsTextType { + transformX(op1: Operation, op2: Operation): Operation[] + apply(snapshot: Snapshot, op: Operation): Snapshot + compose(op1: Operation, op2: Operation): Operation + + api: { + insert(pos: number, text: string, fromUndo: boolean): void + del(pos: number, length: number, fromUndo: boolean): void + getText(): string + getLength(): number + _register(): void + } + + // stub-interface, provided by sharejs.Doc + submitOp(op: Operation): void +} + // TODO: check the properties of this type export type Message = { v: Version @@ -16,5 +34,6 @@ export type Message = { type?: string } doc?: string - snapshot?: string + snapshot?: string | StringFileData + type?: ShareJsTextType } diff --git a/services/web/frontend/js/features/ide-react/hooks/use-chat-pane.ts b/services/web/frontend/js/features/ide-react/hooks/use-chat-pane.ts index 0c6d816181..44b165fd57 100644 --- a/services/web/frontend/js/features/ide-react/hooks/use-chat-pane.ts +++ b/services/web/frontend/js/features/ide-react/hooks/use-chat-pane.ts @@ -1,6 +1,7 @@ import { useLayoutContext } from '@/shared/context/layout-context' import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel' -import { useCallback, useRef, useState } from 'react' +import useDebounce from '@/shared/hooks/use-debounce' +import { useCallback, useEffect, useRef, useState } from 'react' import { ImperativePanelHandle } from 'react-resizable-panels' export const useChatPane = () => { @@ -8,6 +9,17 @@ export const useChatPane = () => { const [resizing, setResizing] = useState(false) const panelRef = useRef(null) + // Keep track of a debounced local state variable for panel openness and + // only update the external openness state when the debounced value changes. + // This prevents successive calls to onCollapse and onExpand from + // react-resizable-panels updating the openness state multiple times in quick + // succession, which causes confusing behaviour that is different in React 17 + // and 18. Collapsing the chat pane on initialization is necessary because + // react-resizable-panels does not provide a way to specify both that a panel + // should be collapsed and a default size for the panel when expanded. + const [localIsOpen, setLocalIsOpen] = useState(isOpen) + const debouncedLocalIsOpen = useDebounce(localIsOpen, 100) + useCollapsiblePanel(isOpen, panelRef) const togglePane = useCallback(() => { @@ -15,12 +27,16 @@ export const useChatPane = () => { }, [setIsOpen]) const handlePaneExpand = useCallback(() => { - setIsOpen(true) - }, [setIsOpen]) + setLocalIsOpen(true) + }, []) const handlePaneCollapse = useCallback(() => { - setIsOpen(false) - }, [setIsOpen]) + setLocalIsOpen(false) + }, []) + + useEffect(() => { + setIsOpen(debouncedLocalIsOpen) + }, [debouncedLocalIsOpen, setIsOpen]) return { isOpen, diff --git a/services/web/frontend/js/features/ide-redesign/components/chat/chat.tsx b/services/web/frontend/js/features/ide-redesign/components/chat/chat.tsx index d76d82cb22..9ebe33e065 100644 --- a/services/web/frontend/js/features/ide-redesign/components/chat/chat.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/chat/chat.tsx @@ -2,7 +2,6 @@ import ChatFallbackError from '@/features/chat/components/chat-fallback-error' import InfiniteScroll from '@/features/chat/components/infinite-scroll' import MessageInput from '@/features/chat/components/message-input' import { useChatContext } from '@/features/chat/context/chat-context' -import OLBadge from '@/features/ui/components/ol/ol-badge' import { FetchError } from '@/infrastructure/fetch-json' import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner' import MaterialIcon from '@/shared/components/material-icon' @@ -11,6 +10,7 @@ import { lazy, Suspense, useEffect } from 'react' import { useTranslation } from 'react-i18next' import classNames from 'classnames' import { RailPanelHeader } from '../rail' +import { RailIndicator } from '../rail-indicator' const MessageList = lazy(() => import('../../../chat/components/message-list')) @@ -19,7 +19,7 @@ export const ChatIndicator = () => { if (unreadMessageCount === 0) { return null } - return {unreadMessageCount} + return } const Loading = () => diff --git a/services/web/frontend/js/features/ide-redesign/components/errors.tsx b/services/web/frontend/js/features/ide-redesign/components/errors.tsx index 73b3ae4bc1..2313022d3c 100644 --- a/services/web/frontend/js/features/ide-redesign/components/errors.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/errors.tsx @@ -1,7 +1,7 @@ import PdfLogsViewer from '@/features/pdf-preview/components/pdf-logs-viewer' import { PdfPreviewProvider } from '@/features/pdf-preview/components/pdf-preview-provider' import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context' -import OLBadge from '@/features/ui/components/ol/ol-badge' +import { RailIndicator } from './rail-indicator' export const ErrorIndicator = () => { const { logEntries } = useCompileContext() @@ -19,7 +19,10 @@ export const ErrorIndicator = () => { } return ( - 0 ? 'danger' : 'warning'}>{totalCount} + 0 ? 'danger' : 'warning'} + /> ) } diff --git a/services/web/frontend/js/features/ide-redesign/components/file-tree-toolbar.tsx b/services/web/frontend/js/features/ide-redesign/components/file-tree-toolbar.tsx deleted file mode 100644 index f1d72941f5..0000000000 --- a/services/web/frontend/js/features/ide-redesign/components/file-tree-toolbar.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { useTranslation } from 'react-i18next' -import * as eventTracking from '../../../infrastructure/event-tracking' -import { useFileTreeActionable } from '@/features/file-tree/contexts/file-tree-actionable' -import { useFileTreeData } from '@/shared/context/file-tree-data-context' -import OLTooltip from '@/features/ui/components/ol/ol-tooltip' -import MaterialIcon, { - AvailableUnfilledIcon, -} from '@/shared/components/material-icon' -import React from 'react' -import useCollapsibleFileTree from '../hooks/use-collapsible-file-tree' -import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider' -import { usePermissionsContext } from '@/features/ide-react/context/permissions-context' - -function FileTreeToolbar() { - const { t } = useTranslation() - const { fileTreeExpanded, toggleFileTreeExpanded } = useCollapsibleFileTree() - - return ( -
- - -
- ) -} - -function FileTreeActionButtons() { - const { t } = useTranslation() - const { fileTreeReadOnly } = useFileTreeData() - const { write } = usePermissionsContext() - - const { - canCreate, - startCreatingFolder, - startCreatingDocOrFile, - startUploadingDocOrFile, - } = useFileTreeActionable() - useCommandProvider(() => { - if (!canCreate || fileTreeReadOnly || !write) return - return [ - { - label: t('new_file'), - id: 'new_file', - handler: ({ location }) => { - eventTracking.sendMB('new-file-click', { location }) - startCreatingDocOrFile() - }, - }, - { - label: t('new_folder'), - id: 'new_folder', - handler: startCreatingFolder, - }, - { - label: t('upload_file'), - id: 'upload_file', - handler: ({ location }) => { - eventTracking.sendMB('upload-click', { location }) - startUploadingDocOrFile() - }, - }, - ] - }, [ - canCreate, - fileTreeReadOnly, - startCreatingDocOrFile, - t, - startCreatingFolder, - startUploadingDocOrFile, - write, - ]) - - if (!canCreate || fileTreeReadOnly) return null - - const createWithAnalytics = () => { - eventTracking.sendMB('new-file-click', { location: 'toolbar' }) - startCreatingDocOrFile() - } - - const uploadWithAnalytics = () => { - eventTracking.sendMB('upload-click', { location: 'toolbar' }) - startUploadingDocOrFile() - } - - return ( -
- - - -
- ) -} - -function FileTreeActionButton({ - id, - description, - onClick, - iconType, -}: { - id: string - description: string - onClick: () => void - iconType: AvailableUnfilledIcon -}) { - return ( - - - - ) -} - -export default FileTreeToolbar diff --git a/services/web/frontend/js/features/ide-redesign/components/file-tree/file-tree-action-button.tsx b/services/web/frontend/js/features/ide-redesign/components/file-tree/file-tree-action-button.tsx new file mode 100644 index 0000000000..5678db58c8 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/file-tree/file-tree-action-button.tsx @@ -0,0 +1,33 @@ +import OLTooltip from '@/features/ui/components/ol/ol-tooltip' +import MaterialIcon, { + AvailableUnfilledIcon, +} from '@/shared/components/material-icon' +import React from 'react' + +export default function FileTreeActionButton({ + id, + description, + onClick, + iconType, +}: { + id: string + description: string + onClick: () => void + iconType: AvailableUnfilledIcon +}) { + return ( + + + + ) +} diff --git a/services/web/frontend/js/features/ide-redesign/components/file-tree/file-tree-action-buttons.tsx b/services/web/frontend/js/features/ide-redesign/components/file-tree/file-tree-action-buttons.tsx new file mode 100644 index 0000000000..93760b97ab --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/file-tree/file-tree-action-buttons.tsx @@ -0,0 +1,107 @@ +import { useTranslation } from 'react-i18next' +import * as eventTracking from '../../../../infrastructure/event-tracking' +import { useFileTreeActionable } from '@/features/file-tree/contexts/file-tree-actionable' +import { useFileTreeData } from '@/shared/context/file-tree-data-context' +import React from 'react' +import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider' +import { usePermissionsContext } from '@/features/ide-react/context/permissions-context' +import FileTreeActionButton from './file-tree-action-button' + +export default function FileTreeActionButtons() { + const { t } = useTranslation() + const { fileTreeReadOnly } = useFileTreeData() + const { write } = usePermissionsContext() + + const { + canCreate, + canBulkDelete, + startDeleting, + startCreatingFolder, + startCreatingDocOrFile, + startUploadingDocOrFile, + } = useFileTreeActionable() + + useCommandProvider(() => { + if (!canCreate || fileTreeReadOnly || !write) return + return [ + { + label: t('new_file'), + id: 'new_file', + handler: ({ location }) => { + eventTracking.sendMB('new-file-click', { location }) + startCreatingDocOrFile() + }, + }, + { + label: t('new_folder'), + id: 'new_folder', + handler: startCreatingFolder, + }, + { + label: t('upload_file'), + id: 'upload_file', + handler: ({ location }) => { + eventTracking.sendMB('upload-click', { location }) + startUploadingDocOrFile() + }, + }, + ] + }, [ + canCreate, + fileTreeReadOnly, + startCreatingDocOrFile, + t, + startCreatingFolder, + startUploadingDocOrFile, + write, + ]) + + if (fileTreeReadOnly) return null + + const createWithAnalytics = () => { + eventTracking.sendMB('new-file-click', { location: 'toolbar' }) + startCreatingDocOrFile() + } + + const uploadWithAnalytics = () => { + eventTracking.sendMB('upload-click', { location: 'toolbar' }) + startUploadingDocOrFile() + } + + return ( +
+ {canCreate && ( + + )} + {canCreate && ( + + )} + {canCreate && ( + + )} + {canBulkDelete && ( + + )} +
+ ) +} diff --git a/services/web/frontend/js/features/ide-redesign/components/file-tree-outline-panel.tsx b/services/web/frontend/js/features/ide-redesign/components/file-tree/file-tree-outline-panel.tsx similarity index 95% rename from services/web/frontend/js/features/ide-redesign/components/file-tree-outline-panel.tsx rename to services/web/frontend/js/features/ide-redesign/components/file-tree/file-tree-outline-panel.tsx index bf69d40e59..62c228973e 100644 --- a/services/web/frontend/js/features/ide-redesign/components/file-tree-outline-panel.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/file-tree/file-tree-outline-panel.tsx @@ -3,7 +3,7 @@ import { FileTree } from '@/features/ide-react/components/file-tree' import { OutlineContainer } from '@/features/outline/components/outline-container' import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle' import { useOutlinePane } from '@/features/ide-react/hooks/use-outline-pane' -import useCollapsibleFileTree from '../hooks/use-collapsible-file-tree' +import useCollapsibleFileTree from '../../hooks/use-collapsible-file-tree' import classNames from 'classnames' function FileTreeOutlinePanel() { diff --git a/services/web/frontend/js/features/ide-redesign/components/file-tree/file-tree-toolbar.tsx b/services/web/frontend/js/features/ide-redesign/components/file-tree/file-tree-toolbar.tsx new file mode 100644 index 0000000000..231f98e7d7 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/file-tree/file-tree-toolbar.tsx @@ -0,0 +1,32 @@ +import { useTranslation } from 'react-i18next' +import MaterialIcon from '@/shared/components/material-icon' +import React from 'react' +import useCollapsibleFileTree from '../../hooks/use-collapsible-file-tree' +import FileTreeActionButtons from './file-tree-action-buttons' + +function FileTreeToolbar() { + const { t } = useTranslation() + const { fileTreeExpanded, toggleFileTreeExpanded } = useCollapsibleFileTree() + + return ( +
+ + +
+ ) +} + +export default FileTreeToolbar diff --git a/services/web/frontend/js/features/ide-redesign/components/main-layout.tsx b/services/web/frontend/js/features/ide-redesign/components/main-layout.tsx index ccdf7c8b53..2c422af279 100644 --- a/services/web/frontend/js/features/ide-redesign/components/main-layout.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/main-layout.tsx @@ -8,11 +8,17 @@ import { HorizontalToggler } from '@/features/ide-react/components/resize/horizo import { useTranslation } from 'react-i18next' import { usePdfPane } from '@/features/ide-react/hooks/use-pdf-pane' import { useLayoutContext } from '@/shared/context/layout-context' -import { useState } from 'react' +import { ElementType, useState } from 'react' import EditorPanel from './editor-panel' import { useRailContext } from '../contexts/rail-context' import HistoryContainer from '@/features/ide-react/components/history-container' import { DefaultSynctexControl } from '@/features/pdf-preview/components/detach-synctex-control' +import importOverleafModules from '../../../../macros/import-overleaf-module.macro' + +const mainEditorLayoutModalsModules: Array<{ + import: { default: ElementType } + path: string +}> = importOverleafModules('mainEditorLayoutModals') export default function MainLayout() { const [resizing, setResizing] = useState(false) @@ -111,6 +117,11 @@ export default function MainLayout() {
+ {mainEditorLayoutModalsModules.map( + ({ import: { default: Component }, path }) => ( + + ) + )} ) } diff --git a/services/web/frontend/js/features/ide-redesign/components/online-users/online-users-widget.tsx b/services/web/frontend/js/features/ide-redesign/components/online-users/online-users-widget.tsx new file mode 100644 index 0000000000..bac1787e10 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/online-users/online-users-widget.tsx @@ -0,0 +1,133 @@ +import { OnlineUser } from '@/features/ide-react/context/online-users-context' +import { + Dropdown, + DropdownHeader, + DropdownItem, + DropdownMenu, + DropdownToggle, +} from '@/features/ui/components/bootstrap-5/dropdown-menu' +import OLTooltip from '@/features/ui/components/ol/ol-tooltip' +import { getBackgroundColorForUserId } from '@/shared/utils/colors' +import { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +// Should be kept in sync with $max-user-circles-displayed CSS constant +const MAX_USER_CIRCLES_DISPLAYED = 5 + +// We don't want a +1 circle since we could just show the user instead +const MAX_USERS_WITH_OVERFLOW_VISIBLE = MAX_USER_CIRCLES_DISPLAYED - 1 + +export const OnlineUsersWidget = ({ + onlineUsers, + goToUser, +}: { + onlineUsers: OnlineUser[] + goToUser: (user: OnlineUser) => void +}) => { + const hasOverflow = onlineUsers.length > MAX_USER_CIRCLES_DISPLAYED + const usersBeforeOverflow = useMemo( + () => + hasOverflow + ? onlineUsers.slice(0, MAX_USERS_WITH_OVERFLOW_VISIBLE) + : onlineUsers, + [onlineUsers, hasOverflow] + ) + const usersInOverflow = useMemo( + () => + hasOverflow ? onlineUsers.slice(MAX_USERS_WITH_OVERFLOW_VISIBLE) : [], + [onlineUsers, hasOverflow] + ) + + return ( +
+ {usersBeforeOverflow.map((user, index) => ( + + ))} + {hasOverflow && ( + + )} +
+ ) +} + +const OnlineUserWidget = ({ + user, + goToUser, + id, +}: { + user: OnlineUser + goToUser: (user: OnlineUser) => void + id: string +}) => { + const onClick = useCallback(() => { + goToUser(user) + }, [goToUser, user]) + return ( + + + + ) +} + +const OnlineUserCircle = ({ user }: { user: OnlineUser }) => { + const backgroundColor = getBackgroundColorForUserId(user.user_id) + return ( + + {user.name.charAt(0)} + + ) +} + +const OnlineUserOverflow = ({ + goToUser, + users, +}: { + goToUser: (user: OnlineUser) => void + users: OnlineUser[] +}) => { + const { t } = useTranslation() + return ( + + + + +{users.length} + + + + + {users.map((user, index) => ( +
  • + goToUser(user)} + > + {user.name} + +
  • + ))} +
    +
    + ) +} diff --git a/services/web/frontend/js/features/ide-redesign/components/rail-indicator.tsx b/services/web/frontend/js/features/ide-redesign/components/rail-indicator.tsx new file mode 100644 index 0000000000..d6cf15ebc4 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/rail-indicator.tsx @@ -0,0 +1,17 @@ +import OLBadge from '@/features/ui/components/ol/ol-badge' + +type RailIndicatorProps = { + type: 'danger' | 'warning' | 'info' + count: number +} + +function formatNumber(num: number) { + if (num > 99) { + return '99+' + } + return Math.floor(num).toString() +} + +export const RailIndicator = ({ count, type }: RailIndicatorProps) => { + return {formatNumber(count)} +} diff --git a/services/web/frontend/js/features/ide-redesign/components/rail.tsx b/services/web/frontend/js/features/ide-redesign/components/rail.tsx index 34cb7df3f4..d6e1112536 100644 --- a/services/web/frontend/js/features/ide-redesign/components/rail.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/rail.tsx @@ -1,6 +1,6 @@ import { FC, ReactElement, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { Nav, NavLink, Tab, TabContainer } from 'react-bootstrap-5' +import { Nav, NavLink, Tab, TabContainer } from 'react-bootstrap' import MaterialIcon, { AvailableUnfilledIcon, } from '@/shared/components/material-icon' @@ -12,7 +12,7 @@ import { RailTabKey, useRailContext, } from '../contexts/rail-context' -import FileTreeOutlinePanel from './file-tree-outline-panel' +import FileTreeOutlinePanel from './file-tree/file-tree-outline-panel' import { ChatIndicator, ChatPane } from './chat/chat' import getMeta from '@/utils/meta' import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle' @@ -32,6 +32,8 @@ import { HistorySidebar } from '@/features/ide-react/components/history-sidebar' import DictionarySettingsModal from './settings/editor-settings/dictionary-settings-modal' import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import OLIconButton from '@/features/ui/components/ol/ol-icon-button' +import { useChatContext } from '@/features/chat/context/chat-context' +import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' type RailElement = { icon: AvailableUnfilledIcon @@ -75,6 +77,7 @@ const RAIL_MODALS: { ] export const RailLayout = () => { + const { sendEvent } = useEditorAnalytics() const { t } = useTranslation() const { activeModal, @@ -91,6 +94,8 @@ export const RailLayout = () => { const { view, setLeftMenuShown } = useLayoutContext() + const { markMessagesAsRead } = useChatContext() + const isHistoryView = view === 'history' const railTabs: RailElement[] = useMemo( @@ -144,16 +149,20 @@ export const RailLayout = () => { key: 'settings', icon: 'settings', title: t('settings'), - action: () => setLeftMenuShown(true), + action: () => { + sendEvent('rail-click', { tab: 'settings' }) + setLeftMenuShown(true) + }, }, ], - [setLeftMenuShown, t] + [setLeftMenuShown, t, sendEvent] ) const onTabSelect = useCallback( (key: string | null) => { if (key === selectedTab) { togglePane() + sendEvent('rail-click', { tab: key, type: 'toggle' }) } else { // HACK: Apparently the onSelect event is triggered with href attributes // from DropdownItems @@ -161,11 +170,17 @@ export const RailLayout = () => { // Attempting to open a non-existent tab return } + const keyOrDefault = key ?? 'file-tree' // Change the selected tab and make sure it's open - openTab((key ?? 'file-tree') as RailTabKey) + openTab(keyOrDefault as RailTabKey) + sendEvent('rail-click', { tab: keyOrDefault }) + + if (key === 'chat') { + markMessagesAsRead() + } } }, - [openTab, togglePane, selectedTab, railTabs] + [openTab, togglePane, selectedTab, railTabs, sendEvent, markMessagesAsRead] ) const isReviewPanelOpen = selectedTab === 'review-panel' diff --git a/services/web/frontend/js/features/ide-redesign/components/settings/dropdown-setting.tsx b/services/web/frontend/js/features/ide-redesign/components/settings/dropdown-setting.tsx index 957acdce6e..47c5c54ef0 100644 --- a/services/web/frontend/js/features/ide-redesign/components/settings/dropdown-setting.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/settings/dropdown-setting.tsx @@ -2,7 +2,7 @@ import OLFormSelect from '@/features/ui/components/ol/ol-form-select' import { ChangeEventHandler, useCallback } from 'react' import Setting from './setting' import classNames from 'classnames' -import { Spinner } from 'react-bootstrap-5' +import { Spinner } from 'react-bootstrap' type PossibleValue = string | number | boolean diff --git a/services/web/frontend/js/features/ide-redesign/components/settings/settings-modal-body.tsx b/services/web/frontend/js/features/ide-redesign/components/settings/settings-modal-body.tsx index f1a6099c6d..b9a1fddbfe 100644 --- a/services/web/frontend/js/features/ide-redesign/components/settings/settings-modal-body.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/settings/settings-modal-body.tsx @@ -8,7 +8,7 @@ import { TabContainer, TabContent, TabPane, -} from 'react-bootstrap-5' +} from 'react-bootstrap' import { useTranslation } from 'react-i18next' import EditorSettings from './editor-settings/editor-settings' import AppearanceSettings from './appearance-settings/appearance-settings' diff --git a/services/web/frontend/js/features/ide-redesign/components/switcher-modal/modal.tsx b/services/web/frontend/js/features/ide-redesign/components/switcher-modal/modal.tsx index fb5898fcb0..6942674de5 100644 --- a/services/web/frontend/js/features/ide-redesign/components/switcher-modal/modal.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/switcher-modal/modal.tsx @@ -14,6 +14,7 @@ import { import Notification from '@/shared/components/notification' import { useSwitchEnableNewEditorState } from '../../hooks/use-switch-enable-new-editor-state' import { Trans, useTranslation } from 'react-i18next' +import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' export const IdeRedesignSwitcherModal = () => { const { t } = useTranslation() @@ -66,13 +67,18 @@ const SwitcherModalContentEnabled: FC = ({ loading, }) => { const { t } = useTranslation() + const { sendEvent } = useEditorAnalytics() const disable = useCallback(() => { + sendEvent('editor-redesign-toggle', { + action: 'disable', + location: 'modal', + }) setEditorRedesignStatus(false) .then(hide) .catch(() => { // do nothing, we're already showing the error }) - }, [setEditorRedesignStatus, hide]) + }, [setEditorRedesignStatus, hide, sendEvent]) return ( <> @@ -116,13 +122,18 @@ const SwitcherModalContentDisabled: FC = ({ loading, }) => { const { t } = useTranslation() + const { sendEvent } = useEditorAnalytics() const enable = useCallback(() => { + sendEvent('editor-redesign-toggle', { + action: 'enable', + location: 'modal', + }) setEditorRedesignStatus(true) .then(hide) .catch(() => { // do nothing, we're already showing the error }) - }, [setEditorRedesignStatus, hide]) + }, [setEditorRedesignStatus, hide, sendEvent]) return ( <> @@ -147,7 +158,12 @@ const SwitcherWhatsNew = () => { const { t } = useTranslation() return (
    -

    {t('whats_new')}

    +

    {t('latest_updates')}

    +
      +
    • {t('double_clicking_on_the_pdf_shows')}
    • +
    +
    +

    {t('whats_different_in_the_new_editor')}

    • {t('new_look_and_feel')}
    • @@ -157,11 +173,6 @@ const SwitcherWhatsNew = () => {
    • {t('improved_dark_mode')}
    • {t('review_panel_and_error_logs_moved_to_the_left')}
    -
    -

    {t('whats_next')}

    -
      -
    • {t('more_changes_based_on_your_feedback')}
    • -
    ) } diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/change-layout-options.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/change-layout-options.tsx index 3c3126e8da..0ca4531e2f 100644 --- a/services/web/frontend/js/features/ide-redesign/components/toolbar/change-layout-options.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/change-layout-options.tsx @@ -9,10 +9,10 @@ import { } from '@/shared/context/layout-context' import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import * as eventTracking from '../../../../infrastructure/event-tracking' import useEventListener from '@/shared/hooks/use-event-listener' import { DetachRole } from '@/shared/context/detach-context' -import { Spinner } from 'react-bootstrap-5' +import { Spinner } from 'react-bootstrap' +import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' type LayoutOption = 'sideBySide' | 'editorOnly' | 'pdfOnly' | 'detachedPdf' @@ -87,6 +87,7 @@ const LayoutDropdownItem = ({ } export default function ChangeLayoutOptions() { + const { sendEvent } = useEditorAnalytics() const { reattach, detach, @@ -99,16 +100,16 @@ export default function ChangeLayoutOptions() { const handleDetach = useCallback(() => { detach() - eventTracking.sendMB('project-layout-detach') - }, [detach]) + sendEvent('project-layout-detach') + }, [detach, sendEvent]) const handleReattach = useCallback(() => { if (detachRole !== 'detacher') { return } reattach() - eventTracking.sendMB('project-layout-reattach') - }, [detachRole, reattach]) + sendEvent('project-layout-reattach') + }, [detachRole, reattach, sendEvent]) // reattach when the PDF pane opens useEventListener('ui:pdf-open', handleReattach) @@ -117,12 +118,12 @@ export default function ChangeLayoutOptions() { (newLayout: IdeLayout, newView?: IdeView) => { handleReattach() changeLayout(newLayout, newView) - eventTracking.sendMB('project-layout-change', { + sendEvent('project-layout-change', { layout: newLayout, view: newView, }) }, - [changeLayout, handleReattach] + [changeLayout, handleReattach, sendEvent] ) const { t } = useTranslation() diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx index ca23de7fce..e08cf8873a 100644 --- a/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx @@ -101,6 +101,7 @@ const CommandDropdownChild = ({ item }: { item: Entry }) => { if (isTaggedCommand(item)) { return ( { const { t } = useTranslation() @@ -29,6 +33,9 @@ export const ToolbarMenuBar = () => { setShowSwitcherModal(true) }, [setShowSwitcherModal]) const { setView, view } = useLayoutContext() + const { pdfUrl } = useCompileContext() + const wordCountEnabled = pdfUrl || isSplitTestEnabled('word-count-client') + const [showWordCountModal, setShowWordCountModal] = useState(false) useCommandProvider( () => [ @@ -40,8 +47,17 @@ export const ToolbarMenuBar = () => { }, id: 'show_version_history', }, + { + type: 'command', + label: t('word_count_lower'), + disabled: !wordCountEnabled, + handler: () => { + setShowWordCountModal(true) + }, + id: 'word_count', + }, ], - [t, setView, view] + [t, setView, view, wordCountEnabled] ) const fileMenuStructure: MenuStructure = useMemo( () => [ @@ -49,7 +65,7 @@ export const ToolbarMenuBar = () => { id: 'file-file-tree', children: ['new_file', 'new_folder', 'upload_file'], }, - { id: 'file-history', children: ['show_version_history'] }, + { id: 'file-tools', children: ['show_version_history', 'word_count'] }, { id: 'file-download', children: ['download-as-source-zip', 'download-pdf'], @@ -175,81 +191,101 @@ export const ToolbarMenuBar = () => { setActiveModal('contact-us') }, [setActiveModal]) return ( - - - - - + - - Editor settings - + + - - - + + Editor settings + + + + + + + + + + + + + + + + setShowWordCountModal(false)} /> - - - - - - - - - - - + ) } const SwitchToOldEditorMenuBarOption = () => { const { loading, error, setEditorRedesignStatus } = useSwitchEnableNewEditorState() + const { sendEvent } = useEditorAnalytics() const disable: MouseEventHandler = useCallback( event => { // Don't close the dropdown event.stopPropagation() + sendEvent('editor-redesign-toggle', { + action: 'disable', + location: 'menu-bar', + }) setEditorRedesignStatus(false) }, - [setEditorRedesignStatus] + [setEditorRedesignStatus, sendEvent] ) let icon = null if (loading) { @@ -259,6 +295,7 @@ const SwitchToOldEditorMenuBarOption = () => { } return ( { const { openDoc } = useEditorManagerContext() diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/project-title.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/project-title.tsx index 648fcb0b8f..68860da4ea 100644 --- a/services/web/frontend/js/features/ide-redesign/components/toolbar/project-title.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/project-title.tsx @@ -52,12 +52,12 @@ export const ToolbarProjectTitle = () => { } return ( - + - {name} + {name} { const action = view === 'history' ? 'close' : 'open' - eventTracking.sendMB('navigation-clicked-history', { action }) + sendEvent('navigation-clicked-history', { action }) setView(view === 'history' ? 'editor' : 'history') - }, [view, setView]) + }, [view, setView, sendEvent]) return (
    ) diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.tsx index 2695616fb4..0167a98db1 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.tsx @@ -7,8 +7,8 @@ import * as eventTracking from '../../../infrastructure/event-tracking' import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import OLButton from '@/features/ui/components/ol/ol-button' import MaterialIcon from '@/shared/components/material-icon' -import { Spinner } from 'react-bootstrap-5' -import { Placement } from 'react-bootstrap-5/types' +import { Spinner } from 'react-bootstrap' +import { Placement } from 'react-bootstrap/types' import useSynctex from '../hooks/use-synctex' const GoToCodeButton = memo(function GoToCodeButton({ @@ -57,17 +57,19 @@ const GoToCodeButton = memo(function GoToCodeButton({ description={t('go_to_pdf_location_in_code')} overlayProps={overlayProps} > - - {buttonIcon} - {isDetachLayout ?  {t('show_in_code')} : ''} - + + + {buttonIcon} + {isDetachLayout ?  {t('show_in_code')} : ''} + + ) }) @@ -106,17 +108,19 @@ const GoToPdfButton = memo(function GoToPdfButton({ description={t('go_to_code_location_in_pdf')} overlayProps={{ placement: tooltipPlacement }} > - - {buttonIcon} - {isDetachLayout ?  {t('show_in_pdf')} : ''} - + + + {buttonIcon} + {isDetachLayout ?  {t('show_in_pdf')} : ''} + + ) }) diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-viewer-controls-toolbar.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-viewer-controls-toolbar.tsx index f11769f4e6..fed34bf3ee 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-viewer-controls-toolbar.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-viewer-controls-toolbar.tsx @@ -8,6 +8,7 @@ import PdfViewerControlsMenuButton from './pdf-viewer-controls-menu-button' import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider' import { useTranslation } from 'react-i18next' +import { useLayoutContext } from '@/shared/context/layout-context' type PdfViewerControlsToolbarProps = { requestPresentationMode: () => void @@ -44,8 +45,15 @@ function PdfViewerControlsToolbar({ const { elementRef: pdfControlsRef } = useResizeObserver(handleResize) - useCommandProvider( - () => [ + const { view: ideView, pdfLayout } = useLayoutContext() + const editorOnly = ideView !== 'pdf' && pdfLayout === 'flat' + + useCommandProvider(() => { + if (editorOnly) { + return + } + + return [ { id: 'view-pdf-presentation-mode', label: t('presentation_mode'), @@ -71,9 +79,8 @@ function PdfViewerControlsToolbar({ label: t('fit_to_height'), handler: () => setZoom('page-height'), }, - ], - [t, requestPresentationMode, setZoom] - ) + ] + }, [t, requestPresentationMode, setZoom, editorOnly]) if (!toolbarControlsElement) { return null diff --git a/services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts b/services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts index 686d988dee..77986bfeac 100644 --- a/services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts +++ b/services/web/frontend/js/features/pdf-preview/hooks/use-synctex.ts @@ -18,6 +18,7 @@ import { CursorPosition } from '@/features/ide-react/types/cursor-position' import { isValidTeXFile } from '@/main/is-valid-tex-file' import { PdfScrollPosition } from '@/shared/hooks/use-pdf-scroll-position' import { showFileErrorToast } from '@/features/pdf-preview/components/synctex-toasts' +import { sendMB } from '@/infrastructure/event-tracking' export default function useSynctex(): { syncToPdf: () => void @@ -115,6 +116,12 @@ export default function useSynctex(): { .then(data => { setShowLogs(false) setHighlights(data.pdf) + if (data.downloadedFromCache) { + sendMB('synctex-downloaded-from-cache', { + projectId, + method: 'code', + }) + } }) .catch(debugConsole.error) .finally(() => { @@ -223,6 +230,12 @@ export default function useSynctex(): { .then(data => { const [{ file, line }] = data.code goToCodeLine(file, line) + if (data.downloadedFromCache) { + sendMB('synctex-downloaded-from-cache', { + projectId, + method: 'pdf', + }) + } }) .catch(debugConsole.error) .finally(() => { diff --git a/services/web/frontend/js/features/pdf-preview/util/file-list.ts b/services/web/frontend/js/features/pdf-preview/util/file-list.ts index 310fbb55fb..a8a37a9e2b 100644 --- a/services/web/frontend/js/features/pdf-preview/util/file-list.ts +++ b/services/web/frontend/js/features/pdf-preview/util/file-list.ts @@ -13,6 +13,7 @@ export function buildFileList( outputFiles: Map, { clsiServerId, + clsiCacheShard, compileGroup, outputFilesArchive, fromCache = false, @@ -24,7 +25,7 @@ export function buildFileList( const params = new URLSearchParams() if (fromCache) { - params.set('clsiserverid', 'cache') + params.set('clsiserverid', clsiCacheShard || 'cache') } else if (clsiServerId) { params.set('clsiserverid', clsiServerId) } diff --git a/services/web/frontend/js/features/pdf-preview/util/highlights.js b/services/web/frontend/js/features/pdf-preview/util/highlights.js index a2da6b2621..2fb204ac11 100644 --- a/services/web/frontend/js/features/pdf-preview/util/highlights.js +++ b/services/web/frontend/js/features/pdf-preview/util/highlights.js @@ -1,28 +1,55 @@ import { PDFJS } from '@/features/pdf-preview/util/pdf-js' export function buildHighlightElement(highlight, viewer) { - const pageView = viewer.getPageView(highlight.page - 1) + const { viewport, div } = viewer.getPageView(highlight.page - 1) - const viewport = pageView.viewport + // page coordinates from synctex + const rectangle = { + left: highlight.h, + right: highlight.h + highlight.width, + top: highlight.v, + bottom: highlight.v + highlight.height, + } - const height = viewport.viewBox[3] + // needed because PDF page origin is at the bottom left + const viewBoxHeight = viewport.viewBox[3] + 10 - const rect = viewport.convertToViewportRectangle([ - highlight.h, // xMin - height - (highlight.v + highlight.height) + 10, // yMin - highlight.h + highlight.width, // xMax - height - highlight.v + 10, // yMax + // account for scaling + const viewportRectangle = viewport.convertToViewportRectangle([ + rectangle.left, + viewBoxHeight - rectangle.bottom, + rectangle.right, + viewBoxHeight - rectangle.top, ]) - const [left, top, right, bottom] = PDFJS.Util.normalizeRect(rect) + // flip top/bottom, left/right if needed + const normalizedRectangle = PDFJS.Util.normalizeRect(viewportRectangle) + + const [left, top, right, bottom] = normalizedRectangle + + // restrict to within the page container + const clampedRectangle = { + left: Math.max(left, 0), + right: Math.min(right, div.clientWidth), + top: Math.max(top, 0), + bottom: Math.min(bottom, div.clientHeight), + } + + // convert to screen positions + const positions = { + left: div.offsetLeft + clampedRectangle.left, + right: div.offsetLeft + clampedRectangle.right, + top: div.offsetTop + clampedRectangle.top, + bottom: div.offsetTop + clampedRectangle.bottom, + } const element = document.createElement('div') - element.style.left = Math.floor(pageView.div.offsetLeft + left) + 'px' - element.style.top = Math.floor(pageView.div.offsetTop + top) + 'px' - element.style.width = Math.ceil(right - left) + 'px' - element.style.height = Math.ceil(bottom - top) + 'px' - element.style.backgroundColor = 'rgba(255,255,0)' element.style.position = 'absolute' + element.style.left = Math.floor(positions.left) + 'px' + element.style.top = Math.floor(positions.top) + 'px' + element.style.width = Math.floor(positions.right - positions.left) + 'px' + element.style.height = Math.floor(positions.bottom - positions.top) + 'px' + element.style.backgroundColor = 'rgb(255,255,0)' element.style.display = 'inline-block' element.style.scrollMargin = '72px' element.style.pointerEvents = 'none' diff --git a/services/web/frontend/js/features/pdf-preview/util/output-files.js b/services/web/frontend/js/features/pdf-preview/util/output-files.js index 6c93a02368..3ee0dc1180 100644 --- a/services/web/frontend/js/features/pdf-preview/util/output-files.js +++ b/services/web/frontend/js/features/pdf-preview/util/output-files.js @@ -17,6 +17,7 @@ export function handleOutputFiles(outputFiles, projectId, data) { if (!outputFile) return null outputFile.editorId = outputFile.editorId || EDITOR_SESSION_ID + outputFile.clsiCacheShard = data.clsiCacheShard || 'cache' // build the URL for viewing the PDF in the preview UI const params = new URLSearchParams() diff --git a/services/web/frontend/js/features/pdf-preview/util/pdf-caching-flags.js b/services/web/frontend/js/features/pdf-preview/util/pdf-caching-flags.js index dd7ed2c1b5..fb2a5b12b2 100644 --- a/services/web/frontend/js/features/pdf-preview/util/pdf-caching-flags.js +++ b/services/web/frontend/js/features/pdf-preview/util/pdf-caching-flags.js @@ -30,4 +30,4 @@ export const projectOwnerHasPremiumOnPageLoad = getMeta( 'ol-projectOwnerHasPremiumOnPageLoad' ) export const fallBackToClsiCache = - projectOwnerHasPremiumOnPageLoad && isFlagEnabled('fall-back-to-clsi-cache') + projectOwnerHasPremiumOnPageLoad && isFlagEnabled('populate-clsi-cache') diff --git a/services/web/frontend/js/features/pdf-preview/util/pdf-caching-transport.js b/services/web/frontend/js/features/pdf-preview/util/pdf-caching-transport.js index 4497b57398..f568c634a4 100644 --- a/services/web/frontend/js/features/pdf-preview/util/pdf-caching-transport.js +++ b/services/web/frontend/js/features/pdf-preview/util/pdf-caching-transport.js @@ -116,7 +116,9 @@ export function generatePdfCachingTransportFactory() { return ( u.pathname.endsWith( `build/${this.pdfFile.editorId}-${this.pdfFile.build}/output/output.pdf` - ) && u.searchParams.get('clsiserverid') === 'cache' + ) && + (u.searchParams.get('clsiserverid') === 'cache' || + u.searchParams.get('clsiserverid')?.startsWith('clsi-cache-')) ) } const canTryFromCache = err => { @@ -127,7 +129,7 @@ export function generatePdfCachingTransportFactory() { const getOutputPDFURLFromCache = () => { if (usesCache(this.url)) return this.url const u = new URL(this.url) - u.searchParams.set('clsiserverid', 'cache') + u.searchParams.set('clsiserverid', this.pdfFile.clsiCacheShard) u.pathname = u.pathname.replace( /build\/[a-f0-9-]+\//, `build/${this.pdfFile.editorId}-${this.pdfFile.build}/` diff --git a/services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx b/services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx index 058f0319ce..7b2bd909f8 100644 --- a/services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx +++ b/services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { Spinner } from 'react-bootstrap-5' +import { Spinner } from 'react-bootstrap' import { Dropdown, DropdownItem, diff --git a/services/web/frontend/js/features/project-list/components/sidebar/sidebar-ds-nav.tsx b/services/web/frontend/js/features/project-list/components/sidebar/sidebar-ds-nav.tsx index 74bdc5a8e2..2aaed364a7 100644 --- a/services/web/frontend/js/features/project-list/components/sidebar/sidebar-ds-nav.tsx +++ b/services/web/frontend/js/features/project-list/components/sidebar/sidebar-ds-nav.tsx @@ -5,7 +5,7 @@ import NewProjectButton from '../new-project-button' import SidebarFilters from './sidebar-filters' import AddAffiliation, { useAddAffiliation } from '../add-affiliation' import { usePersistedResize } from '@/shared/hooks/use-resize' -import { Dropdown } from 'react-bootstrap-5' +import { Dropdown } from 'react-bootstrap' import getMeta from '@/utils/meta' import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import { useTranslation } from 'react-i18next' @@ -73,6 +73,7 @@ function SidebarDsNav() { sendMB('menu-expand', { item: 'help', location: 'sidebar' }) } }} + role="menu" > -
  • +
  • diff --git a/services/web/frontend/js/features/review-panel-new/components/review-mode-promo.tsx b/services/web/frontend/js/features/review-panel-new/components/review-mode-promo.tsx new file mode 100644 index 0000000000..0979cc58ac --- /dev/null +++ b/services/web/frontend/js/features/review-panel-new/components/review-mode-promo.tsx @@ -0,0 +1,80 @@ +import { FC, RefObject, useCallback, useEffect } from 'react' +import { Button, Overlay, Popover } from 'react-bootstrap' +import Close from '@/shared/components/close' + +export const ReviewModePromo: FC<{ + target: RefObject + showPopup: boolean + tryShowingPopup: () => void + hideUntilReload: () => void + completeTutorial: (props: { + action: 'complete' + event: 'promo-click' | 'promo-dismiss' + }) => void +}> = ({ + showPopup, + tryShowingPopup, + hideUntilReload, + completeTutorial, + target, +}) => { + useEffect(() => { + tryShowingPopup() + }, [tryShowingPopup]) + + const handleHide = useCallback(() => { + hideUntilReload() + }, [hideUntilReload]) + + const handleClose = useCallback(() => { + completeTutorial({ + action: 'complete', + event: 'promo-dismiss', + }) + }, [completeTutorial]) + + const handleAccept = useCallback(() => { + completeTutorial({ + action: 'complete', + event: 'promo-click', + }) + }, [completeTutorial]) + + if (!showPopup) { + return null + } + + return ( + + + + +

    Track changes have moved

    +

    + Choose Reviewing mode in the dropdown to turn on track + changes. +

    +
    + + +
    +
    +
    +
    + ) +} diff --git a/services/web/frontend/js/features/review-panel-new/components/review-mode-switcher.tsx b/services/web/frontend/js/features/review-panel-new/components/review-mode-switcher.tsx index de1a5faaf0..f2bb2763bd 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-mode-switcher.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-mode-switcher.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, MouseEventHandler, useState } from 'react' +import { forwardRef, memo, MouseEventHandler, useRef, useState } from 'react' import { Dropdown, DropdownMenu, @@ -19,6 +19,9 @@ import { sendMB } from '@/infrastructure/event-tracking' import { useEditorContext } from '@/shared/context/editor-context' import { useProjectContext } from '@/shared/context/project-context' import UpgradeTrackChangesModal from './upgrade-track-changes-modal' +import { ReviewModePromo } from '@/features/review-panel-new/components/review-mode-promo' +import useTutorial from '@/shared/hooks/promotions/use-tutorial' +import { useLayoutContext } from '@/shared/context/layout-context' type Mode = 'view' | 'review' | 'edit' @@ -73,7 +76,7 @@ function ReviewModeSwitcher() { }) saveTrackChangesForCurrentUser(false) }} - description={t('can_edit_content')} + description={t('edit_content_directly')} leadingIcon="edit" active={write && mode === 'edit'} > @@ -141,6 +144,7 @@ const ModeSwitcherToggleButton = forwardRef< iconType="edit" label={t('editing')} ariaExpanded={ariaExpanded} + currentMode={mode} /> ) } else if (mode === 'review') { @@ -152,6 +156,7 @@ const ModeSwitcherToggleButton = forwardRef< iconType="rate_review" label={t('reviewing')} ariaExpanded={ariaExpanded} + currentMode={mode} /> ) } @@ -164,6 +169,7 @@ const ModeSwitcherToggleButton = forwardRef< iconType="visibility" label={t('viewing')} ariaExpanded={ariaExpanded} + currentMode={mode} /> ) }) @@ -176,31 +182,72 @@ const ModeSwitcherToggleButtonContent = forwardRef< iconType: string label: string ariaExpanded: boolean + currentMode: string } ->(({ onClick, className, iconType, label, ariaExpanded }, ref) => { +>(({ onClick, className, iconType, label, ariaExpanded, currentMode }, ref) => { const [isFirstTimeUsed, setIsFirstTimeUsed] = usePersistedState( `modeSwitcherFirstTimeUsed`, true ) + const tutorialProps = useTutorial('review-mode', { + name: 'review-mode-notification', + }) + + const user = useUserContext() + const project = useProjectContext() + const { reviewPanelOpen } = useLayoutContext() + const { inactiveTutorials } = useEditorContext() + + const hasCompletedReviewModeTutorial = + inactiveTutorials.includes('review-mode') + + const canShowReviewModePromo = + reviewPanelOpen && + currentMode !== 'review' && + project.features.trackChanges && + user.signUpDate && + user.signUpDate < '2025-03-15' && + !hasCompletedReviewModeTutorial + + const containerRef = useRef(null) + return ( - + <> + + + + + {canShowReviewModePromo && ( + + )} + ) }) diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-action-icons.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-action-icons.tsx new file mode 100644 index 0000000000..717f7a94ff --- /dev/null +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-action-icons.tsx @@ -0,0 +1,29 @@ +import { memo } from 'react' +import MaterialIcon from '@/shared/components/material-icon' + +export const AddIcon = memo(function AddIcon() { + return ( + + ) +}) + +export const DeleteIcon = memo(function DeleteIcon() { + return ( + + ) +}) + +export const EditIcon = memo(function EditIcon() { + return ( + + ) +}) diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-change-action.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-change-action.tsx new file mode 100644 index 0000000000..532dc9a466 --- /dev/null +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-change-action.tsx @@ -0,0 +1,36 @@ +import Tooltip from '@/features/ui/components/bootstrap-5/tooltip' +import { ComponentProps, memo, MouseEventHandler } from 'react' +import { PreventSelectingEntry } from '@/features/review-panel-new/components/review-panel-prevent-selecting' +import OLTooltip from '@/features/ui/components/ol/ol-tooltip' +import MaterialIcon from '@/shared/components/material-icon' + +const changeActionTooltipProps: Partial> = { + overlayProps: { placement: 'bottom' }, + tooltipProps: { className: 'review-panel-tooltip' }, +} + +export const ChangeAction = memo<{ + id: string + label: string + type: string + handleClick: MouseEventHandler +}>(function ChangeAction({ id, label, type, handleClick }) { + return ( + + + + + + ) +}) diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-change.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-change.tsx index 06d10ece2b..a153b1e1d4 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-change.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-change.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useState } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { useRangesActionsContext } from '../context/ranges-context' import { Change, @@ -8,8 +8,6 @@ import { import { useTranslation } from 'react-i18next' import classnames from 'classnames' import { usePermissionsContext } from '@/features/ide-react/context/permissions-context' -import OLTooltip from '@/features/ui/components/ol/ol-tooltip' -import MaterialIcon from '@/shared/components/material-icon' import { FormatTimeBasedOnYear } from '@/shared/components/format-time-based-on-year' import { useChangesUsersContext } from '../context/changes-users-context' import { ReviewPanelChangeUser } from './review-panel-change-user' @@ -17,7 +15,12 @@ import { ReviewPanelEntry } from './review-panel-entry' import { useModalsContext } from '@/features/ide-react/context/modals-context' import { ExpandableContent } from './review-panel-expandable-content' import { useUserContext } from '@/shared/context/user-context' -import { PreventSelectingEntry } from './review-panel-prevent-selecting' +import { ChangeAction } from '@/features/review-panel-new/components/review-panel-change-action' +import { + AddIcon, + DeleteIcon, + EditIcon, +} from '@/features/review-panel-new/components/review-panel-action-icons' export const ReviewPanelChange = memo<{ change: Change @@ -27,8 +30,8 @@ export const ReviewPanelChange = memo<{ docId: string hoverRanges?: boolean hovered?: boolean - onEnter?: (changeId: string) => void - onLeave?: (changeId: string) => void + handleEnter?: (changeId: string) => void + handleLeave?: () => void }>( ({ change, @@ -38,8 +41,8 @@ export const ReviewPanelChange = memo<{ hoverRanges, editable = true, hovered, - onEnter, - onLeave, + handleEnter, + handleLeave, }) => { const { t } = useTranslation() const { acceptChanges, rejectChanges } = useRangesActionsContext() @@ -68,6 +71,34 @@ export const ReviewPanelChange = memo<{ } }, [acceptChanges, aggregate, change.id, showGenericMessageModal, t]) + const rejectHandler = useCallback(async () => { + if (aggregate) { + await rejectChanges(change.id, aggregate.id) + } else { + await rejectChanges(change.id) + } + }, [aggregate, change, rejectChanges]) + + const translations = useMemo( + () => ({ + accept_change: t('accept_change'), + reject_change: t('reject_change'), + aggregate_changed: t('aggregate_changed'), + aggregate_to: t('aggregate_to'), + tracked_change_added: t('tracked_change_added'), + tracked_change_deleted: t('tracked_change_deleted'), + }), + [t] + ) + + const { handleMouseEnter, handleMouseLeave } = useMemo( + () => ({ + handleMouseEnter: handleEnter && (() => handleEnter(change.id)), + handleMouseLeave: handleLeave && (() => handleLeave()), + }), + [change.id, handleEnter, handleLeave] + ) + if (!changesUsers) { // if users are not loaded yet, do not show "Unknown" user return null @@ -90,14 +121,14 @@ export const ReviewPanelChange = memo<{ docId={docId} hoverRanges={hoverRanges} disabled={accepting} - onEnterEntryIndicator={onEnter && (() => onEnter(change.id))} - onLeaveEntryIndicator={onLeave && (() => onLeave(change.id))} + handleEnter={handleMouseEnter} + handleLeave={handleMouseLeave} entryIndicator="edit" >
    onEnter(change.id))} - onMouseLeave={onLeave && (() => onLeave(change.id))} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} >
    @@ -111,56 +142,22 @@ export const ReviewPanelChange = memo<{ {editable && (
    {permissions.write && ( - - - - - + )} {(permissions.write || (permissions.trackedWrite && isChangeAuthor)) && ( - - - - - + )}
    )} @@ -169,21 +166,11 @@ export const ReviewPanelChange = memo<{
    {'i' in change.op && ( <> - {aggregateChange ? ( - - ) : ( - - )} + {aggregateChange ? : } {aggregateChange ? ( - {t('aggregate_changed')}:{' '} + {translations.aggregate_changed}:{' '} {' '} - {t('aggregate_to')}{' '} + {translations.aggregate_to}{' '} ) : ( - {t('tracked_change_added')}:  + {translations.tracked_change_added}:  - - + - {t('tracked_change_deleted')}:  + {translations.tracked_change_deleted}:  Promise onDeleteThread?: (threadId: ThreadId) => Promise onResolve?: () => Promise - onLeave?: (changeId: string) => void - onEnter?: (changeId: string) => void + onLeave?: () => void + onEnter?: () => void }>( ({ comment, @@ -69,8 +69,8 @@ export const ReviewPanelCommentContent = memo<{ return (
    onEnter(comment.id))} - onMouseLeave={onLeave && (() => onLeave(comment.id))} + onMouseEnter={onEnter} + onMouseLeave={onLeave} > {thread.messages.map((message, i) => { const isReply = i !== 0 diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-comment.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-comment.tsx index dd6366f091..c7f60aac35 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-comment.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-comment.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useState } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { Change, CommentOperation } from '../../../../../types/change' import { useThreadsActionsContext, @@ -21,160 +21,169 @@ export const ReviewPanelComment = memo<{ docId: string top?: number hoverRanges?: boolean - onEnter?: (changeId: string) => void - onLeave?: (changeId: string) => void + handleEnter?: (changeId: string) => void + handleLeave?: (changeId: string) => void hovered?: boolean -}>(({ comment, top, hovered, onEnter, onLeave, docId, hoverRanges }) => { - const threads = useThreadsContext() - const { - resolveThread, - editMessage, - deleteMessage, - deleteOwnMessage, - deleteThread, - addMessage, - } = useThreadsActionsContext() - const { showGenericMessageModal } = useModalsContext() - const { t } = useTranslation() - const permissions = usePermissionsContext() - - const [processing, setProcessing] = useState(false) - - const handleResolveComment = useCallback(async () => { - setProcessing(true) - try { - await resolveThread(comment.op.t) - } catch (err) { - debugConsole.error(err) - showGenericMessageModal( - t('resolve_comment_error_title'), - t('resolve_comment_error_message') - ) - } finally { - setProcessing(false) - } - }, [comment.op.t, resolveThread, showGenericMessageModal, t]) - - const handleEditMessage = useCallback( - async (commentId: CommentId, content: string) => { - setProcessing(true) - try { - await editMessage(comment.op.t, commentId, content) - } catch (err) { - debugConsole.error(err) - showGenericMessageModal( - t('edit_comment_error_title'), - t('edit_comment_error_message') - ) - } finally { - setProcessing(false) - } - }, - [comment.op.t, editMessage, showGenericMessageModal, t] - ) - - const handleDeleteMessage = useCallback( - async (commentId: CommentId) => { - setProcessing(true) - try { - if (permissions.resolveAllComments) { - // Owners and editors can delete any message - await deleteMessage(comment.op.t, commentId) - } else if (permissions.resolveOwnComments) { - // Reviewers can only delete their own messages - await deleteOwnMessage(comment.op.t, commentId) - } - } catch (err) { - debugConsole.error(err) - showGenericMessageModal( - t('delete_comment_error_title'), - t('delete_comment_error_message') - ) - } finally { - setProcessing(false) - } - }, - [ - comment.op.t, +}>( + ({ comment, top, hovered, handleEnter, handleLeave, docId, hoverRanges }) => { + const threads = useThreadsContext() + const { + resolveThread, + editMessage, deleteMessage, deleteOwnMessage, - showGenericMessageModal, - t, - permissions.resolveOwnComments, - permissions.resolveAllComments, - ] - ) + deleteThread, + addMessage, + } = useThreadsActionsContext() + const { showGenericMessageModal } = useModalsContext() + const { t } = useTranslation() + const permissions = usePermissionsContext() - const handleDeleteThread = useCallback( - async (commentId: ThreadId) => { + const [processing, setProcessing] = useState(false) + + const handleResolveComment = useCallback(async () => { setProcessing(true) try { - await deleteThread(commentId) + await resolveThread(comment.op.t) } catch (err) { debugConsole.error(err) showGenericMessageModal( - t('delete_comment_error_title'), - t('delete_comment_error_message') + t('resolve_comment_error_title'), + t('resolve_comment_error_message') ) } finally { setProcessing(false) } - }, - [deleteThread, showGenericMessageModal, t] - ) + }, [comment.op.t, resolveThread, showGenericMessageModal, t]) - const handleSubmitReply = useCallback( - async (content: string) => { - setProcessing(true) - try { - await addMessage(comment.op.t, content) - } catch (err) { - debugConsole.error(err) - showGenericMessageModal( - t('add_comment_error_title'), - t('add_comment_error_message') - ) - throw err - } finally { - setProcessing(false) + const handleEditMessage = useCallback( + async (commentId: CommentId, content: string) => { + setProcessing(true) + try { + await editMessage(comment.op.t, commentId, content) + } catch (err) { + debugConsole.error(err) + showGenericMessageModal( + t('edit_comment_error_title'), + t('edit_comment_error_message') + ) + } finally { + setProcessing(false) + } + }, + [comment.op.t, editMessage, showGenericMessageModal, t] + ) + + const handleDeleteMessage = useCallback( + async (commentId: CommentId) => { + setProcessing(true) + try { + if (permissions.resolveAllComments) { + // Owners and editors can delete any message + await deleteMessage(comment.op.t, commentId) + } else if (permissions.resolveOwnComments) { + // Reviewers can only delete their own messages + await deleteOwnMessage(comment.op.t, commentId) + } + } catch (err) { + debugConsole.error(err) + showGenericMessageModal( + t('delete_comment_error_title'), + t('delete_comment_error_message') + ) + } finally { + setProcessing(false) + } + }, + [ + comment.op.t, + deleteMessage, + deleteOwnMessage, + showGenericMessageModal, + t, + permissions.resolveOwnComments, + permissions.resolveAllComments, + ] + ) + + const handleDeleteThread = useCallback( + async (commentId: ThreadId) => { + setProcessing(true) + try { + await deleteThread(commentId) + } catch (err) { + debugConsole.error(err) + showGenericMessageModal( + t('delete_comment_error_title'), + t('delete_comment_error_message') + ) + } finally { + setProcessing(false) + } + }, + [deleteThread, showGenericMessageModal, t] + ) + + const handleSubmitReply = useCallback( + async (content: string) => { + setProcessing(true) + try { + await addMessage(comment.op.t, content) + } catch (err) { + debugConsole.error(err) + showGenericMessageModal( + t('add_comment_error_title'), + t('add_comment_error_message') + ) + throw err + } finally { + setProcessing(false) + } + }, + [addMessage, comment.op.t, showGenericMessageModal, t] + ) + + const { handleMouseEnter, handleMouseLeave } = useMemo(() => { + return { + handleMouseEnter: handleEnter && (() => handleEnter(comment.id)), + handleMouseLeave: handleLeave && (() => handleLeave(comment.id)), } - }, - [addMessage, comment.op.t, showGenericMessageModal, t] - ) + }, [comment.id, handleEnter, handleLeave]) - const thread = threads?.[comment.op.t] - if (!thread || thread.resolved || thread.messages.length === 0) { - return null + const thread = threads?.[comment.op.t] + if (!thread || thread.resolved || thread.messages.length === 0) { + return null + } + + return ( + + + + ) } - - return ( - onEnter(comment.id))} - onLeaveEntryIndicator={onLeave && (() => onLeave(comment.id))} - entryIndicator="comment" - > - - - ) -}) +) ReviewPanelComment.displayName = 'ReviewPanelComment' diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-container.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-container.tsx index 269ffde3b6..f7ee56f105 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-container.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-container.tsx @@ -2,28 +2,20 @@ import ReactDOM from 'react-dom' import { useCodeMirrorViewContext } from '../../source-editor/components/codemirror-context' import { memo } from 'react' import ReviewPanel from './review-panel' -import TrackChangesOnWidget from './track-changes-on-widget' -import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' import ReviewModeSwitcher from './review-mode-switcher' -import getMeta from '@/utils/meta' import useReviewPanelLayout from '../hooks/use-review-panel-layout' function ReviewPanelContainer() { const view = useCodeMirrorViewContext() const { showPanel, mini } = useReviewPanelLayout() - const { wantTrackChanges } = useEditorManagerContext() - const enableReviewerRole = getMeta('ol-isReviewerRoleEnabled') if (!view) { return null } - const showTrackChangesWidget = !enableReviewerRole && wantTrackChanges && mini - return ReactDOM.createPortal( <> - {showTrackChangesWidget && } - {enableReviewerRole && } + {showPanel && } , view.scrollDOM diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-current-file.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-current-file.tsx index db9933d66b..b79fc85f1c 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-current-file.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-current-file.tsx @@ -53,7 +53,7 @@ const ReviewPanelCurrentFile: FC = () => { setHoveredEntry(id) }, []) - const handleEntryLeave = useCallback((id: string) => { + const handleEntryLeave = useCallback(() => { clearTimeout(hoverTimeout.current) hoverTimeout.current = window.setTimeout(() => { setHoveredEntry(null) @@ -295,7 +295,11 @@ const ReviewPanelCurrentFile: FC = () => { } return ( - <> +
    {showEmptyState && } {onMoreCommentsAboveClick && ( { top={positions.get(change.id)} aggregate={aggregatedRanges.aggregates.get(change.id)} hovered={hoveredEntry === change.id} - onEnter={handleEntryEnter} - onLeave={handleEntryLeave} + handleEnter={handleEntryEnter} + handleLeave={handleEntryLeave} /> ) )} @@ -344,8 +348,8 @@ const ReviewPanelCurrentFile: FC = () => { comment={comment} top={positions.get(comment.id)} hovered={hoveredEntry === comment.id} - onEnter={handleEntryEnter} - onLeave={handleEntryLeave} + handleEnter={handleEntryEnter} + handleLeave={handleEntryLeave} /> ) )} @@ -356,7 +360,7 @@ const ReviewPanelCurrentFile: FC = () => { direction="downward" /> )} - +
    ) } diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-entry-indicator.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-entry-indicator.tsx new file mode 100644 index 0000000000..ee14dc24f5 --- /dev/null +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-entry-indicator.tsx @@ -0,0 +1,27 @@ +import { memo, MouseEventHandler } from 'react' +import MaterialIcon from '@/shared/components/material-icon' + +export const EntryIndicator = memo<{ + handleMouseEnter?: MouseEventHandler + handleMouseLeave?: MouseEventHandler + handleMouseDown?: MouseEventHandler + type: string +}>(function EntryIndicator({ + handleMouseEnter, + handleMouseLeave, + handleMouseDown, + type, +}) { + return ( +
    + +
    + ) +}) diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-entry.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-entry.tsx index 4192dd518e..3576b7b542 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-entry.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-entry.tsx @@ -12,9 +12,9 @@ import { } from '@/features/source-editor/extensions/ranges' import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' import { EditorSelection } from '@codemirror/state' -import MaterialIcon from '@/shared/components/material-icon' import { OFFSET_FOR_ENTRIES_ABOVE } from '../utils/position-items' import useReviewPanelLayout from '../hooks/use-review-panel-layout' +import { EntryIndicator } from './review-panel-entry-indicator' export const ReviewPanelEntry: FC< React.PropsWithChildren<{ @@ -26,8 +26,8 @@ export const ReviewPanelEntry: FC< selectLineOnFocus?: boolean hoverRanges?: boolean disabled?: boolean - onEnterEntryIndicator?: () => void - onLeaveEntryIndicator?: () => void + handleEnter?: () => void + handleLeave?: () => void entryIndicator?: 'comment' | 'edit' }> > = ({ @@ -40,8 +40,8 @@ export const ReviewPanelEntry: FC< docId, hoverRanges = true, disabled, - onEnterEntryIndicator, - onLeaveEntryIndicator, + handleEnter, + handleLeave, entryIndicator, }) => { const state = useCodeMirrorStateContext() @@ -194,19 +194,12 @@ export const ReviewPanelEntry: FC< }} > {entryIndicator && ( -
    - -
    + )} {children}
    diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-expandable-content.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-expandable-content.tsx index 3850614281..7a8d29f032 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-expandable-content.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-expandable-content.tsx @@ -1,24 +1,24 @@ -import { FC, useCallback, useRef, useState } from 'react' +import { memo, useCallback, useRef, useState } from 'react' import OLButton from '@/features/ui/components/ol/ol-button' import { useTranslation } from 'react-i18next' import classNames from 'classnames' import { PreventSelectingEntry } from './review-panel-prevent-selecting' -export const ExpandableContent: FC<{ +export const ExpandableContent = memo<{ className?: string content: string contentLimit?: number newLineCharsLimit?: number checkNewLines?: boolean inline?: boolean -}> = ({ +}>(function ExpandableContent({ content, className, contentLimit = 50, newLineCharsLimit = 3, checkNewLines = true, inline = false, -}) => { +}) { const { t } = useTranslation() const contentRef = useRef(null) const [isExpanded, setIsExpanded] = useState(false) @@ -83,7 +83,7 @@ export const ExpandableContent: FC<{
    ) -} +}) function indexOfNthLine(content: string, n: number) { if (n < 1) return null diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx index 258922479c..ab5d5509e8 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx @@ -1,36 +1,18 @@ -import { FC, memo, useState } from 'react' +import { FC, memo } from 'react' import { ReviewPanelResolvedThreadsButton } from './review-panel-resolved-threads-button' -import { ReviewPanelTrackChangesMenu } from './review-panel-track-changes-menu' -import ReviewPanelTrackChangesMenuButton from './review-panel-track-changes-menu-button' import { useTranslation } from 'react-i18next' -import getMeta from '@/utils/meta' import { PanelHeading } from '@/shared/components/panel-heading' import useReviewPanelLayout from '../hooks/use-review-panel-layout' -const isReviewerRoleEnabled = getMeta('ol-isReviewerRoleEnabled') - const ReviewPanelHeader: FC = () => { - const [trackChangesMenuExpanded, setTrackChangesMenuExpanded] = - useState(false) const { closeReviewPanel } = useReviewPanelLayout() const { t } = useTranslation() return (
    - {isReviewerRoleEnabled && } + - {!isReviewerRoleEnabled && ( -
    - - -
    - )} - - {trackChangesMenuExpanded && }
    ) } diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-overview.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-overview.tsx index 575419677d..0f2a9ea49a 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-overview.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-overview.tsx @@ -48,7 +48,11 @@ export const ReviewPanelOverview: FC = () => { }, [rangesForDocs]) return ( -
    +
    {error &&
    {t('something_went_wrong')}
    } {showEmptyState && } diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads-button.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads-button.tsx index b44f39c16e..93192800ed 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads-button.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads-button.tsx @@ -5,7 +5,6 @@ import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import { ReviewPanelResolvedThreadsMenu } from './review-panel-resolved-threads-menu' import { useTranslation } from 'react-i18next' import MaterialIcon from '@/shared/components/material-icon' -import getMeta from '@/utils/meta' export const ReviewPanelResolvedThreadsButton: FC = () => { const [expanded, setExpanded] = useState(false) @@ -20,13 +19,10 @@ export const ReviewPanelResolvedThreadsButton: FC = () => { description={t('resolved_comments')} > diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-tabs.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-tabs.tsx index b7a1709b4d..dff291e573 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-tabs.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-tabs.tsx @@ -16,6 +16,10 @@ const ReviewPanelTabs: FC = () => { return ( <> - - - - ) -} - -export default memo(ReviewPanelTrackChangesMenuButton) diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-track-changes-menu.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-track-changes-menu.tsx deleted file mode 100644 index 85f5cb4c26..0000000000 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-track-changes-menu.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { FC } from 'react' -import TrackChangesToggle from '@/features/review-panel-new/components/track-changes-toggle' -import { useProjectContext } from '@/shared/context/project-context' -import { usePermissionsContext } from '@/features/ide-react/context/permissions-context' -import { useTranslation } from 'react-i18next' -import { - useTrackChangesStateActionsContext, - useTrackChangesStateContext, -} from '../context/track-changes-state-context' -import { useChangesUsersContext } from '../context/changes-users-context' -import { buildName } from '../utils/build-name' - -export const ReviewPanelTrackChangesMenu: FC = () => { - const { t } = useTranslation() - const permissions = usePermissionsContext() - const project = useProjectContext() - const trackChanges = useTrackChangesStateContext() - const { saveTrackChanges } = useTrackChangesStateActionsContext() - const changesUsers = useChangesUsersContext() - - if (trackChanges === undefined || !changesUsers) { - return null - } - - const { onForEveryone, onForGuests, onForMembers } = trackChanges - - const canToggle = project.features.trackChanges && permissions.write - - return ( -
    -
    - {t('tc_everyone')} - - - saveTrackChanges(onForEveryone ? { on_for: {} } : { on: true }) - } - value={onForEveryone} - disabled={!canToggle} - /> -
    - - {[project.owner, ...project.members].map(member => { - const user = changesUsers.get(member._id) ?? member - const name = buildName(user) - - const value = onForEveryone || onForMembers[member._id] === true - - return ( -
    - {name} - - { - saveTrackChanges({ - on_for: { - ...onForMembers, - [member._id]: !value, - }, - on_for_guests: onForGuests, - }) - }} - value={value} - disabled={!canToggle || onForEveryone} - /> -
    - ) - })} - -
    - {t('tc_guests')} - - - saveTrackChanges({ - on_for: onForMembers, - on_for_guests: !onForGuests, - }) - } - value={onForGuests} - disabled={!canToggle || onForEveryone} - /> -
    -
    - ) -} diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx index 160a55b0c7..7d8b694f68 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx @@ -25,14 +25,18 @@ const ReviewPanel: FC<{ mini?: boolean }> = ({ mini = false }) => { }) return ( -
    +
    {!newEditor && !mini && } {activeSubView === 'cur_file' && } {activeSubView === 'overview' && } -
    +
    diff --git a/services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx b/services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx index 066cffae56..85fe5830f6 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx @@ -34,13 +34,10 @@ import { numberOfChangesInSelection } from '../utils/changes-in-selection' import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context' import classNames from 'classnames' import useEventListener from '@/shared/hooks/use-event-listener' -import getMeta from '@/utils/meta' import useReviewPanelLayout from '../hooks/use-review-panel-layout' -const isReviewerRoleEnabled = getMeta('ol-isReviewerRoleEnabled') -const TRACK_CHANGES_ON_WIDGET_HEIGHT = 25 const EDIT_MODE_SWITCH_WIDGET_HEIGHT = 40 -const CM_LINE_RIGHT_PADDING = isReviewerRoleEnabled ? 8 : 2 +const CM_LINE_RIGHT_PADDING = 8 const TOOLTIP_SHOW_DELAY = 120 const ReviewTooltipMenu: FC = () => { @@ -131,8 +128,8 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({ showGenericConfirmModal({ message: t('confirm_accept_selected_changes', { count: nChanges }), title: t('accept_selected_changes'), - onConfirm: () => { - acceptChanges(...changeIdsInSelection) + onConfirm: async () => { + await acceptChanges(...changeIdsInSelection) }, primaryVariant: 'danger', }) @@ -153,8 +150,8 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({ showGenericConfirmModal({ message: t('confirm_reject_selected_changes', { count: nChanges }), title: t('reject_selected_changes'), - onConfirm: () => { - rejectChanges(...changeIdsInSelection) + onConfirm: async () => { + await rejectChanges(...changeIdsInSelection) }, primaryVariant: 'danger', }) @@ -190,16 +187,9 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({ return } - let widgetOffset = 0 - if (isReviewerRoleEnabled) { - widgetOffset = EDIT_MODE_SWITCH_WIDGET_HEIGHT - } else if (wantTrackChanges && !reviewPanelOpen) { - widgetOffset = TRACK_CHANGES_ON_WIDGET_HEIGHT - } - return { position: 'fixed' as const, - top: scrollDomRect.top + widgetOffset, + top: scrollDomRect.top + EDIT_MODE_SWITCH_WIDGET_HEIGHT, right: window.innerWidth - editorRightPos, } }, @@ -244,6 +234,7 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({ @@ -256,6 +247,7 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({ diff --git a/services/web/frontend/js/features/review-panel-new/components/track-changes-on-widget.tsx b/services/web/frontend/js/features/review-panel-new/components/track-changes-on-widget.tsx deleted file mode 100644 index 63ee10060f..0000000000 --- a/services/web/frontend/js/features/review-panel-new/components/track-changes-on-widget.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Trans } from 'react-i18next' -import { EditorView } from '@codemirror/view' -import classnames from 'classnames' -import { useCodeMirrorStateContext } from '@/features/source-editor/components/codemirror-context' -import { useLayoutContext } from '@/shared/context/layout-context' -import { useCallback } from 'react' - -function TrackChangesOnWidget() { - const { setReviewPanelOpen } = useLayoutContext() - const state = useCodeMirrorStateContext() - const darkTheme = state.facet(EditorView.darkTheme) - - const openReviewPanel = useCallback(() => { - setReviewPanelOpen(true) - }, [setReviewPanelOpen]) - - return ( -
    -
    - -
    -
    - ) -} - -export default TrackChangesOnWidget diff --git a/services/web/frontend/js/features/review-panel-new/components/upgrade-track-changes-modal-legacy.tsx b/services/web/frontend/js/features/review-panel-new/components/upgrade-track-changes-modal-legacy.tsx deleted file mode 100644 index ccc5f1d452..0000000000 --- a/services/web/frontend/js/features/review-panel-new/components/upgrade-track-changes-modal-legacy.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { useProjectContext } from '@/shared/context/project-context' -import { useUserContext } from '@/shared/context/user-context' -import teaserVideo from '../images/teaser-track-changes.mp4' -import teaserImage from '../images/teaser-track-changes.gif' -import { startFreeTrial, upgradePlan } from '@/main/account-upgrade' -import { memo } from 'react' -import OLModal, { - OLModalBody, - OLModalFooter, - OLModalHeader, - OLModalTitle, -} from '@/features/ui/components/ol/ol-modal' -import OLButton from '@/features/ui/components/ol/ol-button' -import OLRow from '@/features/ui/components/ol/ol-row' -import OLCol from '@/features/ui/components/ol/ol-col' -import MaterialIcon from '@/shared/components/material-icon' - -type UpgradeTrackChangesModalProps = { - show: boolean - setShow: React.Dispatch> -} - -function UpgradeTrackChangesModalLegacy({ - show, - setShow, -}: UpgradeTrackChangesModalProps) { - const { t } = useTranslation() - const project = useProjectContext() - const user = useUserContext() - - return ( - setShow(false)}> - - {t('upgrade_to_track_changes')} - - -
    - {/* eslint-disable-next-line jsx-a11y/media-has-caption */} - -
    -

    - {t('see_changes_in_your_documents_live')} -

    - - -
      - {[ - t('track_any_change_in_real_time'), - t('review_your_peers_work'), - t('accept_or_reject_each_changes_individually'), - ].map(translation => ( -
    • - -  {translation} -
    • - ))} -
    -
    -
    -

    - {t('already_subscribed_try_refreshing_the_page')} -

    - {project.owner && ( -
    - {project.owner._id === user.id ? ( - user.allowedFreeTrial ? ( - startFreeTrial('track-changes')} - > - {t('try_it_for_free')} - - ) : ( - upgradePlan('project-sharing')} - > - {t('upgrade')} - - ) - ) : ( -

    - - {t( - 'please_ask_the_project_owner_to_upgrade_to_track_changes' - )} - -

    - )} -
    - )} -
    - - setShow(false)}> - {t('close')} - - -
    - ) -} - -export default memo(UpgradeTrackChangesModalLegacy) diff --git a/services/web/frontend/js/features/review-panel-new/context/ranges-context.tsx b/services/web/frontend/js/features/review-panel-new/context/ranges-context.tsx index 2e816ccdb2..7066a78bb3 100644 --- a/services/web/frontend/js/features/review-panel-new/context/ranges-context.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/ranges-context.tsx @@ -32,8 +32,8 @@ export type Ranges = { export const RangesContext = createContext(undefined) type RangesActions = { - acceptChanges: (...ids: string[]) => void - rejectChanges: (...ids: string[]) => void + acceptChanges: (...ids: string[]) => Promise + rejectChanges: (...ids: string[]) => Promise } const buildRanges = (currentDocument: DocumentContainer | null) => { @@ -166,7 +166,7 @@ export const RangesProvider: FC = ({ children }) => { setRanges(buildRanges(currentDocument)) } }, - rejectChanges(...ids: string[]) { + async rejectChanges(...ids: string[]) { if (currentDocument?.ranges) { view.dispatch(rejectChanges(view.state, currentDocument.ranges, ids)) } diff --git a/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx b/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx index 2d77ef9d8d..73ffe78b5d 100644 --- a/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx +++ b/services/web/frontend/js/features/review-panel-new/context/track-changes-state-context.tsx @@ -17,7 +17,6 @@ import { postJSON } from '@/infrastructure/fetch-json' import useEventListener from '@/shared/hooks/use-event-listener' import { ProjectContextValue } from '@/shared/context/types/project-context' import { usePermissionsContext } from '@/features/ide-react/context/permissions-context' -import getMeta from '@/utils/meta' export type TrackChangesState = { onForEveryone: boolean @@ -99,25 +98,15 @@ export const TrackChangesStateProvider: FC = ({ const saveTrackChangesForCurrentUser = useCallback( async (trackChanges: boolean) => { if (user.id) { - if (getMeta('ol-isReviewerRoleEnabled')) { - saveTrackChanges({ - on_for: { - ...onForMembers, - [user.id]: trackChanges, - }, - }) - } else { - saveTrackChanges({ - on_for: { - ...onForMembers, - [user.id]: trackChanges, - }, - on_for_guests: onForGuests, - }) - } + saveTrackChanges({ + on_for: { + ...onForMembers, + [user.id]: trackChanges, + }, + }) } }, - [onForMembers, onForGuests, user.id, saveTrackChanges] + [onForMembers, user.id, saveTrackChanges] ) const actions = useMemo( @@ -138,27 +127,16 @@ export const TrackChangesStateProvider: FC = ({ !onForEveryone ) { const value = onForMembers[user.id] - if (getMeta('ol-isReviewerRoleEnabled')) { - actions.saveTrackChanges({ - on_for: { - ...onForMembers, - [user.id]: !value, - }, - }) - } else { - actions.saveTrackChanges({ - on_for: { - ...onForMembers, - [user.id]: !value, - }, - on_for_guests: onForGuests, - }) - } + actions.saveTrackChanges({ + on_for: { + ...onForMembers, + [user.id]: !value, + }, + }) } }, [ actions, onForMembers, - onForGuests, onForEveryone, permissions.write, project.features.trackChanges, diff --git a/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts b/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts index 59006db354..7e7dda1850 100644 --- a/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts +++ b/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts @@ -1,14 +1,11 @@ import { CSSProperties, useCallback, useEffect, useState } from 'react' import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context' -import getMeta from '@/utils/meta' export const useReviewPanelStyles = (mini: boolean) => { const view = useCodeMirrorViewContext() const [styles, setStyles] = useState({ - '--review-panel-header-height': getMeta('ol-isReviewerRoleEnabled') - ? '36px' - : '69px', + '--review-panel-header-height': '36px', } as CSSProperties) const updateScrollDomVariables = useCallback((element: HTMLDivElement) => { diff --git a/services/web/frontend/js/features/review-panel-new/utils/position-items.ts b/services/web/frontend/js/features/review-panel-new/utils/position-items.ts index 26fecc19de..54489310a9 100644 --- a/services/web/frontend/js/features/review-panel-new/utils/position-items.ts +++ b/services/web/frontend/js/features/review-panel-new/utils/position-items.ts @@ -1,8 +1,7 @@ -import getMeta from '@/utils/meta' import { debounce } from 'lodash' export const OFFSET_FOR_ENTRIES_ABOVE = 70 -const COLLAPSED_HEADER_HEIGHT = getMeta('ol-isReviewerRoleEnabled') ? 42 : 75 +const COLLAPSED_HEADER_HEIGHT = 42 const GAP_BETWEEN_ENTRIES = 4 export const positionItems = debounce( @@ -44,37 +43,39 @@ export const positionItems = debounce( const activeItemTop = getTopPosition(activeItem, activeItemIndex === 0) - activeItem.style.top = `${activeItemTop}px` - activeItem.style.visibility = 'visible' - const focusedItemRect = activeItem.getBoundingClientRect() + const positions: [HTMLElement, number][] = [] + positions.push([activeItem, activeItemTop]) // above the active item let topLimit = activeItemTop for (let i = activeItemIndex - 1; i >= 0; i--) { const item = items[i] - const rect = item.getBoundingClientRect() + const height = item.offsetHeight let top = getTopPosition(item, i === 0) - const bottom = top + rect.height + const bottom = top + height if (bottom > topLimit) { - top = topLimit - rect.height - GAP_BETWEEN_ENTRIES + top = topLimit - height - GAP_BETWEEN_ENTRIES } - item.style.top = `${top}px` - item.style.visibility = 'visible' + positions.push([item, top]) topLimit = top } // below the active item - let bottomLimit = activeItemTop + focusedItemRect.height + let bottomLimit = activeItemTop + activeItem.offsetHeight for (let i = activeItemIndex + 1; i < items.length; i++) { const item = items[i] - const rect = item.getBoundingClientRect() + const height = item.offsetHeight let top = getTopPosition(item, false) if (top < bottomLimit) { top = bottomLimit + GAP_BETWEEN_ENTRIES } + positions.push([item, top]) + bottomLimit = top + height + } + + for (const [item, top] of positions) { item.style.top = `${top}px` item.style.visibility = 'visible' - bottomLimit = top + rect.height } return { diff --git a/services/web/frontend/js/features/settings/components/account-info-section.tsx b/services/web/frontend/js/features/settings/components/account-info-section.tsx index 4fd410d89f..1ff865e757 100644 --- a/services/web/frontend/js/features/settings/components/account-info-section.tsx +++ b/services/web/frontend/js/features/settings/components/account-info-section.tsx @@ -70,7 +70,7 @@ function AccountInfoSection() { return ( <> -

    {t('update_account_info')}

    +

    {t('update_account_info')}

    {hasAffiliationsFeature ? null : ( {t('update')} diff --git a/services/web/frontend/js/features/settings/components/emails-section.tsx b/services/web/frontend/js/features/settings/components/emails-section.tsx index a8bc54b017..e58dcd213a 100644 --- a/services/web/frontend/js/features/settings/components/emails-section.tsx +++ b/services/web/frontend/js/features/settings/components/emails-section.tsx @@ -26,6 +26,20 @@ function EmailsSectionContent() { // Only show the "add email" button if the user has permission to add a secondary email const hideAddSecondaryEmail = getMeta('ol-cannot-add-secondary-email') + // Sort emails: primary first, then confirmed secondary emails, then unconfirmed secondary emails + const sortedUserEmails = [...userEmails].sort((a, b) => { + // Primary email comes first + if (a.default) return -1 + if (b.default) return 1 + + // Then sort by confirmation status + if (a.confirmedAt && !b.confirmedAt) return -1 + if (!a.confirmedAt && b.confirmedAt) return 1 + + // If both have the same status, sort by email string + return a.email.localeCompare(b.email) + }) + return ( <>

    {t('emails_and_affiliations_title')}

    @@ -54,7 +68,7 @@ function EmailsSectionContent() {
    ) : ( <> - {userEmails?.map(userEmail => ( + {sortedUserEmails.map(userEmail => (
    diff --git a/services/web/frontend/js/features/settings/components/emails/institution-and-role.tsx b/services/web/frontend/js/features/settings/components/emails/institution-and-role.tsx index c00a4f053a..21368c39ff 100644 --- a/services/web/frontend/js/features/settings/components/emails/institution-and-role.tsx +++ b/services/web/frontend/js/features/settings/components/emails/institution-and-role.tsx @@ -115,7 +115,7 @@ function InstitutionAndRole({ userEmailData }: InstitutionAndRoleProps) { > {!affiliation.department && !affiliation.role ? t('add_role_and_department') - : t('change')} + : t('change_email')}
    ) : ( diff --git a/services/web/frontend/js/features/settings/components/emails/row.tsx b/services/web/frontend/js/features/settings/components/emails/row.tsx index fd74281ad8..3cbb84ef9b 100644 --- a/services/web/frontend/js/features/settings/components/emails/row.tsx +++ b/services/web/frontend/js/features/settings/components/emails/row.tsx @@ -28,7 +28,7 @@ function EmailsRow({ userEmailData, primary }: EmailsRowProps) { return ( <> - + diff --git a/services/web/frontend/js/features/settings/components/linking-section.tsx b/services/web/frontend/js/features/settings/components/linking-section.tsx index 69d2dc268e..0b9001927e 100644 --- a/services/web/frontend/js/features/settings/components/linking-section.tsx +++ b/services/web/frontend/js/features/settings/components/linking-section.tsx @@ -90,9 +90,7 @@ function LinkingSection() {

    {t('linked_accounts_explained')}

    {haslangFeedbackLinkingWidgets ? ( <> -

    - {t('ai_features')} -

    +

    {t('ai_features')}

    {langFeedbackLinkingWidgets.map( ({ import: { default: widget }, path }, widgetIndex) => ( @@ -108,9 +106,7 @@ function LinkingSection() { ) : null} {hasIntegrationLinkingSection ? ( <> -

    - {t('project_synchronisation')} -

    +

    {t('project_synchronisation')}

    {projectSyncSuccessMessage ? ( -

    - {t('reference_managers')} -

    +

    {t('reference_managers')}

    {referenceLinkingWidgets.map( ({ import: importObject, path }, widgetIndex) => ( @@ -152,9 +146,7 @@ function LinkingSection() { ) : null} {hasSSOLinkingSection ? ( <> -

    - {t('linked_accounts')} -

    +

    {t('linked_accounts')}

    {ssoErrorMessage ? ( - {t('upgrade')} + {t('upgrade')} ) } else if (linked) { diff --git a/services/web/frontend/js/features/settings/components/linking/integration-widget.tsx b/services/web/frontend/js/features/settings/components/linking/integration-widget.tsx index def60625a2..62c569067a 100644 --- a/services/web/frontend/js/features/settings/components/linking/integration-widget.tsx +++ b/services/web/frontend/js/features/settings/components/linking/integration-widget.tsx @@ -20,6 +20,7 @@ function trackLinkingClick(integration: string) { } type IntegrationLinkingWidgetProps = { + id: string logo: ReactNode title: string description: string @@ -35,6 +36,7 @@ type IntegrationLinkingWidgetProps = { } export function IntegrationLinkingWidget({ + id, logo, title, description, @@ -65,19 +67,20 @@ export function IntegrationLinkingWidget({
    {logo}
    -

    {title}

    +

    {title}

    {!hasFeature && {t('premium_feature')}}

    {description}{' '} - {t('learn_more')} + {t('learn_more_about', { appName: title })}

    {hasFeature && statusIndicator}
    void linkPath: string disabled?: boolean + titleId: string } function ActionButton({ @@ -114,16 +118,20 @@ function ActionButton({ linkPath, disabled, integration, + titleId, }: ActionButtonProps) { const { t } = useTranslation() + const linkTextId = `${titleId}-link` + if (!hasFeature) { return ( trackUpgradeClick(integration)} + aria-labelledby={`${titleId} ${linkTextId}`} > - {t('upgrade')} + {t('upgrade')} ) } else if (linked) { @@ -140,14 +148,13 @@ function ActionButton({ return ( <> {disabled ? ( - + {t('link')} ) : ( trackLinkingClick(integration)} > {t('link')} diff --git a/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx b/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx index 8d5bc169d1..800a7540ae 100644 --- a/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx +++ b/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx @@ -69,13 +69,13 @@ export function SSOLinkingWidget({
    {providerLogos[providerId]}
    -

    {title}

    +

    {title}

    {description?.replace(/<[^>]+>/g, '')}{' '} {helpPath ? ( - {t('learn_more')} + {t('learn_more_about', { appName: title })} ) : null}

    @@ -85,6 +85,7 @@ export function SSOLinkingWidget({
    void + titleId: string } function ActionButton({ @@ -113,8 +115,11 @@ function ActionButton({ accountIsLinked, linkPath, onUnlinkClick, + titleId, }: ActionButtonProps) { const { t } = useTranslation() + const linkTextId = `${titleId}-link` + if (unlinkRequestInflight) { return ( @@ -123,13 +128,23 @@ function ActionButton({ ) } else if (accountIsLinked) { return ( - + {t('unlink')} ) } else { return ( - + {t('link')} ) diff --git a/services/web/frontend/js/features/share-project-modal/components/add-collaborators.jsx b/services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx similarity index 89% rename from services/web/frontend/js/features/share-project-modal/components/add-collaborators.jsx rename to services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx index cad9d03177..8606fb11fa 100644 --- a/services/web/frontend/js/features/share-project-modal/components/add-collaborators.jsx +++ b/services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx @@ -2,21 +2,19 @@ import { useEffect, useState, useMemo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useMultipleSelection } from 'downshift' import { useShareProjectContext } from './share-project-modal' -import SelectCollaborators from './select-collaborators' +import SelectCollaborators, { ContactItem } from './select-collaborators' import { resendInvite, sendInvite } from '../utils/api' import { useUserContacts } from '../hooks/use-user-contacts' import useIsMounted from '@/shared/hooks/use-is-mounted' import { useProjectContext } from '@/shared/context/project-context' import { sendMB } from '@/infrastructure/event-tracking' import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer' -import PropTypes from 'prop-types' import OLForm from '@/features/ui/components/ol/ol-form' import OLFormGroup from '@/features/ui/components/ol/ol-form-group' import { Select } from '@/shared/components/select' import OLButton from '@/features/ui/components/ol/ol-button' -import getMeta from '@/utils/meta' -export default function AddCollaborators({ readOnly }) { +export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) { const [privileges, setPrivileges] = useState('readAndWrite') const isMounted = useIsMounted() @@ -44,7 +42,7 @@ export default function AddCollaborators({ readOnly }) { ) }, [contacts, currentMemberEmails]) - const multipleSelectionProps = useMultipleSelection({ + const multipleSelectionProps = useMultipleSelection({ initialActiveIndex: 0, initialSelectedItems: [], }) @@ -130,7 +128,7 @@ export default function AddCollaborators({ readOnly }) { ? previousViewersAmount + 1 : previousViewersAmount, }) - } catch (error) { + } catch (error: any) { setInFlight(false) setError( error.data?.errorReason || @@ -178,29 +176,23 @@ export default function AddCollaborators({ readOnly }) { ]) const privilegeOptions = useMemo(() => { - const options = [ + return [ { key: 'readAndWrite', label: t('editor'), }, - ] - - if (getMeta('ol-isReviewerRoleEnabled')) { - options.push({ + { key: 'review', label: t('reviewer'), description: !features.trackChanges ? t('comment_only_upgrade_for_track_changes') : null, - }) - } - - options.push({ - key: 'readOnly', - label: t('viewer'), - }) - - return options + }, + { + key: 'readOnly', + label: t('viewer'), + }, + ] }, [features.trackChanges, t]) return ( @@ -220,13 +212,17 @@ export default function AddCollaborators({ readOnly }) { dataTestId="add-collaborator-select" items={privilegeOptions} itemToKey={item => item.key} - itemToString={item => item.label} - itemToSubtitle={item => item.description || ''} - itemToDisabled={item => readOnly && item.key !== 'readOnly'} + itemToString={item => item?.label || ''} + itemToSubtitle={item => item?.description || ''} + itemToDisabled={item => !!(readOnly && item?.key !== 'readOnly')} selected={privilegeOptions.find( option => option.key === privileges )} - onSelectedItemChanged={item => setPrivileges(item.key)} + onSelectedItemChanged={item => { + if (item) { + setPrivileges(item.key) + } + }} /> ) } - -AddCollaborators.propTypes = { - readOnly: PropTypes.bool, -} diff --git a/services/web/frontend/js/features/share-project-modal/components/edit-member.tsx b/services/web/frontend/js/features/share-project-modal/components/edit-member.tsx index 45a04dd99c..6d806968b1 100644 --- a/services/web/frontend/js/features/share-project-modal/components/edit-member.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/edit-member.tsx @@ -1,5 +1,4 @@ import { useState, useEffect, useMemo } from 'react' -import PropTypes from 'prop-types' import { Trans, useTranslation } from 'react-i18next' import { useShareProjectContext } from './share-project-modal' import TransferOwnershipModal from './transfer-ownership-modal' @@ -14,9 +13,7 @@ import OLButton from '@/features/ui/components/ol/ol-button' import OLFormGroup from '@/features/ui/components/ol/ol-form-group' import OLCol from '@/features/ui/components/ol/ol-col' import MaterialIcon from '@/shared/components/material-icon' -import getMeta from '@/utils/meta' import { useUserContext } from '@/shared/context/user-context' -import { isSplitTestEnabled } from '@/utils/splitTestUtils' import { upgradePlan } from '@/main/account-upgrade' type PermissionsOption = PermissionsLevel | 'removeAccess' | 'downgraded' @@ -229,15 +226,6 @@ export default function EditMember({ ) } -EditMember.propTypes = { - member: PropTypes.shape({ - _id: PropTypes.string.isRequired, - email: PropTypes.string.isRequired, - privileges: PropTypes.string.isRequired, - }), - hasExceededCollaboratorLimit: PropTypes.bool.isRequired, - canAddCollaborators: PropTypes.bool.isRequired, -} type SelectPrivilegeProps = { value: string @@ -256,21 +244,13 @@ function SelectPrivilege({ const { features } = useProjectContext() const privileges = useMemo( - (): Privilege[] => - getMeta('ol-isReviewerRoleEnabled') - ? [ - { key: 'owner', label: t('make_owner') }, - { key: 'readAndWrite', label: t('editor') }, - { key: 'review', label: t('reviewer') }, - { key: 'readOnly', label: t('viewer') }, - { key: 'removeAccess', label: t('remove_access') }, - ] - : [ - { key: 'owner', label: t('make_owner') }, - { key: 'readAndWrite', label: t('editor') }, - { key: 'readOnly', label: t('viewer') }, - { key: 'removeAccess', label: t('remove_access') }, - ], + (): Privilege[] => [ + { key: 'owner', label: t('make_owner') }, + { key: 'readAndWrite', label: t('editor') }, + { key: 'review', label: t('reviewer') }, + { key: 'readOnly', label: t('viewer') }, + { key: 'removeAccess', label: t('remove_access') }, + ], [t] ) @@ -284,27 +264,13 @@ function SelectPrivilege({ return '' } - if (hasBeenDowngraded) { - if (isSplitTestEnabled('reviewer-role')) { - return t('limited_to_n_collaborators_per_project', { - count: features.collaborators, - }) - } else { - return t('limited_to_n_editors', { count: features.collaborators }) - } - } else if ( - !canAddCollaborators && - !['readAndWrite', 'review'].includes(value) + if ( + hasBeenDowngraded || + (!canAddCollaborators && !['readAndWrite', 'review'].includes(value)) ) { - if (isSplitTestEnabled('reviewer-role')) { - return t('limited_to_n_collaborators_per_project', { - count: features.collaborators, - }) - } else { - return t('limited_to_n_editors_per_project', { - count: features.collaborators, - }) - } + return t('limited_to_n_collaborators_per_project', { + count: features.collaborators, + }) } else { return '' } diff --git a/services/web/frontend/js/features/share-project-modal/components/invite.jsx b/services/web/frontend/js/features/share-project-modal/components/invite.tsx similarity index 86% rename from services/web/frontend/js/features/share-project-modal/components/invite.jsx rename to services/web/frontend/js/features/share-project-modal/components/invite.tsx index 3ca6f60d66..e9d761e4ee 100644 --- a/services/web/frontend/js/features/share-project-modal/components/invite.jsx +++ b/services/web/frontend/js/features/share-project-modal/components/invite.tsx @@ -1,5 +1,4 @@ import { useCallback } from 'react' -import PropTypes from 'prop-types' import { useShareProjectContext } from './share-project-modal' import { useTranslation } from 'react-i18next' import MemberPrivileges from './member-privileges' @@ -11,8 +10,15 @@ import OLCol from '@/features/ui/components/ol/ol-col' import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import OLButton from '@/features/ui/components/ol/ol-button' import MaterialIcon from '@/shared/components/material-icon' +import { ProjectContextMember } from '@/shared/context/types/project-context' -export default function Invite({ invite, isProjectOwner }) { +export default function Invite({ + invite, + isProjectOwner, +}: { + invite: ProjectContextMember + isProjectOwner: boolean +}) { const { t } = useTranslation() return ( @@ -38,12 +44,7 @@ export default function Invite({ invite, isProjectOwner }) { ) } -Invite.propTypes = { - invite: PropTypes.object.isRequired, - isProjectOwner: PropTypes.bool.isRequired, -} - -function ResendInvite({ invite }) { +function ResendInvite({ invite }: { invite: ProjectContextMember }) { const { t } = useTranslation() const { monitorRequest, setError, inFlight } = useShareProjectContext() const { _id: projectId } = useProjectContext() @@ -66,7 +67,9 @@ function ResendInvite({ invite }) { // if (buttonRef.current) { // buttonRef.current.blur() // } - document.activeElement.blur() + if (document.activeElement) { + ;(document.activeElement as HTMLElement).blur() + } }), [invite, monitorRequest, projectId, setError] ) @@ -84,16 +87,12 @@ function ResendInvite({ invite }) { ) } -ResendInvite.propTypes = { - invite: PropTypes.object.isRequired, -} - -function RevokeInvite({ invite }) { +function RevokeInvite({ invite }: { invite: ProjectContextMember }) { const { t } = useTranslation() const { updateProject, monitorRequest } = useShareProjectContext() const { _id: projectId, invites, members } = useProjectContext() - function handleClick(event) { + function handleClick(event: React.MouseEvent) { event.preventDefault() monitorRequest(() => revokeInvite(projectId, invite)).then(() => { @@ -126,7 +125,3 @@ function RevokeInvite({ invite }) { ) } - -RevokeInvite.propTypes = { - invite: PropTypes.object.isRequired, -} diff --git a/services/web/frontend/js/features/share-project-modal/components/link-sharing.jsx b/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx similarity index 87% rename from services/web/frontend/js/features/share-project-modal/components/link-sharing.jsx rename to services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx index 4e9e60c28c..d235bd248b 100644 --- a/services/web/frontend/js/features/share-project-modal/components/link-sharing.jsx +++ b/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx @@ -1,5 +1,4 @@ import { useCallback, useState, useEffect } from 'react' -import PropTypes from 'prop-types' import { useTranslation } from 'react-i18next' import { useShareProjectContext } from './share-project-modal' import { setProjectAccessLevel } from '../utils/api' @@ -18,6 +17,16 @@ import OLButton from '@/features/ui/components/ol/ol-button' import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import MaterialIcon from '@/shared/components/material-icon' +type Tokens = { + readAndWrite: string + readAndWriteHashPrefix: string + readAndWritePrefix: string + readOnly: string + readOnlyHashPrefix: string +} + +type AccessLevel = 'private' | 'tokenBased' | 'readAndWrite' | 'readOnly' + export default function LinkSharing() { const [inflight, setInflight] = useState(false) const [showLinks, setShowLinks] = useState(true) @@ -28,7 +37,7 @@ export default function LinkSharing() { // set the access level of a project const setAccessLevel = useCallback( - newPublicAccessLevel => { + (newPublicAccessLevel: string) => { setInflight(true) sendMB('link-sharing-click-off', { project_id: projectId, @@ -75,6 +84,7 @@ export default function LinkSharing() { case 'readAndWrite': case 'readOnly': return ( + // TODO: do we even need this anymore? void + inflight: boolean + projectId: string + setShowLinks: (show: boolean) => void +}) { const { t } = useTranslation() return ( @@ -113,23 +133,21 @@ function PrivateSharing({ setAccessLevel, inflight, projectId, setShowLinks }) { ) } -PrivateSharing.propTypes = { - setAccessLevel: PropTypes.func.isRequired, - inflight: PropTypes.bool, - projectId: PropTypes.string, - setShowLinks: PropTypes.func.isRequired, -} - function TokenBasedSharing({ setAccessLevel, inflight, setShowLinks, showLinks, +}: { + setAccessLevel: (level: AccessLevel) => void + inflight: boolean + setShowLinks: (show: boolean) => void + showLinks: boolean }) { const { t } = useTranslation() const { _id: projectId } = useProjectContext() - const [tokens, setTokens] = useState(null) + const [tokens, setTokens] = useState(null) const { signal } = useAbortController() @@ -190,14 +208,15 @@ function TokenBasedSharing({ ) } -TokenBasedSharing.propTypes = { - setAccessLevel: PropTypes.func.isRequired, - inflight: PropTypes.bool, - setShowLinks: PropTypes.func.isRequired, - showLinks: PropTypes.bool, -} - -function LegacySharing({ accessLevel, setAccessLevel, inflight }) { +function LegacySharing({ + accessLevel, + setAccessLevel, + inflight, +}: { + accessLevel: AccessLevel + setAccessLevel: (level: AccessLevel) => void + inflight: boolean +}) { const { t } = useTranslation() return ( @@ -223,17 +242,11 @@ function LegacySharing({ accessLevel, setAccessLevel, inflight }) { ) } -LegacySharing.propTypes = { - accessLevel: PropTypes.string.isRequired, - setAccessLevel: PropTypes.func.isRequired, - inflight: PropTypes.bool, -} - export function ReadOnlyTokenLink() { const { t } = useTranslation() const { _id: projectId } = useProjectContext() - const [tokens, setTokens] = useState(null) + const [tokens, setTokens] = useState(null) const { signal } = useAbortController() @@ -260,7 +273,17 @@ export function ReadOnlyTokenLink() { ) } -function AccessToken({ token, tokenHashPrefix, path, tooltipId }) { +function AccessToken({ + token, + tokenHashPrefix, + path, + tooltipId, +}: { + token?: string + tokenHashPrefix?: string + path: string + tooltipId: string +}) { const { t } = useTranslation() const { isAdmin } = useUserContext() @@ -288,13 +311,6 @@ function AccessToken({ token, tokenHashPrefix, path, tooltipId }) { ) } -AccessToken.propTypes = { - token: PropTypes.string, - tokenHashPrefix: PropTypes.string, - tooltipId: PropTypes.string.isRequired, - path: PropTypes.string.isRequired, -} - function LinkSharingInfo() { const { t } = useTranslation() diff --git a/services/web/frontend/js/features/share-project-modal/components/member-privileges.jsx b/services/web/frontend/js/features/share-project-modal/components/member-privileges.tsx similarity index 59% rename from services/web/frontend/js/features/share-project-modal/components/member-privileges.jsx rename to services/web/frontend/js/features/share-project-modal/components/member-privileges.tsx index bae354858a..c2b7bf98ef 100644 --- a/services/web/frontend/js/features/share-project-modal/components/member-privileges.jsx +++ b/services/web/frontend/js/features/share-project-modal/components/member-privileges.tsx @@ -1,7 +1,11 @@ -import PropTypes from 'prop-types' +import { ProjectContextMember } from '@/shared/context/types/project-context' import { useTranslation } from 'react-i18next' -export default function MemberPrivileges({ privileges }) { +export default function MemberPrivileges({ + privileges, +}: { + privileges: ProjectContextMember['privileges'] +}) { const { t } = useTranslation() switch (privileges) { @@ -18,6 +22,3 @@ export default function MemberPrivileges({ privileges }) { return null } } -MemberPrivileges.propTypes = { - privileges: PropTypes.string.isRequired, -} diff --git a/services/web/frontend/js/features/share-project-modal/components/owner-info.jsx b/services/web/frontend/js/features/share-project-modal/components/owner-info.tsx similarity index 100% rename from services/web/frontend/js/features/share-project-modal/components/owner-info.jsx rename to services/web/frontend/js/features/share-project-modal/components/owner-info.tsx diff --git a/services/web/frontend/js/features/share-project-modal/components/select-collaborators.jsx b/services/web/frontend/js/features/share-project-modal/components/select-collaborators.tsx similarity index 86% rename from services/web/frontend/js/features/share-project-modal/components/select-collaborators.jsx rename to services/web/frontend/js/features/share-project-modal/components/select-collaborators.tsx index a683201d0b..464c5b5368 100644 --- a/services/web/frontend/js/features/share-project-modal/components/select-collaborators.jsx +++ b/services/web/frontend/js/features/share-project-modal/components/select-collaborators.tsx @@ -1,14 +1,20 @@ import { useEffect, useMemo, useState, useRef, useCallback } from 'react' -import PropTypes from 'prop-types' import { useTranslation } from 'react-i18next' import { matchSorter } from 'match-sorter' -import { useCombobox } from 'downshift' +import { useCombobox, UseMultipleSelectionReturnValue } from 'downshift' import classnames from 'classnames' import MaterialIcon from '@/shared/components/material-icon' import Tag from '@/features/ui/components/bootstrap-5/tag' import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu' -import { Spinner } from 'react-bootstrap-5' +import { Spinner } from 'react-bootstrap' +import { Contact } from '../utils/types' + +export type ContactItem = { + email: string + display: string + type: string +} // Unicode characters in these Unicode groups: // "General Punctuation — Spaces" @@ -21,6 +27,11 @@ export default function SelectCollaborators({ options, placeholder, multipleSelectionProps, +}: { + loading: boolean + options: Contact[] + placeholder: string + multipleSelectionProps: UseMultipleSelectionReturnValue }) { const { t } = useTranslation() const { @@ -58,12 +69,14 @@ export default function SelectCollaborators({ }) }, [unselectedOptions, inputValue]) - const inputRef = useRef(null) + const inputRef = useRef(null) const focusInput = useCallback(() => { if (inputRef.current) { window.setTimeout(() => { - inputRef.current.focus() + if (inputRef.current) { + inputRef.current.focus() + } }, 10) } }, [inputRef]) @@ -80,7 +93,7 @@ export default function SelectCollaborators({ return true }, [inputValue, selectedItems]) - function stateReducer(state, actionAndChanges) { + function stateReducer(_: unknown, actionAndChanges: any) { const { type, changes } = actionAndChanges // force selected item to be null so that adding, removing, then re-adding the same collaborator is recognised as a selection change if (type === useCombobox.stateChangeTypes.InputChange) { @@ -101,7 +114,7 @@ export default function SelectCollaborators({ inputValue, defaultHighlightedIndex: 0, items: filteredOptions, - itemToString: item => item && item.name, + itemToString: item => (item && item.name) || '', stateReducer, onStateChange: ({ inputValue, type, selectedItem }) => { switch (type) { @@ -119,7 +132,7 @@ export default function SelectCollaborators({ }) const addNewItem = useCallback( - (_email, focus = true) => { + (_email: string, focus = true) => { const email = _email.replace(matchAllSpaces, '') if ( @@ -200,7 +213,7 @@ export default function SelectCollaborators({ addNewItem(inputValue, false) }, onChange: e => { - setInputValue(e.target.value) + setInputValue((e.target as HTMLInputElement).value) }, onClick: () => focusInput, onKeyDown: event => { @@ -230,7 +243,7 @@ export default function SelectCollaborators({ const data = // modern browsers event.clipboardData?.getData('text/plain') ?? - // IE11 + // @ts-ignore IE11 window.clipboardData?.getData('text') if (data) { @@ -276,20 +289,18 @@ export default function SelectCollaborators({
    ) } -SelectCollaborators.propTypes = { - loading: PropTypes.bool.isRequired, - options: PropTypes.array.isRequired, - placeholder: PropTypes.string, - multipleSelectionProps: PropTypes.shape({ - getSelectedItemProps: PropTypes.func.isRequired, - getDropdownProps: PropTypes.func.isRequired, - addSelectedItem: PropTypes.func.isRequired, - removeSelectedItem: PropTypes.func.isRequired, - selectedItems: PropTypes.array.isRequired, - }).isRequired, -} -function Option({ selected, item, getItemProps, index }) { +function Option({ + selected, + item, + getItemProps, + index, +}: { + selected: boolean + item: Contact + getItemProps: (any: any) => any + index: number +}) { return (
  • void + selectedItem: ContactItem + focusInput: () => void + getSelectedItemProps: (any: any) => any + index: number }) { const handleClick = useCallback( - event => { + (event: React.MouseEvent) => { event.preventDefault() event.stopPropagation() removeSelectedItem(selectedItem) @@ -344,13 +352,3 @@ function SelectedItem({ ) } - -SelectedItem.propTypes = { - focusInput: PropTypes.func.isRequired, - removeSelectedItem: PropTypes.func.isRequired, - selectedItem: PropTypes.shape({ - display: PropTypes.string.isRequired, - }), - getSelectedItemProps: PropTypes.func.isRequired, - index: PropTypes.number.isRequired, -} diff --git a/services/web/frontend/js/features/share-project-modal/components/send-invites.jsx b/services/web/frontend/js/features/share-project-modal/components/send-invites.tsx similarity index 80% rename from services/web/frontend/js/features/share-project-modal/components/send-invites.jsx rename to services/web/frontend/js/features/share-project-modal/components/send-invites.tsx index f27d42f404..da704d039f 100644 --- a/services/web/frontend/js/features/share-project-modal/components/send-invites.jsx +++ b/services/web/frontend/js/features/share-project-modal/components/send-invites.tsx @@ -2,7 +2,6 @@ import AddCollaborators from './add-collaborators' import AddCollaboratorsUpgrade from './add-collaborators-upgrade' import CollaboratorsLimitUpgrade from './collaborators-limit-upgrade' import AccessLevelsChanged from './access-levels-changed' -import PropTypes from 'prop-types' import OLRow from '@/features/ui/components/ol/ol-row' export default function SendInvites({ @@ -10,6 +9,11 @@ export default function SendInvites({ hasExceededCollaboratorLimit, haveAnyEditorsBeenDowngraded, somePendingEditorsResolved, +}: { + canAddCollaborators: boolean + hasExceededCollaboratorLimit: boolean + haveAnyEditorsBeenDowngraded: boolean + somePendingEditorsResolved: boolean }) { return ( @@ -30,10 +34,3 @@ export default function SendInvites({ ) } - -SendInvites.propTypes = { - canAddCollaborators: PropTypes.bool, - hasExceededCollaboratorLimit: PropTypes.bool, - haveAnyEditorsBeenDowngraded: PropTypes.bool, - somePendingEditorsResolved: PropTypes.bool, -} diff --git a/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx b/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx index cabf2ddd70..3f08fdf3a1 100644 --- a/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx @@ -11,7 +11,7 @@ import OLModal, { } from '@/features/ui/components/ol/ol-modal' import OLNotification from '@/features/ui/components/ol/ol-notification' import OLButton from '@/features/ui/components/ol/ol-button' -import { Spinner } from 'react-bootstrap-5' +import { Spinner } from 'react-bootstrap' const ReadOnlyTokenLink = lazy(() => import('./link-sharing').then(({ ReadOnlyTokenLink }) => ({ diff --git a/services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.jsx b/services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.tsx similarity index 90% rename from services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.jsx rename to services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.tsx index 81961d7ede..02100d2a2a 100644 --- a/services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.jsx +++ b/services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.tsx @@ -1,6 +1,5 @@ import { useState } from 'react' import { Trans, useTranslation } from 'react-i18next' -import PropTypes from 'prop-types' import { transferProjectOwnership } from '../utils/api' import { useProjectContext } from '@/shared/context/project-context' import { useLocation } from '@/shared/hooks/use-location' @@ -12,9 +11,16 @@ import OLModal, { } from '@/features/ui/components/ol/ol-modal' import OLNotification from '@/features/ui/components/ol/ol-notification' import OLButton from '@/features/ui/components/ol/ol-button' -import { Spinner } from 'react-bootstrap-5' +import { Spinner } from 'react-bootstrap' +import { ProjectContextMember } from '@/shared/context/types/project-context' -export default function TransferOwnershipModal({ member, cancel }) { +export default function TransferOwnershipModal({ + member, + cancel, +}: { + member: ProjectContextMember + cancel: () => void +}) { const { t } = useTranslation() const [inflight, setInflight] = useState(false) @@ -82,7 +88,3 @@ export default function TransferOwnershipModal({ member, cancel }) { ) } -TransferOwnershipModal.propTypes = { - member: PropTypes.object.isRequired, - cancel: PropTypes.func.isRequired, -} diff --git a/services/web/frontend/js/features/share-project-modal/components/view-member.jsx b/services/web/frontend/js/features/share-project-modal/components/view-member.tsx similarity index 68% rename from services/web/frontend/js/features/share-project-modal/components/view-member.jsx rename to services/web/frontend/js/features/share-project-modal/components/view-member.tsx index 3faec91612..d4cf2e9333 100644 --- a/services/web/frontend/js/features/share-project-modal/components/view-member.jsx +++ b/services/web/frontend/js/features/share-project-modal/components/view-member.tsx @@ -1,10 +1,14 @@ -import PropTypes from 'prop-types' import MemberPrivileges from './member-privileges' import OLRow from '@/features/ui/components/ol/ol-row' import OLCol from '@/features/ui/components/ol/ol-col' import MaterialIcon from '@/shared/components/material-icon' +import { ProjectContextMember } from '@/shared/context/types/project-context' -export default function ViewMember({ member }) { +export default function ViewMember({ + member, +}: { + member: ProjectContextMember +}) { return ( @@ -19,11 +23,3 @@ export default function ViewMember({ member }) { ) } - -ViewMember.propTypes = { - member: PropTypes.shape({ - _id: PropTypes.string.isRequired, - email: PropTypes.string.isRequired, - privileges: PropTypes.string.isRequired, - }).isRequired, -} diff --git a/services/web/frontend/js/features/share-project-modal/hooks/use-user-contacts.js b/services/web/frontend/js/features/share-project-modal/hooks/use-user-contacts.ts similarity index 85% rename from services/web/frontend/js/features/share-project-modal/hooks/use-user-contacts.js rename to services/web/frontend/js/features/share-project-modal/hooks/use-user-contacts.ts index f23af1fbd3..35f9f9db9f 100644 --- a/services/web/frontend/js/features/share-project-modal/hooks/use-user-contacts.js +++ b/services/web/frontend/js/features/share-project-modal/hooks/use-user-contacts.ts @@ -1,10 +1,11 @@ import { useEffect, useState } from 'react' import { getJSON } from '../../../infrastructure/fetch-json' import useAbortController from '../../../shared/hooks/use-abort-controller' +import { Contact } from '../utils/types' export function useUserContacts() { const [loading, setLoading] = useState(true) - const [data, setData] = useState(null) + const [data, setData] = useState(null) const [error, setError] = useState(false) const { signal } = useAbortController() @@ -21,7 +22,7 @@ export function useUserContacts() { return { loading, data, error } } -function buildContact(contact) { +function buildContact(contact: Omit): Contact { const [emailPrefix] = contact.email.split('@') // the name is not just the default "email prefix as first name" diff --git a/services/web/frontend/js/features/share-project-modal/utils/types.ts b/services/web/frontend/js/features/share-project-modal/utils/types.ts new file mode 100644 index 0000000000..9636f14fb5 --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/utils/types.ts @@ -0,0 +1,9 @@ +export type Contact = { + id: string + display: string + email: string + first_name: string + last_name: string + name: string + type: 'user' +} diff --git a/services/web/frontend/js/features/socket-diagnostics/components/diagnostic-component.tsx b/services/web/frontend/js/features/socket-diagnostics/components/diagnostic-component.tsx index 7adddc5329..91829a09e2 100644 --- a/services/web/frontend/js/features/socket-diagnostics/components/diagnostic-component.tsx +++ b/services/web/frontend/js/features/socket-diagnostics/components/diagnostic-component.tsx @@ -1,7 +1,7 @@ import React from 'react' import classnames from 'classnames' import type { ConnectionStatus } from './types' -import { Badge, Button } from 'react-bootstrap-5' +import { Badge, Button } from 'react-bootstrap' import OLNotification from '@/features/ui/components/ol/ol-notification' import MaterialIcon from '@/shared/components/material-icon' diff --git a/services/web/frontend/js/features/socket-diagnostics/components/socket-diagnostics.tsx b/services/web/frontend/js/features/socket-diagnostics/components/socket-diagnostics.tsx index 498a9ecc23..39876d250e 100644 --- a/services/web/frontend/js/features/socket-diagnostics/components/socket-diagnostics.tsx +++ b/services/web/frontend/js/features/socket-diagnostics/components/socket-diagnostics.tsx @@ -7,7 +7,7 @@ import { DiagnosticItem, ErrorAlert, } from './diagnostic-component' -import { Col, Container, Row } from 'react-bootstrap-5' +import { Col, Container, Row } from 'react-bootstrap' import MaterialIcon from '@/shared/components/material-icon' import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox' import { CopyToClipboard } from '@/shared/components/copy-to-clipboard' diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx index c013dcdb42..90a968add6 100644 --- a/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx +++ b/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx @@ -29,7 +29,6 @@ import MaterialIcon from '@/shared/components/material-icon' import OLButtonGroup from '@/features/ui/components/ol/ol-button-group' import OLFormControl from '@/features/ui/components/ol/ol-form-control' import OLCloseButton from '@/features/ui/components/ol/ol-close-button' -import { isSplitTestEnabled } from '@/utils/splitTestUtils' import { useTranslation } from 'react-i18next' import classnames from 'classnames' import { useUserSettingsContext } from '@/shared/context/user-settings-context' @@ -444,9 +443,7 @@ const CodeMirrorSearchForm: FC = () => { - {!newEditor && isSplitTestEnabled('full-project-search') && ( - - )} + {!newEditor && } {position !== null && (
    diff --git a/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx b/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx index e4e77c5859..698204d89c 100644 --- a/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx +++ b/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx @@ -7,7 +7,7 @@ import { forwardRef, memo, Ref, useCallback, useEffect, useRef } from 'react' import { useCodeMirrorViewContext } from './codemirror-context' import MaterialIcon from '@/shared/components/material-icon' import { useTranslation } from 'react-i18next' -import { Overlay, Popover } from 'react-bootstrap-5' +import { Overlay, Popover } from 'react-bootstrap' import Close from '@/shared/components/close' import useTutorial from '@/shared/hooks/promotions/use-tutorial' import { useEditorContext } from '@/shared/context/editor-context' @@ -80,7 +80,7 @@ export const FullProjectSearchButton = ({ query }: { query: SearchQuery }) => { diff --git a/services/web/frontend/js/features/source-editor/components/math-preview-tooltip.tsx b/services/web/frontend/js/features/source-editor/components/math-preview-tooltip.tsx index 4e2a200f44..60d9c430e2 100644 --- a/services/web/frontend/js/features/source-editor/components/math-preview-tooltip.tsx +++ b/services/web/frontend/js/features/source-editor/components/math-preview-tooltip.tsx @@ -23,6 +23,7 @@ import { mathPreviewStateField } from '../extensions/math-preview' import { getTooltip } from '@codemirror/view' import ReactDOM from 'react-dom' import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item' +import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils' const MathPreviewTooltipContainer: FC = () => { const state = useCodeMirrorStateContext() @@ -57,6 +58,8 @@ const MathPreviewTooltip: FC<{ mathContent: HTMLDivElement }> = ({ }) => { const { t } = useTranslation() + const newEditor = useIsNewEditorEnabled() + const [showDisableModal, setShowDisableModal] = useState(false) const { setMathPreview } = useProjectSettingsContext() const openDisableModal = useCallback(() => setShowDisableModal(true), []) @@ -133,10 +136,17 @@ const MathPreviewTooltip: FC<{ mathContent: HTMLDivElement }> = ({ {t('disable_equation_preview_confirm')}
    - }} - /> + {newEditor ? ( + }} + /> + ) : ( + }} + /> + )}
    diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx index 0de999b214..50f15d71b8 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/tabular.tsx @@ -139,6 +139,33 @@ export class TableData { if (this.rows.length === 0 || this.columns.length === 0) { return null } + const hasTopRule = this.rows[0].borderTop === 1 + const hasMidRule = + this.rows[1]?.borderTop === 1 && + (this.rows.length === 2 || this.rows[1]?.borderBottom === 0) + const hasBottomRule = + this.rows[this.rows.length - 1].borderBottom === 1 && + (this.rows.length === 2 || + this.rows[this.rows.length - 1].borderTop === 0) + + if (hasTopRule && hasMidRule && hasBottomRule) { + let isBooktabs = true + // Check for no other borders + for (const row of this.rows.slice(2, -1)) { + if (row.borderTop > 0 || row.borderBottom > 0) { + isBooktabs = false + } + } + for (const column of this.columns) { + if (column.borderLeft > 0 || column.borderRight > 0) { + isBooktabs = false + } + } + if (isBooktabs) { + return BorderTheme.BOOKTABS + } + } + const lastRow = this.rows[this.rows.length - 1] const hasBottomBorder = lastRow.borderBottom > 0 const firstColumn = this.columns[0] @@ -180,8 +207,10 @@ export class TableData { if (hasAllRowBorders && hasAllColumnBorders) { return BorderTheme.FULLY_BORDERED - } else { + } else if (!hasAllRowBorders && !hasAllColumnBorders) { return BorderTheme.NO_BORDERS + } else { + return null } } } diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts index 0d8798f275..2645e853bd 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts +++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts @@ -20,8 +20,224 @@ import { WidthSelection } from './column-width-modal/column-width' export enum BorderTheme { NO_BORDERS = 0, FULLY_BORDERED = 1, + BOOKTABS = 2, } /* eslint-enable no-unused-vars */ + +type ThemeGenerator = { + column: ( + number: number, + numColumns: number + ) => { left: boolean; right: boolean } + row: (number: number, numRows: number) => string | false + lastRow?: () => string | false + multicolumn: () => { left: boolean; right: boolean } +} + +const themeGenerators: Record = { + [BorderTheme.NO_BORDERS]: { + column: () => ({ left: false, right: false }), + row: () => false, + multicolumn: () => ({ left: false, right: false }), + }, + [BorderTheme.FULLY_BORDERED]: { + column: (number: number, numColumns: number) => ({ + left: true, + right: number === numColumns - 1, + }), + row: (number: number, numRows: number) => '\\hline', + multicolumn: () => ({ left: true, right: true }), + lastRow: () => '\\hline', + }, + [BorderTheme.BOOKTABS]: { + column: (number: number, numColumns: number) => ({ + left: false, + right: false, + }), + row: (number: number, numRows: number) => { + if (number === 0) { + return '\\toprule' + } + if (number === 1) { + return '\\midrule' + } + return false + }, + lastRow: () => '\\bottomrule', + multicolumn: () => ({ left: false, right: false }), + }, +} + +function applyBorderTheme( + generator: ThemeGenerator, + view: EditorView, + table: TableData, + positions: Positions, + rowSeparators: RowSeparator[] +): ChangeSpec[] { + const changes: ChangeSpec[] = [] + + // Update specification + const spec = view.state.sliceDoc( + positions.columnDeclarations.from, + positions.columnDeclarations.to + ) + const columnSpecification = parseColumnSpecifications(spec) + columnSpecification.forEach((column, index) => { + const { left, right } = generator.column(index, columnSpecification.length) + column.borderLeft = left ? 1 : 0 + column.borderRight = right ? 1 : 0 + }) + const newSpec = generateColumnSpecification(columnSpecification) + if (newSpec !== spec) { + changes.push({ + from: positions.columnDeclarations.from, + to: positions.columnDeclarations.to, + insert: newSpec, + }) + } + + for (let i = 0; i < positions.rowPositions.length; i++) { + const row = positions.rowPositions[i] + const topBorder = generator.row(i, positions.rowPositions.length) + if (topBorder) { + let borderPresent = false + for (const hline of row.hlines) { + if (hline.from > rowSeparators[i]?.to) { + continue + } + if (borderPresent) { + // Remove extra border + changes.push({ + from: hline.from, + to: hline.to, + insert: '', + }) + } else { + const type = view.state.sliceDoc(hline.from, hline.to) + if (type.trim() !== topBorder) { + // Replace with the correct border + changes.push({ + from: hline.from, + to: hline.to, + insert: topBorder, + }) + } + borderPresent = true + } + } + if (!borderPresent) { + // Add the border + changes.push({ + from: row.from, + to: row.from, + insert: topBorder, + }) + } + } else { + for (const hline of row.hlines) { + if (hline.from > rowSeparators[i]?.to) { + continue + } + changes.push({ + from: hline.from, + to: hline.to, + insert: '', + }) + } + } + } + + const lastRow = positions.rowPositions[positions.rowPositions.length - 1] + const lastRowBorder = generator.lastRow?.() + const hasLastRowSeparator = + positions.rowPositions.length === rowSeparators.length + if (hasLastRowSeparator) { + if (lastRowBorder) { + let borderPresent = false + for (const hline of lastRow.hlines) { + if (hline.from < rowSeparators[positions.rowPositions.length - 1].to) { + continue + } + if (borderPresent) { + // Remove extra border + changes.push({ + from: hline.from, + to: hline.to, + insert: '', + }) + } else { + const type = view.state.sliceDoc(hline.from, hline.to) + if (type.trim() !== lastRowBorder) { + // Replace with the correct border + changes.push({ + from: hline.from, + to: hline.to, + insert: lastRowBorder, + }) + } + borderPresent = true + } + } + if (!borderPresent) { + const rowSeparator = rowSeparators[positions.rowPositions.length - 1] + + // Add the border + changes.push({ + from: rowSeparator.to, + to: rowSeparator.to, + insert: ` ${lastRowBorder}`, + }) + } + } else { + for (const hline of lastRow.hlines) { + if (hline.from < rowSeparators[positions.rowPositions.length - 1].to) { + continue + } + changes.push({ + from: hline.from, + to: hline.to, + insert: '', + }) + } + } + } else if (lastRowBorder) { + changes.push({ + from: lastRow.to, + to: lastRow.to, + insert: `\\\\ ${lastRowBorder}`, + }) + } + + // Update multicolumn + for (const row of table.rows) { + for (const cell of row.cells) { + if (cell.multiColumn) { + const { left, right } = generator.multicolumn() + const spec = view.state.sliceDoc( + cell.multiColumn.columns.from, + cell.multiColumn.columns.to + ) + const columnSpecification = parseColumnSpecifications(spec) + columnSpecification.forEach(column => { + column.borderLeft = left ? 1 : 0 + column.borderRight = right ? 1 : 0 + }) + const newSpec = generateColumnSpecification(columnSpecification) + if (newSpec !== spec) { + changes.push({ + from: cell.multiColumn.columns.from, + to: cell.multiColumn.columns.to, + insert: newSpec, + }) + } + } + } + } + + return changes +} + export const setBorders = ( view: EditorView, theme: BorderTheme, @@ -29,118 +245,18 @@ export const setBorders = ( rowSeparators: RowSeparator[], table: TableData ) => { - const specification = view.state.sliceDoc( - positions.columnDeclarations.from, - positions.columnDeclarations.to + const generator = themeGenerators[theme] + const changes = applyBorderTheme( + generator, + view, + table, + positions, + rowSeparators ) - if (theme === BorderTheme.NO_BORDERS) { - const removeColumnBorders = view.state.changes({ - from: positions.columnDeclarations.from, - to: positions.columnDeclarations.to, - insert: specification.replace(/\|/g, ''), - }) - const removeHlines: ChangeSpec[] = [] - for (const row of positions.rowPositions) { - for (const hline of row.hlines) { - removeHlines.push({ - from: hline.from, - to: hline.to, - insert: '', - }) - } - } - const removeMulticolumnBorders: ChangeSpec[] = [] - for (const row of table.rows) { - for (const cell of row.cells) { - if (cell.multiColumn) { - const specification = view.state.sliceDoc( - cell.multiColumn.columns.from, - cell.multiColumn.columns.to - ) - removeMulticolumnBorders.push({ - from: cell.multiColumn.columns.from, - to: cell.multiColumn.columns.to, - insert: specification.replace(/\|/g, ''), - }) - } - } - } - view.dispatch({ - changes: [ - removeColumnBorders, - ...removeHlines, - ...removeMulticolumnBorders, - ], - }) - } else if (theme === BorderTheme.FULLY_BORDERED) { - const newSpec = generateColumnSpecification( - addColumnBordersToSpecification(table.columns) - ) - const insertColumns = view.state.changes({ - from: positions.columnDeclarations.from, - to: positions.columnDeclarations.to, - insert: newSpec, - }) - - const insertHlines: ChangeSpec[] = [] - for (const row of positions.rowPositions) { - if (row.hlines.length === 0) { - insertHlines.push( - view.state.changes({ - from: row.from, - to: row.from, - insert: ' \\hline ', - }) - ) - } - } - const lastRow = positions.rowPositions[positions.rowPositions.length - 1] - if (lastRow.hlines.length < 2) { - let toInsert = ' \\hline' - if (rowSeparators.length < positions.rowPositions.length) { - // We need a trailing \\ - toInsert = ` \\\\${toInsert}` - } - insertHlines.push( - view.state.changes({ - from: lastRow.to, - to: lastRow.to, - insert: toInsert, - }) - ) - } - const addMulticolumnBorders: ChangeSpec[] = [] - for (const row of table.rows) { - for (const cell of row.cells) { - if (cell.multiColumn) { - addMulticolumnBorders.push({ - from: cell.multiColumn.columns.from, - to: cell.multiColumn.columns.to, - insert: generateColumnSpecification( - addColumnBordersToSpecification( - cell.multiColumn.columns.specification - ) - ), - }) - } - } - } - - view.dispatch({ - changes: [insertColumns, ...insertHlines, ...addMulticolumnBorders], - }) - } -} - -const addColumnBordersToSpecification = (specification: ColumnDefinition[]) => { - const newSpec = specification.map(column => ({ - ...column, - borderLeft: 1, - borderRight: 0, - })) - newSpec[newSpec.length - 1].borderRight = 1 - return newSpec + view.dispatch({ + changes, + }) } export const setAlignment = ( diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx index 1e92398f6f..637764fd76 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx @@ -3,7 +3,6 @@ import { useSelectionContext } from '../contexts/selection-context' import { ToolbarButton } from './toolbar-button' import { ToolbarButtonMenu } from './toolbar-button-menu' import { ToolbarDropdown, ToolbarDropdownItem } from './toolbar-dropdown' -import MaterialIcon from '../../../../../shared/components/material-icon' import { BorderTheme, insertColumn, @@ -47,6 +46,8 @@ export const Toolbar = memo(function Toolbar() { return t('all_borders') case BorderTheme.NO_BORDERS: return t('no_borders') + case BorderTheme.BOOKTABS: + return t('booktabs') default: return t('custom_borders') } @@ -203,12 +204,22 @@ export const Toolbar = memo(function Toolbar() { > {t('no_borders')} -
    -
    - -
    - {t('more_options_for_border_settings_coming_soon')} -
    + { + setBorders( + view, + BorderTheme.BOOKTABS, + positions, + rowSeparators, + table + ) + }} + active={table.getBorderTheme() === BorderTheme.BOOKTABS} + icon="border_top" + > + {t('booktabs')} +
    diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/button-menu.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/button-menu.tsx index 12fa52032e..086ecc66e5 100644 --- a/services/web/frontend/js/features/source-editor/components/toolbar/button-menu.tsx +++ b/services/web/frontend/js/features/source-editor/components/toolbar/button-menu.tsx @@ -1,4 +1,4 @@ -import { FC, memo, useRef } from 'react' +import { FC, memo, useEffect, useRef } from 'react' import useDropdown from '../../../../shared/hooks/use-dropdown' import OLListGroup from '@/features/ui/components/ol/ol-list-group' import OLTooltip from '@/features/ui/components/ol/ol-tooltip' @@ -13,13 +13,27 @@ export const ToolbarButtonMenu: FC< id: string label: string icon: React.ReactNode + disablePopover?: boolean altCommand?: (view: EditorView) => void }> -> = memo(function ButtonMenu({ icon, id, label, altCommand, children }) { +> = memo(function ButtonMenu({ + icon, + id, + label, + altCommand, + disablePopover, + children, +}) { const target = useRef(null) const { open, onToggle, ref } = useDropdown() const view = useCodeMirrorViewContext() + useEffect(() => { + if (disablePopover && open) { + onToggle(false) + } + }, [open, disablePopover, onToggle]) + const button = (
    diff --git a/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx b/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx index 513b1b14ba..367a5e35a9 100644 --- a/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx +++ b/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' import moment from 'moment' import { useTranslation, Trans } from 'react-i18next' import { @@ -20,6 +20,7 @@ import OLButton from '@/features/ui/components/ol/ol-button' import { subscriptionUpdateUrl } from '@/features/subscription/data/subscription-url' import * as eventTracking from '@/infrastructure/event-tracking' import sparkleText from '@/shared/svgs/ai-sparkle-text.svg' +import { useFeatureFlag } from '@/shared/context/split-test-context' function PreviewSubscriptionChange() { const preview = getMeta( @@ -29,29 +30,37 @@ function PreviewSubscriptionChange() { const { t } = useTranslation() const payNowTask = useAsync() const location = useLocation() + const aiAssistEnabled = useFeatureFlag('overleaf-assist-bundle') + + useEffect(() => { + if (preview.change.type === 'add-on-purchase') { + eventTracking.sendMB('preview-subscription-change-view', { + plan: preview.change.addOn.code, + upgradeType: 'add-on', + referrer: purchaseReferrer, + }) + } + }, [preview.change, purchaseReferrer]) const handlePayNowClick = useCallback(() => { - let addOnSegmentation: Record | null = null if (preview.change.type === 'add-on-purchase') { - addOnSegmentation = { - addOn: preview.change.addOn.code, + eventTracking.sendMB('subscription-change-form-submit', { + plan: preview.change.addOn.code, upgradeType: 'add-on', - } - if (purchaseReferrer) { - addOnSegmentation.referrer = purchaseReferrer - } - eventTracking.sendMB('subscription-change-form-submit', addOnSegmentation) + referrer: purchaseReferrer, + }) } eventTracking.sendMB('assistant-add-on-purchase') payNowTask .runAsync(payNow(preview)) .then(() => { - if (addOnSegmentation) { - eventTracking.sendMB( - 'subscription-change-form-success', - addOnSegmentation - ) + if (preview.change.type === 'add-on-purchase') { + eventTracking.sendMB('subscription-change-form-success', { + plan: preview.change.addOn.code, + upgradeType: 'add-on', + referrer: purchaseReferrer, + }) } location.replace('/user/subscription/thank-you') }) @@ -100,20 +109,37 @@ function PreviewSubscriptionChange() { {aiAddOnChange && (
    -
    )} diff --git a/services/web/frontend/js/features/subscription/components/successful-subscription/root.tsx b/services/web/frontend/js/features/subscription/components/successful-subscription/root.tsx index f7ef400ded..437ac2928e 100644 --- a/services/web/frontend/js/features/subscription/components/successful-subscription/root.tsx +++ b/services/web/frontend/js/features/subscription/components/successful-subscription/root.tsx @@ -1,3 +1,4 @@ +import { UserProvider } from '@/shared/context/user-context' import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n' import { SubscriptionDashboardProvider } from '../../context/subscription-dashboard-context' import SuccessfulSubscription from './successful-subscription' @@ -13,7 +14,9 @@ function Root() { return ( - + + + ) diff --git a/services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx b/services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx index f211c85f10..68d57f57fb 100644 --- a/services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx +++ b/services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx @@ -13,6 +13,7 @@ import { isStandaloneAiPlanCode, } from '../../data/add-on-codes' import { PaidSubscription } from '../../../../../../types/subscription/dashboard/subscription' +import { useBroadcastUser } from '@/shared/hooks/user-channel/use-broadcast-user' function SuccessfulSubscription() { const { t } = useTranslation() @@ -20,6 +21,7 @@ function SuccessfulSubscription() { useSubscriptionDashboardContext() const postCheckoutRedirect = getMeta('ol-postCheckoutRedirect') const { appName, adminEmail } = getMeta('ol-ExposedSettings') + useBroadcastUser() if (!subscription || !('payment' in subscription)) return null @@ -78,7 +80,7 @@ function SuccessfulSubscription() { subscription={subscription} onAiStandalonePlan={onAiStandalonePlan} /> - {!onAiStandalonePlan && } +

    {t('need_anything_contact_us_at')}  @@ -112,7 +114,7 @@ function SuccessfulSubscription() { href={postCheckoutRedirect || '/project'} rel="noopener noreferrer" > - < {t('back_to_your_projects')} + {t('back_to_your_projects')}

    diff --git a/services/web/frontend/js/features/subscription/data/add-on-codes.ts b/services/web/frontend/js/features/subscription/data/add-on-codes.ts index 52d47f98b9..4ee7a65b22 100644 --- a/services/web/frontend/js/features/subscription/data/add-on-codes.ts +++ b/services/web/frontend/js/features/subscription/data/add-on-codes.ts @@ -1,9 +1,32 @@ -export const AI_STANDALONE_PLAN_CODE = 'assistant' +import { PaidSubscription } from "../../../../../types/subscription/dashboard/subscription" +import { PendingPaymentProviderPlan } from "../../../../../types/subscription/plan" + +export const AI_ASSIST_STANDALONE_MONTHLY_PLAN_CODE = 'assistant' export const AI_ADD_ON_CODE = 'assistant' // we dont want translations on plan or add-on names -export const ADD_ON_NAME = "Error Assist" -export const AI_STANDALONE_ANNUAL_PLAN_CODE = 'assistant-annual' +export const ADD_ON_NAME = "AI Assist" +export const AI_ASSIST_STANDALONE_ANNUAL_PLAN_CODE = 'assistant-annual' -export function isStandaloneAiPlanCode(planCode: string) { - return planCode === AI_STANDALONE_PLAN_CODE || planCode === AI_STANDALONE_ANNUAL_PLAN_CODE +export function isStandaloneAiPlanCode(planCode?: string) { + return planCode === AI_ASSIST_STANDALONE_MONTHLY_PLAN_CODE || planCode === AI_ASSIST_STANDALONE_ANNUAL_PLAN_CODE } + + + +export function hasPendingAiAddonCancellation(subscription: PaidSubscription){ + + const pendingPlan = subscription.pendingPlan as PendingPaymentProviderPlan + + const hasAiAddon = subscription.addOns?.some( + addOn => addOn.addOnCode === AI_ADD_ON_CODE + ) + + // cancellation of entire plan counts as removing the add-on + if(hasAiAddon && !pendingPlan){ + return true + } + + return hasAiAddon && + !pendingPlan.addOns?.some(addOn => addOn.code === AI_ADD_ON_CODE) + +} \ No newline at end of file diff --git a/services/web/frontend/js/features/subscription/util/is-monthly-collaborator-plan.ts b/services/web/frontend/js/features/subscription/util/is-monthly-collaborator-plan.ts deleted file mode 100644 index 550c5ec2a5..0000000000 --- a/services/web/frontend/js/features/subscription/util/is-monthly-collaborator-plan.ts +++ /dev/null @@ -1,10 +0,0 @@ -export default function isMonthlyCollaboratorPlan( - planCode: string, - isGroupPlan?: boolean -) { - return ( - planCode.indexOf('collaborator') !== -1 && - planCode.indexOf('ann') === -1 && - !isGroupPlan - ) -} diff --git a/services/web/frontend/js/features/subscription/util/show-downgrade-option.ts b/services/web/frontend/js/features/subscription/util/show-downgrade-option.ts deleted file mode 100644 index 283823f481..0000000000 --- a/services/web/frontend/js/features/subscription/util/show-downgrade-option.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Nullable } from '../../../../../types/utils' -import isInFreeTrial from './is-in-free-trial' -import isMonthlyCollaboratorPlan from './is-monthly-collaborator-plan' - -export default function showDowngradeOption( - planCode: string, - isGroupPlan?: boolean, - trialEndsAt?: string | null, - pausedAt?: Nullable, - remainingPauseCycles?: Nullable -) { - return ( - !pausedAt && - !remainingPauseCycles && - isMonthlyCollaboratorPlan(planCode, isGroupPlan) && - !isInFreeTrial(trialEndsAt) - ) -} diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/badge-link-with-tooltip.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/badge-link-with-tooltip.tsx index aea97ad6f2..43910f1a4c 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/badge-link-with-tooltip.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/badge-link-with-tooltip.tsx @@ -1,4 +1,4 @@ -import { OverlayTrigger, Tooltip } from 'react-bootstrap-5' +import { OverlayTrigger, Tooltip } from 'react-bootstrap' import type { MergeAndOverride } from '../../../../../../types/utils' import BadgeLink, { type BadgeLinkProps } from './badge-link' import { useEffect, useRef, useState } from 'react' diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/badge.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/badge.tsx index 0e66500bc0..642be2e6b2 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/badge.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/badge.tsx @@ -1,4 +1,4 @@ -import { Badge as BSBadge, BadgeProps as BSBadgeProps } from 'react-bootstrap-5' +import { Badge as BSBadge, BadgeProps as BSBadgeProps } from 'react-bootstrap' import { MergeAndOverride } from '../../../../../../types/utils' export type BadgeProps = MergeAndOverride< diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/button.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/button.tsx index 4eec23475a..cede608249 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/button.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/button.tsx @@ -1,5 +1,5 @@ import { forwardRef } from 'react' -import { Button as BS5Button, Spinner } from 'react-bootstrap-5' +import { Button as BS5Button, Spinner } from 'react-bootstrap' import type { ButtonProps } from '@/features/ui/components/types/button-props' import classNames from 'classnames' import { useTranslation } from 'react-i18next' @@ -52,6 +52,7 @@ const Button = forwardRef( ref={ref} disabled={isLoading || props.disabled} data-ol-loading={isLoading} + role={undefined} > {isLoading && ( diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-menu.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-menu.tsx index aa35c158d9..402d465923 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-menu.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-menu.tsx @@ -7,8 +7,7 @@ import { DropdownDivider as BS5DropdownDivider, DropdownHeader as BS5DropdownHeader, Button as BS5Button, - type ButtonProps, -} from 'react-bootstrap-5' +} from 'react-bootstrap' import type { DropdownProps, DropdownItemProps, @@ -109,19 +108,20 @@ const ForwardReferredDropdownItem = fixedForwardRef(DropdownItem, { export { ForwardReferredDropdownItem as DropdownItem } -export const DropdownToggleCustom = forwardRef( - ({ children, className, ...props }, ref) => ( - - {children} - - - ) -) -DropdownToggleCustom.displayName = 'CustomCaret' +export const DropdownToggleCustom = forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ children, className, ...props }, ref) => ( + + {children} + + +)) +DropdownToggleCustom.displayName = 'DropdownToggleCustom' export const DropdownToggle = forwardRef< typeof BS5DropdownToggle, diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip.tsx index f2ba81fbad..cdf20e3dd3 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip.tsx @@ -1,12 +1,12 @@ import { ReactNode, forwardRef } from 'react' -import { BsPrefixRefForwardingComponent } from 'react-bootstrap-5/helpers' +import { BsPrefixRefForwardingComponent } from 'react-bootstrap/helpers' import type { DropdownToggleProps } from '@/features/ui/components/types/dropdown-menu-props' import { DropdownToggle as BS5DropdownToggle, OverlayTrigger, OverlayTriggerProps, Tooltip, -} from 'react-bootstrap-5' +} from 'react-bootstrap' import type { MergeAndOverride } from '../../../../../../types/utils' type DropdownToggleWithTooltipProps = MergeAndOverride< diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-control.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-control.tsx index 178bab52c1..5a4ab4f00f 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-control.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-control.tsx @@ -1,8 +1,5 @@ import React, { forwardRef } from 'react' -import { - Form, - FormControlProps as BS5FormControlProps, -} from 'react-bootstrap-5' +import { Form, FormControlProps as BS5FormControlProps } from 'react-bootstrap' import classnames from 'classnames' export type OLBS5FormControlProps = BS5FormControlProps & { diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-feedback.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-feedback.tsx index 85c91031ba..67c31aa96c 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-feedback.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-feedback.tsx @@ -1,4 +1,4 @@ -import { Form } from 'react-bootstrap-5' +import { Form } from 'react-bootstrap' import FormText from '@/features/ui/components/bootstrap-5/form/form-text' import { ComponentProps } from 'react' diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-group.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-group.tsx index 19b88cdc65..5807ef9839 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-group.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-group.tsx @@ -1,5 +1,5 @@ import { forwardRef } from 'react' -import { FormGroup as BS5FormGroup, FormGroupProps } from 'react-bootstrap-5' +import { FormGroup as BS5FormGroup, FormGroupProps } from 'react-bootstrap' import classnames from 'classnames' const FormGroup = forwardRef( diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-text.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-text.tsx index fe9b939680..78a962ad49 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-text.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-text.tsx @@ -1,4 +1,4 @@ -import { Form, FormTextProps as BS5FormTextProps } from 'react-bootstrap-5' +import { Form, FormTextProps as BS5FormTextProps } from 'react-bootstrap' import MaterialIcon from '@/shared/components/material-icon' import classnames from 'classnames' import { MergeAndOverride } from '../../../../../../../types/utils' diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/account-menu-items.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/account-menu-items.tsx index 825d56c100..d00e2f964b 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/account-menu-items.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/account-menu-items.tsx @@ -1,4 +1,4 @@ -import { Dropdown } from 'react-bootstrap-5' +import { Dropdown } from 'react-bootstrap' import { useTranslation } from 'react-i18next' import getMeta from '@/utils/meta' import type { NavbarSessionUser } from '@/features/ui/components/types/navbar' @@ -25,7 +25,7 @@ export function AccountMenuItems({ - {t('Account Settings')} + {t('account_settings')} {showSubscriptionLink ? ( diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/contact-us-item.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/contact-us-item.tsx index 80d731cc46..d14be5ab55 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/contact-us-item.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/contact-us-item.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { DropdownItem } from 'react-bootstrap-5' +import { DropdownItem } from 'react-bootstrap' import DropdownListItem from '@/features/ui/components/bootstrap-5/dropdown-list-item' import { type ExtraSegmentations, diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/default-navbar.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/default-navbar.tsx index 9066f5bbe7..2480b7f061 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/default-navbar.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/default-navbar.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { sendMB } from '@/infrastructure/event-tracking' import { useTranslation } from 'react-i18next' -import { Button, Container, Nav, Navbar } from 'react-bootstrap-5' +import { Button, Container, Nav, Navbar } from 'react-bootstrap' import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n' import AdminMenu from '@/features/ui/components/bootstrap-5/navbar/admin-menu' import type { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata' @@ -34,20 +34,15 @@ function DefaultNavbar(props: DefaultNavbarMetadata) { items, } = props const { t } = useTranslation() - const { isReady } = useWaitForI18n() const [expanded, setExpanded] = useState(false) - // The Contact Us modal is rendered at this level rather than inside the nav + // The Contact us modal is rendered at this level rather than inside the nav // bar because otherwise the help wiki search results dropdown doesn't show up const { modal: contactUsModal, showModal: showContactUsModal } = useContactUsModal({ autofillProjectUrl: false, }) - if (!isReady) { - return null - } - return ( <> { + const { isReady } = useWaitForI18n() + + if (!isReady) { + return null + } + + return +} + export default DefaultNavbar diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item.tsx index 5acc808c21..1d418a344b 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item.tsx @@ -1,7 +1,7 @@ import { ReactNode } from 'react' import DropdownListItem from '@/features/ui/components/bootstrap-5/dropdown-list-item' -import { DropdownItem } from 'react-bootstrap-5' -import { DropdownItemProps } from 'react-bootstrap-5/DropdownItem' +import { DropdownItem } from 'react-bootstrap' +import { DropdownItemProps } from 'react-bootstrap/DropdownItem' export default function NavDropdownLinkItem({ href, diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-menu.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-menu.tsx index 6c588eef46..9b962238c1 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-menu.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-menu.tsx @@ -1,5 +1,5 @@ import { type ReactNode, useState } from 'react' -import { Dropdown } from 'react-bootstrap-5' +import { Dropdown } from 'react-bootstrap' import { CaretUp, CaretDown } from '@phosphor-icons/react' import { useDsNavStyle } from '@/features/project-list/components/use-is-ds-nav' diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-item.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-item.tsx index 58295a47eb..3a55f2791b 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-item.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-item.tsx @@ -1,4 +1,4 @@ -import { Nav, NavItemProps } from 'react-bootstrap-5' +import { Nav, NavItemProps } from 'react-bootstrap' export default function NavItem(props: Omit) { const { children, ...rest } = props diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-link-item.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-link-item.tsx index 4e3875f5bb..519b84762e 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-link-item.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-link-item.tsx @@ -1,5 +1,5 @@ import { ReactNode } from 'react' -import { Nav } from 'react-bootstrap-5' +import { Nav } from 'react-bootstrap' import NavItem from '@/features/ui/components/bootstrap-5/navbar/nav-item' export default function NavLinkItem({ diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/table.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/table.tsx index 8f6fe3ee60..176d6f737c 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/table.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/table.tsx @@ -1,4 +1,4 @@ -import { Table as BS5Table } from 'react-bootstrap-5' +import { Table as BS5Table } from 'react-bootstrap' import classnames from 'classnames' export function TableContainer({ diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/tag.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/tag.tsx index dda1c9861e..3a3fec7879 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/tag.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/tag.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { Badge, BadgeProps } from 'react-bootstrap-5' +import { Badge, BadgeProps } from 'react-bootstrap' import MaterialIcon from '@/shared/components/material-icon' import { MergeAndOverride } from '../../../../../../types/utils' import classnames from 'classnames' diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/tooltip.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/tooltip.tsx index fa479b58c6..f16bcb5425 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/tooltip.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/tooltip.tsx @@ -4,7 +4,7 @@ import { OverlayTriggerProps, Tooltip as BSTooltip, TooltipProps as BSTooltipProps, -} from 'react-bootstrap-5' +} from 'react-bootstrap' import { callFnsInSequence } from '@/utils/functions' type OverlayProps = Omit diff --git a/services/web/frontend/js/features/ui/components/ol/ol-button-group.tsx b/services/web/frontend/js/features/ui/components/ol/ol-button-group.tsx index dc26595aac..4b6c4dece0 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-button-group.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-button-group.tsx @@ -1,4 +1,4 @@ -import { ButtonGroup, ButtonGroupProps } from 'react-bootstrap-5' +import { ButtonGroup, ButtonGroupProps } from 'react-bootstrap' function OLButtonGroup({ as, ...rest }: ButtonGroupProps) { return diff --git a/services/web/frontend/js/features/ui/components/ol/ol-button-toolbar.tsx b/services/web/frontend/js/features/ui/components/ol/ol-button-toolbar.tsx index 377228f72d..a60b854732 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-button-toolbar.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-button-toolbar.tsx @@ -1,4 +1,4 @@ -import { ButtonToolbar, ButtonToolbarProps } from 'react-bootstrap-5' +import { ButtonToolbar, ButtonToolbarProps } from 'react-bootstrap' function OLButtonToolbar(props: ButtonToolbarProps) { return diff --git a/services/web/frontend/js/features/ui/components/ol/ol-card.tsx b/services/web/frontend/js/features/ui/components/ol/ol-card.tsx index 1f0eb70ace..f5ff025d10 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-card.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-card.tsx @@ -1,4 +1,4 @@ -import { Card } from 'react-bootstrap-5' +import { Card } from 'react-bootstrap' import { FC } from 'react' const OLCard: FC> = ({ diff --git a/services/web/frontend/js/features/ui/components/ol/ol-close-button.tsx b/services/web/frontend/js/features/ui/components/ol/ol-close-button.tsx index 414a329eec..c07d1cc390 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-close-button.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-close-button.tsx @@ -1,4 +1,4 @@ -import { CloseButton, CloseButtonProps } from 'react-bootstrap-5' +import { CloseButton, CloseButtonProps } from 'react-bootstrap' import { useTranslation } from 'react-i18next' import { forwardRef } from 'react' diff --git a/services/web/frontend/js/features/ui/components/ol/ol-col.tsx b/services/web/frontend/js/features/ui/components/ol/ol-col.tsx index dc70ca23c8..a482ccf97d 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-col.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-col.tsx @@ -1,4 +1,4 @@ -import { Col } from 'react-bootstrap-5' +import { Col } from 'react-bootstrap' function OLCol(props: React.ComponentProps) { return diff --git a/services/web/frontend/js/features/ui/components/ol/ol-form-checkbox.tsx b/services/web/frontend/js/features/ui/components/ol/ol-form-checkbox.tsx index d82e49ea2d..191f850159 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-form-checkbox.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-form-checkbox.tsx @@ -1,4 +1,4 @@ -import { Form, FormCheckProps } from 'react-bootstrap-5' +import { Form, FormCheckProps } from 'react-bootstrap' import { MergeAndOverride } from '../../../../../../types/utils' import FormText from '../bootstrap-5/form/form-text' diff --git a/services/web/frontend/js/features/ui/components/ol/ol-form-feedback.tsx b/services/web/frontend/js/features/ui/components/ol/ol-form-feedback.tsx index 1c850c1ffc..59b81bb6a4 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-form-feedback.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-form-feedback.tsx @@ -1,4 +1,4 @@ -import { Form } from 'react-bootstrap-5' +import { Form } from 'react-bootstrap' import { ComponentProps } from 'react' import FormFeedback from '@/features/ui/components/bootstrap-5/form/form-feedback' diff --git a/services/web/frontend/js/features/ui/components/ol/ol-form-group.tsx b/services/web/frontend/js/features/ui/components/ol/ol-form-group.tsx index 8ccc974d4e..9c6db07e93 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-form-group.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-form-group.tsx @@ -1,4 +1,4 @@ -import { FormGroupProps } from 'react-bootstrap-5' +import { FormGroupProps } from 'react-bootstrap' import FormGroup from '@/features/ui/components/bootstrap-5/form/form-group' function OLFormGroup(props: FormGroupProps) { diff --git a/services/web/frontend/js/features/ui/components/ol/ol-form-label.tsx b/services/web/frontend/js/features/ui/components/ol/ol-form-label.tsx index 1e1038f9e3..ed46dd8117 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-form-label.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-form-label.tsx @@ -1,4 +1,4 @@ -import { Form } from 'react-bootstrap-5' +import { Form } from 'react-bootstrap' function OLFormLabel(props: React.ComponentProps<(typeof Form)['Label']>) { return diff --git a/services/web/frontend/js/features/ui/components/ol/ol-form-select.tsx b/services/web/frontend/js/features/ui/components/ol/ol-form-select.tsx index dfcd147dfd..8c111932ce 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-form-select.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-form-select.tsx @@ -1,5 +1,5 @@ import { forwardRef } from 'react' -import { Form, FormSelectProps } from 'react-bootstrap-5' +import { Form, FormSelectProps } from 'react-bootstrap' const OLFormSelect = forwardRef( (props, ref) => { diff --git a/services/web/frontend/js/features/ui/components/ol/ol-form-switch.tsx b/services/web/frontend/js/features/ui/components/ol/ol-form-switch.tsx index a9a6ffe041..3e349a6f76 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-form-switch.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-form-switch.tsx @@ -1,4 +1,4 @@ -import { FormCheck, FormCheckProps, FormLabel } from 'react-bootstrap-5' +import { FormCheck, FormCheckProps, FormLabel } from 'react-bootstrap' type OLFormSwitchProps = FormCheckProps & { inputRef?: React.MutableRefObject diff --git a/services/web/frontend/js/features/ui/components/ol/ol-form.tsx b/services/web/frontend/js/features/ui/components/ol/ol-form.tsx index 724578769e..d53dec0ad3 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-form.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-form.tsx @@ -1,4 +1,4 @@ -import { Form } from 'react-bootstrap-5' +import { Form } from 'react-bootstrap' import { ComponentProps } from 'react' function OLForm(props: ComponentProps) { diff --git a/services/web/frontend/js/features/ui/components/ol/ol-list-group-item.tsx b/services/web/frontend/js/features/ui/components/ol/ol-list-group-item.tsx index a27457fa7a..c99b8c1cb4 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-list-group-item.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-list-group-item.tsx @@ -1,4 +1,4 @@ -import { ListGroupItem, ListGroupItemProps } from 'react-bootstrap-5' +import { ListGroupItem, ListGroupItemProps } from 'react-bootstrap' function OLListGroupItem(props: ListGroupItemProps) { const as = props.as ?? 'button' diff --git a/services/web/frontend/js/features/ui/components/ol/ol-list-group.tsx b/services/web/frontend/js/features/ui/components/ol/ol-list-group.tsx index a28c7e977d..4eb473217a 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-list-group.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-list-group.tsx @@ -1,4 +1,4 @@ -import { ListGroup, ListGroupProps } from 'react-bootstrap-5' +import { ListGroup, ListGroupProps } from 'react-bootstrap' function OLListGroup(props: ListGroupProps) { const as = props.as ?? 'div' diff --git a/services/web/frontend/js/features/ui/components/ol/ol-modal.tsx b/services/web/frontend/js/features/ui/components/ol/ol-modal.tsx index bf20d18eef..057d8dc3ce 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-modal.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-modal.tsx @@ -4,8 +4,8 @@ import { ModalHeaderProps, ModalTitleProps, ModalFooterProps, -} from 'react-bootstrap-5' -import { ModalBodyProps } from 'react-bootstrap-5/ModalBody' +} from 'react-bootstrap' +import { ModalBodyProps } from 'react-bootstrap/ModalBody' type OLModalProps = ModalProps & { size?: 'sm' | 'lg' diff --git a/services/web/frontend/js/features/ui/components/ol/ol-overlay.tsx b/services/web/frontend/js/features/ui/components/ol/ol-overlay.tsx index bcf2a024c2..eb68066dd9 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-overlay.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-overlay.tsx @@ -1,4 +1,4 @@ -import { Overlay, OverlayProps } from 'react-bootstrap-5' +import { Overlay, OverlayProps } from 'react-bootstrap' function OLOverlay(props: OverlayProps) { return diff --git a/services/web/frontend/js/features/ui/components/ol/ol-page-content-card.tsx b/services/web/frontend/js/features/ui/components/ol/ol-page-content-card.tsx index c10de1c0c6..c47be6d9de 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-page-content-card.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-page-content-card.tsx @@ -1,4 +1,4 @@ -import { Card, CardBody } from 'react-bootstrap-5' +import { Card, CardBody } from 'react-bootstrap' import { FC } from 'react' import classNames from 'classnames' diff --git a/services/web/frontend/js/features/ui/components/ol/ol-popover.tsx b/services/web/frontend/js/features/ui/components/ol/ol-popover.tsx index 772084bc22..a20a2af13f 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-popover.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-popover.tsx @@ -1,5 +1,5 @@ import { forwardRef } from 'react' -import { Popover, PopoverProps } from 'react-bootstrap-5' +import { Popover, PopoverProps } from 'react-bootstrap' type OLPopoverProps = Omit & { title?: React.ReactNode diff --git a/services/web/frontend/js/features/ui/components/ol/ol-row.tsx b/services/web/frontend/js/features/ui/components/ol/ol-row.tsx index 88c05ce102..88f2bc82d6 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-row.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-row.tsx @@ -1,4 +1,4 @@ -import { Row } from 'react-bootstrap-5' +import { Row } from 'react-bootstrap' function OLRow(props: React.ComponentProps) { return diff --git a/services/web/frontend/js/features/ui/components/ol/ol-spinner.tsx b/services/web/frontend/js/features/ui/components/ol/ol-spinner.tsx index 4c1be6b125..ebd73ffab1 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-spinner.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-spinner.tsx @@ -1,4 +1,4 @@ -import { Spinner } from 'react-bootstrap-5' +import { Spinner } from 'react-bootstrap' export type OLSpinnerSize = 'sm' | 'lg' diff --git a/services/web/frontend/js/features/ui/components/ol/ol-toast-container.tsx b/services/web/frontend/js/features/ui/components/ol/ol-toast-container.tsx index ca8250bf49..26c0ff3545 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-toast-container.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-toast-container.tsx @@ -1,5 +1,5 @@ import { CSSProperties, FC } from 'react' -import { ToastContainer as BS5ToastContainer } from 'react-bootstrap-5' +import { ToastContainer as BS5ToastContainer } from 'react-bootstrap' type OLToastContainerProps = { style?: CSSProperties diff --git a/services/web/frontend/js/features/ui/components/ol/ol-toast.tsx b/services/web/frontend/js/features/ui/components/ol/ol-toast.tsx index 7460a6b269..e4256f4fca 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-toast.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-toast.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames' -import { Toast as BS5Toast } from 'react-bootstrap-5' +import { Toast as BS5Toast } from 'react-bootstrap' import { NotificationIcon, NotificationType, diff --git a/services/web/frontend/js/features/ui/components/ol/ol-toggle-button-group.tsx b/services/web/frontend/js/features/ui/components/ol/ol-toggle-button-group.tsx index 35777d29a6..3344fdade1 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-toggle-button-group.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-toggle-button-group.tsx @@ -1,4 +1,4 @@ -import { ToggleButtonGroup, ToggleButtonGroupProps } from 'react-bootstrap-5' +import { ToggleButtonGroup, ToggleButtonGroupProps } from 'react-bootstrap' function OLToggleButtonGroup(props: ToggleButtonGroupProps) { return diff --git a/services/web/frontend/js/features/ui/components/ol/ol-toggle-button.tsx b/services/web/frontend/js/features/ui/components/ol/ol-toggle-button.tsx index d56d0b216a..a58772b78a 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-toggle-button.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-toggle-button.tsx @@ -1,4 +1,4 @@ -import { ToggleButton, ToggleButtonProps } from 'react-bootstrap-5' +import { ToggleButton, ToggleButtonProps } from 'react-bootstrap' function OLToggleButton(props: ToggleButtonProps) { return diff --git a/services/web/frontend/js/features/utils/disableElement.js b/services/web/frontend/js/features/utils/disableElement.js index a60f1e9b98..7d423a26a3 100644 --- a/services/web/frontend/js/features/utils/disableElement.js +++ b/services/web/frontend/js/features/utils/disableElement.js @@ -1,9 +1,19 @@ +import { isBootstrap5 } from './bootstrap-5' + export function disableElement(el) { - el.setAttribute('disabled', '') + if (isBootstrap5() && el.tagName.toLowerCase() === 'a') { + el.classList.add('disabled') + } else { + el.disabled = true + } el.setAttribute('aria-disabled', 'true') } export function enableElement(el) { - el.removeAttribute('disabled') + if (isBootstrap5() && el.tagName.toLowerCase() === 'a') { + el.classList.remove('disabled') + } else { + el.disabled = false + } el.removeAttribute('aria-disabled') } diff --git a/services/web/frontend/js/features/utils/format-date.ts b/services/web/frontend/js/features/utils/format-date.ts index 5210bf6962..d347c832b0 100644 --- a/services/web/frontend/js/features/utils/format-date.ts +++ b/services/web/frontend/js/features/utils/format-date.ts @@ -11,8 +11,13 @@ moment.updateLocale('en', { }, }) -export function formatTime(date: moment.MomentInput, format = 'h:mm a') { - return moment(date).format(format) +export function formatTime( + date: moment.MomentInput, + format = 'h:mm a', + utc = false +) { + const momentDate = utc ? moment.utc(date) : moment(date) + return momentDate.format(format) } export function relativeDate(date: moment.MomentInput) { diff --git a/services/web/frontend/js/features/word-count-modal/components/word-count-client.tsx b/services/web/frontend/js/features/word-count-modal/components/word-count-client.tsx index 5f2660d4a8..899d8de2f5 100644 --- a/services/web/frontend/js/features/word-count-modal/components/word-count-client.tsx +++ b/services/web/frontend/js/features/word-count-modal/components/word-count-client.tsx @@ -11,8 +11,8 @@ import { debugConsole } from '@/utils/debugging' import { signalWithTimeout } from '@/utils/abort-signal' import { isMainFile } from '@/features/pdf-preview/util/editor-files' import { countWordsInFile } from '@/features/word-count-modal/utils/count-words-in-file' -import { WordCounts } from '@/features/word-count-modal/components/word-counts' import { createSegmenters } from '@/features/word-count-modal/utils/segmenters' +import { WordCountsClient } from './word-counts-client' export const WordCountClient: FC = () => { const [loading, setLoading] = useState(true) @@ -59,7 +59,8 @@ export const WordCountClient: FC = () => { footnoteWords: 0, footnoteCharacters: 0, outside: 0, - outsideCharacters: 0, + otherWords: 0, + otherCharacters: 0, headers: 0, elements: 0, mathInline: 0, @@ -99,7 +100,7 @@ export const WordCountClient: FC = () => { <> {loading && !error && } {error && } - {data && } + {data && } ) } diff --git a/services/web/frontend/js/features/word-count-modal/components/word-count-data.ts b/services/web/frontend/js/features/word-count-modal/components/word-count-data.ts index a69dc55d45..e322ac9636 100644 --- a/services/web/frontend/js/features/word-count-modal/components/word-count-data.ts +++ b/services/web/frontend/js/features/word-count-modal/components/word-count-data.ts @@ -20,6 +20,6 @@ export type WordCountData = ServerWordCountData & { footnoteCharacters: number abstractWords: number abstractCharacters: number - // outsideWords: number - outsideCharacters: number + otherWords: number + otherCharacters: number } diff --git a/services/web/frontend/js/features/word-count-modal/components/word-count-loading.tsx b/services/web/frontend/js/features/word-count-modal/components/word-count-loading.tsx index 61a4263b04..e1f277d714 100644 --- a/services/web/frontend/js/features/word-count-modal/components/word-count-loading.tsx +++ b/services/web/frontend/js/features/word-count-modal/components/word-count-loading.tsx @@ -1,4 +1,4 @@ -import { Spinner } from 'react-bootstrap-5' +import { Spinner } from 'react-bootstrap' import { useTranslation } from 'react-i18next' export const WordCountLoading = () => { diff --git a/services/web/frontend/js/features/word-count-modal/components/word-count-server.tsx b/services/web/frontend/js/features/word-count-modal/components/word-count-server.tsx index fccd49f8c3..a34807864e 100644 --- a/services/web/frontend/js/features/word-count-modal/components/word-count-server.tsx +++ b/services/web/frontend/js/features/word-count-modal/components/word-count-server.tsx @@ -42,7 +42,7 @@ export const WordCountServer: FC = () => { <> {loading && !error && } {error && } - {data && } + {data && } ) } diff --git a/services/web/frontend/js/features/word-count-modal/components/word-counts-client.tsx b/services/web/frontend/js/features/word-count-modal/components/word-counts-client.tsx new file mode 100644 index 0000000000..dac4934a50 --- /dev/null +++ b/services/web/frontend/js/features/word-count-modal/components/word-counts-client.tsx @@ -0,0 +1,138 @@ +import { FC, useMemo } from 'react' +import { WordCountData } from '@/features/word-count-modal/components/word-count-data' +import { useTranslation } from 'react-i18next' +import { Container, Row, Col, Form } from 'react-bootstrap' +import OLNotification from '@/features/ui/components/ol/ol-notification' +import usePersistedState from '@/shared/hooks/use-persisted-state' + +export const WordCountsClient: FC<{ data: WordCountData }> = ({ data }) => { + const { t } = useTranslation() + + const [included, setIncluded] = usePersistedState( + 'word-count-total', + ['text'] + ) + + const items = useMemo(() => { + return [ + { + key: 'text', + label: t('text'), + words: data.textWords, + chars: data.textCharacters, + }, + { + key: 'headers', + label: t('headers'), + words: data.headWords, + chars: data.headCharacters, + }, + { + key: 'abstract', + label: t('abstract'), + words: data.abstractWords, + chars: data.abstractCharacters, + }, + { + key: 'captions', + label: t('captions'), + words: data.captionWords, + chars: data.captionCharacters, + }, + { + key: 'footnotes', + label: t('footnotes'), + words: data.footnoteWords, + chars: data.footnoteCharacters, + }, + { + key: 'other', + label: t('other'), + words: data.otherWords, + chars: data.otherCharacters, + }, + ] + }, [data, t]) + + const totals = useMemo(() => { + const totals = { + words: 0, + chars: 0, + } + + for (const item of items) { + if (included.includes(item.key)) { + totals.words += item.words + totals.chars += item.chars + } + } + + return totals + }, [included, items]) + + return ( + + {data.messages && ( + + + {data.messages}

    + } + /> + +
    + )} + + {items.map(item => ( + + + + setIncluded(prevValue => { + return event.target.checked + ? prevValue.concat(item.key) + : prevValue.filter(key => key !== item.key) + }) + } + aria-label={`Include ${item.label} in total`} + /> + + + {item.words} words +
    + {item.chars} chars + +
    + ))} + + + + + {t('total')}: {totals.words} words +
    + {totals.chars} chars +
    + +
    +
    + ) +} diff --git a/services/web/frontend/js/features/word-count-modal/components/word-counts.tsx b/services/web/frontend/js/features/word-count-modal/components/word-counts.tsx index dec4d2e6d8..e8a9731b7b 100644 --- a/services/web/frontend/js/features/word-count-modal/components/word-counts.tsx +++ b/services/web/frontend/js/features/word-count-modal/components/word-counts.tsx @@ -1,22 +1,12 @@ -import { - ServerWordCountData, - WordCountData, -} from '@/features/word-count-modal/components/word-count-data' -import { useTranslation } from 'react-i18next' import { FC } from 'react' -import { Container, Row, Col } from 'react-bootstrap-5' +import { ServerWordCountData } from '@/features/word-count-modal/components/word-count-data' +import { useTranslation } from 'react-i18next' +import { Container, Row, Col } from 'react-bootstrap' import OLNotification from '@/features/ui/components/ol/ol-notification' -export const WordCounts: FC< - | { - data: ServerWordCountData - source: 'server' - } - | { - data: WordCountData - source: 'client' - } -> = ({ data, source }) => { +export const WordCounts: FC<{ + data: ServerWordCountData +}> = ({ data }) => { const { t } = useTranslation() return ( @@ -34,69 +24,32 @@ export const WordCounts: FC<
    )} - {source === 'client' ? ( - <> - - -
    Text:
    - - {data.textWords} -
    + + +
    {t('total_words')}:
    + + {data.textWords} +
    + + +
    {t('headers')}:
    + + {data.headers} +
    - - -
    Headers:
    - - {data.headWords} -
    + + +
    {t('math_inline')}:
    + + {data.mathInline} +
    - - -
    Captions:
    - - {data.captionWords} -
    - - - -
    Footnotes:
    - - {data.footnoteWords} -
    - - ) : ( - - -
    {t('total_words')}:
    - - {data.textWords} -
    - )} - - {source === 'server' && ( - <> - - -
    {t('headers')}:
    - - {data.headers} -
    - - - -
    {t('math_inline')}:
    - - {data.mathInline} -
    - - - -
    {t('math_display')}:
    - - {data.mathDisplay} -
    - - )} + + +
    {t('math_display')}:
    + + {data.mathDisplay} +
    ) } diff --git a/services/web/frontend/js/features/word-count-modal/utils/count-words-in-file.ts b/services/web/frontend/js/features/word-count-modal/utils/count-words-in-file.ts index d9a9154620..a14120d8d5 100644 --- a/services/web/frontend/js/features/word-count-modal/utils/count-words-in-file.ts +++ b/services/web/frontend/js/features/word-count-modal/utils/count-words-in-file.ts @@ -1,4 +1,3 @@ -import { ProjectSnapshot } from '@/infrastructure/project-snapshot' import { LaTeXLanguage } from '@/features/source-editor/languages/latex/latex-language' import { WordCountData } from '@/features/word-count-modal/components/word-count-data' import { NodeType, SyntaxNodeRef } from '@lezer/common' @@ -8,7 +7,7 @@ import { Segmenters } from './segmenters' const whiteSpaceRe = /^\s$/ -type Context = 'text' | 'header' | 'abstract' | 'caption' | 'footnote' +type Context = 'text' | 'header' | 'abstract' | 'caption' | 'footnote' | 'other' const counters: Record< Context, @@ -37,10 +36,15 @@ const counters: Record< word: 'footnoteWords', character: 'footnoteCharacters', }, + other: { + word: 'otherWords', + character: 'otherCharacters', + }, } +// https://en.wikibooks.org/wiki/LaTeX/Special_Characters#Escaped_codes const replacementsMap: Map = new Map([ - // LaTeX commands that create part of a word + // LaTeX commands that create characters ['aa', 'å'], ['AA', 'Å'], ['ae', 'æ'], @@ -63,19 +67,36 @@ const replacementsMap: Map = new Map([ ['NG', 'Ŋ'], ['i', 'ı'], ['j', 'ȷ'], + // reserved characters + ['&', '&'], + ['$', '$'], + ['%', '%'], + ['#', '#'], ['_', '_'], - // modifier commands for the character in the arguments - ['H', 'a'], - ['c', 'a'], - ['d', 'a'], - ['k', 'a'], - ['v', 'a'], + ['{', '{'], + ['}', '}'], + // modifier commands for the subsequent character(s) (in braces) + ['H', 'ő'], // long Hungarian umlaut (double acute) + ['b', 'o'], // bar under the letter + ['c', 'ç'], // cedilla + ['d', 'o'], // dot under the letter + ['k', 'ą'], // ogonek + ['r', 'å'], // ring over the letter + ['t', 'o͡o'], // "tie" over the two letters + ['u', 'ŏ'], // breve over the letter + ['v', 'š'], // caron/háček over the letter // modifier symbols for the subsequent character - ["'", ''], - ['^', ''], - ['"', ''], - ['=', ''], - ['.', ''], + ["'", ''], // acute + ['^', ''], // circumflex + ['"', ''], // umlaut, trema or dieresis + ['=', ''], // macron accent (a bar over the letter) + ['.', ''], // dot over the letter + ['`', ''], // grave + ['~', ''], // tilde + // commands that create text + ['TeX', 'TeX'], + ['LaTeX', 'LaTeX'], + ['textbackslash', '\\'], ]) type TextNode = { @@ -87,7 +108,7 @@ type TextNode = { export const countWordsInFile = ( data: WordCountData, - projectSnapshot: ProjectSnapshot, + projectSnapshot: { getDocContents(path: string): string | null }, docPath: string, segmenters: Segmenters ) => { @@ -106,10 +127,9 @@ export const countWordsInFile = ( const iterateNode = (nodeRef: SyntaxNodeRef, context: Context = 'text') => { const previousContext = currentContext currentContext = context - const { node } = nodeRef - node.cursor().iterate(childNodeRef => { + nodeRef.node.cursor().iterate(childNodeRef => { // TODO: a better way to iterate only descendants? - if (childNodeRef.node !== node) { + if (childNodeRef.node !== nodeRef.node) { return bodyMatcher(childNodeRef.type)?.(childNodeRef) } }) @@ -141,34 +161,72 @@ export const countWordsInFile = ( const child = nodeRef.node.getChild('UnknownCommand') if (!child) return - const grandchild = child.getChild('CtrlSeq') ?? child.getChild('CtrlSym') + const grandchild = + child.getChild('$CtrlSeq') ?? child.getChild('$CtrlSym') if (!grandchild) return const commandName = content.substring(grandchild.from + 1, grandchild.to) if (!commandName) return + switch (commandName) { + case 'thanks': + iterateNode(nodeRef, 'other') + return false + } + if (!replacementsMap.has(commandName)) return + // TODO: handle accented character in braces after a CtrlSym, e.g. \'{a} + // TODO: handle markup within words, e.g. inter\textbf{nal}formatting + // TODO: handle commands like \egrave and \eacute + const text = replacementsMap.get(commandName)! + textNodes.push({ from: nodeRef.from, to: nodeRef.to, text, context: currentContext, }) + return false }, - BeginEnv(nodeRef) { - const envName = content - ?.substring(nodeRef.from + '\\begin{'.length, nodeRef.to - 1) - .replace(/\*$/, '') + $Environment(nodeRef) { + const envNameNode = nodeRef.node + .getChild('BeginEnv') + ?.getChild('EnvNameGroup') + ?.getChild('EnvName') - if (envName === 'abstract') { - data.headers++ - iterateNode(nodeRef, 'abstract') - return false + if (envNameNode) { + const envName = content + ?.substring(envNameNode.from, envNameNode.to) + .replace(/\*$/, '') + + if (envName === 'abstract') { + data.headers++ + + const contentNode = nodeRef.node.getChild('Content') + if (contentNode) { + iterateNode(contentNode, 'abstract') + } + + return false + } } }, + BeginEnv() { + return false // ignore text in \begin arguments + }, + Math(nodeRef) { + const parent = nodeRef.node.parent + if (parent?.type.is('InlineMath') || parent?.type.is('ParenMath')) { + data.mathInline++ + } else { + data.mathDisplay++ + } + + return false // TODO: count \text in math nodes? + }, 'ShortTextArgument ShortOptionalArg'() { return false }, @@ -177,12 +235,6 @@ export const countWordsInFile = ( iterateNode(nodeRef, 'header') return false }, - 'DisplayMath BracketMath'() { - data.mathDisplay++ - }, - 'InlineMath ParenMath'() { - data.mathInline++ - }, Caption(nodeRef) { iterateNode(nodeRef, 'caption') return false @@ -201,6 +253,14 @@ export const countWordsInFile = ( countWordsInFile(data, projectSnapshot, path, segmenters) } }, + 'BlankLine LineBreak'(nodeRef) { + textNodes.push({ + from: nodeRef.from, + to: nodeRef.to, + text: '\n', + context: currentContext, + }) + }, }) const preambleExtent = findPreambleExtent(tree) @@ -226,6 +286,7 @@ export const countWordsInFile = ( caption: '', text: '', footnote: '', + other: '', } let pos = 0 @@ -240,12 +301,18 @@ export const countWordsInFile = ( for (const [context, text] of Object.entries(texts)) { const counter = counters[context as Context] - for (const value of segmenters.word.segment(text)) { + // TODO: replace - and _ with a word character if hyphenated words should be counted as one word? + + for (const value of segmenters.word.segment( + text.replace(/\w[-_]\w/g, 'aaa') + )) { if (value.isWordLike) { data[counter.word]++ } } + // TODO: count hyphens as characters? + for (const value of segmenters.character.segment(text)) { // TODO: option for whether to include whitespace? if (!whiteSpaceRe.test(value.segment)) { diff --git a/services/web/frontend/js/features/word-count-modal/utils/segmenters.ts b/services/web/frontend/js/features/word-count-modal/utils/segmenters.ts index e14c3365fd..7e52e1e6fa 100644 --- a/services/web/frontend/js/features/word-count-modal/utils/segmenters.ts +++ b/services/web/frontend/js/features/word-count-modal/utils/segmenters.ts @@ -1,5 +1,5 @@ const wordRe = /['\-.\p{L}]+/gu -const wordLikeRe = /\p{L}/gu // must contain at least one "letter" to be a word +const wordLikeRe = /\p{L}/u // must contain at least one "letter" to be a word const characterRe = /\S/gu type SegmentDataLike = { diff --git a/services/web/frontend/js/infrastructure/error-reporter.ts b/services/web/frontend/js/infrastructure/error-reporter.ts index a21a376594..5d5734535a 100644 --- a/services/web/frontend/js/infrastructure/error-reporter.ts +++ b/services/web/frontend/js/infrastructure/error-reporter.ts @@ -73,6 +73,19 @@ function sentryReporter() { return null // Block the event from sending } + // Do not send link-sharing token to Sentry + if (event.request?.headers?.Referer) { + const refererUrl = new URL(event.request.headers.Referer) + + if ( + refererUrl.hostname === location.hostname && + refererUrl.pathname.startsWith('/read/') + ) { + refererUrl.pathname = '/read/' + event.request.headers.Referer = refererUrl.toString() + } + } + return event }, }) diff --git a/services/web/frontend/js/infrastructure/event-tracking.ts b/services/web/frontend/js/infrastructure/event-tracking.ts index a2b65a1ce4..6fbe93a53a 100644 --- a/services/web/frontend/js/infrastructure/event-tracking.ts +++ b/services/web/frontend/js/infrastructure/event-tracking.ts @@ -1,7 +1,7 @@ import sessionStorage from './session-storage' import getMeta from '@/utils/meta' -type Segmentation = Record< +export type Segmentation = Record< string, string | number | boolean | undefined | unknown | any // TODO: RecurlyError > diff --git a/services/web/frontend/js/infrastructure/project-snapshot.ts b/services/web/frontend/js/infrastructure/project-snapshot.ts index eb7c768adf..976eb9d63f 100644 --- a/services/web/frontend/js/infrastructure/project-snapshot.ts +++ b/services/web/frontend/js/infrastructure/project-snapshot.ts @@ -124,9 +124,15 @@ export class ProjectSnapshot { */ private async loadChanges() { await flushHistory(this.projectId) - const changes = await fetchLatestChanges(this.projectId, this.version) - this.snapshot.applyAll(changes) - this.version += changes.length + let hasMore = true + while (hasMore) { + const response = await fetchLatestChanges(this.projectId, this.version) + const changes = response.changes + this.snapshot.applyAll(changes) + this.version += changes.length + hasMore = response.hasMore + } + await this.loadDocs() } @@ -181,14 +187,43 @@ async function fetchLatestChunk(projectId: string): Promise { return Chunk.fromRaw(response.chunk) } +type FetchLatestChangesResponse = { + changes: Change[] + hasMore: boolean +} + +type FetchLatestChangesApiResponse = + | RawChange[] + | { + changes: RawChange[] + hasMore: boolean + } + async function fetchLatestChanges( projectId: string, version: number -): Promise { - const response = await getJSON( - `/project/${projectId}/changes?since=${version}` +): Promise { + // TODO: The paginated flag is a transition flag. It can be removed after this + // code has been deployed for a few weeks. + const response = await getJSON( + `/project/${projectId}/changes?since=${version}&paginated=true` ) - return response.map(Change.fromRaw).filter(change => change != null) + + let changes, hasMore + if (Array.isArray(response)) { + // deprecated response format is a simple array of changes + // TODO: Remove this branch after the transition + changes = response + hasMore = false + } else { + changes = response.changes + hasMore = response.hasMore + } + + return { + changes: changes.map(Change.fromRaw).filter(change => change != null), + hasMore, + } } async function fetchBlob(projectId: string, hash: string): Promise { diff --git a/services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx b/services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx index 49aee93ca0..dedeffe15d 100644 --- a/services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx +++ b/services/web/frontend/js/pages/user/subscription/group-management/manually-collected-subscription.tsx @@ -1,9 +1,14 @@ import '../base' import { createRoot } from 'react-dom/client' import ManuallyCollectedSubscription from '@/features/group-management/components/manually-collected-subscription' +import { SplitTestProvider } from '@/shared/context/split-test-context' const element = document.getElementById('manually-collected-subscription-root') if (element) { const root = createRoot(element) - root.render() + root.render( + + + + ) } diff --git a/services/web/frontend/js/pages/user/subscription/group-management/subtotal-limit-exceeded.tsx b/services/web/frontend/js/pages/user/subscription/group-management/subtotal-limit-exceeded.tsx index 242c04f5cf..ecc38222c2 100644 --- a/services/web/frontend/js/pages/user/subscription/group-management/subtotal-limit-exceeded.tsx +++ b/services/web/frontend/js/pages/user/subscription/group-management/subtotal-limit-exceeded.tsx @@ -1,8 +1,9 @@ import '../base' -import ReactDOM from 'react-dom' +import { createRoot } from 'react-dom/client' import SubtotalLimitExceeded from '@/features/group-management/components/subtotal-limit-exceeded' const element = document.getElementById('subtotal-limit-exceeded-root') if (element) { - ReactDOM.render(, element) + const root = createRoot(element) + root.render() } diff --git a/services/web/frontend/js/pages/user/subscription/preview-change.tsx b/services/web/frontend/js/pages/user/subscription/preview-change.tsx index 7b4b4c68b0..9ff5f14d45 100644 --- a/services/web/frontend/js/pages/user/subscription/preview-change.tsx +++ b/services/web/frontend/js/pages/user/subscription/preview-change.tsx @@ -1,9 +1,14 @@ import '@/marketing' import { createRoot } from 'react-dom/client' import PreviewSubscriptionChange from '@/features/subscription/components/preview-subscription-change/root' +import { SplitTestProvider } from '@/shared/context/split-test-context' const element = document.getElementById('subscription-preview-change') if (element) { const root = createRoot(element) - root.render() + root.render( + + + + ) } diff --git a/services/web/frontend/js/shared/components/menu-bar/menu-bar-dropdown.tsx b/services/web/frontend/js/shared/components/menu-bar/menu-bar-dropdown.tsx index 3cb495f0dd..bbdd117b23 100644 --- a/services/web/frontend/js/shared/components/menu-bar/menu-bar-dropdown.tsx +++ b/services/web/frontend/js/shared/components/menu-bar/menu-bar-dropdown.tsx @@ -7,7 +7,7 @@ import { FC, forwardRef, useCallback } from 'react' import classNames from 'classnames' import { useNestableDropdown } from '@/shared/hooks/use-nestable-dropdown' import { NestableDropdownContextProvider } from '@/shared/context/nestable-dropdown-context' -import { AnchorProps } from 'react-bootstrap-5' +import { AnchorProps } from 'react-bootstrap' import MaterialIcon from '../material-icon' import { DropdownMenuProps } from '@/features/ui/components/types/dropdown-menu-props' diff --git a/services/web/frontend/js/shared/components/menu-bar/menu-bar-option.tsx b/services/web/frontend/js/shared/components/menu-bar/menu-bar-option.tsx index 692f555ea7..9eaa9fb26a 100644 --- a/services/web/frontend/js/shared/components/menu-bar/menu-bar-option.tsx +++ b/services/web/frontend/js/shared/components/menu-bar/menu-bar-option.tsx @@ -1,7 +1,8 @@ import DropdownListItem from '@/features/ui/components/bootstrap-5/dropdown-list-item' import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu' +import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' import { useNestableDropdown } from '@/shared/hooks/use-nestable-dropdown' -import { MouseEventHandler, ReactNode } from 'react' +import { MouseEventHandler, ReactNode, useCallback } from 'react' type MenuBarOptionProps = { title: string @@ -11,18 +12,30 @@ type MenuBarOptionProps = { href?: string target?: string rel?: string + eventKey?: string } export const MenuBarOption = ({ title, - onClick, + onClick: clickHandler, href, disabled, trailingIcon, target, rel, + eventKey, }: MenuBarOptionProps) => { const { setSelected } = useNestableDropdown() + const { sendEvent } = useEditorAnalytics() + const onClick: MouseEventHandler = useCallback( + e => { + if (eventKey) { + sendEvent('menu-bar-option-click', { key: eventKey }) + } + return clickHandler?.(e) + }, + [clickHandler, eventKey, sendEvent] + ) return ( void setPermissionsLevel: (permissionsLevel: PermissionsLevel) => void showSymbolPalette?: boolean toggleSymbolPalette?: () => void - insertSymbol?: (symbol: string) => void + insertSymbol?: (symbol: SymbolWithCharacter) => void isProjectOwner: boolean isRestrictedTokenMember?: boolean isPendingEditor: boolean @@ -49,8 +41,6 @@ export const EditorContext = createContext< currentPopup: string | null setCurrentPopup: Dispatch> setOutOfSync: (value: boolean) => void - assistantUpgraded: boolean - setAssistantUpgraded: (value: boolean) => void hasPremiumSuggestion: boolean setHasPremiumSuggestion: (value: boolean) => void setPremiumSuggestionResetDate: (date: Date) => void @@ -98,7 +88,6 @@ export const EditorProvider: FC = ({ children }) => { ) const [currentPopup, setCurrentPopup] = useState(null) - const [assistantUpgraded, setAssistantUpgraded] = useState(false) const [hasPremiumSuggestion, setHasPremiumSuggestion] = useState( () => { return Boolean( @@ -181,7 +170,7 @@ export const EditorProvider: FC = ({ children }) => { setTitle(title) }, [projectName, setTitle, role]) - const insertSymbol = useCallback((symbol: string) => { + const insertSymbol = useCallback((symbol: SymbolWithCharacter) => { window.dispatchEvent( new CustomEvent('editor:insert-symbol', { detail: symbol, @@ -214,8 +203,6 @@ export const EditorProvider: FC = ({ children }) => { setHasPremiumSuggestion, premiumSuggestionResetDate, setPremiumSuggestionResetDate, - assistantUpgraded, - setAssistantUpgraded, writefullInstance, setWritefullInstance, }), @@ -241,8 +228,6 @@ export const EditorProvider: FC = ({ children }) => { setHasPremiumSuggestion, premiumSuggestionResetDate, setPremiumSuggestionResetDate, - assistantUpgraded, - setAssistantUpgraded, writefullInstance, setWritefullInstance, ] diff --git a/services/web/frontend/js/shared/context/layout-context.tsx b/services/web/frontend/js/shared/context/layout-context.tsx index bcd0167812..e82dcbc350 100644 --- a/services/web/frontend/js/shared/context/layout-context.tsx +++ b/services/web/frontend/js/shared/context/layout-context.tsx @@ -18,7 +18,6 @@ import { debugConsole } from '@/utils/debugging' import { BinaryFile } from '@/features/file-view/types/binary-file' import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter' import useEventListener from '@/shared/hooks/use-event-listener' -import { isSplitTestEnabled } from '@/utils/splitTestUtils' import { isMac } from '@/shared/utils/os' import { sendSearchEvent } from '@/features/event-tracking/search-events' import { useRailContext } from '@/features/ide-redesign/contexts/rail-context' @@ -161,14 +160,12 @@ export const LayoutProvider: FC = ({ children }) => { event.shiftKey && event.code === 'KeyF' ) { - if (isSplitTestEnabled('full-project-search')) { - event.preventDefault() - sendSearchEvent('search-open', { - searchType: 'full-project', - method: 'keyboard', - }) - setProjectSearchIsOpen(true) - } + event.preventDefault() + sendSearchEvent('search-open', { + searchType: 'full-project', + method: 'keyboard', + }) + setProjectSearchIsOpen(true) } }, []) ) diff --git a/services/web/frontend/js/shared/context/local-compile-context.tsx b/services/web/frontend/js/shared/context/local-compile-context.tsx index ba63b406bd..4ac3654a45 100644 --- a/services/web/frontend/js/shared/context/local-compile-context.tsx +++ b/services/web/frontend/js/shared/context/local-compile-context.tsx @@ -50,6 +50,8 @@ import { isSplitTestEnabled } from '@/utils/splitTestUtils' import { captureException } from '@/infrastructure/error-reporter' import OError from '@overleaf/o-error' import getMeta from '@/utils/meta' +import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils' +import { useRailContext } from '@/features/ide-redesign/contexts/rail-context' type PdfFile = Record @@ -125,6 +127,8 @@ export const LocalCompileProvider: FC = ({ const { openDocWithId, openDocs, currentDocument } = useEditorManagerContext() const { role } = useDetachContext() + const newEditor = useIsNewEditorEnabled() + const { _id: projectId, rootDocId, @@ -135,6 +139,8 @@ export const LocalCompileProvider: FC = ({ const { pdfPreviewOpen } = useLayoutContext() + const { openTab: openRailTab } = useRailContext() + const { features, alphaProgram, labsProgram } = useUserContext() const { fileTreeData } = useFileTreeData() @@ -204,7 +210,7 @@ export const LocalCompileProvider: FC = ({ // fetch initial compile response from cache const [initialCompileFromCache, setInitialCompileFromCache] = useState( getMeta('ol-projectOwnerHasPremiumOnPageLoad') && - isSplitTestEnabled('initial-compile-from-clsi-cache') && + isSplitTestEnabled('populate-clsi-cache') && // Avoid fetching the initial compile from cache in PDF detach tab role !== 'detached' ) @@ -742,8 +748,12 @@ export const LocalCompileProvider: FC = ({ const lastCompileOptions = useMemo(() => data?.options || {}, [data]) useEffect(() => { - const listener = (event: Event) => { - setShowLogs((event as CustomEvent).detail as boolean) + const listener = () => { + if (newEditor) { + openRailTab('errors') + } else { + setShowLogs(true) + } } window.addEventListener('editor:show-logs', listener) @@ -751,7 +761,7 @@ export const LocalCompileProvider: FC = ({ return () => { window.removeEventListener('editor:show-logs', listener) } - }, []) + }, [newEditor, openRailTab]) const value = useMemo( () => ({ diff --git a/services/web/frontend/js/shared/context/types/project-context.tsx b/services/web/frontend/js/shared/context/types/project-context.tsx index 18eb42010c..4e1abdc420 100644 --- a/services/web/frontend/js/shared/context/types/project-context.tsx +++ b/services/web/frontend/js/shared/context/types/project-context.tsx @@ -1,6 +1,7 @@ import { UserId } from '../../../../../types/user' import { PublicAccessLevel } from '../../../../../types/public-access-level' import { ProjectSnapshot } from '@/infrastructure/project-snapshot' +import { Tag } from '../../../../../app/src/Features/Tags/types' export type ProjectContextMember = { _id: UserId @@ -43,11 +44,7 @@ export type ProjectContextValue = { privileges: string signUpDate: string } - tags: { - _id: string - name: string - color?: string - }[] + tags: Tag[] trackChangesState: boolean | Record projectSnapshot: ProjectSnapshot joinedOnce: boolean diff --git a/services/web/frontend/js/shared/context/types/writefull-instance.ts b/services/web/frontend/js/shared/context/types/writefull-instance.ts index 213ab67f18..18b6d08616 100644 --- a/services/web/frontend/js/shared/context/types/writefull-instance.ts +++ b/services/web/frontend/js/shared/context/types/writefull-instance.ts @@ -1,6 +1,7 @@ export interface WritefullEvents { 'writefull-login-complete': { method: 'email-password' | 'login-with-overleaf' + isPremium: boolean } 'writefull-received-suggestions': { numberOfSuggestions: number } 'writefull-register-as-auto-account': { email: string } @@ -41,4 +42,5 @@ export interface WritefullAPI { ): void openTableGenerator(): void openEquationGenerator(): void + refreshSession(): void } diff --git a/services/web/frontend/js/shared/context/user-features-context.tsx b/services/web/frontend/js/shared/context/user-features-context.tsx new file mode 100644 index 0000000000..a162ae0540 --- /dev/null +++ b/services/web/frontend/js/shared/context/user-features-context.tsx @@ -0,0 +1,69 @@ +import { + createContext, + FC, + useCallback, + useContext, + useEffect, + useState, +} from 'react' +import { User } from '../../../../types/user' +import { useUserContext } from './user-context' +import { useReceiveUser } from '../hooks/user-channel/use-receive-user' +import { getJSON } from '@/infrastructure/fetch-json' +import { useEditorContext } from './editor-context' + +export const UserFeaturesContext = createContext(undefined) + +export const UserFeaturesProvider: FC = ({ + children, +}) => { + const user = useUserContext() + const { writefullInstance } = useEditorContext() + const [features, setFeatures] = useState(user.features || {}) + + useReceiveUser( + useCallback(data => { + if (data?.features) { + setFeatures(data.features) + } + }, []) + ) + + useEffect(() => { + const listener = async ({ isPremium }: { isPremium: boolean }) => { + if (features?.aiErrorAssistant === isPremium) { + // the user is premium on writefull and has the AI assist, no need to refresh the features + return + } + const newFeatures = await getJSON('/user/features') + setFeatures(newFeatures) + } + + writefullInstance?.addEventListener('writefull-login-complete', listener) + + return () => { + writefullInstance?.removeEventListener( + 'writefull-login-complete', + listener + ) + } + }, [features?.aiErrorAssistant, writefullInstance]) + + return ( + + {children} + + ) +} + +export function useUserFeaturesContext() { + const context = useContext(UserFeaturesContext) + + if (!context) { + throw new Error( + 'useUserFeaturesContext is only available inside UserFeaturesContext' + ) + } + + return context +} diff --git a/services/web/frontend/js/shared/hooks/use-editor-analytics.ts b/services/web/frontend/js/shared/hooks/use-editor-analytics.ts new file mode 100644 index 0000000000..55a0235f76 --- /dev/null +++ b/services/web/frontend/js/shared/hooks/use-editor-analytics.ts @@ -0,0 +1,44 @@ +import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils' +import { + Segmentation, + sendMB, + sendMBOnce, + sendMBSampled, +} from '@/infrastructure/event-tracking' +import { useCallback } from 'react' + +export const useEditorAnalytics = () => { + const editorRedesign = useIsNewEditorEnabled() + + const populateSegmentation = useCallback( + (segmentation: Segmentation | undefined = {}): Segmentation => { + return editorRedesign + ? { ...segmentation, 'editor-redesign': 'enabled' } + : segmentation + }, + [editorRedesign] + ) + + const sendEvent: typeof sendMB = useCallback( + (key, segmentation) => { + sendMB(key, populateSegmentation(segmentation)) + }, + [populateSegmentation] + ) + + const sendEventOnce: typeof sendMBOnce = useCallback( + (key, segmentation) => { + sendMBOnce(key, populateSegmentation(segmentation)) + }, + [populateSegmentation] + ) + + const sendEventSampled: typeof sendMBSampled = useCallback( + (key, segmentation, rate) => { + sendMBSampled(key, populateSegmentation(segmentation), rate) + }, + [populateSegmentation] + ) + + return { sendEvent, sendEventOnce, sendEventSampled } +} diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 7aab88b050..9461635625 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -55,7 +55,11 @@ import type { ScriptLogType } from '../../../modules/admin-panel/frontend/js/fea import { ActiveExperiment } from './labs-utils' export interface Meta { 'ol-ExposedSettings': ExposedSettings - 'ol-addonPrices': Record + 'ol-addonPrices': Record< + string, + { annual: string; monthly: string; annualDividedByTwelve: string } + > + 'ol-aiAssistViaWritefullSource': string 'ol-allInReconfirmNotificationPeriods': UserEmailData[] 'ol-allowedExperiments': string[] 'ol-allowedImageNames': AllowedImageName[] @@ -78,6 +82,7 @@ export interface Meta { 'ol-cannot-reactivate-subscription': boolean 'ol-cannot-use-ai': boolean 'ol-chatEnabled': boolean + 'ol-compilesUserContentDomain': string 'ol-countryCode': PricingFormState['country'] 'ol-couponCode': PricingFormState['coupon'] @@ -103,6 +108,7 @@ export interface Meta { 'ol-gitBridgeEnabled': boolean 'ol-gitBridgePublicBaseUrl': string 'ol-github': { enabled: boolean; error: boolean } + 'ol-groupAuditLogs': [] 'ol-groupId': string 'ol-groupName': string 'ol-groupPlans': GroupPlans @@ -136,7 +142,6 @@ export interface Meta { 'ol-isProfessional': boolean 'ol-isRegisteredViaGoogle': boolean 'ol-isRestrictedTokenMember': boolean - 'ol-isReviewerRoleEnabled': boolean 'ol-isSaas': boolean 'ol-itm_campaign': string 'ol-itm_content': string diff --git a/services/web/frontend/stories/menu-bar.stories.tsx b/services/web/frontend/stories/menu-bar.stories.tsx index 1fe5628bec..fe8a7dc926 100644 --- a/services/web/frontend/stories/menu-bar.stories.tsx +++ b/services/web/frontend/stories/menu-bar.stories.tsx @@ -9,7 +9,7 @@ export const Default = () => { - + diff --git a/services/web/frontend/stories/online-users.stories.tsx b/services/web/frontend/stories/online-users.stories.tsx new file mode 100644 index 0000000000..7d2796cb2e --- /dev/null +++ b/services/web/frontend/stories/online-users.stories.tsx @@ -0,0 +1,69 @@ +import { Meta } from '@storybook/react' +import { OnlineUser } from '@/features/ide-react/context/online-users-context' +import OnlineUsersWidgetOld from '@/features/editor-navigation-toolbar/components/online-users-widget' +import { OnlineUsersWidget } from '@/features/ide-redesign/components/online-users/online-users-widget' + +const NAMES = [ + 'Alice', + 'Bob', + 'Charlie', + 'Dave', + 'Erin', + 'Frank', + 'Grace', + 'Heidi', + 'Ivan', + 'Judy', + 'Mallory', + 'Niaj', + 'Olivia', + 'Peggy', + 'Rupert', +] + +const generateUser = (_: any, index: number): OnlineUser => { + const name = NAMES[index % NAMES.length] + return { + user_id: `user_${'b'.repeat(index)}`, + name, + id: `user-${index}`, + email: `${name.toLowerCase()}@example.com`, + } +} + +export const OnlineUsersRedesign = ({ users }: { users: number }) => { + const generatedUsers = Array.from({ length: users }, generateUser) + return ( +
    + {}} /> +
    + ) +} + +export const OnlineUsersOld = ({ users }: { users: number }) => { + const generatedUsers = Array.from({ length: users }, generateUser) + return ( +
    + {}} /> +
    + ) +} + +const meta: Meta = { + title: 'Editor / Online Users Widget', + args: { + users: 6, + }, +} + +export default meta diff --git a/services/web/frontend/stories/project-list/new-project-button.stories.tsx b/services/web/frontend/stories/project-list/new-project-button.stories.tsx index 9d2e03ad2d..fcb19deb4c 100644 --- a/services/web/frontend/stories/project-list/new-project-button.stories.tsx +++ b/services/web/frontend/stories/project-list/new-project-button.stories.tsx @@ -102,6 +102,6 @@ export const Error = () => { } export default { - title: 'Project List / New Project Button', + title: 'Project List / New project Button', component: NewProjectButton, } diff --git a/services/web/frontend/stories/ui/form/form-check-bs5.stories.tsx b/services/web/frontend/stories/ui/form/form-check-bs5.stories.tsx index 82ba8eba13..cfe1de227c 100644 --- a/services/web/frontend/stories/ui/form/form-check-bs5.stories.tsx +++ b/services/web/frontend/stories/ui/form/form-check-bs5.stories.tsx @@ -1,5 +1,5 @@ import { useRef, useLayoutEffect } from 'react' -import { Form } from 'react-bootstrap-5' +import { Form } from 'react-bootstrap' import type { Meta, StoryObj } from '@storybook/react' const meta: Meta<(typeof Form)['Check']> = { diff --git a/services/web/frontend/stories/ui/form/form-input-bs5.stories.tsx b/services/web/frontend/stories/ui/form/form-input-bs5.stories.tsx index 65c1121ea3..6d24f21901 100644 --- a/services/web/frontend/stories/ui/form/form-input-bs5.stories.tsx +++ b/services/web/frontend/stories/ui/form/form-input-bs5.stories.tsx @@ -1,4 +1,4 @@ -import { Form } from 'react-bootstrap-5' +import { Form } from 'react-bootstrap' import type { Meta, StoryObj } from '@storybook/react' import FormGroup from '@/features/ui/components/bootstrap-5/form/form-group' import FormText from '@/features/ui/components/bootstrap-5/form/form-text' diff --git a/services/web/frontend/stories/ui/form/form-radio-bs5.stories.tsx b/services/web/frontend/stories/ui/form/form-radio-bs5.stories.tsx index 95c15dc41e..99ce0cb40d 100644 --- a/services/web/frontend/stories/ui/form/form-radio-bs5.stories.tsx +++ b/services/web/frontend/stories/ui/form/form-radio-bs5.stories.tsx @@ -1,4 +1,4 @@ -import { Form } from 'react-bootstrap-5' +import { Form } from 'react-bootstrap' import type { Meta, StoryObj } from '@storybook/react' const meta: Meta<(typeof Form)['Check']> = { diff --git a/services/web/frontend/stories/ui/form/form-select-bs5.stories.tsx b/services/web/frontend/stories/ui/form/form-select-bs5.stories.tsx index 61e96c7aa2..a8cd9cec82 100644 --- a/services/web/frontend/stories/ui/form/form-select-bs5.stories.tsx +++ b/services/web/frontend/stories/ui/form/form-select-bs5.stories.tsx @@ -1,4 +1,4 @@ -import { Form, FormSelectProps } from 'react-bootstrap-5' +import { Form, FormSelectProps } from 'react-bootstrap' import type { Meta, StoryObj } from '@storybook/react' import FormGroup from '@/features/ui/components/bootstrap-5/form/form-group' import FormText from '@/features/ui/components/bootstrap-5/form/form-text' diff --git a/services/web/frontend/stories/ui/form/form-textarea-bs5.stories.tsx b/services/web/frontend/stories/ui/form/form-textarea-bs5.stories.tsx index 898ec97960..6bca986d0d 100644 --- a/services/web/frontend/stories/ui/form/form-textarea-bs5.stories.tsx +++ b/services/web/frontend/stories/ui/form/form-textarea-bs5.stories.tsx @@ -1,4 +1,4 @@ -import { Form } from 'react-bootstrap-5' +import { Form } from 'react-bootstrap' import type { Meta, StoryObj } from '@storybook/react' import FormGroup from '@/features/ui/components/bootstrap-5/form/form-group' import FormText from '@/features/ui/components/bootstrap-5/form/form-text' diff --git a/services/web/frontend/stories/ui/row.stories.tsx b/services/web/frontend/stories/ui/row.stories.tsx index 4b6411619c..a5a7409bb6 100644 --- a/services/web/frontend/stories/ui/row.stories.tsx +++ b/services/web/frontend/stories/ui/row.stories.tsx @@ -1,4 +1,4 @@ -import { Container, Row, Col } from 'react-bootstrap-5' +import { Container, Row, Col } from 'react-bootstrap' import { Meta } from '@storybook/react' type Args = React.ComponentProps diff --git a/services/web/frontend/stories/ui/split-button.stories.tsx b/services/web/frontend/stories/ui/split-button.stories.tsx index d78643b4cd..674d2e796b 100644 --- a/services/web/frontend/stories/ui/split-button.stories.tsx +++ b/services/web/frontend/stories/ui/split-button.stories.tsx @@ -9,7 +9,7 @@ import { DropdownToggle, } from '@/features/ui/components/bootstrap-5/dropdown-menu' import Button from '@/features/ui/components/bootstrap-5/button' -import { ButtonGroup } from 'react-bootstrap-5' +import { ButtonGroup } from 'react-bootstrap' type Args = React.ComponentProps diff --git a/services/web/frontend/stylesheets/app/contact-us.less b/services/web/frontend/stylesheets/app/contact-us.less index 48400dee10..e72c620676 100644 --- a/services/web/frontend/stylesheets/app/contact-us.less +++ b/services/web/frontend/stylesheets/app/contact-us.less @@ -59,7 +59,8 @@ } } - .fa { + .fa, + .material-symbols { display: table-cell; text-align: right; color: @gray-lighter; diff --git a/services/web/frontend/stylesheets/app/review-features-page.less b/services/web/frontend/stylesheets/app/review-features-page.less deleted file mode 100644 index b03256af50..0000000000 --- a/services/web/frontend/stylesheets/app/review-features-page.less +++ /dev/null @@ -1,481 +0,0 @@ -@rfp-h1-size: 2.442em; -@rfp-h2-size: 1.953em; -@rfp-h3-size: 1.563em; -@rfp-lead-size: 1.25em; - -@rfp-sl-red: @red; -@rfp-rp-blue: @rp-type-blue; - -@rfp-rp-blue-light: #f8f9fd; -@rfp-rp-blue-dark: shade(@rfp-rp-blue, 50%); -@rfp-rp-blue-darker: shade(@rfp-rp-blue, 65%); -@rfp-rp-blue-darkest: shade(@rfp-rp-blue, 75%); - -@rfp-card-shadow: 0 0 30px 5px rgba(0, 0, 0, 0.3); -@rfp-border-radius: 5px; - -@rfp-header-height: 80px; -@rfp-header-height-collapsed: 50px; - -.rfp-main { - background-color: @content-alt-bg-color; - font-size: 18px; - min-width: 240px; -} - -// Typographical scale and basics. -.rfp-h1 { - font-size: @rfp-h2-size; - margin-bottom: 1.6em; - color: inherit; - @media (min-width: @screen-xs-min) { - font-size: @rfp-h1-size; - } -} -.rfp-h1-masthead { - color: #fff; - margin-bottom: 1em; -} -.rfp-h2 { - font-size: @rfp-h2-size; - margin-bottom: 1.6em; - color: inherit; -} -.rfp-h3 { - font-size: @rfp-h3-size; - margin-bottom: 1.6em; - color: inherit; -} -.rfp-h3-cta { - margin-top: 0; - margin-bottom: 40px; -} -.rfp-lead { - margin-bottom: 1.6em; - max-width: 30em; - margin-left: auto; - margin-right: auto; - @media (min-width: @screen-xs-min) { - font-size: @rfp-lead-size; - } -} -.rfp-lead-cta { - margin-top: 0; - margin-bottom: 40px; -} -.rfp-lead-strong { - font-weight: 700; - .rfp-section-masthead & { - margin-bottom: 0; - } -} -.rfp-p { - margin-bottom: 1.6em; - max-width: 30em; - margin-left: auto; - margin-right: auto; - .rfp-section-feature & { - margin-left: initial; - } - .rfp-section-feature-alt & { - margin-left: auto; - margin-right: initial; - } -} -.rfp-highlight { - font-weight: 700; -} -// Sections -.rfp-header { - position: fixed; - top: 0; - width: 100%; - z-index: 2; - height: @rfp-header-height; - transition: height 0.2s; - background-color: fade(@rfp-rp-blue-darkest, 90%); - padding: 15px 20px; - min-width: 320px; - @media (min-width: @screen-xs-min) { - padding-left: 30px; - padding-right: 30px; - } - @media (min-width: @screen-sm-min) { - padding-left: 60px; - padding-right: 60px; - } - .rfp-main-header-collapsed & { - height: @rfp-header-height-collapsed; - padding-top: 10px; - padding-bottom: 10px; - } -} -.rfp-header-wrapper { - display: flex; - align-items: center; - justify-content: space-between; - max-width: @container-large-desktop; - height: 100%; - margin: auto; -} -.rfp-header-logo-container, -.rfp-header-logo { - height: 100%; -} -.rfp-section { - padding: 30px; - text-align: center; - overflow: hidden; - @media (min-width: @screen-xs-min) { - padding: 30px; - } - @media (min-width: @screen-sm-min) { - padding: 60px; - } -} -.rfp-section-masthead { - color: #fff; - background-size: cover; - background-position: center; - background-color: @rfp-rp-blue-darker; - padding-top: @rfp-header-height; - .rfp-lead { - opacity: 0; - transition: opacity 0.8s ease; - } - &.rfp-section-masthead-in { - .rfp-lead { - opacity: 1; - } - } -} -.rfp-section-blockquote { - position: relative; - padding-top: 30px; - padding-bottom: 30px; - background-color: @brand-secondary; - box-shadow: @rfp-card-shadow; -} -.rfp-section-feature { - display: block; - text-align: left; - @media (min-width: @screen-sm-min) { - .rfp-section-wrapper { - display: flex; - align-items: center; - } - } -} -.rfp-feature-description-container, -.rfp-feature-video-container { - flex: 0 0 50%; -} -.rfp-feature-description-container { - @media (min-width: @screen-sm-min) { - padding-right: 1em; - .rfp-section-feature-alt & { - padding-right: 0; - padding-left: 1em; - } - } -} -.rfp-feature-video-container { - @media (min-width: @screen-sm-min) { - padding-left: 1em; - .rfp-section-feature-alt & { - padding-left: 0; - padding-right: 1em; - order: -1; - } - } -} -.rfp-section-feature-alt { - color: #fff; - background-color: @ol-blue-gray-5; - @media (min-width: @screen-sm-min) { - text-align: right; - } -} -.rfp-section-feature-white { - background: #ffffff; -} -.rfp-section-testimonials { - background-color: @rfp-rp-blue-darkest; -} -.rfp-section-final { - background-color: @rfp-rp-blue-darker; -} -.rfp-section-wrapper { - max-width: @container-large-desktop; - margin: 0 auto; -} -// Elements -.rfp-h1-masthead-portion { - display: inline-block; - transform: translate(150px, 0); - opacity: 0; - transition: - transform 0.8s ease 0s, - opacity 0.8s ease 0s; - &:nth-child(2) { - transition-delay: 0.5s, 0.5s; - } - &:nth-child(3) { - transition-delay: 0.5s, 0.5s; - } - &:nth-child(4) { - transition-delay: 1s, 1s; - } - &:nth-child(5) { - transition-delay: 1s, 1s; - } - - .rfp-section-masthead-in & { - transform: translate(0, 0); - opacity: 1; - } -} -.rfp-video { - max-width: 100%; - box-shadow: @rfp-card-shadow; - border-radius: @rfp-border-radius; -} -.rfp-video-masthead { - width: 270px; - height: 163px; - margin-bottom: 2em; - transform: translate(0, 100px); - opacity: 0; - transition: - transform 0.8s ease 1s, - opacity 0.8s ease 1s; - box-shadow: none; - max-width: none; - - @media (min-width: @screen-xs-min) { - width: 400px; - height: 241px; - } - @media (min-width: 600px) { - width: 525px; - height: 316px; - } - @media (min-width: @screen-sm-min) { - width: 633px; - height: 381px; - } - @media (min-width: @screen-sm-min) { - width: 697px; - height: 420px; - } - .rfp-section-masthead-in & { - transform: translate(0, 0); - opacity: 1; - box-shadow: @rfp-card-shadow; - } -} -.rfp-video-anim { - transition: - transform 0.8s ease, - opacity 0.8s ease; - transform: translate(100%, 0); - opacity: 0; -} -.rfp-video-anim-alt { - transform: translate(-100%, 0); -} -.rfp-video-anim-in { - transform: translate(0, 0); - opacity: 1; -} -.rfp-quote-section { - @media (min-width: @screen-md-min) { - display: flex; - } -} -.rfp-quote { - display: block; - width: 100%; - padding: 20px 40px; - border-left: 0; - max-width: 30em; - font-size: @rfp-lead-size; - quotes: '\201C' '\201D'; - box-shadow: @rfp-card-shadow; - border-radius: @rfp-border-radius; - background-color: #fff; - color: @rfp-rp-blue-dark; - font-size: 1em; - margin: 0 auto 20px; - - @media (min-width: @screen-xs-min) { - font-size: @rfp-lead-size; - } - - @media (min-width: @screen-md-min) { - display: flex; - flex-direction: column; - justify-content: space-between; - flex: 0 1 50%; - margin-right: 20px; - } - // Override weird Boostrap default. - p { - display: block; - } - &:last-of-type { - @media (min-width: @screen-md-min) { - margin-right: 0; - } - } - &::before { - content: none; - } -} -.rfp-quote-main { - color: #ffffff; - display: block; - max-width: none; - border-left: 0; - margin: 0 auto; - padding: 0; - quotes: '\201C' '\201D'; - font-size: @rfp-lead-size; - @media (min-width: @screen-md-min) { - display: flex; - } - // Override weird Boostrap default. - p { - display: block; - } - &::before { - content: none; - } -} -.rfp-quoted-text { - position: relative; - display: inline-block; - font-family: @font-family-serif; - font-style: italic; - text-align: left; - margin: 0 0 40px 0; - &::before { - content: open-quote; - display: block; - position: absolute; - font-family: @font-family-serif; - font-size: @rfp-lead-size; - line-height: inherit; - color: inherit; - left: -0.75em; - } - .rfp-quote-main & { - @media (min-width: @screen-md-min) { - flex: 1 1 70%; - margin: auto 40px auto auto; - } - } -} -.rfp-quoted-person { - display: inline-block; - font-size: 0.8em; - .rfp-quote-main & { - display: flex; - align-items: center; - justify-content: center; - flex: 0 0 30%; - } -} -.rfp-quoted-person-name { - margin: 0; -} -.rfp-quoted-person-affil { - margin: 0; - font-size: 0.8em; - &:hover, - &:focus { - text-decoration: none; - cursor: pointer; - } - .rfp-quote-main & { - color: #fff; - &:hover, - &:focus { - color: #fff; - } - } -} -.rfp-quoted-person-photo { - border-radius: 3em; - width: 6em; - margin-bottom: 20px; - .rfp-quote-main & { - margin-bottom: 0; - margin-right: 20px; - } -} -.rfp-users { - display: flex; - flex-wrap: wrap; - margin: 0 1em 2em; - @media (min-width: @screen-md-min) { - flex-wrap: nowrap; - align-items: center; - } -} -.rfp-user-container { - flex: 0 0 100%; - padding: 10px; - @media (min-width: @screen-xs-min) { - flex-basis: 50%; - } - @media (min-width: @screen-md-min) { - flex-basis: 25%; - padding: 20px; - } -} -.rfp-user-logo { - max-width: 100%; -} -.rfp-cta-container { - max-width: 40em; - margin: 0 auto; - padding: 40px; - background-color: #fff; - color: @rfp-rp-blue-dark; - box-shadow: @rfp-card-shadow; - border-radius: @rfp-border-radius; -} -.rfp-cta-header { - font-size: 1em; - padding: 0.2em 1em; -} -.rfp-cta-main { - display: block; - transition: transform 0.25s; - transform: translate(0, 0); -} -.rfp-cta-extra { - display: block; - position: absolute; - left: 50%; - text-transform: uppercase; - transition: - opacity 0.25s, - transform 0.25s; - transform: translate(-50%, 100%); - opacity: 0; - font-size: 0.5em; -} -.rfp-universities { - text-align: center; - img { - display: inline-block; - padding: 0 @padding-md; - width: 20%; - @media only screen and (max-width: @screen-sm-max) { - padding: @padding-md; - width: 50%; - } - } -} diff --git a/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss b/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss index a4bf73f87c..650bdc727f 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/base/layout.scss @@ -78,3 +78,12 @@ hr { .row-spaced-extra-large { margin-top: calc(var(--line-height-03) * 4); } + +.inline-material-symbols { + display: inline-flex; + align-items: center; + + a.material-symbols { + text-decoration: none; + } +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/button.scss b/services/web/frontend/stylesheets/bootstrap-5/components/button.scss index 3b782ed1f8..80220a6911 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/button.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/button.scss @@ -252,7 +252,6 @@ // Set the visited colour for a link that is styled as a button. This is necessary because we have a generic rule that // sets the colour of visited links -a[role='button']:visited, a.btn:visited { color: var(--bs-btn-color); } diff --git a/services/web/frontend/stylesheets/bootstrap-5/modules/writefull.scss b/services/web/frontend/stylesheets/bootstrap-5/modules/writefull.scss index bc4107e3ae..9d5afb20e6 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/modules/writefull.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/modules/writefull.scss @@ -10,3 +10,27 @@ max-width: 520px; text-align: left; } + +.feature-rebrand-promo-container { + z-index: 12; + position: absolute; + bottom: 15px; + right: 15px; + background: $bg-dark-primary; + color: $content-primary-dark; + max-width: 450px; +} + +.feature-rebrand-promo-title-container { + display: flex; + justify-content: space-between; +} + +.feature-rebrand-promo-title-text { + color: white; + font-size: 14px; +} + +.feature-rebrand-promo-body { + color: white; +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss index b8e206b6ae..1caeb22c1d 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss @@ -154,6 +154,10 @@ history-root { } } + .history-version-main-details { + color: var(--content-primary); + } + .version-element-within-selected { background-color: var(--bg-light-secondary); border-left: var(--spacing-02) solid var(--green-50); diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide-redesign-switcher-modal.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide-redesign-switcher-modal.scss index 4bba755e44..bb9c930848 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide-redesign-switcher-modal.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide-redesign-switcher-modal.scss @@ -13,8 +13,20 @@ padding: var(--spacing-05); margin: var(--spacing-05) 0; - ul li:not(:last-child) { - margin-bottom: var(--spacing-04); + hr { + margin: var(--spacing-04) 0; + } + + h4 { + margin-top: 0; + } + + ul { + margin-bottom: 0; + + li:not(:last-child) { + margin-bottom: var(--spacing-04); + } } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/online-users.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/online-users.scss index 52265c6e97..2a03c22c19 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/online-users.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/online-users.scss @@ -1,9 +1,15 @@ :root { --toolbar-btn-color: var(--white); + --online-users-border-color: var(--bg-dark-primary); + --online-users-text-color: var(--content-primary); + --online-users-overflow-button-background-color: var(--bg-light-secondary); } @include theme('light') { --toolbar-btn-color: var(--neutral-70); + --online-users-border-color: var(--bg-light-primary); + --online-users-text-color: var(--content-primary); + --online-users-overflow-button-background-color: var(--bg-light-secondary); } .online-users { @@ -35,3 +41,87 @@ align-items: center; } } + +.online-users-row { + // Keep in sync with the MAX_USER_CIRCLES_DISPLAYED js constant + $max-user-circles-displayed: 5; + + --online-users-circle-size: 24px; + --online-users-border-size: 1px; + --online-users-overlap: 8px; + --online-users-circle-padding: var(--spacing-01); + + .online-user-overflow-dropdown { + --online-users-border-color: transparent; + } + + display: flex; + align-items: center; + color: var(--online-users-text-color); + + .online-users-row-button { + padding: 0; + margin: 0; + background: none; + border: none; + color: var(--online-users-text-color); + min-width: var(--online-users-circle-size); + height: var(--online-users-circle-size); + display: block; + font-weight: var(--font-weight-regular); + + &.dropdown-toggle::after { + display: none; + } + + &:not(:last-child, .online-user-overflow-toggle) { + margin-right: calc( + var(--online-users-overlap) * -1 + var(--online-users-border-size) + ); + } + + @for $i from 1 through $max-user-circles-displayed { + &:nth-child(#{$i}):not(:hover):not(.online-user-overflow-toggle) { + z-index: $i; + } + } + + &:hover { + z-index: $max-user-circles-displayed + 2; + } + } + + .online-user-overflow-toggle { + &:not(:hover) { + z-index: $max-user-circles-displayed + 1; + } + + &:hover, + &:active, + & { + background: none; + } + + .online-user-circle { + background-color: var(--online-users-overflow-button-background-color); + color: var(--online-users-text-color); + } + } + + .online-user-circle { + padding: var(--online-users-circle-padding); + border-radius: 50%; + min-width: var(--online-users-circle-size); + height: var(--online-users-circle-size); + line-height: calc( + var(--online-users-circle-size) - 2 * var(--online-users-border-size) - 2 * + var(--online-users-circle-padding) + ); + font-size: var(--font-size-01); + text-align: center; + border: var(--online-users-border-size) solid + var(--online-users-border-color); + box-sizing: border-box; + display: inline-block; + } +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/outline.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/outline.scss index dc83a234bc..cc815ea058 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/outline.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/outline.scss @@ -281,7 +281,6 @@ font-size: inherit; vertical-align: inherit; position: relative; - z-index: 1; color: var(--outline-item-carat-color); margin-right: calc(var(--spacing-03) * -1); border-radius: var(--border-radius-base); @@ -304,7 +303,6 @@ background-color: transparent; border: 0; position: relative; - z-index: 1; padding: 0 var(--spacing-03); line-height: var(--spacing-08); border-radius: var(--border-radius-base); diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf.scss index 8649eacd1c..df5c9e2b77 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf.scss @@ -193,7 +193,6 @@ div.pdf-canvas { background: white; box-shadow: 0 0 10px rgb(0 0 0 / 50%); - will-change: transform; } div.pdf-canvas.pdfng-empty { @@ -237,6 +236,18 @@ outline: none; } + /* Avoid slowdown in Safari when text layers are reset on selection change */ + /* stylelint-disable-next-line selector-class-pattern */ + .textLayer { + will-change: transform; + } + + /* Avoid multiple small layers within annotation layer */ + /* stylelint-disable-next-line selector-class-pattern */ + .annotationLayer { + will-change: transform; + } + /* Avoids https://github.com/mozilla/pdf.js/issues/13840 in Chrome */ /* stylelint-disable-next-line selector-class-pattern */ .textLayer br::selection { diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss index 96df113261..b285dc084e 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss @@ -65,7 +65,7 @@ color: var(--ide-rail-color); background-color: var(--ide-rail-link-background); position: relative; - overflow-y: hidden; + overflow: visible; &:visited, &:focus { @@ -108,8 +108,12 @@ .badge { position: absolute; - top: 0; - right: 0; + top: -4px; + right: -4px; + pointer-events: none; + z-index: 10; + padding: var(--spacing-00) var(--spacing-02); + border-radius: var(--border-radius-full); } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel-new.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel-new.scss index 074870d0f7..7f1c32b139 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel-new.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel-new.scss @@ -250,75 +250,7 @@ del.review-panel-content-highlight { z-index: 4; } -// TODO: Update this when we move the track changes menu to the new design -.rp-tc-state { - position: absolute; - top: 100%; - left: 0; - right: 0; - overflow: hidden; - list-style: none; - padding: 0 var(--spacing-03); - margin: 0; - border-bottom: 1px solid var(--rp-border-grey); - text-align: left; - background-color: var(--white); - max-height: calc( - 100vh - var(--review-panel-top) - var(--review-panel-header-height) - ); - overflow-y: auto; - - .rp-tc-state-item { - display: flex; - align-items: center; - padding: var(--spacing-02) 0; - - &:last-of-type { - padding-bottom: var(--spacing-03); - } - } - - .rp-tc-state-item-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex-grow: 1; - font-weight: 600; - } -} - -.review-panel-tools { - display: flex; - align-items: center; - justify-content: space-between; - padding-left: var(--spacing-02); - padding-right: var(--spacing-05); - flex-shrink: 0; - flex-basis: 32px; -} - .review-panel-resolved-comments-toggle { - background-color: var(--bg-light-secondary); - font-size: var(--font-size-02); - color: color.adjust($rp-type-blue, $lightness: 25%); - border: solid 1px var(--rp-border-grey); - border-radius: var(--border-radius-base); - padding: 0; - height: 22px; - width: 22px; - line-height: 1.4; - display: flex; - align-items: center; - justify-content: center; - - &:hover, - &:focus { - text-decoration: none; - color: var(--rp-type-blue); - } -} - -.review-panel-resolved-comments-toggle-reviewer-role { display: flex; align-items: center; border: none; @@ -333,27 +265,6 @@ del.review-panel-content-highlight { } } -.track-changes-indicator-circle { - width: 8px; - height: 8px; - border-radius: 100%; - background-color: var(--bg-accent-01); -} - -.track-changes-menu-button { - border: none; - background: none; - padding: 0; - display: flex; - align-items: center; - gap: var(--spacing-02); - font-size: var(--font-size-02); - - i { - width: 8px; - } -} - .review-panel-resolved-comments { --bs-popover-border-width: 1px; --bs-popover-bg: var(--bg-light-secondary); @@ -780,56 +691,6 @@ del.review-panel-content-highlight { pointer-events: none; // this is to prevent mouseLeave event from firing when hovering over the tooltip } -.review-panel-in-editor-widgets { - position: sticky; - top: 0; - right: 0; - font-size: 11px; - z-index: 2; - font-family: $font-family-base; - - .review-panel-in-editor-widgets-inner { - position: absolute; - top: 0; - right: 0; - display: flex; - flex-direction: column; - } - - .review-panel-track-changes-indicator { - border: 0; - } -} - -.review-panel-track-changes-indicator { - display: block; - padding: 5px 10px; - background-color: rgb(240 240 240 / 90%); - color: var(--rp-type-blue); - text-align: center; - border-bottom-left-radius: 3px; - white-space: nowrap; - - &.review-panel-track-changes-indicator-on-dark { - background-color: rgb(88 88 88 / 80%); - color: #fff; - - &:hover, - &:focus { - background-color: rgb(88 88 88 / 100%); - color: #fff; - } - } - - &:hover, - &:focus { - outline: 0; - text-decoration: none; - background-color: rgb(240 240 240 / 100%); - color: var(--rp-type-blue); - } -} - .review-mode-switcher-container { position: sticky; top: 0; @@ -860,6 +721,11 @@ del.review-panel-content-highlight { display: block; } } + + // prevent gap between button and menu + > .dropdown-menu { + margin-top: -2px; + } } .review-mode-switcher-toggle-button { diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar-redesign.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar-redesign.scss index e77ad32a51..a4df2e4e36 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar-redesign.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar-redesign.scss @@ -36,12 +36,18 @@ box-sizing: border-box; height: $toolbar-height; padding: 0 var(--spacing-02); + gap: var(--spacing-05); .ide-redesign-toolbar-menu { display: flex; gap: var(--spacing-05); } + .ide-redesign-toolbar-menu, + .ide-redesign-toolbar-actions { + flex: 0 0 auto; + } + .ide-redesign-toolbar-home-button { width: $home-button-size; height: $home-button-size; @@ -182,6 +188,22 @@ } } +.ide-redesign-toolbar-project-dropdown { + flex: 0 1 auto; + min-width: 0; +} + +.ide-redesign-toolbar-project-dropdown-toggle { + display: flex; + max-width: 100%; +} + +.ide-redesign-toolbar-project-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .ide-redesign-labs-button.btn.btn-secondary { @include labs-button; } diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/onboarding.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/onboarding.scss index 616f6e498b..da59fafdca 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/onboarding.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/onboarding.scss @@ -106,7 +106,6 @@ user-select: none; } -.onboarding-collapse-button, .onboarding-privacy-extended { @include media-breakpoint-down(md) { padding: 0 var(--spacing-08); diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss index 6f93dbf454..83b6fbd28a 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss @@ -384,6 +384,8 @@ ul.project-list-filters { .dash-cell-owner { width: 20%; + overflow: hidden; + text-overflow: ellipsis; } .dash-cell-date { diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss index ed2acdf5b5..cc01e1abd2 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss @@ -182,10 +182,6 @@ h4 { margin-bottom: var(--spacing-06); - - &:has(+ .payment-nudge-annual-button) { - margin-bottom: 0; - } } .features-list { @@ -452,16 +448,6 @@ } } -.payment-nudge-annual-button { - margin: var(--spacing-02) 0 var(--spacing-06) 0; - - @include body-sm; - - button { - padding: 0; - } -} - .add-on-card { display: flex; align-items: center; diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss index bdd168a519..2e069d0599 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/website-redesign.scss @@ -262,12 +262,173 @@ height: 20px; } + .centered-block { + @include media-breakpoint-up(lg) { + text-align: center; + } + } + + .header-description { + p { + font-size: var(--font-size-05); + line-height: var(--line-height-03); + margin-bottom: 0; + + @include media-breakpoint-down(lg) { + font-size: var(--font-size-04); + line-height: var(--line-height-02); + } + } + } + + .resources { + @include media-breakpoint-up(lg) { + display: flex; + + /* equal heights */ + flex-wrap: wrap; + } + + .resources-card { + display: flex; + flex-flow: column wrap; + margin-bottom: 48px; + align-content: flex-start; + + @include media-breakpoint-down(lg) { + margin-bottom: 16px; + } + + img { + width: 56px; + } + + h3 { + width: 100%; + font-size: var(--font-size-05); + } + + a { + margin-top: auto; + + @include heading-xs; + } + + p { + margin-bottom: var(--spacing-05); + } + } + } + .green-round-background { @extend .round-background; background: var(--green-30); } + .why-latex { + h1 { + margin-top: var(--spacing-08); + } + + .sub-heading { + font-size: var(--font-size-04); + } + } + + .info-cards { + padding: 0; + + @include media-breakpoint-up(lg) { + display: flex; + + /* equal heights */ + flex-wrap: wrap; + } + + .info-card-container { + margin-bottom: var(--spacing-06); + padding-left: var(--spacing-06); + padding-right: var(--spacing-06); + + h3 { + font-size: var(--font-size-05); + line-height: var(--line-height-04); + } + + .info-card { + border-radius: 8px; + height: 100%; + box-shadow: + 0 2px 4px 0 #1e253014, + 0 4px 12px 0 #1e25301f; + border-top: 8px solid var(--sapphire-blue); + padding: var(--spacing-09) var(--spacing-10); + + &.info-card-big-text { + h3 { + font-size: var(--font-size-06); + line-height: var(--line-height-02); + } + + p { + font-size: var(--font-size-04); + line-height: var(--line-height-02); + } + } + + i.material-symbols { + color: var(--sapphire-blue); + } + } + } + } + + .heading-section-md-align-left { + @include media-breakpoint-down(lg) { + display: flex; + flex-direction: column; + align-items: baseline; + + h2 { + text-align: left; + } + + p { + text-align: left; + } + } + } + + .responsive-button-container { + display: flex; + margin-top: var(--spacing-08); + gap: var(--spacing-06); + + &.centered-buttons { + justify-content: center; + } + + &.align-left-button-sm { + @include media-breakpoint-down(md) { + justify-content: start; + } + } + + @include media-breakpoint-down(md) { + width: 100%; + flex-direction: column; + } + } + + .overleaf-sticker { + width: unset; + + @include media-breakpoint-down(lg) { + width: 74px; // 70% of 106px + } + } + .features-card { display: flex; /* equal heights */ flex-wrap: wrap; diff --git a/services/web/frontend/stylesheets/core/type.less b/services/web/frontend/stylesheets/core/type.less index 3d51fac4eb..be3347b6eb 100755 --- a/services/web/frontend/stylesheets/core/type.less +++ b/services/web/frontend/stylesheets/core/type.less @@ -190,9 +190,6 @@ cite { } // Transformations -.text-capitalize { - text-transform: capitalize; -} .text-lowercase { text-transform: lowercase; } diff --git a/services/web/frontend/stylesheets/main-style.less b/services/web/frontend/stylesheets/main-style.less index 696da82176..d42a2ab502 100644 --- a/services/web/frontend/stylesheets/main-style.less +++ b/services/web/frontend/stylesheets/main-style.less @@ -126,7 +126,6 @@ @import 'app/ol-chat.less'; @import 'app/templates-v2.less'; @import 'app/login-register.less'; -@import 'app/review-features-page.less'; @import 'app/institution-hub.less'; @import 'app/publisher-hub.less'; @import 'app/admin-hub.less'; diff --git a/services/web/locales/da.json b/services/web/locales/da.json index 6746c028b7..86911ff083 100644 --- a/services/web/locales/da.json +++ b/services/web/locales/da.json @@ -35,7 +35,6 @@ "accept_change_error_description": "Der opstod en fejl under accepten af en ændring. Prøv venligst igen om lidt.", "accept_change_error_title": "Fejl i accept af ændring", "accept_invitation": "Accepter invitation", - "accept_or_reject_each_changes_individually": "Accepter eller afvis hver rettelse individuelt", "accept_terms_and_conditions": "Accepter vilkår og betingelser", "accepted_invite": "Accepteret invitation", "accepting_invite_as": "Du accepterer denne invitation som", @@ -121,7 +120,6 @@ "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "Tillader søgning på forfatter, title, m.fl. Muligt at hente resultater fra din henvisningsmanager (hvis tilkoblet).", "already_have_an_account": "Har du allerede en konto?", "already_have_sl_account": "Har du allerede en __appName__-konto?", - "already_subscribed_try_refreshing_the_page": "Har du allerede abonneret? Prøv at genindlæse siden.", "also": "Derudover", "alternatively_create_new_institution_account": "Alternativt kan du oprette en ny konto med din institutionelle e-mailaddresse (__email__), ved at klikke __clickText__.", "an_email_has_already_been_sent_to": "En e-mail er allerede blevet sendt til <0>__email__. Vent lidt og prøv igen senere.", @@ -983,10 +981,6 @@ "let_us_know_what_you_think": "Fortæl os hvad du synes", "library": "Bibliotek", "license": "Licens", - "limited_to_n_editors": "Begrænset til __count__ redaktører", - "limited_to_n_editors_per_project": "Begrænset til __count__ redaktører per projekt", - "limited_to_n_editors_per_project_plural": "Begrænset til __count__ redaktører per projekt", - "limited_to_n_editors_plural": "Begrænset til __count__ redaktører", "line_height": "Linjehøjde", "line_width_is_the_width_of_the_line_in_the_current_environment": "Linjebredde er bredden af linjen i det nuværende miljø, f.eks. hele siden i et enkelt-kolonne layout, eller halvdelen af siden i et to-kolonne layout.", "link": "Forbind", @@ -1120,7 +1114,6 @@ "more_comments": "Flere kommentarer", "more_info": "Mere info", "more_options": "Flere muligheder", - "more_options_for_border_settings_coming_soon": "Flere muligheder for kantindstillinger kommer snart.", "more_project_collaborators": "<0>Flere <0>samarbejdspartnere i projekter", "more_than_one_kind_of_snippet_was_requested": "Linket til at åbne dette indhold i Overleaf havde nogle ugyldige parametre. Hvis du bliver ved med at opleve det her med links fra en bestemt side, bliver du næsten nødt til at fortælle dem om det.", "most_popular_uppercase": "Mest populære", @@ -1565,7 +1558,6 @@ "reverse_x_sort_order": "Omvendt __x__ sortering", "revert_pending_plan_change": "Fortryd planlagte abonnementsændring", "review": "Review", - "review_your_peers_work": "Gennemgå dine samarbejdspartneres arbejde", "revoke": "Tilbagekald", "revoke_invite": "Tilbagekald invitation", "right": "Højrejusteret", @@ -1620,7 +1612,6 @@ "searched_path_for_lines_containing": "Ledte i __path__ efter linjer som indeholdte \"__query__\"", "secondary_email_password_reset": "Den e-mailaddresse er registreret som en sekundær e-mailaddresse. Du kan kun logges ind, hvis du skriver din kontos primære e-mailaddresse.", "security": "Sikkerhed", - "see_changes_in_your_documents_live": "Se ændringer i dokumentet live", "select_a_column_or_a_merged_cell_to_align": "Marker en kolonne eller flettet celle for at justere", "select_a_column_to_adjust_column_width": "Marker en kolonne for at justere kolonnebredde", "select_a_file": "Vælg en fil", @@ -1842,8 +1833,6 @@ "tags": "Tags", "take_me_home": "Tag mig hjem!", "take_short_survey": "Besvar et kort spørgeskema", - "tc_everyone": "Alle", - "tc_guests": "Gæster", "template": "Skabelon", "template_approved_by_publisher": "Denne skabelon er blevet godkendt af forlaget", "template_description": "Skabelonsbeskrivelse", @@ -1959,12 +1948,7 @@ "total_with_subtotal_and_tax": "Total: <0>__total__ (__subtotal__ + __tax__ moms) per år", "total_words": "Totalt antal ord", "tr": "Tyrkisk", - "track_any_change_in_real_time": "Følg alle ændringer i realtid", "track_changes": "Følg ændringer", - "track_changes_for_everyone": "Følg ændringer for alle", - "track_changes_for_x": "Følg ændringer for __name__", - "track_changes_is_off": "“Følg ændringer” er slået fra", - "track_changes_is_on": "“Følg ændringer” er slået til", "tracked_change_added": "Tilføjet", "tracked_change_deleted": "Slettet", "transfer_management_of_your_account": "Overdrag styring af din Overleaf konto", @@ -2049,7 +2033,6 @@ "upgrade_for_12x_more_compile_time": "Opgrader for at få 12x mere kompileringstid", "upgrade_now": "Opgrader nu", "upgrade_to_get_feature": "Opgrader for at få __feature__, plus:", - "upgrade_to_track_changes": "Opgrader til “Følg ændringer”", "upload": "Upload", "upload_failed": "Overførsel mislykkedes", "upload_file": "Overfør Fil", diff --git a/services/web/locales/de.json b/services/web/locales/de.json index 51ad4355e5..928c95499e 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -29,7 +29,6 @@ "abstract": "Abstrakt", "accept": "Akzeptieren", "accept_invitation": "Einladung annehmen", - "accept_or_reject_each_changes_individually": "Akzeptiere oder Verwerfe jede Änderung individuell", "accepted_invite": "Einladung angenommen", "accepting_invite_as": "Du akzeptierst die Einladung als", "access_denied": "Zugriff verweigert", @@ -1074,7 +1073,6 @@ "return_to_login_page": "Zurück zur Login-Seite", "revert_pending_plan_change": "Abonnement-Änderung rückgängig machen", "review": "Überprüfen", - "review_your_peers_work": "Überprüfe die Arbeit deiner Kollegen", "revoke": "Zurückziehen", "revoke_invite": "Einladung zurückziehen", "ro": "Rumänisch", @@ -1101,7 +1099,6 @@ "search_replace_all": "Alles Ersetzen", "secondary_email_password_reset": "Diese E-Mail-Adresse ist als sekundäre E-Mail-Adresse hinterlegt. Bitte gib die primäre E-Mail-Adresse für dein Konto an.", "security": "Sicherheit", - "see_changes_in_your_documents_live": "Verfolge Änderungen in deinen Dokumenten, live", "select_a_file": "Datei auswählen", "select_a_project": "Projekt auswählen", "select_all_projects": "Alle Projekte auswählen", @@ -1196,8 +1193,6 @@ "tags": "Stichworte", "take_me_home": "Bring mich nach Hause!", "take_short_survey": "Nimm an einer kurzen Umfrage teil", - "tc_everyone": "Jeder", - "tc_guests": "Gäste", "template": "Vorlage", "template_approved_by_publisher": "Diese Vorlage wurde vom Verlag genehmigt", "template_description": "Vorlagenbeschreibung", @@ -1260,10 +1255,7 @@ "total_per_year": "Insgesamt pro Jahr", "total_words": "Gesamtwortanzahl", "tr": "Türkisch", - "track_any_change_in_real_time": "Verfolge jegliche Änderung, in Echtzeit", "track_changes": "Änderungen verfolgen", - "track_changes_is_off": "Änderungen verfolgen ist aus", - "track_changes_is_on": "Änderungen verfolgen ist an", "tracked_change_added": "Hinzugefügt", "tracked_change_deleted": "Gelöscht", "trash": "Löschen", @@ -1317,7 +1309,6 @@ "upgrade_cc_btn": "Upgrade jetzt, zahle nach sieben Tagen", "upgrade_now": "Jetzt aktualisieren", "upgrade_to_get_feature": "Upgrade nötig, um __feature__ zu bekommen, sowie zusätzlich:", - "upgrade_to_track_changes": "Upgrade, um Änderungen verfolgen zu können", "upload": "Hochladen", "upload_failed": "Hochladen fehlgeschlagen", "upload_file": "Datei hochladen", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 389e3078b5..4729f54756 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -6,7 +6,6 @@ "3_4_width": "¾ width", "About": "About", "Account": "Account", - "Account Settings": "Account Settings", "Documentation": "Documentation", "Projects": "Projects", "Security": "Security", @@ -34,7 +33,6 @@ "about_to_leave_project": "You are about to leave this project.", "about_to_leave_projects": "You are about to leave the following projects:", "about_to_trash_projects": "You are about to trash the following projects:", - "about_writefull": "About Writefull", "abstract": "Abstract", "accept": "Accept", "accept_and_continue": "Accept and continue", @@ -42,7 +40,6 @@ "accept_change_error_description": "There was an error accepting a track change. Please try again in a few moments.", "accept_change_error_title": "Accept Change Error", "accept_invitation": "Accept invitation", - "accept_or_reject_each_changes_individually": "Accept or reject each change individually", "accept_or_reject_individual_edits": "Accept or reject individual edits", "accept_selected_changes": "Accept selected changes", "accept_terms_and_conditions": "Accept terms and conditions", @@ -58,10 +55,10 @@ "account_billed_manually": "Account billed manually", "account_has_been_link_to_institution_account": "Your __appName__ account on __email__ has been linked to your __institutionName__ institutional account.", "account_has_past_due_invoice_change_plan_warning": "Your account currently has a past due invoice. You will not be able to change your plan until this is resolved.", - "account_linking": "Account Linking", + "account_linking": "Account linking", "account_managed_by_group_administrator": "Your account is managed by your group administrator (__admin__)", "account_not_linked_to_dropbox": "Your account is not linked to Dropbox", - "account_settings": "Account Settings", + "account_settings": "Account settings", "account_with_email_exists": "It looks like an __appName__ account with the email __email__ already exists.", "acct_linked_to_institution_acct_2": "You can <0>log in to Overleaf through your <0>__institutionName__ institutional login.", "actions": "Actions", @@ -74,8 +71,11 @@ "add_a_recovery_email_address": "Add a recovery email address", "add_add_on_to_your_plan": "Add __addOnName__ to your plan", "add_additional_certificate": "Add another certificate", - "add_affiliation": "Add Affiliation", + "add_affiliation": "Add affiliation", "add_ai_assist": "Add AI Assist", + "add_ai_assist_annual_and_get_unlimited_access": "Add AI Assist Annual and get unlimited* access to Overleaf and Writefull AI features.", + "add_ai_assist_monthly_and_get_unlimited_access": "Add AI Assist Monthly and get unlimited* access to Overleaf and Writefull AI features.", + "add_ai_assist_to_your_plan": "Add AI Assist to your plan and get unlimited* access to Overleaf and Writefull AI features.", "add_another_address_line": "Add another address line", "add_another_email": "Add another email", "add_another_token": "Add another token", @@ -84,12 +84,12 @@ "add_comment_error_message": "There was an error adding your comment. Please try again in a few moments.", "add_comment_error_title": "Add Comment Error", "add_company_details": "Add company details", - "add_email": "Add Email", + "add_email": "Add email", "add_email_address": "Add email address", "add_email_to_claim_features": "Add an institutional email address to claim your features.", "add_error_assist_annual_to_your_projects": "Add Error Assist Annual to your projects and get unlimited AI help to fix LaTeX errors faster.", "add_error_assist_to_your_projects": "Add Error Assist to your projects and get unlimited AI help to fix LaTeX errors faster.", - "add_files": "Add Files", + "add_files": "Add files", "add_more_collaborators": "Add more collaborators", "add_more_licenses_to_my_plan": "Add more licenses to my plan", "add_more_managers": "Add more managers", @@ -97,12 +97,11 @@ "add_on": "Add-on", "add_ons": "Add-ons", "add_or_remove_project_from_tag": "Add or remove project from tag __tagName__", - "add_overleaf_assist_to_your_group_subscription": "Add Overleaf Assist to your group subscription", - "add_overleaf_assist_to_your_institution": "Add Overleaf Assist to your institution", "add_people": "Add people", "add_role_and_department": "Add role and department", - "add_to_dictionary": "Add to Dictionary", + "add_to_dictionary": "Add to dictionary", "add_to_tag": "Add to tag", + "add_unlimited_ai_to_overleaf": "Add unlimited AI* to Overleaf", "add_unlimited_ai_to_your_overleaf_plan": "Add unlimited AI* to your Overleaf __planName__ plan", "add_your_comment_here": "Add your comment here", "add_your_first_group_member_now": "Add your first group members now", @@ -126,7 +125,8 @@ "aggregate_to": "to", "agree": "Agree", "agree_with_the_terms": "I agree with the Overleaf terms", - "ai_assist_in_overleaf_is_included_via_writefull": "AI Assist in Overleaf is included as part of your Writefull subscription. You can cancel or manage your access to AI Assist in your Writefull subscription settings.", + "ai_assist_in_overleaf_is_included_via_writefull_groups": "AI Assist in Overleaf is included as part of your group or organization’s Writefull subscription. To make changes you’ll need to speak to your subscription admin", + "ai_assist_in_overleaf_is_included_via_writefull_individual": "AI Assist in Overleaf is included as part of your Writefull subscription. You can cancel or manage your access to AI Assist in your Writefull subscription settings.", "ai_assistance_to_help_you": "AI assistance to help you fix LaTeX errors", "ai_based_language_tools": "AI-based language tools tailored to research writing", "ai_can_make_mistakes": "AI can make mistakes. Review fixes before you apply them.", @@ -145,16 +145,15 @@ "all_premium_features": "All premium features", "all_premium_features_including": "All premium features, including:", "all_prices_displayed_are_in_currency": "All prices displayed are in __recommendedCurrency__.", - "all_projects": "All Projects", + "all_projects": "All projects", "all_projects_will_be_transferred_immediately": "All projects will be transferred to the new owner immediately.", - "all_templates": "All Templates", + "all_templates": "All templates", "all_the_pros_of_our_standard_plan_plus_unlimited_collab": "All the pros of our standard plan, plus unlimited collaborators per project.", "all_these_experiments_are_available_exclusively": "All these experiments are available exclusively to members of the Labs program. If you sign up, you can choose which experiments you want to try.", "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "Allows to search by author, title, etc. Possible to pull results directly from your reference manager (if connected).", "already_have_a_papers_account": "Managing your citations and bibliographies in Overleaf just got way easier! Already have a Papers account? <0>Link your account here.", "already_have_an_account": "Already have an account?", "already_have_sl_account": "Already have an __appName__ account?", - "already_subscribed_try_refreshing_the_page": "Already subscribed? Try refreshing the page.", "also": "Also", "alternatively_create_new_institution_account": "Alternatively, you can create a new account with your institution email (__email__) by clicking __clickText__.", "an_email_has_already_been_sent_to": "An email has already been sent to <0>__email__. Please wait and try again later.", @@ -174,9 +173,9 @@ "apply_suggestion": "Apply suggestion", "april": "April", "archive": "Archive", - "archive_projects": "Archive Projects", + "archive_projects": "Archive projects", "archived": "Archived", - "archived_projects": "Archived Projects", + "archived_projects": "Archived projects", "archiving_projects_wont_affect_collaborators": "Archiving projects won’t affect your collaborators.", "are_you_affiliated_with_an_institution": "Are you affiliated with an institution?", "are_you_getting_an_undefined_control_sequence_error": "Are you getting an Undefined Control Sequence error? If you are, make sure you’ve loaded the graphicx package—<0>\\usepackage{graphicx}—in the preamble (first section of code) in your document. <1>Learn more", @@ -196,7 +195,7 @@ "august": "August", "author": "Author", "auto_close_brackets": "Auto-close brackets", - "auto_compile": "Auto Compile", + "auto_compile": "Auto compile", "auto_complete": "Auto-complete", "autocompile": "Autocompile", "autocompile_disabled": "Autocompile disabled", @@ -213,7 +212,7 @@ "back_to_configuration": "Back to configuration", "back_to_editor": "Back to editor", "back_to_log_in": "Back to log in", - "back_to_subscription": "Back to Subscription", + "back_to_subscription": "Back to subscription", "back_to_your_projects": "Back to your projects", "basic": "Basic", "basic_compile_time": "Basic compile time", @@ -236,10 +235,11 @@ "billing": "Billing", "billing_period_sentence_case": "Billing period", "binary_history_error": "Preview not available for this file type", - "blank_project": "Blank Project", + "blank_project": "Blank project", "blocked_filename": "This file name is blocked.", "blog": "Blog", "bold": "Bold", + "booktabs": "Booktabs", "brl_discount_offer_plans_page_banner": "__flag__ Great news! We’ve applied a 50% discount to premium plans on this page for our users in Brazil. Check out the new lower prices.", "browser": "Browser", "built_in": "Built-In", @@ -251,7 +251,6 @@ "by_joining_labs": "By joining Labs, you agree to receive occasional emails and updates from Overleaf—for example, to request your feedback. You also agree to our <0>terms of service and <1>privacy notice.", "by_registering_you_agree_to_our_terms_of_service": "By registering, you agree to our <0>terms of service and <1>privacy notice.", "by_subscribing_you_agree_to_our_terms_of_service": "By subscribing, you agree to our <0>terms of service.", - "can_edit_content": "Can edit content", "can_link_institution_email_acct_to_institution_acct": "You can now link your __email__ __appName__ account to your __institutionName__ institutional account.", "can_link_institution_email_by_clicking": "You can link your __email__ __appName__ account to your __institutionName__ account by clicking __clickText__.", "can_link_institution_email_to_login": "You can link your __email__ __appName__ account to your __institutionName__ account, which will allow you to log in to __appName__ through your institution and will reconfirm your institutional email address.", @@ -274,6 +273,7 @@ "cant_see_what_youre_looking_for_question": "Can’t see what you’re looking for?", "caption_above": "Caption above", "caption_below": "Caption below", + "captions": "Captions", "card_details": "Card details", "card_details_are_not_valid": "Card details are not valid", "card_must_be_authenticated_by_3dsecure": "Your card must be authenticated with 3D Secure before continuing", @@ -289,17 +289,18 @@ "certificate": "Certificate", "change": "Change", "change_currency": "Change currency", + "change_email": "Change email", "change_language": "Change language", "change_or_cancel-cancel": "cancel", "change_or_cancel-change": "Change", "change_or_cancel-or": "or", "change_owner": "Change owner", - "change_password": "Change Password", - "change_password_in_account_settings": "Change password in Account Settings", + "change_password": "Change password", + "change_password_in_account_settings": "Change password in Account settings", "change_plan": "Change plan", "change_primary_email": "Change primary email", - "change_primary_email_address_instructions": "To change your primary email, please add your new primary email address first (by clicking <0>Add another email) and confirm it. Then click the <0>Make Primary button. <1>Learn more about managing your __appName__ emails.", - "change_project_owner": "Change Project Owner", + "change_primary_email_address_instructions": "To change your primary email, please add your new primary email address first (by clicking <0>Add another email) and confirm it. Then click the <0>Make primary button. <1>Learn more about managing your __appName__ emails.", + "change_project_owner": "Change project owner", "change_the_ownership_of_your_personal_projects": "Change the ownership of your personal projects to the new account. <0>Find out how to change project owner.", "change_to_group_plan": "Change to a group plan", "change_to_this_plan": "Change to this plan", @@ -321,15 +322,15 @@ "city": "City", "clear_cached_files": "Clear cached files", "clear_search": "clear search", - "clear_sessions": "Clear Sessions", - "clear_sessions_description": "This is a list of other sessions (logins) which are active on your account, not including your current session. Click the \"Clear Sessions\" button below to log them out.", - "clear_sessions_success": "Sessions Cleared", + "clear_sessions": "Clear sessions", + "clear_sessions_description": "This is a list of other sessions (logins) which are active on your account, not including your current session. Click the \"Clear sessions\" button below to log them out.", + "clear_sessions_success": "Sessions cleared", "clearing": "Clearing", "click_here_to_view_sl_in_lng": "Click here to use __appName__ in <0>__lngName__", "click_link_to_proceed": "Click __clickText__ below to proceed.", "click_to_give_feedback": "Click to give feedback.", "click_to_unpause": "Click to unpause and reactivate your Overleaf premium features.", - "clicking_delete_will_remove_sso_config_and_clear_saml_data": "Clicking <0>Delete will remove your SSO configuration and unlink all users. You can only do this when SSO is disabled in your Group settings.", + "clicking_delete_will_remove_sso_config_and_clear_saml_data": "Clicking <0>Delete will remove your SSO configuration and unlink all users. You can only do this when SSO is disabled in your group settings.", "clone_with_git": "Clone with Git", "close": "Close", "clsi_maintenance": "The compile servers are down for maintenance, and will be back shortly.", @@ -360,34 +361,34 @@ "community_articles": "Community articles", "community_articles_lowercase": "community articles", "compact": "Compact", - "company_name": "Company Name", + "company_name": "Company name", "compare": "Compare", "compare_all_plans": "Compare all plans on our <0>pricing page", "compare_features": "Compare features", "comparing_from_x_to_y": "Comparing from <0>__startTime__ to <0>__endTime__", "compile_error_entry_description": "An error which prevented this project from compiling", - "compile_error_handling": "Compile Error Handling", + "compile_error_handling": "Compile error handling", "compile_larger_projects": "Compile larger projects", - "compile_mode": "Compile Mode", + "compile_mode": "Compile mode", "compile_servers": "Compile servers", "compile_servers_info_new": "The servers used to compile your project. Compiles for users on paid plans always run on the fastest available servers.", - "compile_terminated_by_user": "The compile was cancelled using the ‘Stop Compilation’ button. You can download the raw logs to see where the compile stopped.", + "compile_terminated_by_user": "The compile was cancelled using the ‘Stop compilation’ button. You can download the raw logs to see where the compile stopped.", "compile_timeout_short": "Compile timeout", "compile_timeout_short_info_new": "This is how much time you get to compile your project on Overleaf. You may need additional time for longer or more complex projects.", "compiler": "Compiler", "compiling": "Compiling", "complete": "Complete", "compliance": "Compliance", - "compromised_password": "Compromised Password", + "compromised_password": "Compromised password", "configure_sso": "Configure SSO", "configured": "Configured", "confirm": "Confirm", "confirm_accept_selected_changes": "Are you sure you want to accept the selected change?", "confirm_accept_selected_changes_plural": "Are you sure you want to accept the selected __count__ changes?", - "confirm_affiliation": "Confirm Affiliation", + "confirm_affiliation": "Confirm affiliation", "confirm_affiliation_to_relink_dropbox": "Please confirm you are still at the institution and on their license, or upgrade your account in order to relink your Dropbox account.", "confirm_delete_user_type_email_address": "To confirm you want to delete __userName__ please type the email address associated with their account", - "confirm_email": "Confirm Email", + "confirm_email": "Confirm email", "confirm_new_password": "Confirm new password", "confirm_primary_email_change": "Confirm primary email change", "confirm_reject_selected_changes": "Are you sure you want to reject the selected change?", @@ -401,16 +402,16 @@ "conflicting_paths_found": "Conflicting Paths Found", "congratulations_youve_successfully_join_group": "Congratulations! You‘ve successfully joined the group subscription.", "connect_overleaf_with_github": "Connect __appName__ with GitHub for easy project syncing and real-time version control.", - "connected_users": "Connected Users", + "connected_users": "Connected users", "connecting": "Connecting", "connection_lost_with_unsaved_changes": "Connection lost with unsaved changes.", "contact": "Contact", "contact_group_admin": "Please contact your group administrator.", "contact_message_label": "Message", - "contact_sales": "Contact Sales", + "contact_sales": "Contact sales", "contact_support": "Contact Support", - "contact_support_to_change_group_subscription": "Please <0>contact support if you wish to change your group subscription.", - "contact_us": "Contact Us", + "contact_support_to_change_group_subscription": "Please <0>contact Support if you wish to change your group subscription.", + "contact_us": "Contact us", "contact_us_lowercase": "Contact us", "contacting_the_sales_team": "Contacting the Sales team", "continue": "Continue", @@ -422,7 +423,7 @@ "copied": "Copied", "copy": "Copy", "copy_code": "Copy code", - "copy_project": "Copy Project", + "copy_project": "Copy project", "copy_response": "Copy response", "copying": "Copying", "cost_summary": "Cost summary", @@ -444,6 +445,7 @@ "create_new_subscription": "Create new subscription", "create_new_tag": "Create new tag", "create_project_in_github": "Create a GitHub repository", + "created": "Created", "created_at": "Created at", "creating": "Creating", "credit_card": "Credit Card", @@ -476,7 +478,7 @@ "dedicated_account_manager": "Dedicated account manager", "default": "Default", "delete": "Delete", - "delete_account": "Delete Account", + "delete_account": "Delete account", "delete_account_confirmation_label": "I understand this will delete all projects in my __appName__ account with email address <0>__userDefaultEmail__", "delete_account_warning_message_3": "You are about to permanently delete all of your account data, including your projects and settings. Please type your account email address and password in the boxes below to proceed.", "delete_acct_no_existing_pw": "Please use the password reset form to set a password before deleting your account", @@ -520,6 +522,7 @@ "disable_equation_preview": "Disable equation preview", "disable_equation_preview_confirm": "This will disable equation preview for you in all projects.", "disable_equation_preview_enable": "You can enable it again from the Menu.", + "disable_equation_preview_enable_in_settings": "You can enable it again in Settings.", "disable_single_sign_on": "Disable single sign-on", "disable_sso": "Disable SSO", "disable_stop_on_first_error": "Disable “Stop on first error”", @@ -558,6 +561,7 @@ "dont_forget_you_currently_have": "Don’t forget, you currently have:", "dont_have_account": "Don’t have an account?", "dont_reload_or_close_this_tab": "Don’t reload or close this tab.", + "double_clicking_on_the_pdf_shows": "Double clicking on the PDF shows the corresponding location in code. Added word count (7 May 2025)", "download": "Download", "download_all": "Download all", "download_as_pdf": "Download as PDF", @@ -604,6 +608,7 @@ "edit": "Edit", "edit_comment_error_message": "There was an error editing the comment. Please try again in a few moments.", "edit_comment_error_title": "Edit Comment Error", + "edit_content_directly": "Edit content directly", "edit_dictionary": "Edit Dictionary", "edit_dictionary_empty": "Your custom dictionary is empty.", "edit_dictionary_remove": "Remove from dictionary", @@ -649,7 +654,7 @@ "email_sent": "Email Sent", "emails": "Emails", "emails_and_affiliations_explanation": "Add additional email addresses to your account to access any upgrades your university or institution has, to make it easier for collaborators to find you, and to make sure you can recover your account.", - "emails_and_affiliations_title": "Emails and Affiliations", + "emails_and_affiliations_title": "Emails and affiliations", "empty": "Empty", "empty_zip_file": "Zip doesn’t contain any file", "en": "English", @@ -690,7 +695,7 @@ "everything_in_group_standard_plus": "Everything in Group Standard, plus…", "everything_in_standard_plus": "Everything in Standard, plus…", "example": "Example", - "example_project": "Example Project", + "example_project": "Example project", "examples": "Examples", "examples_lowercase": "examples", "examples_to_help_you_learn": "Examples to help you learn how to use powerful LaTeX packages and techniques.", @@ -768,6 +773,7 @@ "footer_contact_us": "Contact us", "footer_navigation": "Footer navigation", "footer_plans_and_pricing": "Plans & pricing", + "footnotes": "Footnotes", "for_business": "For business", "for_enterprise": "For enterprise", "for_government": "For government", @@ -812,6 +818,7 @@ "gallery_show_more_tags": "Show more", "general": "General", "generate_from_text_or_image": "From text or image", + "generate_tables_and_equations": "Generate tables and equations from text and images. Try it for free in the Overleaf toolbar!", "generate_token": "Generate token", "generic_if_problem_continues_contact_us": "If the problem continues please contact us", "generic_linked_file_compile_error": "This project’s output files are not available because it failed to compile. Please open the project to see the compilation error details.", @@ -823,7 +830,9 @@ "get_in_touch": "Get in touch", "get_in_touch_having_problems": "Get in touch with support if you’re having problems", "get_involved": "Get involved", - "get_most_subscription_discover_premium_features": "Get the most from your __appName__ subscription. <0>Discover premium features.", + "get_most_subscription_by_checking_ai_writefull": "Get the most out of your subscription by checking out <0>Overleaf’s AI features and <1>Writefull’s features.", + "get_most_subscription_by_checking_overleaf": "Get the most out of your subscription by checking out <0>Overleaf’s features.", + "get_most_subscription_by_checking_overleaf_ai_writefull": "Get the most out of your subscription by checking out <0>Overleaf’s features, <1>Overleaf’s AI features and <2>Writefull’s features.", "get_real_time_track_changes": "Get real-time track changes", "get_the_best_overleaf_experience": "Get the best Overleaf experience", "get_the_most_out_headline": "Get the most out of __appName__ with features such as:", @@ -831,17 +840,17 @@ "git_authentication_token": "Git authentication token", "git_authentication_token_create_modal_info_1": "This is your Git authentication token. You should enter this when prompted for a password.", "git_authentication_token_create_modal_info_2": "<0>You will only see this authentication token once so please copy it and keep it safe. For full instructions on using authentication tokens, visit our <1>help page.", - "git_bridge_modal_click_generate": "Click Generate token to generate your authentication token now. Or do this later in your Account Settings.", + "git_bridge_modal_click_generate": "Click Generate token to generate your authentication token now. Or do this later in your Account settings.", "git_bridge_modal_enter_authentication_token": "When prompted for a password, enter your new authentication token:", "git_bridge_modal_git_clone_your_project": "Git clone your project by using the link below and a Git authentication token", "git_bridge_modal_learn_more_about_authentication_tokens": "Learn more about Git integration authentication tokens.", "git_bridge_modal_read_only": "You have read-only access to this project. This means you can pull from __appName__ but you can’t push any changes you make back to this project.", "git_bridge_modal_review_access": "<0>You have review access to this project. This means you can pull from __appName__ but you can’t push any changes you make back to this project.", - "git_bridge_modal_see_once": "You’ll only see this token once. To delete it or generate a new one, visit Account Settings. For detailed instructions and troubleshooting, read our <0>help page.", - "git_bridge_modal_use_previous_token": "If you’re prompted for a password, you can use a previously generated Git authentication token. Or you can generate a new one in Account Settings. For more support, read our <0>help page.", - "git_gitHub_dropbox_mendeley_papers_and_zotero_integrations": "Git, GitHub, Dropbox, Mendeley, Papers, and Zotero integrations", - "git_integration": "Git Integration", - "git_integration_info": "With Git integration, you can clone your Overleaf projects with Git. For full instructions on how to do this, read <0>our help page.", + "git_bridge_modal_see_once": "You’ll only see this token once. To delete it or generate a new one, visit Account settings. For detailed instructions and troubleshooting, read our <0>help page.", + "git_bridge_modal_use_previous_token": "If you’re prompted for a password, you can use a previously generated Git authentication token. Or you can generate a new one in Account settings. For more support, read our <0>help page.", + "git_gitHub_dropbox_mendeley_papers_and_zotero_integrations": "Git, GitHub, Dropbox, Papers, Zotero, and Mendeley integrations", + "git_integration": "Git integration", + "git_integration_info": "With Git integration, you can clone your Overleaf projects with Git. For full instructions on how to do this, <0>read our Git Integration help page.", "github": "GitHub", "github_commit_message_placeholder": "Commit message for changes made in __appName__...", "github_credentials_expired": "Your GitHub authorization credentials have expired", @@ -872,13 +881,14 @@ "github_workflow_files_delete_github_repo": "The repository has been created on GitHub but linking was unsuccessful. You will have to delete GitHub repository or choose a new name.", "github_workflow_files_error": "The __appName__ GitHub sync service couldn’t sync GitHub Workflow files (in .github/workflows/). Please authorize __appName__ to edit your GitHub workflow files and try again.", "give_feedback": "Give feedback", + "give_feedback_about": "Give feedback about __appName__", "give_your_feedback": "give your feedback", "global": "global", "go_back_and_link_accts": "Go back and link your accounts", "go_next_page": "Go to Next Page", "go_page": "Go to page __page__", "go_prev_page": "Go to Previous Page", - "go_to_account_settings": "Go to Account Settings", + "go_to_account_settings": "Go to Account settings", "go_to_code_location_in_pdf": "Go to code location in PDF", "go_to_first_page": "Go to first page", "go_to_last_page": "Go to last page", @@ -1105,6 +1115,7 @@ "it": "Italian", "it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "It looks like that didn’t work. You can try again or <0>get in touch with our Support team for more help.", "it_looks_like_your_account_is_billed_manually": "It looks like your account is being billed manually - adding seats or upgrading your subscription can only be done by the Support team. Please <0>get in touch for help.", + "it_looks_like_your_account_is_billed_manually_upgrading_subscription": "It looks like your account is being billed manually - upgrading your subscription can only be done by the Support team. Please <0>get in touch for help.", "it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "It looks like your payment details are missing. Please <0>update your billing information, or <1>get in touch with our Support team for more help.", "italics": "Italics", "ja": "Japanese", @@ -1143,16 +1154,17 @@ "last_active_description": "Last time a project was opened.", "last_edit": "Last edit", "last_logged_in": "Last logged in", - "last_modified": "Last Modified", + "last_modified": "Last modified", "last_name": "Last name", "last_resort_trouble_shooting_guide": "If that doesn’t help, follow our <0>troubleshooting guide.", "last_suggested_fix": "Last suggested fix", "last_updated": "Last Updated", "last_updated_date_by_x": "__lastUpdatedDate__ by __person__", - "last_used": "last used", + "last_used": "Last used", "latam_discount_modal_info": "Unlock the full potential of Overleaf with a __discount__% discount on premium subscriptions paid in __currencyName__. Get a longer compile timeout, full document history, track changes, additional collaborators, and more.", "latam_discount_modal_title": "Premium subscription discount", "latam_discount_offer_plans_page_banner": "__flag__ We’ve applied a __discount__ discount to premium plans on this page for our users in __country__. Check out the new lower prices (in __currency__).", + "latest_updates": "Latest updates", "latex_articles_page_title": "Articles - Papers, Presentations, Reports and more", "latex_examples": "LaTeX examples", "latex_examples_page_title": "Examples - Equations, Formatting, TikZ, Packages and More", @@ -1170,6 +1182,7 @@ "ldap_create_admin_instructions": "Choose an email address for the first __appName__ admin account. This should correspond to an account in the LDAP system. You will then be asked to log in with this account.", "learn": "Learn", "learn_more": "Learn more", + "learn_more_about": "Learn more about __appName__", "learn_more_about_account": "<0>Learn more about managing your __appName__ account.", "learn_more_about_compile_timeouts": "<0>Learn more about compile timeouts.", "learn_more_about_emails": "<0>Learn more about managing your __appName__ emails.", @@ -1196,16 +1209,12 @@ "limited_document_history": "Limited document history", "limited_to_n_collaborators_per_project": "Limited to __count__ collaborator per project", "limited_to_n_collaborators_per_project_plural": "Limited to __count__ collaborators per project", - "limited_to_n_editors": "Limited to __count__ editor", - "limited_to_n_editors_per_project": "Limited to __count__ editor per project", - "limited_to_n_editors_per_project_plural": "Limited to __count__ editors per project", - "limited_to_n_editors_plural": "Limited to __count__ editors", "line_height": "Line Height", "line_width_is_the_width_of_the_line_in_the_current_environment": "Line width is the width of the line in the current environment. e.g. a full page width in single-column layout or half a page width in a two-column layout.", "link": "Link", "link_account": "Link Account", - "link_accounts": "Link Accounts", - "link_accounts_and_add_email": "Link Accounts and Add Email", + "link_accounts": "Link accounts", + "link_accounts_and_add_email": "Link accounts and add email", "link_institutional_email_get_started": "Link an institutional email address to your account to get started.", "link_overleaf_with_git": "Link __appName__ with Git for seamless project syncing and version control across your repositories.", "link_sharing": "Link sharing", @@ -1217,7 +1226,7 @@ "link_to_papers": "Link to Papers", "link_to_zotero": "Link to Zotero", "link_your_accounts": "Link your accounts", - "linked_accounts": "linked accounts", + "linked_accounts": "Linked accounts", "linked_accounts_explained": "You can link your __appName__ account with other services to enable the features described below.", "linked_collabratec_description": "Use Collabratec to manage your __appName__ projects.", "linked_file": "Imported file", @@ -1279,8 +1288,8 @@ "make_a_copy": "Make a copy", "make_email_primary_description": "Make this the primary email, used to log in", "make_owner": "Make owner", - "make_primary": "Make Primary", - "make_private": "Make Private", + "make_primary": "Make primary", + "make_private": "Make private", "manage_beta_program_membership": "Manage Beta Program Membership", "manage_files_from_your_dropbox_folder": "Manage files from your Dropbox folder", "manage_group_members_subtext": "Add or remove members from your group subscription", @@ -1321,7 +1330,7 @@ "mendeley_dynamic_sync_description": "With the Mendeley integration, you can import your references into __appName__. You can either import all your references at once or dynamically search your Mendeley library directly from __appName__.", "mendeley_groups_loading_error": "There was an error loading groups from Mendeley", "mendeley_groups_relink": "There was an error accessing your Mendeley data. This was likely caused by lack of permissions. Please re-link your account and try again.", - "mendeley_integration": "Mendeley Integration", + "mendeley_integration": "Mendeley integration", "mendeley_is_premium": "Mendeley integration is a premium feature", "mendeley_reference_loading_error": "Error, could not load references from Mendeley", "mendeley_reference_loading_error_expired": "Mendeley token expired, please re-link your account", @@ -1343,14 +1352,12 @@ "monthly": "Monthly", "more": "More", "more_actions": "More actions", - "more_changes_based_on_your_feedback": "More changes based on your feedback!", "more_collabs_per_project": "More collaborators per project", "more_comments": "More comments", "more_compile_time": "More compile time", "more_editor_toolbar_item": "More editor toolbar items", "more_info": "More Info", "more_options": "More options", - "more_options_for_border_settings_coming_soon": "More options for border settings coming soon.", "more_project_collaborators": "<0>More project <0>collaborators", "more_than_one_kind_of_snippet_was_requested": "The link to open this content on Overleaf included some invalid parameters. If this keeps happening for links on a particular site, please report this to them.", "most_popular_uppercase": "Most popular", @@ -1359,6 +1366,8 @@ "my_library": "My Library", "n_items": "__count__ item", "n_items_plural": "__count__ items", + "n_more_collaborators": "__count__ more collaborator", + "n_more_collaborators_plural": "__count__ more collaborators", "n_more_updates_above": "__count__ more update above", "n_more_updates_above_plural": "__count__ more updates above", "n_more_updates_below": "__count__ more update below", @@ -1370,7 +1379,7 @@ "navigate_log_source": "Navigate to log position in source code: __location__", "navigation": "Navigation", "nearly_activated": "You’re one step away from activating your __appName__ account!", - "need_20_plus_users_discount": "20+ users? <0>Contact Sales to get the best discounts.", + "need_20_plus_users_discount": "20+ users? <0>Contact sales to get the best discounts.", "need_anything_contact_us_at": "If there is anything you ever need please feel free to contact us directly at", "need_contact_group_admin_to_make_changes": "You’ll need to contact your group admin if you want to make certain changes to your account. <0>Read more about managed users.", "need_make_changes": "You need to make some changes", @@ -1379,6 +1388,7 @@ "need_to_leave": "Need to leave?", "neither_agree_nor_disagree": "Neither agree nor disagree", "new_compile_domain_notice": "We’ve recently migrated PDF downloads to a new domain. Something might be blocking your browser from accessing that new domain, <0>__compilesUserContentDomain__. This could be caused by network blocking or a strict browser plugin rule. Please follow our <1>troubleshooting guide.", + "new_create_tables_and_equations": "NEW! Create tables and equations in seconds", "new_file": "New file", "new_folder": "New folder", "new_font_open_dyslexic": "New font: OpenDyslexic Mono is designed to improve readability for those with dyslexia.", @@ -1388,10 +1398,11 @@ "new_navigation_introducing_left_hand_side_rail_and_top_menus": "New navigation - introducing left-hand side rail and top menus", "new_overleaf_editor": "New Overleaf editor", "new_password": "New password", - "new_project": "New Project", + "new_project": "New project", + "new_project_name": "New project name", "new_snippet_project": "Untitled", "new_subscription_will_be_billed_immediately": "Your new subscription will be billed immediately to your current payment method.", - "new_tag": "New Tag", + "new_tag": "New tag", "new_tag_name": "New tag name", "newsletter": "Newsletter", "newsletter_info_note": "Please note: you will still receive important emails, such as project invites and security notifications (password resets, account linking, etc).", @@ -1536,7 +1547,7 @@ "papers_dynamic_sync_description": "With the Papers integration, you can import your references into __appName__. You can either import all your references at once or dynamically search your Papers library directly from __appName__.", "papers_groups_loading_error": "There was an error loading libraries from Papers", "papers_groups_relink": "There was an error accessing your Papers data. This was likely caused by lack of permissions. Please re-link your account and try again.", - "papers_integration": "Papers Integration", + "papers_integration": "Papers integration", "papers_is_premium": "Papers integration is a premium feature", "papers_presentations_reports_and_more": "Papers, presentations, reports and more, written in LaTeX and published by our community.", "papers_reference_loading_error": "Error, could not load references from Papers", @@ -1592,6 +1603,7 @@ "per_license": "per license", "per_month": "per month", "per_month_billed_annually": "per month, billed annually", + "per_month_x_annually": "per month, __price__ annually", "per_user_month": "per user / month", "per_user_year": "per user / year", "per_year": "per year", @@ -1618,7 +1630,7 @@ "please_confirm_primary_email": "Please confirm your primary email address __emailAddress__ by clicking on the link in the confirmation email.", "please_confirm_secondary_email": "Please confirm your secondary email address __emailAddress__ by clicking on the link in the confirmation email.", "please_confirm_your_email_before_making_it_default": "Please confirm your email before making it the primary.", - "please_contact_support_to_makes_change_to_your_plan": "Please <0>contact support to make changes to your plan", + "please_contact_support_to_makes_change_to_your_plan": "Please <0>contact Support to make changes to your plan", "please_contact_us_if_you_think_this_is_in_error": "Please <0>contact us if you think this is in error.", "please_enter_confirmation_code": "Please enter your confirmation code", "please_enter_email": "Please enter your email address", @@ -1642,6 +1654,8 @@ "plus_more": "plus more", "plus_x_additional_licenses_for_a_total_of_y_licenses": "Plus <0>__additionalLicenses__ additional license(s) for a total of <1>__count__ licenses", "po_number": "PO Number", + "po_number_can_include_digits_and_letters_only": "PO number can include digits and letters only", + "po_number_must_not_exceed_x_characters": "PO number must not exceed __count__ characters", "popular_tags": "Popular Tags", "portal_add_affiliation_to_join": "It looks like you are already logged in to __appName__. If you have a __portalTitle__ email you can add it now.", "position": "Position", @@ -1698,7 +1712,7 @@ "project_search_file_count_plural": "in __count__ files", "project_search_result_count": "__count__ result", "project_search_result_count_plural": "__count__ results", - "project_synchronisation": "Project Synchronisation", + "project_synchronisation": "Project synchronisation", "project_timed_out_enable_stop_on_first_error": "<0>Enable “Stop on first error” to help you find and fix errors right away.", "project_timed_out_fatal_error": "A <0>fatal compile error may be completely blocking compilation.", "project_timed_out_intro": "Sorry, your compile took too long to run and timed out. The most common causes of timeouts are:", @@ -1779,7 +1793,7 @@ "regards": "Regards", "register": "Register", "register_error": "Registration error", - "register_intercept_sso": "You can link your __authProviderName__ account from the Account Settings page after logging in.", + "register_intercept_sso": "You can link your __authProviderName__ account from the Account settings page after logging in.", "register_to_accept_invitation": "Register to accept invitation", "register_to_edit_template": "Please register to edit the __templateName__ template", "register_with_another_email": "Register with __appName__ using another email.", @@ -1873,7 +1887,6 @@ "review": "Review", "review_panel": "Review panel", "review_panel_and_error_logs_moved_to_the_left": "Review panel and error logs moved to the left", - "review_your_peers_work": "Review your peers’ work", "reviewer": "Reviewer", "reviewer_dropbox_sync_message": "As a reviewer you can sync the current project version to Dropbox, but changes made in Dropbox will <0>not sync back to Overleaf.", "reviewing": "Reviewing", @@ -1901,7 +1914,6 @@ "saml_response": "SAML Response", "save": "Save", "save_20_percent": "save 20%", - "save_20_percent_when_you_switch_to_annual": "Save 20% when you switch to annual", "save_or_cancel-cancel": "Cancel", "save_or_cancel-or": "or", "save_or_cancel-save": "Save", @@ -1938,7 +1950,6 @@ "searched_path_for_lines_containing": "Searched __path__ for lines containing \"__query__\"", "secondary_email_password_reset": "That email is registered as a secondary email. Please enter the primary email for your account.", "security": "Security", - "see_changes_in_your_documents_live": "See changes in your documents, live", "see_suggestions_from_collaborators": "See suggestions from collaborators", "select_a_column_or_a_merged_cell_to_align": "Select a column or a merged cell to align", "select_a_column_to_adjust_column_width": "Select a column to adjust column width", @@ -2037,7 +2048,7 @@ "skip_to_content": "Skip to content", "something_not_right": "Something’s not right", "something_went_wrong": "Something went wrong", - "something_went_wrong_canceling_your_subscription": "Something went wrong canceling your subscription. Please contact support.", + "something_went_wrong_canceling_your_subscription": "Something went wrong canceling your subscription. Please contact Support.", "something_went_wrong_loading_pdf_viewer": "Something went wrong loading the PDF viewer. This might be caused by issues like <0>temporary network problems or an <0>outdated web browser. Please follow the <1>troubleshooting steps for access, loading and display problems. If the issue persists, please <2>let us know.", "something_went_wrong_processing_the_request": "Something went wrong processing the request", "something_went_wrong_rendering_pdf": "Something went wrong while rendering this PDF.", @@ -2170,7 +2181,6 @@ "sure_you_want_to_delete": "Are you sure you want to permanently delete the following files?", "sure_you_want_to_leave_group": "Are you sure you want to leave this group?", "sv": "Swedish", - "switch_back_to_monthly_pay_20_more": "Switch back to monthly (20% more)", "switch_compile_mode_for_faster_draft_compilation": "Switch compile mode for faster draft compilation", "switch_to_editor": "Switch to editor", "switch_to_new_editor": "Switch to new editor", @@ -2199,8 +2209,6 @@ "take_me_home": "Take me home!", "take_short_survey": "Take a short survey", "take_survey": "Take survey", - "tc_everyone": "Everyone", - "tc_guests": "Guests", "tell_the_project_owner_and_ask_them_to_upgrade": "<0>Tell the project owner and ask them to upgrade their Overleaf plan if you need more compile time.", "template": "Template", "template_approved_by_publisher": "This template has been approved by the publisher", @@ -2223,6 +2231,7 @@ "test_configuration_successful": "Test configuration successful", "tex_live_version": "TeX Live version", "texgpt": "TexGPT", + "text": "Text", "thank_you": "Thank you!", "thank_you_email_confirmed": "Thank you, your email is now confirmed", "thank_you_exclamation": "Thank you!", @@ -2266,7 +2275,7 @@ "then_x_price_per_year": "Then __price__ per year", "there_are_lots_of_options_to_edit_and_customize_your_figures": "There are lots of options to edit and customize your figures, such as wrapping text around the figure, rotating the image, or including multiple images in a single figure. You’ll need to edit the LaTeX code to do this. <0>Find out how", "there_is_an_unrecoverable_latex_error": "There is an unrecoverable LaTeX error. If there are LaTeX errors shown below or in the raw logs, please try to fix them and compile again.", - "there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us": "There was a problem restoring the project. Please try again in a few moments. Contact us of the problem persists.", + "there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us": "There was a problem restoring the project. Please try again in a few moments. Contact us if the problem persists.", "there_was_an_error_opening_your_content": "There was an error creating your project", "thesis": "Thesis", "they_lose_access_to_account": "They lose all access to this Overleaf account immediately", @@ -2318,7 +2327,7 @@ "to_use_text_wrapping_in_your_table_make_sure_you_include_the_array_package": "<0>Please note: To use text wrapping in your table, make sure you include the <1>array package in your document preamble:", "toggle_compile_options_menu": "Toggle compile options menu", "toggle_unknown_group": "Toggle unknown group", - "token": "token", + "token": "Token", "token_access_failure": "Cannot grant access; contact the project owner for help", "token_limit_reached": "You’ve reached the 10 token limit. To generate a new authentication token, please delete an existing one.", "token_read_only": "token read-only", @@ -2377,6 +2386,7 @@ "tooltip_show_pdf": "Click to show the PDF", "top_pick": "Top pick", "total": "Total", + "total_due_in_x_days": "Total due in __days__ days", "total_due_today": "Total due today", "total_per_month": "Total per month", "total_per_year": "Total per year", @@ -2385,12 +2395,7 @@ "total_with_subtotal_and_tax": "Total: <0>__total__ (__subtotal__ + __tax__ tax) per year", "total_words": "Total Words", "tr": "Turkish", - "track_any_change_in_real_time": "Track any change, in real-time", "track_changes": "Track changes", - "track_changes_for_everyone": "Track changes for everyone", - "track_changes_for_x": "Track changes for __name__", - "track_changes_is_off": "Track changes is off", - "track_changes_is_on": "Track changes is on", "tracked_change_added": "Added", "tracked_change_deleted": "Deleted", "transfer_management_of_your_account": "Transfer management of your Overleaf account", @@ -2402,7 +2407,7 @@ "trash": "Trash", "trash_projects": "Trash Projects", "trashed": "Trashed", - "trashed_projects": "Trashed Projects", + "trashed_projects": "Trashed projects", "trashing_projects_wont_affect_collaborators": "Trashing projects won’t affect your collaborators.", "trial_last_day": "This is the last day of your Overleaf Premium trial", "trial_remaining_days": "__days__ more days on your Overleaf Premium trial", @@ -2452,8 +2457,8 @@ "unlink_dropbox_warning": "Any projects that you have synced with Dropbox will be disconnected and no longer kept in sync with Dropbox. Are you sure you want to unlink your Dropbox account?", "unlink_github_repository": "Unlink GitHub repository", "unlink_github_warning": "Any projects that you have synced with GitHub will be disconnected and no longer kept in sync with GitHub. Are you sure you want to unlink your GitHub account?", - "unlink_linked_accounts": "Unlink any linked accounts (such as ORCID ID, IEEE). <0>Remove them in Account Settings (under Linked Accounts).", - "unlink_linked_google_account": "Unlink your Google account. <0>Remove it in Account Settings (under Linked Accounts).", + "unlink_linked_accounts": "Unlink any linked accounts (such as ORCID ID, IEEE). <0>Remove them in Account settings (under Linked Accounts).", + "unlink_linked_google_account": "Unlink your Google account. <0>Remove it in Account settings (under Linked Accounts).", "unlink_provider_account_title": "Unlink __provider__ Account", "unlink_provider_account_warning": "Warning: When you unlink your account from __provider__ you will not be able to sign in using __provider__ anymore.", "unlink_reference": "Unlink References Provider", @@ -2473,7 +2478,7 @@ "until_then_you_can_still": "Until then you can still:", "untrash": "Restore", "update": "Update", - "update_account_info": "Update Account Info", + "update_account_info": "Update account info", "update_dropbox_settings": "Update Dropbox Settings", "update_your_billing_details": "Update your billing details", "updates_to_project_sharing": "Updates to project sharing", @@ -2489,14 +2494,13 @@ "upgrade_to_add_more_collaborators_and_access_collaboration_features": "Upgrade to add more collaborators and access collaboration features like track changes and full project history.", "upgrade_to_get_feature": "Upgrade to get __feature__, plus:", "upgrade_to_review": "Upgrade to Review", - "upgrade_to_track_changes": "Upgrade to track changes", "upgrade_to_unlock_more_time": "Upgrade now to unlock 12x more compile time on our fastest servers.", "upgrade_your_subscription": "Upgrade your subscription", "upload": "Upload", "upload_failed": "Upload failed", "upload_file": "Upload file", "upload_from_computer": "Upload from computer", - "upload_project": "Upload Project", + "upload_project": "Upload project", "upload_zipped_project": "Upload Zipped Project", "url_to_fetch_the_file_from": "URL to fetch the file from", "us_gov_banner_fedramp": "<0>Now FedRAMP® authorized for LI-SaaS: Overleaf’s Group Professional subscription. Need an air-gapped deployment? We offer an on-premises solution too. Talk to our US federal government team.", @@ -2537,7 +2541,7 @@ "vat_number": "VAT Number", "verify_email_address_before_enabling_managed_users": "You need to verify your email address before enabling managed users.", "view": "View", - "view_all": "View All", + "view_all": "View all", "view_billing_details": "View billing details", "view_code": "View code", "view_configuration": "View configuration", @@ -2581,6 +2585,7 @@ "we_sent_new_code": "We’ve sent a new code. If it doesn’t arrive, make sure to check your spam and any promotions folders.", "we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months": "We’ll charge you now for the cost of your additional licenses based on the remaining months of your current subscription.", "we_will_charge_you_now_for_your_new_plan_based_on_the_remaining_months_of_your_current_subscription": "We’ll charge you now for your new plan based on the remaining months of your current subscription.", + "we_will_invoice_you_now_for_the_additional_licenses_based_on_remaining_months": "We’ll invoice you now for the additional licences based on the remaining months of your current subscription, and payment will be due in __days__ days.", "we_will_use_your_existing_payment_method": "We’ll use your existing payment method __paymentMethod__.", "webinars": "Webinars", "website_status": "Website status", @@ -2598,8 +2603,7 @@ "what_does_this_mean_for_you": "This means:", "what_happens_when_sso_is_enabled": "What happens when SSO is enabled?", "what_should_we_call_you": "What should we call you?", - "whats_new": "What’s new?", - "whats_next": "What’s next?", + "whats_different_in_the_new_editor": "What’s different in the new editor?", "when_you_join_labs": "When you join Labs, you can choose which experiments you want to be part of. Once you’ve done that, you can use Overleaf as normal, but you’ll see any labs features marked with this badge:", "when_you_tick_the_include_caption_box": "When you tick the box “Include caption” the image will be inserted into your document with a placeholder caption. To edit it, you simply select the placeholder text and type to replace it with your own.", "why_latex": "Why LaTeX?", @@ -2610,6 +2614,7 @@ "will_need_to_log_out_from_and_in_with": "You will need to log out from your __email1__ account and then log in with __email2__.", "with_premium_subscription_you_also_get": "With an Overleaf Premium subscription you also get", "word_count": "Word Count", + "word_count_lower": "Word count", "work_in_vim_or_emacs_emulation_mode": "Work in Vim or Emacs emulation mode", "work_offline": "Work offline", "work_offline_pull_to_overleaf": "Work offline, then pull to __appName__", @@ -2642,15 +2647,14 @@ "you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "You are a <1>manager of the <0>__planName__ group subscription <1>__groupName__ administered by <1>__adminEmail__.", "you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z_you": "You are a <1>manager of the <0>__planName__ group subscription <1>__groupName__ administered by <1>you (__adminEmail__).", "you_are_currently_logged_in_as": "You are currently logged in as __email__.", - "you_are_now_saving_20_percent": "You are now saving 20%", - "you_are_on_a_paid_plan_contact_support_to_find_out_more": "You’re on an __appName__ Paid plan. <0>Contact support to find out more.", + "you_are_on_a_paid_plan_contact_support_to_find_out_more": "You’re on an __appName__ Paid plan. <0>Contact Support to find out more.", "you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "You are on our <0>__planName__ plan as a <1>confirmed member of <1>__institutionName__", "you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "You are on our <0>__planName__ plan as a <1>member of the group subscription <1>__groupName__ administered by <1>__adminEmail__", "you_can_also_choose_to_view_anonymously_or_leave_the_project": "You can also choose to <0>view anonymously (you will lose edit access) or <1>leave the project.", "you_can_buy_this_plan_but_not_as_a_trial": "You can buy this plan but not as a trial, as you’ve completed a trial recently.", "you_can_leave_the_experiment_from_your_account_settings_at_any_time": "You can leave the experiment from your <0>account settings at any time.", "you_can_manage_your_reference_manager_integrations_from_your_account_settings_page": "You can manage your reference manager integrations from your <0>account settings page.", - "you_can_now_enable_sso": "You can now enable SSO on your Group settings page.", + "you_can_now_enable_sso": "You can now enable SSO on your group settings page.", "you_can_now_log_in_sso": "You can now log in through your institution and if eligible you will receive <0>__appName__ Professional features.", "you_can_now_sync_your_papers_library_directly_with_your_overleaf_projects": "You can now sync your Papers library directly with your Overleaf projects", "you_can_opt_in_and_out_of_the_program_at_any_time_on_this_page": "You can <0>opt in and out of the program at any time on this page", @@ -2713,7 +2717,7 @@ "your_project_exceeded_compile_timeout_limit_on_free_plan": "Your project exceeded the compile timeout limit on our free plan.", "your_project_near_compile_timeout_limit": "Your project is near the compile timeout limit for our free plan.", "your_project_need_more_time_to_compile": "It looks like your project may need more time to compile than our free plan allows.", - "your_projects": "Your Projects", + "your_projects": "Your projects", "your_questions_answered": "Your questions answered", "your_role": "Your role", "your_sessions": "Your Sessions", @@ -2748,7 +2752,7 @@ "zotero_dynamic_sync_description": "With the Zotero integration, you can import your references into __appName__. You can either import all your references at once or dynamically search your Zotero library directly from __appName__.", "zotero_groups_loading_error": "There was an error loading groups from Zotero", "zotero_groups_relink": "There was an error accessing your Zotero data. This was likely caused by lack of permissions. Please re-link your account and try again.", - "zotero_integration": "Zotero Integration", + "zotero_integration": "Zotero integration", "zotero_is_premium": "Zotero integration is a premium feature", "zotero_reference_loading_error": "Error, could not load references from Zotero", "zotero_reference_loading_error_expired": "Zotero token expired, please re-link your account", diff --git a/services/web/locales/es.json b/services/web/locales/es.json index ec1a9d59bc..0565d9d541 100644 --- a/services/web/locales/es.json +++ b/services/web/locales/es.json @@ -32,7 +32,6 @@ "accept_and_continue": "Aceptar y continuar", "accept_change": "Aceptar cambio", "accept_invitation": "Aceptar invitación", - "accept_or_reject_each_changes_individually": "Aceptar o rechazar cada cambio individualmente", "accept_terms_and_conditions": "Aceptar términos y condiciones", "accepted_invite": "Invitación aceptada", "accepting_invite_as": "Estás aceptando esta invitación como ", @@ -112,7 +111,6 @@ "all_these_experiments_are_available_exclusively": "Todos estos experimentos están disponibles exclusivamente para los miembros del programa Labs. Si te inscribes, puedes elegir qué experimentos quieres probar.", "already_have_an_account": "¿Ya tiene una cuenta?", "already_have_sl_account": "¿Ya tienes una cuenta de __appName__?", - "already_subscribed_try_refreshing_the_page": "¿Ya estás suscrito? Prueba a actualizar la página.", "also": "También", "alternatively_create_new_institution_account": "Alternativamente, puede crear una nueva cuenta con su correo institucional (__email__) haciendo click en __clickText__.", "an_email_has_already_been_sent_to": "Ya se ha enviado un correo electrónico a <0>__email__. Espere e inténtelo de nuevo más tarde.", diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json index 2b006f75ad..b60de8ed5a 100644 --- a/services/web/locales/fr.json +++ b/services/web/locales/fr.json @@ -33,7 +33,6 @@ "accept_change": "Accepter les changements", "accept_change_error_description": "Une erreur s’est produite lors de l’acceptation d’un suivi de modification. Veuillez réessayer dans quelques instants.", "accept_invitation": "Accepter l’invitation", - "accept_or_reject_each_changes_individually": "Acceptez ou rejetez chaque modification individuellement", "accept_terms_and_conditions": "Accepter les termes et conditions", "accepted_invite": "Invitation acceptée", "accepting_invite_as": "Vous allez accepter cette invitation en tant que", @@ -971,7 +970,6 @@ "return_to_login_page": "Retourner à la page de connexion", "revert_pending_plan_change": "Annuler la modification prévue d’offre", "review": "Relire", - "review_your_peers_work": "Relisez le travail de vos pairs", "revoke": "Révoquer", "revoke_invite": "Retirer l’invitation", "ro": "Roumain", @@ -996,7 +994,6 @@ "search_references": "Rechercher les fichiers .bib dans ce projet", "secondary_email_password_reset": "Cette adresse courriel est une adresse secondaire. Veuillez saisir l’adresse principale associée à votre compte.", "security": "Sécurité", - "see_changes_in_your_documents_live": "Observez les modifications dans vos documents, en direct", "select_a_file": "Choisir un fichier", "select_a_project": "Choisir un projet", "select_all_projects": "Tout sélectionner", @@ -1082,8 +1079,6 @@ "tag_name_is_already_used": "L’étiquette \"__tagName__\" existe déjà", "tags": "Étiquettes", "take_me_home": "Retour à la maison !", - "tc_everyone": "Tout le monde", - "tc_guests": "Invités", "template_description": "Description des modèles", "template_gallery": "Galerie de modèles", "template_not_found_description": "Cette méthode de création de projets à partir de modèles n’est plus disponible. Merci de vous rendre sur notre galerie des modèles pour trouver d’autres modèles.", @@ -1140,10 +1135,7 @@ "tooltip_show_pdf": "Cliquez pour afficher le PDF", "total_words": "Total des mots", "tr": "Turque", - "track_any_change_in_real_time": "Suivez toute modification, en temps réel", "track_changes": "Suivre les modifications", - "track_changes_is_off": "Le suivi des modifs. est off", - "track_changes_is_on": "Le suivi des modifs. est on", "tracked_change_added": "Ajout de", "tracked_change_deleted": "Suppression de", "trash": "Corbeille", @@ -1188,7 +1180,6 @@ "upgrade_cc_btn": "Mettez à niveau maintenant, payez dans 7 jours", "upgrade_now": "Mettre à niveau maintenant", "upgrade_to_get_feature": "Mettre à niveau pour profiter de __feature__, plus :", - "upgrade_to_track_changes": "Mettez à niveau pour suivre les modifications", "upload": "Importer", "upload_failed": "Échec du téléversement", "upload_file": "Importer un fichier", diff --git a/services/web/locales/ko.json b/services/web/locales/ko.json index 4f4e26af03..cb320bd996 100644 --- a/services/web/locales/ko.json +++ b/services/web/locales/ko.json @@ -12,7 +12,6 @@ "about_to_delete_projects": "다음과 같은 프로젝트를 삭제하려 합니다:", "about_to_leave_projects": "다음과 같은 프로젝트를 나가려고합니다:", "accept": "승락", - "accept_or_reject_each_changes_individually": "각각의 변경 사항 승락 또는 거절", "accepting_invite_as": "다음 이메일로 온 초대를 승락합니다.", "account": "계정", "account_not_linked_to_dropbox": "계정이 Dropbox에 연결되지 않았습니다", @@ -421,7 +420,6 @@ "restricted_no_permission": "이 페이지를 불러올 권한이 없습니다.", "return_to_login_page": "로그인 페이지로 이동", "review": "검토", - "review_your_peers_work": "동료의 작업 검토", "revoke_invite": "초대 취소", "ro": "로마니아어", "role": "역할", @@ -434,7 +432,6 @@ "search_projects": "프로젝트 검색", "search_references": "이 프로젝트에서 .bib 파일 검색", "security": "보안", - "see_changes_in_your_documents_live": "문서 변경사항 실시간으로 보기", "select_all_projects": "전체 선택", "select_github_repository": "__appName__에 불러올 GitHub 저장소를 선택합니다.", "send": "발신", @@ -483,8 +480,6 @@ "sync_to_github": "GitHub 동기화", "syntax_validation": "코드 확인", "take_me_home": "홈으로!", - "tc_everyone": "모든 사람", - "tc_guests": "게스트", "template_description": "템플릿 설명", "templates": "템플릿", "terminated": "컴파일 취소됨", @@ -513,10 +508,7 @@ "tooltip_show_pdf": "PDF를 보려면 클릭", "total_words": "총 단어 수", "tr": "Türkçe", - "track_any_change_in_real_time": "실시간으로 모든 변경 사항 추적", "track_changes": "변경 내용 추적", - "track_changes_is_off": "변경 내용 추적 꺼짐", - "track_changes_is_on": "변경 내용 추적 사용", "tracked_change_added": "추가됨", "tracked_change_deleted": "삭제됨", "try_it_for_free": "무료로 사용해보세요", @@ -547,7 +539,6 @@ "upgrade_cc_btn": "지금 업그레이드하고 7일 후 지불", "upgrade_now": "지금 업그레이드", "upgrade_to_get_feature": "__feature__와 다음 기능 사용을 위해 업그레이드:", - "upgrade_to_track_changes": "변경 내용 추적을 위해 업그레이드", "upload": "업로드", "upload_file": "파일 업로드", "upload_project": "프로젝트 업로드", diff --git a/services/web/locales/nl.json b/services/web/locales/nl.json index 39ab174b4d..14e0d7b703 100644 --- a/services/web/locales/nl.json +++ b/services/web/locales/nl.json @@ -14,7 +14,6 @@ "about_to_leave_projects": "Je staat op het punt de volgende projecten te verlaten:", "accept": "Accepteer", "accept_invitation": "Accepteer de uitnodiging", - "accept_or_reject_each_changes_individually": "Accepteer of verwerp iedere wijziging individueel", "accepted_invite": "Uitnodiging geaccepteerd", "accepting_invite_as": "U accepteert deze uitnodiging als", "account": "Account", @@ -428,7 +427,6 @@ "restricted_no_permission": "Beperkt, sorry. Je hebt geen toegang tot deze pagina.", "return_to_login_page": "Keer terug naar inlogpagina", "review": "Review", - "review_your_peers_work": "Review werk van uw collega’s", "revoke_invite": "Herroep uitnodiging", "ro": "Roemeens", "role": "Functie", @@ -444,7 +442,6 @@ "search_projects": "Projecten zoeken", "search_references": "Doorzoek het .bib bestand in dit project", "security": "Veiligheid", - "see_changes_in_your_documents_live": "Zie verandering in uw documenten, live", "select_github_repository": "Selecteer een GitHub repository om naar __appName__ te importeren.", "send": "Verstuur", "send_first_message": "Verzend je eerste bericht", @@ -517,9 +514,6 @@ "too_recently_compiled": "Dit project is recentelijk nog gecompileerd, daarom is het nu overgeslagen.", "total_words": "Aantal woorden", "tr": "Turks", - "track_any_change_in_real_time": "Hou alle veranderingen bij, in realtime", - "track_changes_is_off": "Wijzigingen bijhouden staat uit", - "track_changes_is_on": "Wijzigingen bijhouden staat aan", "tracked_change_added": "Toegevoegd", "tracked_change_deleted": "Verwijderd", "try_it_for_free": "Probeer het gratis", @@ -547,7 +541,6 @@ "upgrade_cc_btn": "Upgrade nu, betaal na 7 dagen", "upgrade_now": "Upgrade Nu", "upgrade_to_get_feature": "Upgrade om __feature__ te krijgen, plus:", - "upgrade_to_track_changes": "Upgrade naar Wijzigingen Bijhouden", "upload": "Uploaden", "upload_file": "Bestand Uploaden", "upload_project": "Project Uploaden", diff --git a/services/web/locales/pt.json b/services/web/locales/pt.json index 55272084c5..8d2455c369 100644 --- a/services/web/locales/pt.json +++ b/services/web/locales/pt.json @@ -14,7 +14,6 @@ "about_to_leave_projects": "Você está prestes à deixar de seguir os projetos:", "accept": "Aceitar", "accept_invitation": "Aceitar convite", - "accept_or_reject_each_changes_individually": "Aceitar ou rejeitar cada alteração individualmente", "accepted_invite": "Convite aceito", "accepting_invite_as": "Você está aceitando esse convite como", "account": "Conta", @@ -510,7 +509,6 @@ "restricted_no_permission": "Restrito, desculpe você não tem permissão para carregar essa página.", "return_to_login_page": "Retornar à página de Login", "review": "Revisar", - "review_your_peers_work": "Revisar o trabalho de seus colegas", "revoke_invite": "Revogar Convite", "ro": "Romeno", "role": "Papel", @@ -526,7 +524,6 @@ "search_projects": "Buscar projetos", "search_references": "Buscar os arquivos .bib no projeto", "security": "Segurança", - "see_changes_in_your_documents_live": "Ver alterações nos seus documentos, ao vivo", "select_all_projects": "Selecionar todos", "select_github_repository": "Selecione um repositório no GitHub para importar para o __appName__.", "send": "Enviar", @@ -579,8 +576,6 @@ "sync_to_github": "Sincronizar com GitHub", "syntax_validation": "Checar código", "take_me_home": "Ir para o início!", - "tc_everyone": "Todos", - "tc_guests": "Convidados", "template_description": "Descrição do Modelo", "templates": "Modelos", "terminated": "Compilação cancelada", @@ -615,10 +610,7 @@ "tooltip_show_pdf": "Clique para mostrar o PDF", "total_words": "Total de Palavras", "tr": "Turco", - "track_any_change_in_real_time": "Acompanhar qualquer alteração, em tempo real", "track_changes": "Acompanhe as mudanças", - "track_changes_is_off": "Controle de alterações está desligado", - "track_changes_is_on": "Controle de alterações está ligado", "tracked_change_added": "Adicionado", "tracked_change_deleted": "Deletado", "try_again": "Por favor, tente novamente", @@ -652,7 +644,6 @@ "upgrade_cc_btn": "Aprimorar agora, pague depois de 7 dias", "upgrade_now": "Aprimorar Agora", "upgrade_to_get_feature": "Aprimore para ter __feature__, mais:", - "upgrade_to_track_changes": "Atualizar para acompanhar alterações", "upload": "Carregar", "upload_file": "Atualizar Arquivo", "upload_project": "Carregar Projeto", diff --git a/services/web/locales/sv.json b/services/web/locales/sv.json index 70e35fdc8f..da03604b4b 100644 --- a/services/web/locales/sv.json +++ b/services/web/locales/sv.json @@ -16,7 +16,6 @@ "abstract": "Sammanfattning", "accept": "Acceptera", "accept_invitation": "Acceptera inbjudan", - "accept_or_reject_each_changes_individually": "Acceptera eller neka varje förändring för sig", "accepted_invite": "Accepterat inbjudan", "accepting_invite_as": "Du accepterar inbjudan som", "account": "Konto", @@ -717,7 +716,6 @@ "return_to_login_page": "Tillbaka till inloggningssidan", "reverse_x_sort_order": "Omvänd __x__-sortering", "review": "Granska", - "review_your_peers_work": "Granska dina medarbetares bidrag", "revoke": "Återkalla", "revoke_invite": "Återkalla inbjudan", "ro": "Rumänska", @@ -736,7 +734,6 @@ "search_references": "Sök i .bib filerna för det här projektet", "secondary_email_password_reset": "Denna e-postadress är registrerad som sekundär e-postadress. Vänligen ange primär e-postadress för ditt konto.", "security": "Säkerhet", - "see_changes_in_your_documents_live": "Se ändringar i dina dokument, i realtid", "select_a_project": "Välj ett projekt", "select_all_projects": "Välj alla", "select_an_output_file": "Välj en utdatafil", @@ -812,8 +809,6 @@ "tag_name_cannot_exceed_characters": "Taggnamnet får inte överstiga __maxLength__ tecken.", "take_me_home": "Ta mig härifrån!", "take_short_survey": "Gör en kort enkät", - "tc_everyone": "Alla", - "tc_guests": "Gäster", "template_approved_by_publisher": "Denna mall har godkänts av utgivaren", "template_description": "Mallbeskrivning", "template_gallery": "Mallgalleri", @@ -869,10 +864,7 @@ "total_per_year": "Totalt per år", "total_words": "Totalt antal ord", "tr": "Turkiska", - "track_any_change_in_real_time": "Spåra alla ändringar, i realtid", "track_changes": "Spåra ändringar", - "track_changes_is_off": "Spåra ändringar är av", - "track_changes_is_on": "Spåra ändringar är ", "tracked_change_added": "Tillagd", "tracked_change_deleted": "Raderad", "trash": "Papperskorg", @@ -914,7 +906,6 @@ "upgrade_cc_btn": "Uppgradera nu, betala efter 7 dagar", "upgrade_now": "Uppgradera Nu", "upgrade_to_get_feature": "Uppgradera för att få __feature__, plus:", - "upgrade_to_track_changes": "Uppgradera för att spåra ändringar", "upload": "Ladda upp", "upload_failed": "Uppladdning misslyckades", "upload_file": "Ladda upp fil", diff --git a/services/web/locales/zh-CN.json b/services/web/locales/zh-CN.json index 4f9399d132..6611d0f31d 100644 --- a/services/web/locales/zh-CN.json +++ b/services/web/locales/zh-CN.json @@ -34,7 +34,6 @@ "about_to_leave_project": "您即将离开此项目", "about_to_leave_projects": "您将离开下面的项目", "about_to_trash_projects": "您将要把以下项目移至回收站:", - "about_writefull": "关于 Writefull", "abstract": "摘要", "accept": "采纳", "accept_and_continue": "接受并继续", @@ -42,7 +41,6 @@ "accept_change_error_description": "接受跟踪更改时出现错误,请稍后重试。", "accept_change_error_title": "接受错误修改", "accept_invitation": "接受邀请", - "accept_or_reject_each_changes_individually": "接受或拒绝修改意见", "accept_or_reject_individual_edits": "接受或拒绝个别修改", "accept_selected_changes": "接受选定修改", "accept_terms_and_conditions": "接受条款和条件", @@ -95,8 +93,6 @@ "add_on": "插件", "add_ons": "插件", "add_or_remove_project_from_tag": "根据标记 __tagName__ 来添加或移除项目", - "add_overleaf_assist_to_your_group_subscription": "将 Overleaf Assist 添加到您的团体订阅", - "add_overleaf_assist_to_your_institution": "将 Overleaf Assist 添加到您的机构", "add_people": "添加人员", "add_role_and_department": "添加角色和部门", "add_to_dictionary": "添加到词典", @@ -149,7 +145,6 @@ "already_have_a_papers_account": "现在可以更轻松的在 Overleaf 中管理你的引文和参考书目!已有 Papers 帐户了吗?<0>在此关联你的帐户。", "already_have_an_account": "已经有一个账户啦?", "already_have_sl_account": "已经拥有 __appName__ 账户了吗?", - "already_subscribed_try_refreshing_the_page": "已经订阅啦?请刷新界面哦。", "also": "也", "alternatively_create_new_institution_account": "或者,您可以通过单击__clickText__来使用机构电子邮件(__email__)创建一个新帐户。", "an_email_has_already_been_sent_to": "一封电子邮件已经被发送给<0>__email__。请稍后再尝试。", @@ -246,7 +241,6 @@ "by_joining_labs": "加入实验室即表示您同意接收 Overleaf 不定期发送的电子邮件和更新信息(例如,征求您的反馈)。您还同意我们的<0>服务条款和<1>隐私声明。", "by_registering_you_agree_to_our_terms_of_service": "注册即表示您同意我们的 <0>服务条款 和 <1>隐私条款。", "by_subscribing_you_agree_to_our_terms_of_service": "订阅即表示您同意我们的<0>服务条款。", - "can_edit_content": "允许编辑", "can_link_institution_email_acct_to_institution_acct": "您现在可以将您的 __appName__ 账户 __email__ 与您的 __institutionName__ 机构账户关联。", "can_link_institution_email_by_clicking": "您可以通过单击 __clickText__ 将您的 __email__ __appName__ 账户链接到您的 __institutionName__ 帐户。", "can_link_institution_email_to_login": "您可以将您的 __email__ __appName__ 账户链接到你的 __institutionName__ 账户,这将允许您通过机构门户登录到__appName__ 。", @@ -815,7 +809,6 @@ "get_in_touch": "联系", "get_in_touch_having_problems": "如果遇到问题,请与支持部门联系", "get_involved": "加入我们", - "get_most_subscription_discover_premium_features": "充分利用您的 __appName__ 订阅。<0>探索高级功能。", "get_real_time_track_changes": "获取实时跟踪更改", "get_the_best_overleaf_experience": "获取最佳的 Overleaf 体验", "get_the_most_out_headline": "通过以下功能充分利用__appName__:", @@ -1183,10 +1176,6 @@ "limited_document_history": "有限的文档历史记录", "limited_to_n_collaborators_per_project": "每个项目仅限 __count__ 位合作者", "limited_to_n_collaborators_per_project_plural": "每个项目仅限 __count__ 位合作者", - "limited_to_n_editors": "仅限 __count__ 个编辑", - "limited_to_n_editors_per_project": "每个项目仅限 __count__ 个编辑者", - "limited_to_n_editors_per_project_plural": "每个项目最多可有 __count__ 名编辑者", - "limited_to_n_editors_plural": "仅限 __count__ 名编辑者", "line_height": "行高 (编辑器)", "line_width_is_the_width_of_the_line_in_the_current_environment": "行宽是当前环境下行的宽度。例如:单列布局中的全页宽度或两列布局中的半页宽度。", "link": "链接", @@ -1334,7 +1323,6 @@ "more_compile_time": "更长的编译时间", "more_info": "更多信息", "more_options": "更多选择", - "more_options_for_border_settings_coming_soon": "更多的边框设置选项即将推出。", "more_project_collaborators": "<0>更多项目<0>合作者", "more_than_one_kind_of_snippet_was_requested": "在Overleaf打开此内容的链接包含一些无效参数。如果某个网站的链接经常出现这种情况,请向他们报告。", "most_popular_uppercase": "最受欢迎的", @@ -1847,7 +1835,6 @@ "revert_pending_plan_change": "撤销计划的套餐更改", "review": "审阅", "review_panel": "审阅面板", - "review_your_peers_work": "同行评议", "reviewer": "审阅者", "reviewer_dropbox_sync_message": "作为审阅者,您可以将当前项目版本同步到 Dropbox,但在 Dropbox 中所做的更改<0>不会同步回 Overleaf。", "reviewing": "审阅", @@ -1912,7 +1899,6 @@ "searched_path_for_lines_containing": "在 __path__ 中搜索包含“__query__”的行", "secondary_email_password_reset": "该电子邮件已注册为辅助电子邮件。请输入您帐户的主要电子邮件。", "security": "安全性", - "see_changes_in_your_documents_live": "实时查看文档修改情况", "see_suggestions_from_collaborators": "查看合作者的建议", "select_a_column_or_a_merged_cell_to_align": "选择要对齐的列或合并的单元格", "select_a_column_to_adjust_column_width": "选择一列来调整列宽", @@ -2170,8 +2156,6 @@ "take_me_home": "我要返回!", "take_short_survey": "做一个简短的调查", "take_survey": "参加调查", - "tc_everyone": "所有人", - "tc_guests": "受邀用户", "tell_the_project_owner_and_ask_them_to_upgrade": "如果您需要更多编译时间,<0>告诉项目所有者并要求他们升级其 Overleaf 计划。", "template": "模版", "template_approved_by_publisher": "该模板已获得发布者批准", @@ -2355,12 +2339,7 @@ "total_with_subtotal_and_tax": "总计:每年 <0> __total__ (__subtotal__ + __tax__税)", "total_words": "总字数", "tr": "土耳其语", - "track_any_change_in_real_time": "实时记录文档的任何修改情况", "track_changes": "修订", - "track_changes_for_everyone": "跟踪每个人的更改", - "track_changes_for_x": "跟踪 __name__ 的更改", - "track_changes_is_off": "修改追踪功能 关闭", - "track_changes_is_on": "修改追踪功能 开启", "tracked_change_added": "已添加", "tracked_change_deleted": "已删除", "transfer_management_of_your_account": "Overleaf 账户的转移管理", @@ -2459,7 +2438,6 @@ "upgrade_to_add_more_collaborators_and_access_collaboration_features": "升级以添加更多合作者并访问协作功能,如跟踪更改和完整的项目历史记录。", "upgrade_to_get_feature": "升级以获得__feature__,以及:", "upgrade_to_review": "升级以获取评论", - "upgrade_to_track_changes": "升级以记录文档修改历史", "upgrade_to_unlock_more_time": "立即升级即可在我们最快的服务器上解锁 12 倍以上的编译时间。", "upgrade_your_subscription": "升级您的订阅", "upload": "上传", @@ -2567,8 +2545,6 @@ "what_does_this_mean_for_you": "这意味着:", "what_happens_when_sso_is_enabled": "开启单点登录后会发生什么?", "what_should_we_call_you": "我们该怎么称呼你?", - "whats_new": "有什么新功能?", - "whats_next": "接下来?", "when_you_join_labs": "加入实验室后,您可以选择要参与的实验。完成此操作后,您可以正常使用 Overleaf,但您会看到所有实验室功能都标有此徽章:", "when_you_tick_the_include_caption_box": "当您勾选“包含标题”框时,图像将带有占位符标题插入到文档中。 要编辑它,您只需选择占位符文本并键入以将其替换为您自己的文本。", "why_latex": "为何用 LaTeX?", diff --git a/services/web/migrations/20250409155536_group_audit_log_index.mjs b/services/web/migrations/20250409155536_group_audit_log_index.mjs new file mode 100644 index 0000000000..282b3c6d2d --- /dev/null +++ b/services/web/migrations/20250409155536_group_audit_log_index.mjs @@ -0,0 +1,35 @@ +/* eslint-disable no-unused-vars */ + +import Helpers from './lib/helpers.mjs' + +const tags = ['saas'] + +const indexes = [ + { + key: { + groupId: 1, + timestamp: 1, + }, + name: 'groupId_1_timestamp_1', + }, +] + +const migrate = async client => { + const { db } = client + await Helpers.addIndexesToCollection(db.groupAuditLogEntries, indexes) +} + +const rollback = async client => { + const { db } = client + try { + await Helpers.dropIndexesFromCollection(db.groupAuditLogEntries, indexes) + } catch (err) { + console.error('Something went wrong rolling back the migrations', err) + } +} + +export default { + tags, + migrate, + rollback, +} diff --git a/services/web/package.json b/services/web/package.json index 679c226556..5080813d55 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -94,7 +94,7 @@ "ajv": "^8.12.0", "archiver": "^5.3.0", "async": "^3.2.5", - "base-x": "^4.0.0", + "base-x": "^4.0.1", "basic-auth": "^2.0.1", "bcrypt": "^5.0.0", "body-parser": "^1.20.3", @@ -140,7 +140,7 @@ "moment": "^2.29.4", "mongodb-legacy": "6.1.3", "mongoose": "8.9.5", - "multer": "overleaf/multer#e1df247fbf8e7590520d20ae3601eaef9f3d2e9e", + "multer": "overleaf/multer#199c5ff05bd375c508f4074498237baead7f5148", "nocache": "^2.1.0", "node-fetch": "^2.7.0", "nodemailer": "^6.7.0", @@ -165,7 +165,7 @@ "request": "^2.88.2", "requestretry": "^7.1.0", "sanitize-html": "^2.8.1", - "stripe": "^17.7.0", + "stripe": "^18.1.0", "tough-cookie": "^4.0.0", "tsscmp": "^1.0.6", "uid-safe": "^2.1.5", @@ -185,19 +185,19 @@ "@babel/preset-typescript": "^7.27.0", "@babel/register": "^7.25.9", "@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#6445cd056671c98d12d1c597ba705e11327ec4c5", - "@codemirror/commands": "^6.8.0", + "@codemirror/commands": "^6.8.1", "@codemirror/lang-markdown": "^6.3.2", - "@codemirror/language": "^6.10.8", - "@codemirror/lint": "^6.8.4", + "@codemirror/language": "^6.11.0", + "@codemirror/lint": "^6.8.5", "@codemirror/search": "github:overleaf/codemirror-search#04380a528c339cd4b78fb10b3ef017f657ec17bd", "@codemirror/state": "^6.5.2", - "@codemirror/view": "^6.36.3", + "@codemirror/view": "^6.36.8", "@juggle/resize-observer": "^3.3.1", "@lezer/common": "^1.2.3", - "@lezer/generator": "^1.7.1", + "@lezer/generator": "^1.7.3", "@lezer/highlight": "^1.2.1", "@lezer/lr": "^1.4.2", - "@lezer/markdown": "^1.3.2", + "@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/ranges-tracker": "*", @@ -261,7 +261,7 @@ "babel-plugin-module-resolver": "^5.0.2", "backbone": "^1.6.0", "bootstrap": "^3.4.1", - "bootstrap-5": "npm:bootstrap@^5.3.3", + "bootstrap-5": "npm:bootstrap@^5.3.6", "c8": "^7.2.0", "chai": "^4.3.6", "chai-as-promised": "^7.1.1", @@ -326,7 +326,7 @@ "prop-types": "^15.7.2", "qrcode": "^1.4.4", "react": "^18.3.1", - "react-bootstrap-5": "npm:react-bootstrap@^2.10.5", + "react-bootstrap": "^2.10.10", "react-chartjs-2": "^5.0.1", "react-color": "^2.19.3", "react-dnd": "^16.0.1", diff --git a/services/web/public/img/feature-page/feat-accept-poster.jpg b/services/web/public/img/feature-page/feat-accept-poster.jpg deleted file mode 100644 index bad07902f3..0000000000 Binary files a/services/web/public/img/feature-page/feat-accept-poster.jpg and /dev/null differ diff --git a/services/web/public/img/feature-page/feat-accept.mp4 b/services/web/public/img/feature-page/feat-accept.mp4 deleted file mode 100644 index 88285975ff..0000000000 Binary files a/services/web/public/img/feature-page/feat-accept.mp4 and /dev/null differ diff --git a/services/web/public/img/feature-page/feat-changes-poster.jpg b/services/web/public/img/feature-page/feat-changes-poster.jpg deleted file mode 100644 index 1303abbad6..0000000000 Binary files a/services/web/public/img/feature-page/feat-changes-poster.jpg and /dev/null differ diff --git a/services/web/public/img/feature-page/feat-changes.mp4 b/services/web/public/img/feature-page/feat-changes.mp4 deleted file mode 100644 index 8cced26501..0000000000 Binary files a/services/web/public/img/feature-page/feat-changes.mp4 and /dev/null differ diff --git a/services/web/public/img/feature-page/feat-discuss-poster.jpg b/services/web/public/img/feature-page/feat-discuss-poster.jpg deleted file mode 100644 index b632b395e4..0000000000 Binary files a/services/web/public/img/feature-page/feat-discuss-poster.jpg and /dev/null differ diff --git a/services/web/public/img/feature-page/feat-discuss.mp4 b/services/web/public/img/feature-page/feat-discuss.mp4 deleted file mode 100644 index bdb5c952b6..0000000000 Binary files a/services/web/public/img/feature-page/feat-discuss.mp4 and /dev/null differ diff --git a/services/web/public/img/feature-page/feat-todos-poster.jpg b/services/web/public/img/feature-page/feat-todos-poster.jpg deleted file mode 100644 index f221dba570..0000000000 Binary files a/services/web/public/img/feature-page/feat-todos-poster.jpg and /dev/null differ diff --git a/services/web/public/img/feature-page/feat-todos.mp4 b/services/web/public/img/feature-page/feat-todos.mp4 deleted file mode 100644 index 4e5d1bf452..0000000000 Binary files a/services/web/public/img/feature-page/feat-todos.mp4 and /dev/null differ diff --git a/services/web/public/img/feature-page/intro-poster.jpg b/services/web/public/img/feature-page/intro-poster.jpg deleted file mode 100644 index d228d6bd49..0000000000 Binary files a/services/web/public/img/feature-page/intro-poster.jpg and /dev/null differ diff --git a/services/web/public/img/feature-page/intro.mp4 b/services/web/public/img/feature-page/intro.mp4 deleted file mode 100644 index e36e1c84bf..0000000000 Binary files a/services/web/public/img/feature-page/intro.mp4 and /dev/null differ diff --git a/services/web/public/img/feature-page/pamela-marcum.jpg b/services/web/public/img/feature-page/pamela-marcum.jpg deleted file mode 100644 index 66741607d1..0000000000 Binary files a/services/web/public/img/feature-page/pamela-marcum.jpg and /dev/null differ diff --git a/services/web/public/img/website-redesign/stickers/journal-grey.svg b/services/web/public/img/website-redesign/stickers/journal-grey.svg new file mode 100644 index 0000000000..12dfc2de3c --- /dev/null +++ b/services/web/public/img/website-redesign/stickers/journal-grey.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/services/web/public/img/website-redesign/stickers/pen-yellow.svg b/services/web/public/img/website-redesign/stickers/pen-yellow.svg new file mode 100644 index 0000000000..ecd90f914c --- /dev/null +++ b/services/web/public/img/website-redesign/stickers/pen-yellow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/services/web/public/img/website-redesign/stickers/support-green.svg b/services/web/public/img/website-redesign/stickers/support-green.svg new file mode 100644 index 0000000000..29c97bded6 --- /dev/null +++ b/services/web/public/img/website-redesign/stickers/support-green.svg @@ -0,0 +1,4 @@ + + + + diff --git a/services/web/scripts/deactivate_projects.mjs b/services/web/scripts/deactivate_projects.mjs new file mode 100755 index 0000000000..b229af649d --- /dev/null +++ b/services/web/scripts/deactivate_projects.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node +import minimist from 'minimist' +import PQueue from 'p-queue' +import InactiveProjectManager from '../app/src/Features/InactiveData/InactiveProjectManager.js' +import { gracefulShutdown } from '../app/src/infrastructure/GracefulShutdown.js' +import logger from '@overleaf/logger' + +// Global variables for tracking job and error counts +let jobCount = 0 +let succeededCount = 0 +let skippedCount = 0 +let failedCount = 0 +let currentAgeInDays = null +let currentLastOpened = null +let DRY_RUN = false +let gracefulShutdownInitiated = false +const SCRIPT_START_TIME = Date.now() +const MAX_RUNTIME_DEFAULT = null +let MAX_RUNTIME = MAX_RUNTIME_DEFAULT // in milliseconds + +// Configure signal handling +process.on('SIGINT', handleSignal) +process.on('SIGTERM', handleSignal) +function handleSignal() { + if (gracefulShutdownInitiated) return + gracefulShutdownInitiated = true + logger.warn( + { gracefulShutdownInitiated }, + 'graceful shutdown initiated, draining queue' + ) +} + +// Check if max runtime has been exceeded +function hasMaxRuntimeExceeded() { + if (MAX_RUNTIME === null) return false + const elapsedTime = Date.now() - SCRIPT_START_TIME + const hasExceeded = elapsedTime >= MAX_RUNTIME + if (hasExceeded && !gracefulShutdownInitiated) { + gracefulShutdownInitiated = true + logger.warn( + { elapsedTimeMs: elapsedTime, maxRuntimeMs: MAX_RUNTIME }, + 'maximum runtime exceeded, initiating graceful shutdown' + ) + } + return hasExceeded +} + +// Calculates the age in days since the provided lastOpened date. +function getAgeFromLastOpened(lastOpened) { + const lastOpenedDate = new Date(lastOpened) + const now = new Date() + return Number(((now - lastOpenedDate) / (1000 * 60 * 60 * 24)).toFixed(2)) +} + +// Deactivates a single project and handles errors +async function deactivateSingleProject(project) { + const { _id: projectId, lastOpened } = project + jobCount++ + + if (lastOpened) { + currentLastOpened = lastOpened + currentAgeInDays = getAgeFromLastOpened(lastOpened) + } + + // Periodic progress logging + if (jobCount % 1000 === 0) { + logger.info( + { jobCount, failedCount, currentAgeInDays }, + 'project deactivation in progress' + ) + } + + // Debug level detail logging + logger.debug( + { projectId, jobCount, failedCount, dryRun: DRY_RUN }, + 'attempting to deactivate project' + ) + + // Dry run handling + if (DRY_RUN) { + logger.info({ projectId }, '[DRY RUN] would deactivate project') + succeededCount++ + } + + // Actual deactivation with error handling + try { + await InactiveProjectManager.promises.deactivateProject(projectId) + logger.debug({ projectId }, 'successfully deactivated project') + succeededCount++ + } catch (error) { + failedCount++ + logger.error({ projectId, err: error }, 'failed to deactivate project') + } +} + +// Centralized project processing function +async function processProjects(projectCursor, concurrency) { + const queue = new PQueue({ concurrency }) + for await (const project of projectCursor) { + if (gracefulShutdownInitiated || hasMaxRuntimeExceeded()) { + skippedCount++ + break + } + await queue.onEmpty() + logger.debug( + { queueSize: queue.size, queuePending: queue.pending }, + 'queue size before adding new job' + ) + queue.add(async () => { + await deactivateSingleProject(project) + }) + } + await queue.onIdle() +} + +const usage = ` +Usage: scripts/deactivate_projects.mjs [options] + +Options: + --limit Max number of projects to process (default: 10) + --daysOld Min age in days for a project to be considered inactive (default: 7) + --concurrency Number of deactivations to run in parallel (default: 1) + --max-time Maximum runtime in seconds before graceful shutdown (default: no limit) + --dry-run, -n Simulate deactivation without making changes (default: false) + --help Display this usage message +` + +async function main() { + const argv = minimist(process.argv.slice(2), { + string: ['limit', 'daysOld', 'concurrency', 'maxTime'], + boolean: ['dryRun', 'help'], + alias: { + dryRun: ['dry-run', 'n'], + maxTime: 'max-time', + help: 'h', + }, + default: { + limit: '10', + daysOld: '7', + concurrency: '1', + maxTime: '', + dryRun: false, + }, + }) + + if (argv.help || process.argv.length <= 2) { + console.log(usage) + process.exit(0) + } + + const limit = parseInt(argv.limit, 10) + const daysOld = parseInt(argv.daysOld, 10) + const concurrency = parseInt(argv.concurrency, 10) + const maxRuntimeInSeconds = parseInt(argv.maxTime, 10) + DRY_RUN = argv.dryRun + MAX_RUNTIME = maxRuntimeInSeconds * 1000 // Convert seconds to milliseconds + + if (DRY_RUN) { + logger.info( + {}, + 'DRY RUN MODE ENABLED: No actual deactivations will be performed' + ) + } + + logger.info( + { + limit, + daysOld, + concurrency, + dryRun: DRY_RUN, + maxRuntimeSeconds: maxRuntimeInSeconds || 'unlimited', + }, + 'finding inactive projects' + ) + + try { + // Find projects to deactivate + const projectCursor = await InactiveProjectManager.findInactiveProjects( + limit, + daysOld + ) + + // Process the projects + await processProjects(projectCursor, concurrency) + } catch (error) { + logger.error({ err: error }, 'critical error during script execution') + process.exitCode = 1 + } finally { + logger.info( + { + jobCount, + succeededCount, + failedCount, + skippedCount, + currentAgeInDays, + currentLastOpened, + elapsedTimeInSeconds: Math.floor( + (Date.now() - SCRIPT_START_TIME) / 1000 + ), + maxRuntimeInSeconds: maxRuntimeInSeconds || 'unlimited', + }, + 'project deactivation process completed' + ) + } +} + +main() + .then(async () => { + await gracefulShutdown() + }) + .catch(err => { + logger.fatal({ err }, 'unhandled error in main execution') + process.exit(1) + }) diff --git a/services/web/scripts/fix_collaborator_refs_null.mjs b/services/web/scripts/fix_collaborator_refs_null.mjs new file mode 100644 index 0000000000..062662a5f8 --- /dev/null +++ b/services/web/scripts/fix_collaborator_refs_null.mjs @@ -0,0 +1,175 @@ +import minimist from 'minimist' +import { + db, + ObjectId, + READ_PREFERENCE_SECONDARY, +} from '../app/src/infrastructure/mongodb.js' +import lodash from 'lodash' + +const args = minimist(process.argv.slice(2), { + boolean: ['commit'], +}) + +const run = async () => { + try { + const projects = await db.projectAuditLogEntries + .find( + { + operation: 'collaborator-limit-exceeded', + timestamp: { + $gte: new Date('2025-03-26'), + $lt: new Date('2025-04-02'), + }, + }, + { + readPreference: READ_PREFERENCE_SECONDARY, + projection: { + _id: 1, + projectId: 1, + }, + } + ) + .toArray() + + const uniqueProjectIds = lodash.uniq( + projects.map(p => p.projectId.toString()) + ) + + console.log( + `Found ${uniqueProjectIds.length} projects where collaborator-limit-exceeded operation was logged in provided date range` + ) + + let readOnlyCount = 0 + let pendingReviewerCount = 0 + let reviewerCount = 0 + + for (const projectId of uniqueProjectIds) { + if (args.commit) { + const readOnlyUpdate = await db.projects.updateOne( + { + _id: new ObjectId(projectId), + readOnly_refs: null, + }, + { + $set: { + readOnly_refs: [], + }, + } + ) + if (readOnlyUpdate.modifiedCount > 0) { + console.log(`Updated readOnly_refs for project id ${projectId}`) + } + readOnlyCount += readOnlyUpdate.modifiedCount + } else { + const project = await db.projects.findOne( + { + _id: new ObjectId(projectId), + readOnly_refs: null, + }, + { + projection: { + _id: 1, + readOnly_refs: 1, + }, + } + ) + if (project) { + readOnlyCount++ + console.log( + `Dry run: Would update readOnly_refs for project id ${projectId}` + ) + } + } + + if (args.commit) { + const pendingReviewerUpdate = await db.projects.updateOne( + { + _id: new ObjectId(projectId), + pendingReviewer_refs: null, + }, + { + $set: { + pendingReviewer_refs: [], + }, + } + ) + if (pendingReviewerUpdate.modifiedCount > 0) { + console.log( + `Updated pendingReviewer_refs for project id ${projectId}` + ) + } + pendingReviewerCount += pendingReviewerUpdate.modifiedCount + } else { + const project = await db.projects.findOne( + { + _id: new ObjectId(projectId), + pendingReviewer_refs: null, + }, + { + projection: { + _id: 1, + pendingReviewer_refs: 1, + }, + } + ) + if (project) { + pendingReviewerCount++ + console.log( + `Dry run: Would update pendingReviewer_refs for project id ${projectId}` + ) + } + } + + if (args.commit) { + const reviewerUpdate = await db.projects.updateOne( + { + _id: new ObjectId(projectId), + reviewer_refs: null, + }, + { + $set: { + reviewer_refs: [], + }, + } + ) + reviewerCount += reviewerUpdate.modifiedCount + } else { + const project = await db.projects.findOne( + { + _id: new ObjectId(projectId), + reviewer_refs: null, + }, + { + projection: { + _id: 1, + reviewer_refs: 1, + }, + } + ) + if (project) { + reviewerCount++ + console.log( + `Dry run: Would update reviewer_refs for project id ${projectId}` + ) + } + } + } + + if (args.commit) { + console.log( + `Updated readOnly_refs for ${readOnlyCount} projects, pendingReviewer_refs for ${pendingReviewerCount} projects, and reviewer_refs for ${reviewerCount} projects.` + ) + } else { + console.log( + `Dry run: Would update readOnly_refs for ${readOnlyCount} projects, pendingReviewer_refs for ${pendingReviewerCount} projects, and reviewer_refs for ${reviewerCount} projects.` + ) + } + + process.exit(0) + } catch (err) { + console.error('Error while processing projects:', err) + process.exit(1) + } +} + +run() diff --git a/services/web/scripts/oauth/register_client.mjs b/services/web/scripts/oauth/register_client.mjs index a3b798155b..8ca97f7321 100644 --- a/services/web/scripts/oauth/register_client.mjs +++ b/services/web/scripts/oauth/register_client.mjs @@ -34,31 +34,45 @@ async function upsertApplication(opts) { const key = { id: opts.id } const defaults = {} const updates = {} + if (opts.name != null) { updates.name = opts.name } + if (opts.secret != null) { updates.clientSecret = hashSecret(opts.secret) } + if (opts.grants != null) { updates.grants = opts.grants } else { defaults.grants = [] } + if (opts.scopes != null) { updates.scopes = opts.scopes } else { defaults.scopes = [] } + if (opts.redirectUris != null) { updates.redirectUris = opts.redirectUris } else { defaults.redirectUris = [] } + if (opts.mongoId != null) { defaults._id = new ObjectId(opts.mongoId) } + if (opts.enablePkce) { + updates.pkceEnabled = true + } + + if (opts.disablePkce) { + updates.pkceEnabled = false + } + await db.oauthApplications.updateOne( key, { @@ -71,17 +85,24 @@ async function upsertApplication(opts) { function parseArgs() { const args = minimist(process.argv.slice(2), { - boolean: ['help'], + boolean: ['help', 'enable-pkce', 'disable-pkce'], }) + if (args.help) { usage() process.exit(0) } + if (args._.length !== 1) { usage() process.exit(1) } + if (args['enable-pkce'] && args['disable-pkce']) { + console.error('Options --enable-pkce and --disable-pkce are exclusive') + process.exit(1) + } + return { id: args._[0], mongoId: args['mongo-id'], @@ -90,6 +111,8 @@ function parseArgs() { scopes: toArray(args.scope), grants: toArray(args.grant), redirectUris: toArray(args['redirect-uri']), + enablePkce: args['enable-pkce'], + disablePkce: args['disable-pkce'], } } @@ -105,6 +128,8 @@ Options: --grant Accepted grant type (can be given more than once) --redirect-uri Accepted redirect URI (can be given more than once) --mongo-id Mongo ID to use if the configuration is created (optional) + --enable-pkce Enable PKCE + --disable-pkce Disable PKCE `) } diff --git a/services/web/scripts/recurly/generate_addon_prices.mjs b/services/web/scripts/recurly/generate_addon_prices.mjs index 9885d46b06..37378e6baf 100644 --- a/services/web/scripts/recurly/generate_addon_prices.mjs +++ b/services/web/scripts/recurly/generate_addon_prices.mjs @@ -37,6 +37,8 @@ async function main() { localizedAddOnsPricing[currency] = { [ADD_ON_CODE]: {} } } localizedAddOnsPricing[currency][ADD_ON_CODE].annual = unitAmount + localizedAddOnsPricing[currency][ADD_ON_CODE].annualDividedByTwelve = + (unitAmount || 0) / 12 } console.log(JSON.stringify({ localizedAddOnsPricing }, null, 2)) diff --git a/services/web/scripts/remove_emails_with_commas.mjs b/services/web/scripts/remove_emails_with_commas.mjs deleted file mode 100644 index 29d78b129c..0000000000 --- a/services/web/scripts/remove_emails_with_commas.mjs +++ /dev/null @@ -1,124 +0,0 @@ -// @ts-check - -import minimist from 'minimist' -import fs from 'node:fs/promises' -import * as csv from 'csv' -import { promisify } from 'node:util' -import UserAuditLogHandler from '../app/src/Features/User/UserAuditLogHandler.js' -import { db } from '../app/src/infrastructure/mongodb.js' - -const CSV_FILENAME = '/tmp/emails-with-commas.csv' - -/** - * @type {(csvString: string) => Promise} - */ -const parseAsync = promisify(csv.parse) - -function usage() { - console.log('Usage: node remove_emails_with_commas.mjs') - console.log(`Read emails from ${CSV_FILENAME} and remove them from users.`) - console.log('Add support+@overleaf.com instead.') - console.log('Options:') - console.log(' --commit apply the changes\n') - process.exit(0) -} - -const { commit, help } = minimist(process.argv.slice(2), { - boolean: ['commit', 'help'], - alias: { help: 'h' }, - default: { commit: false }, -}) - -async function consumeCsvFileAndUpdate() { - console.time('remove_emails_with_commas') - - const csvContent = await fs.readFile(CSV_FILENAME, 'utf8') - const rows = await parseAsync(csvContent) - const emailsWithComma = rows.map(row => row[0]) - - console.log('Total emails in the CSV:', emailsWithComma.length) - - const unexpectedValidEmails = emailsWithComma.filter( - str => !str.includes(',') - ) - if (unexpectedValidEmails.length > 0) { - throw new Error( - 'CSV file contains unexpected valid emails: ' + - JSON.stringify(emailsWithComma) - ) - } - - let updatedUsersCount = 0 - for (const oldEmail of emailsWithComma) { - const encodedEmail = oldEmail - .replaceAll('_', '_5f') - .replaceAll('@', '_40') - .replaceAll(',', '_2c') - .replaceAll('<', '_60') - .replaceAll('>', '_62') - - const newEmail = `support+${encodedEmail}@overleaf.com` - - console.log(oldEmail, '->', newEmail) - - const user = await db.users.findOne({ email: oldEmail }) - - if (!user) { - console.log('User not found for email:', oldEmail) - continue - } - - if (commit) { - await db.users.updateOne( - { _id: user._id }, - { - $set: { email: newEmail }, - $pull: { emails: { email: oldEmail } }, - } - ) - await db.users.updateOne( - { _id: user._id }, - { - $addToSet: { - emails: { - email: newEmail, - createdAt: Date.now(), - reversedHostname: 'moc.faelrevo', - }, - }, - } - ) - - await UserAuditLogHandler.promises.addEntry( - user._id, - 'remove-email', - undefined, - undefined, - { - removedEmail: oldEmail, - script: true, - note: 'remove primary email containing commas', - } - ) - updatedUsersCount++ - } - } - - console.log('Updated users:', updatedUsersCount) - - if (!commit) { - console.log('Note: this was a dry-run. No changes were made.') - } - console.log() - console.timeEnd('remove_emails_with_commas') - console.log() -} - -try { - if (help) usage() - else await consumeCsvFileAndUpdate() - process.exit(0) -} catch (error) { - console.error(error) - process.exit(1) -} diff --git a/services/web/scripts/translations/Dockerfile b/services/web/scripts/translations/Dockerfile index e033989372..ada45efb8f 100644 --- a/services/web/scripts/translations/Dockerfile +++ b/services/web/scripts/translations/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.18.2 +FROM node:22.15.1 WORKDIR /app/scripts/translations diff --git a/services/web/test/acceptance/src/EditorHttpControllerTests.mjs b/services/web/test/acceptance/src/EditorHttpControllerTests.mjs index de38f79be4..6dfdcf0858 100644 --- a/services/web/test/acceptance/src/EditorHttpControllerTests.mjs +++ b/services/web/test/acceptance/src/EditorHttpControllerTests.mjs @@ -14,43 +14,14 @@ describe('EditorHttpController', function () { done() }) }) - beforeEach('create doc', function (done) { - this.user.createDocInProject( - this.projectId, - null, - 'potato.tex', - (error, docId) => { - this.docId = docId - done(error) - } - ) - }) - describe('joinProject', function () { - it('should emit an empty deletedDocs array', function (done) { + it('returns project details', function (done) { this.user.joinProject(this.projectId, (error, details) => { if (error) return done(error) - expect(details.project.deletedDocs).to.deep.equal([]) + expect(details.project.name).to.equal(this.projectName) done() }) }) - - describe('after deleting a doc', function () { - beforeEach(function (done) { - this.user.deleteItemInProject(this.projectId, 'doc', this.docId, done) - }) - - it('should include the deleted doc in the deletedDocs array', function (done) { - this.user.joinProject(this.projectId, (error, details) => { - if (error) return done(error) - - expect(details.project.deletedDocs).to.deep.equal([ - { _id: this.docId, name: 'potato.tex' }, - ]) - done() - }) - }) - }) }) }) diff --git a/services/web/test/acceptance/src/LinkedFilesTests.mjs b/services/web/test/acceptance/src/LinkedFilesTests.mjs index 2edcada015..8010aac407 100644 --- a/services/web/test/acceptance/src/LinkedFilesTests.mjs +++ b/services/web/test/acceptance/src/LinkedFilesTests.mjs @@ -443,7 +443,7 @@ describe('LinkedFiles', function () { projectTwoRootFolderId = projectTwo.rootFolder[0]._id.toString() }) - it('should import the project.pdf file from the source project and refresh it', async function () { + it('should import the output.pdf file from the source project and refresh it', async function () { // import the file let { response, body } = await owner.doRequest('post', { url: `/project/${projectOneId}/linked_file`, @@ -453,7 +453,7 @@ describe('LinkedFiles', function () { provider: 'project_output_file', data: { source_project_id: projectTwoId, - source_output_file_path: 'project.pdf', + source_output_file_path: 'output.pdf', build_id: '1234-abcd', }, }, @@ -468,7 +468,7 @@ describe('LinkedFiles', function () { expect(firstFile.linkedFileData).to.deep.equal({ provider: 'project_output_file', source_project_id: projectTwoId, - source_output_file_path: 'project.pdf', + source_output_file_path: 'output.pdf', build_id: '1234-abcd', importedAt: new Date().toISOString(), }) @@ -503,7 +503,7 @@ describe('LinkedFiles', function () { linkedFileData: { provider: 'project_output_file', v1_source_doc_id: 9999999, // We won't find this id in the database - source_output_file_path: 'project.pdf', + source_output_file_path: 'output.pdf', }, _id: 'abcdef', rev: 0, diff --git a/services/web/test/acceptance/src/RemoveEmailsWithCommasScriptTest.mjs b/services/web/test/acceptance/src/RemoveEmailsWithCommasScriptTest.mjs deleted file mode 100644 index f50f8f19df..0000000000 --- a/services/web/test/acceptance/src/RemoveEmailsWithCommasScriptTest.mjs +++ /dev/null @@ -1,226 +0,0 @@ -import { promisify } from 'node:util' -import { exec } from 'node:child_process' -import { expect } from 'chai' -import { filterOutput } from './helpers/settings.mjs' -import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js' -import fs from 'node:fs/promises' - -const CSV_FILENAME = '/tmp/emails-with-commas.csv' - -async function runScript(commit) { - const result = await promisify(exec)( - ['node', 'scripts/remove_emails_with_commas.mjs', commit && '--commit'] - .filter(Boolean) - .join(' ') - ) - return { - ...result, - stdout: result.stdout.split('\n').filter(filterOutput), - } -} - -function createUser(email, emails) { - return { - _id: new ObjectId(), - email, - emails, - } -} - -describe('scripts/remove_emails_with_commas', function () { - let user, unchangedUser - - beforeEach(async function () { - await fs.writeFile( - CSV_FILENAME, - '"user,email@test.com"\n"user,another@test.com"\n' - ) - }) - - afterEach(async function () { - try { - await fs.unlink(CSV_FILENAME) - } catch (err) { - // Ignore errors if file doesn't exist - } - }) - - describe('when removing email addresses with commas', function () { - beforeEach(async function () { - user = createUser('user,email@test.com', [ - { - email: 'user,email@test.com', - createdAt: new Date(), - reversedHostname: 'moc.tset', - }, - ]) - await db.users.insertOne(user) - - unchangedUser = createUser('john.doe@example.com', [ - { - email: 'john.doe@example.com', - createdAt: new Date(), - reversedHostname: 'moc.elpmaxe', - }, - ]) - await db.users.insertOne(unchangedUser) - }) - - afterEach(async function () { - await db.users.deleteOne({ _id: user._id }) - }) - - it('should replace emails with commas with encoded support emails', async function () { - const r = await runScript(true) - - expect(r.stdout).to.include( - 'user,email@test.com -> support+user_2cemail_40test.com@overleaf.com' - ) - expect(r.stdout).to.include('Updated users: 1') - - const updatedUser = await db.users.findOne({ _id: user._id }) - expect(updatedUser.email).to.equal( - 'support+user_2cemail_40test.com@overleaf.com' - ) - expect(updatedUser.emails).to.have.length(1) - expect(updatedUser.emails[0].email).to.equal( - 'support+user_2cemail_40test.com@overleaf.com' - ) - expect(updatedUser.emails[0].reversedHostname).to.equal('moc.faelrevo') - - const unchanged = await db.users.findOne({ _id: unchangedUser._id }) - - expect(unchanged.emails).to.have.length(1) - expect(unchanged.email).to.equal('john.doe@example.com') - expect(unchanged.emails[0].email).to.equal('john.doe@example.com') - }) - - it('should not modify anything in dry run mode', async function () { - const r = await runScript(false) - - expect(r.stdout).to.include( - 'user,email@test.com -> support+user_2cemail_40test.com@overleaf.com' - ) - expect(r.stdout).to.include( - 'Note: this was a dry-run. No changes were made.' - ) - - const updatedUser = await db.users.findOne({ _id: user._id }) - expect(updatedUser.email).to.equal('user,email@test.com') - expect(updatedUser.emails).to.have.length(1) - expect(updatedUser.emails[0].email).to.equal('user,email@test.com') - }) - }) - - describe('when handling multiple email replacements', function () { - beforeEach(async function () { - user = createUser('user,email@test.com', [ - { - email: 'user,email@test.com', - createdAt: new Date(), - reversedHostname: 'moc.tset', - }, - { - email: 'normal@test.com', - createdAt: new Date(), - reversedHostname: 'moc.tset', - }, - ]) - await db.users.insertOne(user) - }) - - afterEach(async function () { - await db.users.deleteOne({ _id: user._id }) - }) - - it('should only replace primary email with comma and keep other emails', async function () { - const r = await runScript(true) - - expect(r.stdout).to.include( - 'user,email@test.com -> support+user_2cemail_40test.com@overleaf.com' - ) - expect(r.stdout).to.include('Updated users: 1') - - const updatedUser = await db.users.findOne({ _id: user._id }) - expect(updatedUser.email).to.equal( - 'support+user_2cemail_40test.com@overleaf.com' - ) - expect(updatedUser.emails).to.have.length(2) - expect(updatedUser.emails[0].email).to.equal('normal@test.com') - expect(updatedUser.emails[1].email).to.equal( - 'support+user_2cemail_40test.com@overleaf.com' - ) - }) - }) - - describe('when handling special characters in emails', function () { - beforeEach(async function () { - await fs.writeFile( - CSV_FILENAME, - '"user,email@test.com"\n","\n"user_special@test.co,"\n' - ) - - user = createUser('user,email@test.com', [ - { - email: 'user,email@test.com', - createdAt: new Date(), - reversedHostname: 'moc.tset', - }, - ]) - - await db.users.insertOne(user) - - const user2 = createUser('user<>@test.com', [ - { - email: 'user<>@test.com', - createdAt: new Date(), - reversedHostname: 'moc.tset', - }, - ]) - - await db.users.insertOne(user2) - }) - - afterEach(async function () { - await db.users.deleteMany({ - email: { - $in: [ - 'support+user_2cemail_40test.com@overleaf.com', - 'support+user_60_62_40test.com@overleaf.com', - ], - }, - }) - }) - - it('should correctly encode various special characters', async function () { - const r = await runScript(true) - - expect(r.stdout).to.include( - 'user,email@test.com -> support+user_2cemail_40test.com@overleaf.com' - ) - expect(r.stdout).to.include( - ', -> support+_2c_60user_40test.com_62@overleaf.com' - ) - - const updatedUser1 = await db.users.findOne({ _id: user._id }) - expect(updatedUser1.email).to.equal( - 'support+user_2cemail_40test.com@overleaf.com' - ) - }) - }) - - describe('when user does not exist', function () { - beforeEach(async function () { - await fs.writeFile(CSV_FILENAME, '"nonexistent,email@test.com"\n') - }) - - it('should handle missing users gracefully', async function () { - const r = await runScript(true) - - expect(r.stdout).to.include( - 'User not found for email: nonexistent,email@test.com' - ) - expect(r.stdout).to.include('Updated users: 0') - }) - }) -}) diff --git a/services/web/test/acceptance/src/helpers/groupSSO.mjs b/services/web/test/acceptance/src/helpers/groupSSO.mjs index f7efeb9e63..c5bde77236 100644 --- a/services/web/test/acceptance/src/helpers/groupSSO.mjs +++ b/services/web/test/acceptance/src/helpers/groupSSO.mjs @@ -34,7 +34,7 @@ export const baseSsoConfig = { userIdAttribute, } // the database also sets enabled and validated, but we cannot set that in the POST request for /manage/groups/:ID/settings/sso -export async function createGroupSSO() { +export async function createGroupSSO(SSOConfigValidated = true) { const nonSSOMemberHelper = await UserHelper.createUser() const nonSSOMember = nonSSOMemberHelper.user @@ -47,7 +47,7 @@ export async function createGroupSSO() { const ssoConfig = new SSOConfig({ ...baseSsoConfig, enabled: true, - validated: true, + validated: SSOConfigValidated, }) await ssoConfig.save() @@ -68,12 +68,14 @@ export async function createGroupSSO() { const enrollmentUrl = getEnrollmentUrl(subscriptionId) const internalProviderId = getProviderId(subscriptionId) - await linkGroupMember( - memberUser.email, - memberUser.password, - subscriptionId, - 'mock@email.com' - ) + if (SSOConfigValidated) { + await linkGroupMember( + memberUser.email, + memberUser.password, + subscriptionId, + 'mock@email.com' + ) + } const userHelper = new UserHelper() diff --git a/services/web/test/acceptance/src/mocks/MockClsiApi.mjs b/services/web/test/acceptance/src/mocks/MockClsiApi.mjs index 102b75b0d3..de2fc488c0 100644 --- a/services/web/test/acceptance/src/mocks/MockClsiApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockClsiApi.mjs @@ -9,14 +9,14 @@ class MockClsiApi extends AbstractMockApi { error: null, outputFiles: [ { - url: `http://clsi:3013/project/${req.params.project_id}/build/1234/output/project.pdf`, - path: 'project.pdf', + url: `http://clsi:3013/project/${req.params.project_id}/build/1234/output/output.pdf`, + path: 'output.pdf', type: 'pdf', build: 1234, }, { - url: `http://clsi:3013/project/${req.params.project_id}/build/1234/output/project.log`, - path: 'project.log', + url: `http://clsi:3013/project/${req.params.project_id}/build/1234/output/output.log`, + path: 'output.log', type: 'log', build: 1234, }, @@ -36,9 +36,9 @@ class MockClsiApi extends AbstractMockApi { '/project/:project_id/build/:build_id/output/*', (req, res) => { const filename = req.params[0] - if (filename === 'project.pdf') { + if (filename === 'output.pdf') { plainTextResponse(res, 'mock-pdf') - } else if (filename === 'project.log') { + } else if (filename === 'output.log') { plainTextResponse(res, 'mock-log') } else { res.sendStatus(404) diff --git a/services/web/test/frontend/bootstrap.js b/services/web/test/frontend/bootstrap.js index e98d2c35de..df4d3f1464 100644 --- a/services/web/test/frontend/bootstrap.js +++ b/services/web/test/frontend/bootstrap.js @@ -104,3 +104,8 @@ const fetchMock = require('fetch-mock').default fetchMock.spyGlobal() fetchMock.config.fetch = global.fetch fetchMock.config.Response = fetch.Response + +Object.defineProperty(navigator, 'onLine', { + configurable: true, + get: () => true, +}) diff --git a/services/web/test/frontend/components/editor-left-menu/editor-left-menu.spec.tsx b/services/web/test/frontend/components/editor-left-menu/editor-left-menu.spec.tsx index b5e49765a5..808f97bd4b 100644 --- a/services/web/test/frontend/components/editor-left-menu/editor-left-menu.spec.tsx +++ b/services/web/test/frontend/components/editor-left-menu/editor-left-menu.spec.tsx @@ -75,7 +75,7 @@ describe('', function () { // Actions Menu cy.findByRole('heading', { name: 'Actions' }) - cy.findByRole('button', { name: 'Copy Project' }) + cy.findByRole('button', { name: 'Copy project' }) cy.findByRole('button', { name: 'Word Count' }) // Sync Menu @@ -105,7 +105,7 @@ describe('', function () { cy.findByRole('heading', { name: 'Help' }) cy.findByRole('button', { name: 'Show Hotkeys' }) cy.findByRole('link', { name: 'Documentation' }) - cy.findByRole('button', { name: 'Contact Us' }) + cy.findByRole('button', { name: 'Contact us' }) }) describe('download menu', function () { @@ -154,14 +154,14 @@ describe('', function () { ) - cy.findByRole('button', { name: 'Copy Project' }).click() - cy.findByRole('heading', { name: 'Copy Project' }) + cy.findByRole('button', { name: 'Copy project' }).click() + cy.findByRole('heading', { name: 'Copy project' }) // try closing & re-opening the modal with different methods cy.findByRole('button', { name: 'Close' }).click() - cy.findByRole('button', { name: 'Copy Project' }).click() + cy.findByRole('button', { name: 'Copy project' }).click() cy.findByRole('button', { name: 'Cancel' }).click() - cy.findByRole('button', { name: 'Copy Project' }).click() + cy.findByRole('button', { name: 'Copy project' }).click() cy.findByLabelText('New Name').focus() cy.findByLabelText('New Name').clear() @@ -840,7 +840,7 @@ describe('', function () { ) - cy.findByRole('button', { name: 'Contact Us' }).click() + cy.findByRole('button', { name: 'Contact us' }).click() cy.findByText('Affected project URL (Optional)') }) }) @@ -869,7 +869,7 @@ describe('', function () { // Actions Menu cy.findByRole('heading', { name: 'Actions' }).should('not.exist') - cy.findByRole('button', { name: 'Copy Project' }).should('not.exist') + cy.findByRole('button', { name: 'Copy project' }).should('not.exist') cy.findByRole('button', { name: 'Word Count' }).should('not.exist') // Sync Menu @@ -899,7 +899,7 @@ describe('', function () { cy.findByRole('heading', { name: 'Help' }) cy.findByRole('button', { name: 'Show Hotkeys' }) cy.findByRole('button', { name: 'Documentation' }).should('not.exist') - cy.findByRole('link', { name: 'Contact Us' }).should('not.exist') + cy.findByRole('link', { name: 'Contact us' }).should('not.exist') }) }) }) diff --git a/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx b/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx index db637c1f77..fd2075236c 100644 --- a/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx +++ b/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx @@ -47,7 +47,7 @@ describe('', function () { 'https://compiles-user.dev-overleaf.com' ) window.metaAttributesCache.set('ol-splitTestVariants', { - 'initial-compile-from-clsi-cache': 'enabled', + 'populate-clsi-cache': 'enabled', }) window.metaAttributesCache.set('ol-projectOwnerHasPremiumOnPageLoad', true) cy.interceptEvents() diff --git a/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.jsx b/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.jsx index e22a2c1791..6a837ed81f 100644 --- a/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.jsx +++ b/services/web/test/frontend/features/clone-project-modal/components/clone-project-modal.test.jsx @@ -33,7 +33,7 @@ describe('', function () { { scope: { project } } ) - await screen.findByText('Copy Project') + await screen.findByText('Copy project') }) it('posts the generated project name', async function () { diff --git a/services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.jsx b/services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.jsx index aea2592bad..050e11f653 100644 --- a/services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.jsx +++ b/services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.jsx @@ -21,9 +21,9 @@ describe('', function () { it('shows correct modal when clicked', async function () { renderWithEditorContext() - fireEvent.click(screen.getByRole('button', { name: 'Copy Project' })) + fireEvent.click(screen.getByRole('button', { name: 'Copy project' })) - screen.getByPlaceholderText('New Project Name') + screen.getByPlaceholderText('New project name') }) it('loads the project page when submitted', async function () { @@ -36,10 +36,10 @@ describe('', function () { renderWithEditorContext() - fireEvent.click(screen.getByRole('button', { name: 'Copy Project' })) + fireEvent.click(screen.getByRole('button', { name: 'Copy project' })) - const input = screen.getByPlaceholderText('New Project Name') - fireEvent.change(input, { target: { value: 'New Project' } }) + const input = screen.getByPlaceholderText('New project name') + fireEvent.change(input, { target: { value: 'New project' } }) const button = screen.getByRole('button', { name: 'Copy' }) button.click() diff --git a/services/web/test/frontend/features/editor-left-menu/components/actions-menu.test.jsx b/services/web/test/frontend/features/editor-left-menu/components/actions-menu.test.jsx index d48e172c49..54f0048afa 100644 --- a/services/web/test/frontend/features/editor-left-menu/components/actions-menu.test.jsx +++ b/services/web/test/frontend/features/editor-left-menu/components/actions-menu.test.jsx @@ -41,7 +41,7 @@ describe('', function () { screen.getByText('Actions') screen.getByRole('button', { - name: 'Copy Project', + name: 'Copy project', }) await waitFor(() => { @@ -69,7 +69,7 @@ describe('', function () { expect(screen.queryByText('Actions')).to.equal(null) expect( screen.queryByRole('button', { - name: 'Copy Project', + name: 'Copy project', }) ).to.equal(null) diff --git a/services/web/test/frontend/features/editor-left-menu/components/help-contact-us.test.jsx b/services/web/test/frontend/features/editor-left-menu/components/help-contact-us.test.jsx index 9b37107fad..d75a798214 100644 --- a/services/web/test/frontend/features/editor-left-menu/components/help-contact-us.test.jsx +++ b/services/web/test/frontend/features/editor-left-menu/components/help-contact-us.test.jsx @@ -21,7 +21,7 @@ describe('', function () { renderWithEditorContext() expect(screen.queryByRole('dialog')).to.equal(null) - fireEvent.click(screen.getByRole('button', { name: 'Contact Us' })) + fireEvent.click(screen.getByRole('button', { name: 'Contact us' })) const modal = screen.getAllByRole('dialog')[0] within(modal).getAllByText('Get in touch') within(modal).getByText('Subject') diff --git a/services/web/test/frontend/features/editor-left-menu/components/help-menu.test.jsx b/services/web/test/frontend/features/editor-left-menu/components/help-menu.test.jsx index eec6fdce31..3eb3fe6d04 100644 --- a/services/web/test/frontend/features/editor-left-menu/components/help-menu.test.jsx +++ b/services/web/test/frontend/features/editor-left-menu/components/help-menu.test.jsx @@ -23,7 +23,7 @@ describe('', function () { renderWithEditorContext() screen.getByRole('button', { name: 'Show Hotkeys' }) - screen.getByRole('button', { name: 'Contact Us' }) + screen.getByRole('button', { name: 'Contact us' }) screen.getByRole('link', { name: 'Documentation' }) }) @@ -33,7 +33,7 @@ describe('', function () { renderWithEditorContext() screen.getByRole('button', { name: 'Show Hotkeys' }) - expect(screen.queryByRole('button', { name: 'Contact Us' })).to.equal(null) + expect(screen.queryByRole('button', { name: 'Contact us' })).to.equal(null) expect(screen.queryByRole('link', { name: 'Documentation' })).to.equal(null) }) }) diff --git a/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx b/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx index 91f2dd9841..ead0c74a1c 100644 --- a/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx +++ b/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx @@ -1,5 +1,6 @@ import AddSeats, { MAX_NUMBER_OF_USERS, + MAX_NUMBER_OF_PO_NUMBER_CHARACTERS, } from '@/features/group-management/components/add-seats/add-seats' import { SplitTestProvider } from '@/shared/context/split-test-context' @@ -30,7 +31,7 @@ describe('', function () { it('renders the back button', function () { cy.findByTestId('group-heading').within(() => { - cy.findByRole('button', { name: /back to subscription/i }).should( + cy.findByRole('link', { name: /back to subscription/i }).should( 'have.attr', 'href', '/user/subscription' @@ -70,7 +71,7 @@ describe('', function () { }) it('renders the cancel button', function () { - cy.findByRole('button', { name: /cancel/i }).should( + cy.findByRole('link', { name: /cancel/i }).should( 'have.attr', 'href', '/user/subscription' @@ -97,6 +98,30 @@ describe('', function () { cy.findByLabelText(/i want to add a po number/i).check() cy.findByLabelText(/^po number$/i) }) + + describe('validation', function () { + beforeEach(function () { + cy.findByLabelText(/i want to add a po number/i).check() + }) + + it('should show max characters error', function () { + const totalCharacters = 'a'.repeat( + MAX_NUMBER_OF_PO_NUMBER_CHARACTERS + 1 + ) + cy.findByLabelText(/^po number$/i).type(totalCharacters) + cy.findByText( + new RegExp( + `po number must not exceed ${MAX_NUMBER_OF_PO_NUMBER_CHARACTERS} characters`, + 'i' + ) + ) + }) + + it('should show letters and numbers only error', function () { + cy.findByLabelText(/^po number$/i).type('🚧') + cy.findByText(/po number can include digits and letters only/i) + }) + }) }) describe('"Upgrade my plan" link', function () { @@ -188,7 +213,7 @@ describe('', function () { describe('request', function () { afterEach(function () { - cy.findByRole('button', { name: /go to subscriptions/i }).should( + cy.findByRole('link', { name: /go to subscriptions/i }).should( 'have.attr', 'href', '/user/subscription' @@ -251,6 +276,7 @@ describe('', function () { }, }, currency: 'USD', + netTerms: 30, immediateCharge: { subtotal: 100, tax: 20, @@ -276,13 +302,16 @@ describe('', function () { cy.findByRole('button', { name: /send request/i }).should('not.exist') }) - it('renders the preview data', function () { + function makeRequest(body: object, inputValue: string) { cy.intercept('POST', '/user/subscription/group/add-users/preview', { statusCode: 200, - body: this.body, + body, }).as('addUsersRequest') - cy.get('@input').type(this.adding.toString()) + cy.get('@input').type(inputValue) + } + it('renders common preview data content', function () { + makeRequest(this.body, this.adding.toString()) cy.findByTestId('cost-summary').within(() => { cy.contains( new RegExp( @@ -314,22 +343,55 @@ describe('', function () { cy.findByTestId('discount').should('not.exist') cy.findByTestId('total').within(() => { - cy.findByText(/total due today/i) cy.findByTestId('price').should( 'have.text', `$${this.body.immediateCharge.total}.00` ) }) - cy.findByText( - /we’ll charge you now for the cost of your additional licenses based on the remaining months of your current subscription/i - ) cy.findByText( /after that, we’ll bill you \$1,000\.00 \(\$895\.00 \+ \$105\.00 tax\) annually on December 1, unless you cancel/i ) }) }) + it('renders the preview data with manually billed subscription', function () { + makeRequest(this.body, this.adding.toString()) + cy.findByTestId('cost-summary').within(() => { + cy.findByTestId('total').within(() => { + cy.findByText( + new RegExp(`total due in ${this.body.netTerms} days`, 'i') + ) + }) + }) + cy.findByText( + new RegExp( + `we’ll invoice you now for the additional licences based on the remaining months of your current subscription, and payment will be due in ${this.body.netTerms} days`, + 'i' + ) + ) + }) + + it('renders the preview data with automatically billed subscription', function () { + cy.window().then(win => { + win.metaAttributesCache.set('ol-isCollectionMethodManual', false) + }) + cy.mount( + + + + ) + makeRequest(this.body, this.adding.toString()) + cy.findByTestId('cost-summary').within(() => { + cy.findByTestId('total').within(() => { + cy.findByText(/total due today/i) + }) + }) + cy.findByText( + /we’ll charge you now for the cost of your additional licenses based on the remaining months of your current subscription/i + ) + }) + it('renders the preview data with discount', function () { this.body.immediateCharge.discount = 50 @@ -352,7 +414,7 @@ describe('', function () { describe('request', function () { afterEach(function () { - cy.findByRole('button', { name: /go to subscriptions/i }).should( + cy.findByRole('link', { name: /go to subscriptions/i }).should( 'have.attr', 'href', '/user/subscription' diff --git a/services/web/test/frontend/features/group-management/components/request-status.spec.tsx b/services/web/test/frontend/features/group-management/components/request-status.spec.tsx index 79982a13d1..4a3061f464 100644 --- a/services/web/test/frontend/features/group-management/components/request-status.spec.tsx +++ b/services/web/test/frontend/features/group-management/components/request-status.spec.tsx @@ -12,7 +12,7 @@ describe('', function () { it('renders the back button', function () { cy.findByTestId('group-heading').within(() => { - cy.findByRole('button', { name: /back to subscription/i }).should( + cy.findByRole('link', { name: /back to subscription/i }).should( 'have.attr', 'href', '/user/subscription' @@ -35,7 +35,7 @@ describe('', function () { }) it('renders the link to subscriptions', function () { - cy.findByRole('button', { name: /go to subscriptions/i }).should( + cy.findByRole('link', { name: /go to subscriptions/i }).should( 'have.attr', 'href', '/user/subscription' diff --git a/services/web/test/frontend/features/group-management/components/upgrade-subscription.spec.tsx b/services/web/test/frontend/features/group-management/components/upgrade-subscription.spec.tsx index 5918c1bd68..f7f2c564d7 100644 --- a/services/web/test/frontend/features/group-management/components/upgrade-subscription.spec.tsx +++ b/services/web/test/frontend/features/group-management/components/upgrade-subscription.spec.tsx @@ -66,7 +66,7 @@ describe('', function () { it('shows the "Upgrade" and "Cancel" buttons', function () { cy.findByRole('button', { name: /upgrade/i }) - cy.findByRole('button', { name: /cancel/i }).should( + cy.findByRole('link', { name: /cancel/i }).should( 'have.attr', 'href', '/user/subscription' diff --git a/services/web/test/frontend/features/project-list/components/current-plan-widget.test.tsx b/services/web/test/frontend/features/project-list/components/current-plan-widget.test.tsx index 0ec6db3c9f..84836c204b 100644 --- a/services/web/test/frontend/features/project-list/components/current-plan-widget.test.tsx +++ b/services/web/test/frontend/features/project-list/components/current-plan-widget.test.tsx @@ -70,7 +70,7 @@ describe('', function () { }) it('clicks on upgrade button', function () { - const upgradeLink = screen.getByRole('button', { name: /upgrade/i }) + const upgradeLink = screen.getByRole('link', { name: /upgrade/i }) fireEvent.click(upgradeLink) expect(sendMBSpy).to.be.calledOnce expect(sendMBSpy).calledWith('upgrade-button-click', { diff --git a/services/web/test/frontend/features/project-list/components/new-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/new-project-button.test.tsx index 1c86b861a3..3f3dc89575 100644 --- a/services/web/test/frontend/features/project-list/components/new-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/new-project-button.test.tsx @@ -28,16 +28,16 @@ describe('', function () { renderWithProjectListContext() const newProjectButton = screen.getByRole('button', { - name: 'New Project', + name: 'New project', }) fireEvent.click(newProjectButton) }) it('shows the correct dropdown menu', function () { // static menu - screen.getByText('Blank Project') - screen.getByText('Example Project') - screen.getByText('Upload Project') + screen.getByText('Blank project') + screen.getByText('Example project') + screen.getByText('Upload project') screen.getByText('Import from GitHub') // static text @@ -48,27 +48,27 @@ describe('', function () { screen.getByText('View All') }) - it('open new project modal when clicking at Blank Project', function () { - fireEvent.click(screen.getByRole('menuitem', { name: 'Blank Project' })) + it('open new project modal when clicking at Blank project', function () { + fireEvent.click(screen.getByRole('menuitem', { name: 'Blank project' })) screen.getByPlaceholderText('Project Name') }) - it('open new project modal when clicking at Example Project', function () { - fireEvent.click(screen.getByRole('menuitem', { name: 'Example Project' })) + it('open new project modal when clicking at Example project', function () { + fireEvent.click(screen.getByRole('menuitem', { name: 'Example project' })) screen.getByPlaceholderText('Project Name') }) it('close the new project modal when clicking at the top right "x" button', function () { - fireEvent.click(screen.getByRole('menuitem', { name: 'Blank Project' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Blank project' })) fireEvent.click(screen.getByRole('button', { name: 'Close' })) expect(screen.queryByRole('dialog')).to.be.null }) it('close the new project modal when clicking at the Cancel button', function () { - fireEvent.click(screen.getByRole('menuitem', { name: 'Blank Project' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Blank project' })) fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) expect(screen.queryByRole('dialog')).to.be.null @@ -102,14 +102,14 @@ describe('', function () { renderWithProjectListContext() const newProjectButton = screen.getByRole('button', { - name: 'New Project', + name: 'New project', }) fireEvent.click(newProjectButton) // static menu - screen.getByText('Blank Project') - screen.getByText('Example Project') - screen.getByText('Upload Project') + screen.getByText('Blank project') + screen.getByText('Example project') + screen.getByText('Upload project') screen.getByText('Import from GitHub') // static text for institution templates diff --git a/services/web/test/frontend/features/project-list/components/notifications.test.tsx b/services/web/test/frontend/features/project-list/components/notifications.test.tsx index f91399ff0c..7197ddb365 100644 --- a/services/web/test/frontend/features/project-list/components/notifications.test.tsx +++ b/services/web/test/frontend/features/project-list/components/notifications.test.tsx @@ -141,7 +141,7 @@ describe('', function () { expect(screen.queryByRole('button', { name: /join project/i })).to.be.null - const openProject = screen.getByRole('button', { name: /open project/i }) + const openProject = screen.getByRole('link', { name: /open project/i }) expect(openProject.getAttribute('href')).to.equal( `/project/${notificationProjectInvite.messageOpts.projectId}` ) @@ -203,7 +203,7 @@ describe('', function () { screen.getByRole('alert') screen.getByText(/your free WFH2020 upgrade came to an end on/i) - const viewLink = screen.getByRole('button', { name: /view/i }) + const viewLink = screen.getByRole('link', { name: /view/i }) expect(viewLink.getAttribute('href')).to.equal( 'https://www.overleaf.com/events/wfh2020' ) @@ -242,7 +242,7 @@ describe('', function () { expect(findOutMore.getAttribute('href')).to.equal( 'https://www.overleaf.com/learn/how-to/Institutional_Login' ) - const linkAccount = screen.getByRole('button', { name: /link account/i }) + const linkAccount = screen.getByRole('link', { name: /link account/i }) expect(linkAccount.getAttribute('href')).to.equal( `${exposedSettings.samlInitPath}?university_id=${notificationIPMatchedAffiliation.messageOpts.institutionId}&auto=/project` ) @@ -277,7 +277,7 @@ describe('', function () { /add an institutional email address to claim your features/i ) - const addAffiliation = screen.getByRole('button', { + const addAffiliation = screen.getByRole('link', { name: /add affiliation/i, }) expect(addAffiliation.getAttribute('href')).to.equal(`/user/settings`) @@ -303,7 +303,7 @@ describe('', function () { screen.getByText(/file limit/i) screen.getByText(/You can't add more files to the project or sync it/i) - const accountSettings = screen.getByRole('button', { + const accountSettings = screen.getByRole('link', { name: /Open project/i, }) expect(accountSettings.getAttribute('href')).to.equal('/project/123') @@ -493,7 +493,7 @@ describe('', function () { '/learn/how-to/Institutional_Login' ) - const action = screen.getByRole('button', { name: /link account/i }) + const action = screen.getByRole('link', { name: /link account/i }) expect(action.getAttribute('href')).to.equal( `${exposedSettings.samlInitPath}?university_id=${notificationsInstitution.institutionId}&auto=/project&email=${notificationsInstitution.email}` ) @@ -558,7 +558,7 @@ describe('', function () { screen.getByRole('alert') screen.getByText(/which is already registered with/i) - const action = screen.getByRole('button', { name: /find out more/i }) + const action = screen.getByRole('link', { name: /find out more/i }) expect(action.getAttribute('href')).to.equal( '/learn/how-to/Institutional_Login' ) @@ -931,7 +931,7 @@ describe('', function () { renderWithinProjectListProvider(GroupsAndEnterpriseBanner) await fetchMock.callHistory.flush(true) - expect(screen.queryByRole('button', { name: 'Contact Sales' })).to.be.null + expect(screen.queryByRole('link', { name: 'Contact sales' })).to.be.null }) it('shows the banner for users that have dismissed the previous banners', async function () { @@ -941,7 +941,7 @@ describe('', function () { renderWithinProjectListProvider(GroupsAndEnterpriseBanner) await fetchMock.callHistory.flush(true) - await screen.findByRole('button', { name: 'Contact Sales' }) + await screen.findByRole('link', { name: 'Contact sales' }) }) it('shows the banner for users that have dismissed the banner more than 30 days ago', async function () { @@ -956,7 +956,7 @@ describe('', function () { renderWithinProjectListProvider(GroupsAndEnterpriseBanner) await fetchMock.callHistory.flush(true) - await screen.findByRole('button', { name: 'Contact Sales' }) + await screen.findByRole('link', { name: 'Contact sales' }) }) it('does not show the banner for users that have dismissed the banner within the last 30 days', async function () { @@ -971,7 +971,7 @@ describe('', function () { renderWithinProjectListProvider(GroupsAndEnterpriseBanner) await fetchMock.callHistory.flush(true) - expect(screen.queryByRole('button', { name: 'Contact Sales' })).to.be.null + expect(screen.queryByRole('link', { name: 'Contact sales' })).to.be.null }) describe('users that are not in group and are not affiliated', function () { @@ -1012,7 +1012,7 @@ describe('', function () { await screen.findByText( 'Overleaf On-Premises: Does your company want to keep its data within its firewall? Overleaf offers Server Pro, an on-premises solution for companies. Get in touch to learn more.' ) - const link = screen.getByRole('button', { name: 'Contact Sales' }) + const link = screen.getByRole('link', { name: 'Contact sales' }) expect(link.getAttribute('href')).to.equal(`/for/contact-sales-2`) }) @@ -1029,7 +1029,7 @@ describe('', function () { await screen.findByText( 'Why do Fortune 500 companies and top research institutions trust Overleaf to streamline their collaboration? Get in touch to learn more.' ) - const link = screen.getByRole('button', { name: 'Contact Sales' }) + const link = screen.getByRole('link', { name: 'Contact sales' }) expect(link.getAttribute('href')).to.equal(`/for/contact-sales-4`) }) diff --git a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx index 4cfc119f0b..abc92eefd1 100644 --- a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx +++ b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx @@ -260,7 +260,7 @@ describe('', function () { describe('archived projects', function () { beforeEach(function () { - const filterButton = screen.getAllByText('Archived Projects')[0] + const filterButton = screen.getAllByText('Archived projects')[0] fireEvent.click(filterButton) allCheckboxes = screen.getAllByRole('checkbox') @@ -287,7 +287,6 @@ describe('', function () { fireEvent.click(unarchiveButton) await fetchMock.callHistory.flush(true) - expect(fetchMock.callHistory.done()).to.be.true await screen.findByText('No projects') }) @@ -302,7 +301,6 @@ describe('', function () { ) await fetchMock.callHistory.flush(true) - expect(fetchMock.callHistory.done()).to.be.true expect(screen.queryByText('No projects')).to.be.null }) @@ -310,7 +308,7 @@ describe('', function () { describe('trashed projects', function () { beforeEach(function () { - const filterButton = screen.getAllByText('Trashed Projects')[0] + const filterButton = screen.getAllByText('Trashed projects')[0] fireEvent.click(filterButton) allCheckboxes = screen.getAllByRole('checkbox') @@ -335,7 +333,7 @@ describe('', function () { }) it('clears selected projects when filter changed', function () { - const filterButton = screen.getAllByText('All Projects')[0] + const filterButton = screen.getAllByText('All projects')[0] fireEvent.click(filterButton) const allCheckboxes = @@ -354,7 +352,6 @@ describe('', function () { fireEvent.click(untrashButton) await fetchMock.callHistory.flush(true) - expect(fetchMock.callHistory.done()).to.be.true await screen.findByText('No projects') }) @@ -367,7 +364,6 @@ describe('', function () { expect(allCheckboxesChecked.length).to.equal(trashedList.length - 1) await fetchMock.callHistory.flush(true) - expect(fetchMock.callHistory.done()).to.be.true expect(screen.queryByText('No projects')).to.be.null }) @@ -392,7 +388,6 @@ describe('', function () { expect(confirmButton.disabled).to.be.true await fetchMock.callHistory.flush(true) - expect(fetchMock.callHistory.done()).to.be.true const calls = fetchMock.callHistory.calls().map(({ url }) => url) @@ -457,7 +452,6 @@ describe('', function () { expect(confirmButton.disabled).to.be.true await fetchMock.callHistory.flush(true) - expect(fetchMock.callHistory.done()).to.be.true const calls = fetchMock.callHistory.calls().map(({ url }) => url) leavableList.forEach(project => { @@ -520,7 +514,6 @@ describe('', function () { expect(confirmButton.disabled).to.be.true await fetchMock.callHistory.flush(true) - expect(fetchMock.callHistory.done()).to.be.true const calls = fetchMock.callHistory.calls().map(({ url }) => url) deletableList.forEach(project => { @@ -591,7 +584,6 @@ describe('', function () { expect(confirmButton.disabled).to.be.true await fetchMock.callHistory.flush(true) - expect(fetchMock.callHistory.done()).to.be.true const calls = fetchMock.callHistory.calls().map(({ url }) => url) deletableAndLeavableList.forEach(project => { @@ -851,7 +843,7 @@ describe('', function () { describe('"More" dropdown', function () { beforeEach(async function () { - const filterButton = screen.getAllByText('All Projects')[0] + const filterButton = screen.getAllByText('All projects')[0] fireEvent.click(filterButton) allCheckboxes = screen.getAllByRole('checkbox') }) @@ -1186,7 +1178,6 @@ describe('', function () { fireEvent.click(copyConfirmButton) await fetchMock.callHistory.flush(true) - expect(fetchMock.callHistory.done()).to.be.true expect(sendMBSpy).to.have.been.calledTwice expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash') @@ -1202,7 +1193,7 @@ describe('', function () { expect(screen.queryByText(copiedProjectName)).to.be.null - const yourProjectFilter = screen.getAllByText('Your Projects')[0] + const yourProjectFilter = screen.getAllByText('Your projects')[0] fireEvent.click(yourProjectFilter) await screen.findByText(copiedProjectName) }) diff --git a/services/web/test/frontend/features/project-list/components/sidebar/add-affiliation.test.tsx b/services/web/test/frontend/features/project-list/components/sidebar/add-affiliation.test.tsx index 8d69c2ddda..c49cd38f2c 100644 --- a/services/web/test/frontend/features/project-list/components/sidebar/add-affiliation.test.tsx +++ b/services/web/test/frontend/features/project-list/components/sidebar/add-affiliation.test.tsx @@ -31,7 +31,7 @@ describe('Add affiliation widget', function () { await waitFor(() => expect(fetchMock.callHistory.called('/api/project'))) await screen.findByText(/are you affiliated with an institution/i) - const addAffiliationLink = screen.getByRole('button', { + const addAffiliationLink = screen.getByRole('link', { name: /add affiliation/i, }) expect(addAffiliationLink.getAttribute('href')).to.equal('/user/settings') diff --git a/services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx b/services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx index b8e5768c99..b066d45244 100644 --- a/services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx +++ b/services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx @@ -24,7 +24,7 @@ describe('', function () { fetchMock.post('/tag', { _id: 'eee888eee888', - name: 'New Tag', + name: 'New tag', project_ids: [], }) fetchMock.post('express:/tag/:tagId/projects', 200) @@ -41,20 +41,20 @@ describe('', function () { fetchMock.removeRoutes().clearHistory() }) - it('displays the tags list', function () { - const header = screen.getByTestId('organize-projects') + it('displays the tags list', async function () { + const header = await screen.findByTestId('organize-projects') expect(header.textContent).to.equal('Organize Tags') - screen.getByRole('button', { - name: 'New Tag', + await screen.findByRole('button', { + name: 'New tag', }) - screen.getByRole('button', { + await screen.findByRole('button', { name: 'Tag 1 (1)', }) - screen.getByRole('button', { + await screen.findByRole('button', { name: 'Another tag (2)', }) - screen.getByRole('button', { + await screen.findByRole('button', { name: 'Uncategorized (3)', }) }) @@ -82,7 +82,7 @@ describe('', function () { describe('Create modal', function () { beforeEach(async function () { const newTagButton = screen.getByRole('button', { - name: 'New Tag', + name: 'New tag', }) fireEvent.click(newTagButton) @@ -139,7 +139,7 @@ describe('', function () { it('filling the input and clicking Create sends a request', async function () { const modal = screen.getAllByRole('dialog', { hidden: false })[0] const input = within(modal).getByRole('textbox') - fireEvent.change(input, { target: { value: 'New Tag' } }) + fireEvent.change(input, { target: { value: 'New tag' } }) const createButton = within(modal).getByRole('button', { name: 'Create' }) expect(createButton.hasAttribute('disabled')).to.be.false @@ -155,7 +155,7 @@ describe('', function () { ) screen.getByRole('button', { - name: 'New Tag (0)', + name: 'New tag (0)', }) }) }) @@ -234,7 +234,7 @@ describe('', function () { it('filling the input and clicking Save sends a request', async function () { const modal = screen.getAllByRole('dialog', { hidden: false })[0] const input = within(modal).getByRole('textbox') - fireEvent.change(input, { target: { value: 'New Tag Name' } }) + fireEvent.change(input, { target: { value: 'New tag Name' } }) const saveButton = within(modal).getByRole('button', { name: 'Save' }) expect(saveButton.hasAttribute('disabled')).to.be.false @@ -250,7 +250,7 @@ describe('', function () { ) screen.getByRole('button', { - name: 'New Tag Name (1)', + name: 'New tag Name (1)', }) }) }) diff --git a/services/web/test/frontend/features/project-list/components/survey-widget.test.tsx b/services/web/test/frontend/features/project-list/components/survey-widget.test.tsx index bd6aeb683c..59f6bb9680 100644 --- a/services/web/test/frontend/features/project-list/components/survey-widget.test.tsx +++ b/services/web/test/frontend/features/project-list/components/survey-widget.test.tsx @@ -36,7 +36,7 @@ describe('', function () { screen.getByText(this.preText) screen.getByText(this.linkText) - const link = screen.getByRole('button', { + const link = screen.getByRole('link', { name: 'Take survey', }) as HTMLAnchorElement expect(link.href).to.equal(this.url) diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx index 216f08a89b..f5b55a71ba 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx @@ -31,7 +31,7 @@ describe('', function () { ) const btn = screen.getByRole('button', { name: 'Archive' }) fireEvent.click(btn) - screen.getByText('Archive Projects') + screen.getByText('Archive projects') screen.getByText(archiveableProject.name) }) @@ -56,7 +56,7 @@ describe('', function () { ) const btn = screen.getByRole('button', { name: 'Archive' }) fireEvent.click(btn) - screen.getByText('Archive Projects') + screen.getByText('Archive projects') screen.getByText('You are about to archive the following projects:') screen.getByText('Archiving projects won’t affect your collaborators.') const confirmBtn = screen.getByRole('button', { diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx index 37c38d13d9..98dc43c683 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx @@ -55,7 +55,7 @@ describe('', function () { const btn = screen.getByRole('button', { name: 'Copy' }) fireEvent.click(btn) - screen.getByText('Copy Project') + screen.getByText('Copy project') screen.getByLabelText('New Name') screen.getByDisplayValue(`${copyableProject.name} (Copy)`) const copyBtn = screen.getAllByRole('button', { diff --git a/services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx b/services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx index f1d4a0755d..e870478076 100644 --- a/services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx @@ -7,7 +7,9 @@ import { renderWithProjectListContext } from '../../helpers/render-with-context' const userId = '624333f147cfd8002622a1d3' -describe('', function () { +// TODO(25331): re-enable +// eslint-disable-next-line mocha/no-skipped-tests +describe.skip('', function () { beforeEach(function () { window.metaAttributesCache.set('ol-tags', []) window.metaAttributesCache.set('ol-user_id', userId) @@ -154,17 +156,24 @@ describe('', function () { }) }) - it('unselects all projects when select all checkbox uchecked', async function () { + it('unselects all projects when select all checkbox unchecked', async function () { renderWithProjectListContext() await fetchMock.callHistory.flush(true) const checkbox = await screen.findByLabelText('Select all projects') - fireEvent.click(checkbox) + fireEvent.click(checkbox) await waitFor(() => { const allCheckboxes = screen.queryAllByRole('checkbox') const allCheckboxesChecked = allCheckboxes.filter(c => c.checked) - expect(allCheckboxesChecked.length).to.equal(0) + expect(allCheckboxesChecked).to.have.length(currentProjects.length + 1) + }) + + fireEvent.click(checkbox) + + await waitFor(() => { + const allCheckboxes = screen.queryAllByRole('checkbox') + expect(allCheckboxes.every(c => !c.checked)).to.be.true }) }) @@ -174,12 +183,14 @@ describe('', function () { const checkbox = await screen.findByLabelText('Select all projects') fireEvent.click(checkbox) + // make sure we are unchecking a project checkbox and that it is already + // checked await waitFor(() => { expect( screen - .getAllByRole('checkbox')[1] + .getAllByRole('checkbox', { checked: true })[1] .getAttribute('data-project-id') - ).to.exist // make sure we are unchecking a project checkbox + ).to.exist }) fireEvent.click(screen.getAllByRole('checkbox')[1]) diff --git a/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/archive-projects.button.test.tsx b/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/archive-projects.button.test.tsx index 2963592164..4201c092ec 100644 --- a/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/archive-projects.button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/archive-projects.button.test.tsx @@ -21,6 +21,6 @@ describe('', function () { renderWithProjectListContext() const btn = screen.getByRole('button', { name: 'Archive' }) fireEvent.click(btn) - screen.getByText('Archive Projects') + screen.getByText('Archive projects') }) }) diff --git a/services/web/test/frontend/features/project-list/components/welcome-message.test.tsx b/services/web/test/frontend/features/project-list/components/welcome-message.test.tsx index 064a488e3c..56c3693695 100644 --- a/services/web/test/frontend/features/project-list/components/welcome-message.test.tsx +++ b/services/web/test/frontend/features/project-list/components/welcome-message.test.tsx @@ -30,9 +30,9 @@ describe('', function () { fireEvent.click(button) - screen.getByText('Blank Project') - screen.getByText('Example Project') - screen.getByText('Upload Project') + screen.getByText('Blank project') + screen.getByText('Example project') + screen.getByText('Upload project') screen.getByText('Import from GitHub') }) @@ -52,9 +52,9 @@ describe('', function () { fireEvent.click(button) // static menu - screen.getByText('Blank Project') - screen.getByText('Example Project') - screen.getByText('Upload Project') + screen.getByText('Blank project') + screen.getByText('Example project') + screen.getByText('Upload project') screen.getByText('Import from GitHub') // static text for institution templates @@ -79,9 +79,9 @@ describe('', function () { fireEvent.click(button) - screen.getByText('Blank Project') - screen.getByText('Example Project') - screen.getByText('Upload Project') + screen.getByText('Blank project') + screen.getByText('Example project') + screen.getByText('Upload project') screen.getByText('Import from GitHub') }) @@ -130,9 +130,9 @@ describe('', function () { fireEvent.click(button) - screen.getByText('Blank Project') - screen.getByText('Example Project') - screen.getByText('Upload Project') + screen.getByText('Blank project') + screen.getByText('Example project') + screen.getByText('Upload project') expect(screen.queryByText('Import from GitHub')).to.not.exist }) diff --git a/services/web/test/frontend/features/review-panel/review-panel.spec.tsx b/services/web/test/frontend/features/review-panel/review-panel.spec.tsx index f9ed6e38b6..d667787810 100644 --- a/services/web/test/frontend/features/review-panel/review-panel.spec.tsx +++ b/services/web/test/frontend/features/review-panel/review-panel.spec.tsx @@ -1,21 +1,187 @@ import CodeMirrorEditor from '../../../../frontend/js/features/source-editor/components/codemirror-editor' -import { EditorProviders } from '../../helpers/editor-providers' +import { + EditorProviders, + USER_EMAIL, + USER_ID, +} from '../../helpers/editor-providers' import { mockScope } from '../source-editor/helpers/mock-scope' import { TestContainer } from '../source-editor/helpers/test-container' +import { docId } from '../source-editor/helpers/mock-doc' -// TODO: update tests and re-enable once reviewer role is active -// eslint-disable-next-line mocha/no-skipped-tests -describe.skip('', function () { +describe('', function () { beforeEach(function () { window.metaAttributesCache.set('ol-preventCompileOnLoad', true) cy.interceptEvents() - const scope = mockScope('') - scope.editor.showVisual = true + cy.intercept('GET', '/project/*/changes/users', [ + { + id: USER_ID, + email: USER_EMAIL, + first_name: 'Test', + last_name: 'User', + }, + ]) - // The tests expect no documents, so remove them from the scope - scope.project.rootFolder = [] + const userData = { + avatar_text: 'User', + email: USER_EMAIL, + hue: 180, + id: USER_ID, + isSelf: true, + first_name: 'Test', + last_name: 'User', + } + + const resolvedThreadId = 'resolved-thread-id' + const unresolvedThreadId = 'unresolved-thread-id' + + cy.intercept('GET', '/project/*/threads', { + // Resolved comment thread + [resolvedThreadId]: { + messages: [ + { + content: 'comment text', + id: `${resolvedThreadId}-1`, + timestamp: new Date('2025-01-01T00:00:00.000Z'), + user: userData, + user_id: USER_ID, + }, + ], + resolved: true, + resolved_at: new Date('2025-01-02T00:00:00.000Z').toISOString(), + resolved_by_user_id: USER_ID, + resolved_by_user: userData, + }, + // Unresolved comment thread + [unresolvedThreadId]: { + messages: [ + { + content: 'unresolved comment text', + id: `${unresolvedThreadId}-1`, + timestamp: new Date('2025-01-01T00:00:00.000Z'), + user: userData, + user_id: USER_ID, + }, + { + content: 'reply to thread', + id: `${unresolvedThreadId}-2`, + timestamp: new Date('2025-01-01T01:00:00.000Z'), + user: userData, + user_id: USER_ID, + }, + ], + }, + }) + + const commentOps = [ + { + id: 'resolved-op-id', + op: { p: 161, c: 'Your introduction', t: resolvedThreadId }, + }, + { + id: 'unresolved-op-id', + op: { p: 210, c: 'Your results', t: unresolvedThreadId }, + }, + ] + + const changesOps = [ + { + metadata: { + user_id: USER_ID, + ts: new Date('2025-01-01T00:00:00.000Z'), + }, + id: 'inserted-op-id', + op: { p: 166, t: 'inserted-op-id', i: 'introduction' }, + }, + { + metadata: { + user_id: USER_ID, + ts: new Date('2025-01-01T01:00:00.000Z'), + }, + id: 'deleted-op-id', + op: { p: 110, t: 'deleted-op-id', d: 'beautiful ' }, + }, + ] + + cy.intercept('GET', '/project/*/ranges', [ + { + id: docId, + ranges: { + changes: changesOps, + comments: commentOps, + docId, + }, + }, + ]) + + cy.intercept( + 'POST', + `/project/*/doc/${docId}/thread/${resolvedThreadId}/reopen`, + {} + ).as('reopenThread') + + cy.intercept( + 'POST', + `/project/*/doc/${docId}/thread/${unresolvedThreadId}/resolve`, + {} + ).as('resolveThreadId') + + cy.intercept( + 'POST', + `/project/*/thread/${unresolvedThreadId}/messages/${unresolvedThreadId}-1/edit`, + {} + ).as('editComment') + + cy.intercept( + 'POST', + `/project/*/thread/${unresolvedThreadId}/messages`, + {} + ).as('addReply') + + cy.intercept( + 'POST', + /\/project\/.*\/thread\/[a-z0-9]{24}\/messages/, + {} + ).as('addNewComment') + + cy.intercept( + 'DELETE', + `/project/*/doc/${docId}/thread/${resolvedThreadId}`, + {} + ).as('deleteResolvedThread') + + cy.intercept( + 'DELETE', + `/project/*/thread/${unresolvedThreadId}/messages/${unresolvedThreadId}-2`, + {} + ).as('deleteComment') + + cy.intercept( + 'DELETE', + `/project/*/doc/${docId}/thread/${unresolvedThreadId}`, + {} + ).as('deleteThread') + + cy.intercept('POST', `/project/*/doc/${docId}/changes/accept`, {}).as( + 'acceptChange' + ) + + cy.intercept('POST', `/project/*/doc/${docId}/metadata`, {}) + + const getChanges = cy.stub().as('getChanges').returns([]) + const removeChangeIds = cy.stub().as('removeChangeIds') + + const scope = mockScope(undefined, { + docOptions: { + rangesOptions: { + comments: commentOps, + changes: changesOps, + getChanges, + removeChangeIds, + }, + }, + }) cy.wrap(scope).as('scope') @@ -27,107 +193,80 @@ describe.skip('', function () { ) + // Open the review panel with keyboard shortcut + cy.findByText('contentLine 0').type('{command}j', { scrollBehavior: false }) + cy.findByText('contentLine 1').type('{ctrl}j', { scrollBehavior: false }) + cy.findByTestId('review-panel').as('review-panel') }) describe('toolbar', function () { describe('resolved comments dropdown', function () { - it('renders dropdown button', function () { - cy.findByRole('button', { name: /resolved comments/i }) - }) - - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('opens dropdown', function () { - cy.findByRole('button', { name: /resolved comments/i }).click() - // TODO dropdown opens/closes - }) - - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('renders list of resolved comments', function () {}) - - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('reopens resolved comment', function () {}) - - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('deletes resolved comment', function () {}) - }) - - describe('track changes toggle menu', function () { - it('renders track changes toolbar', function () { - cy.get('@review-panel').within(() => { - cy.findByRole('button', { name: /track changes is (on|off)$/i }) - }) - }) - - it('opens/closes toggle menu', function () { - cy.get('@review-panel').within(() => { - cy.findByTestId('review-panel-track-changes-menu').should('not.exist') - cy.findByRole('button', { name: /track changes is/i }).click() - // verify the menu is expanded - cy.findByTestId('review-panel-track-changes-menu') - .as('menu') - .then($el => { - const height = window - .getComputedStyle($el[0]) - .getPropertyValue('height') - return parseFloat(height) - }) - .should('be.gt', 1) - cy.findByRole('button', { name: /track changes is/i }).click() - cy.get('@menu').should('not.exist') - }) - }) - - it('toggles the "everyone" track changes switch', function () { - cy.get('@review-panel').within(() => { - cy.findByRole('button', { name: /track changes is off/i }).click() - cy.findByLabelText(/track changes for everyone/i).click({ - force: true, + it('renders a dropdown of resolved comments', function () { + // The dropdown button should be visible + cy.findByLabelText('Resolved comments').click() + // It should open the dropdown + cy.findByRole('tooltip') + .should('exist') + .within(() => { + // TODO: Fix selector + cy.get( + '.review-panel-resolved-comments-header .badge-content' + ).should('contain.text', '1') + // Should name the document with the comment + cy.findByText('test.tex').should('exist') + // Should show the comment text + cy.findByText('comment text').should('exist') + // Should show the author name + // TODO: Fix selector + cy.get('.review-panel-entry-user').should( + 'contain.text', + 'Test User' + ) }) - cy.findByLabelText(/track changes for everyone/i).should('be.checked') - // TODO: assert that track changes is on for everyone + }) + + it('reopens resolved comment', function () { + cy.findByLabelText('Resolved comments').click() + cy.findByRole('tooltip').within(() => { + // Find the re-open icon button using the hidden label + cy.findByText('Re-open').click({ force: true }) + // verify the reopen thread API call + cy.wait('@reopenThread') + + // TODO: Figure out a way to plumb the websocket response back through + // to the test so we can verify the comment is no longer resolved + // cy.get( + // '.review-panel-resolved-comments-header .badge-content' + // ).should('contain.text', '0') }) }) - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('renders track changes with "on" state', function () { - const scope = mockScope('') - scope.editor.showVisual = true - scope.editor.wantTrackChanges = true + it('deletes resolved comment', function () { + cy.findByLabelText('Resolved comments').click() + cy.findByRole('tooltip').within(() => { + // Find the Delete icon button using the hidden label + cy.findByText('Delete').click({ force: true }) + // verify the delete thread API call + cy.wait('@deleteResolvedThread') - cy.mount( - - - - - - ) - - cy.findByTestId('review-panel').within(() => { - cy.findByRole('button', { name: /track changes is on/i }).click() + // TODO: Figure out a way to plumb the websocket response back through + // to the test so we can verify the comment is no longer there + // cy.get( + // '.review-panel-resolved-comments-header .badge-content' + // ).should('contain.text', '0') }) }) - - it('renders a disabled guests switch', function () { - cy.findByRole('button', { name: /track changes is off/i }).click() - cy.findByLabelText(/track changes for guests/i).should('be.disabled') - }) }) }) describe('toggler', function () { - it('renders toggler button', function () { + it('should close panel when pressing close button', function () { cy.get('@review-panel').within(() => { - cy.findByRole('button', { name: /toggle review panel/i }) - }) - }) - - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('calls the toggler function on click', function () { - cy.get('@review-panel').within(() => { - cy.findByRole('button', { name: /toggle review panel/i }).click() - cy.get('@scope').its('toggleReviewPanel').should('be.calledOnce') + cy.findByLabelText('Close').click({ scrollBehavior: false }) }) + // We should collapse to the mini state + cy.get('.review-panel-mini').should('exist') }) }) @@ -167,46 +306,177 @@ describe.skip('', function () { }) describe('comment entries', function () { - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('shows threads and comments', function () {}) + it('shows threads and comments', function () { + cy.get('@review-panel').within(() => { + cy.findByText('unresolved comment text').should('exist') + cy.findByText('reply to thread').should('exist') + }) + }) - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('edits comment', function () {}) + it('edits comment', function () { + cy.get('@review-panel').within(() => { + // TODO: Fix selector + cy.get('.review-panel-comment-wrapper') + .first() + .within(() => { + // Find the options icon button using the hidden label + cy.findByText('More options') + .first() + .click({ force: true, scrollBehavior: false }) + cy.findByRole('menu').within(() => { + cy.findByText('Edit').click({ scrollBehavior: false }) + }) + cy.findByRole('textbox').type( + '{selectAll}edited comment text{enter}', + { scrollBehavior: false } + ) + cy.wait('@editComment') + // TODO: Figure out a way to plumb the websocket response back through + // to the test so we can verify the comment is resolved + // cy.findByText('edited comment text').should('exist') + }) + }) + }) - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('deletes comment', function () {}) + it('deletes thread', function () { + cy.get('@review-panel').within(() => { + // TODO: Fix selector + cy.get('.review-panel-comment-wrapper') + .first() + .within(() => { + // Find the options icon button using the hidden label + cy.findByText('More options') + .first() + .click({ force: true, scrollBehavior: false }) + cy.findByRole('menu').within(() => { + cy.findByText('Delete').click({ scrollBehavior: false }) + }) + }) + }) + cy.findByRole('dialog').within(() => { + cy.findByRole('button', { name: 'Delete' }).click() + }) + cy.wait('@deleteThread') + // TODO: Figure out a way to plumb the websocket response back through + // to the test so we can verify the thread is deleted + // cy.findByText('unresolved comment text').should('not.exist') + }) - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('cancels comment editing', function () {}) + it('deletes reply', function () { + cy.get('@review-panel').within(() => { + // TODO: Fix selector + cy.get('.review-panel-comment-wrapper') + .eq(1) + .within(() => { + // Find the options icon button using the hidden label + cy.findByText('More options') + .first() + .click({ force: true, scrollBehavior: false }) + cy.findByRole('menu').within(() => { + cy.findByText('Delete').click({ scrollBehavior: false }) + }) + }) + }) + cy.findByRole('dialog').within(() => { + cy.findByRole('button', { name: 'Delete' }).click() + }) + cy.wait('@deleteComment') + // TODO: Figure out a way to plumb the websocket response back through + // to the test so we can verify the reply is deleted + // cy.findByText('reply to thread').should('not.exist') + }) - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('cancels comment deletion', function () {}) + it('cancels comment deletion', function () { + cy.get('@review-panel').within(() => { + // TODO: Fix selector + cy.get('.review-panel-comment-wrapper') + .eq(1) + .within(() => { + // Find the options icon button using the hidden label + cy.findByText('More options') + .first() + .click({ force: true, scrollBehavior: false }) + cy.findByRole('menu').within(() => { + cy.findByText('Delete').click({ scrollBehavior: false }) + }) + }) + }) + cy.findByRole('dialog').within(() => { + cy.findByRole('button', { name: 'Cancel' }).click() + }) + cy.findByText('unresolved comment text').should('exist') + }) - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('adds new comment (replies) to a thread', function () {}) + it('adds new comment (replies) to a thread', function () { + cy.get('@review-panel').within(() => { + cy.findByRole('textbox').type('a new reply{enter}', { + scrollBehavior: false, + }) + }) + cy.wait('@addReply') + }) - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('resolves comment', function () {}) + it('resolves comment', function () { + cy.get('@review-panel').within(() => { + // Find the resolve icon button using the hidden label + cy.findByText('Resolve comment').click({ force: true }) + cy.wait('@resolveThreadId') + // TODO: Figure out a way to plumb the websocket response back through + // to the test so we can verify the comment is resolved + // cy.findByText('unresolved comment text').should('not.exist') + }) + }) }) describe('change entries', function () { - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('renders inserted entries in current file mode', function () {}) + it('renders inserted entries in current file mode', function () { + cy.get('@review-panel').within(() => { + cy.findByText('Added:').should('exist') + cy.findByText('introduction').should('exist') + }) + }) - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('renders deleted entries in current file mode', function () {}) + it('renders deleted entries in current file mode', function () { + cy.get('@review-panel').within(() => { + cy.findByText('Deleted:').should('exist') + cy.findByText('beautiful').should('exist') + }) + }) - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('renders inserted entries in overview mode', function () {}) + it('accepts change', function () { + cy.get('@review-panel').within(() => { + // TODO: Fix selector + cy.get('.review-panel-entry-insert').within(() => { + // Find the accept icon button using the hidden label + cy.findByText('Accept change').click({ force: true }) + cy.wait('@acceptChange') + }) - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('renders deleted entries in overview mode', function () {}) + // TODO: Fix selector + cy.get('.review-panel-entry-delete').within(() => { + // Find the accept icon button using the hidden label + cy.findByText('Accept change').click({ force: true }) + cy.wait('@acceptChange') + }) + }) + }) - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('accepts change', function () {}) - - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('rejects change', function () {}) + it('rejects change', function () { + cy.get('@review-panel').within(() => { + // TODO: Fix selector + cy.get('.review-panel-entry-insert').within(() => { + // Find the reject icon button using the hidden label + cy.findByText('Reject change').click({ force: true }) + cy.get('@getChanges').should('be.calledOnce') + }) + // TODO: Fix selector + cy.get('.review-panel-entry-delete').within(() => { + // Find the reject icon button using the hidden label + cy.findByText('Reject change').click({ force: true }) + cy.get('@getChanges').should('be.calledTwice') + }) + }) + }) }) describe('aggregate change entries', function () { @@ -218,63 +488,205 @@ describe.skip('', function () { }) describe('add comment entry', function () { - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('renders `add comment button`', function () {}) + beforeEach(function () { + cy.findByText('contentLine 12').type( + '{home}{shift}' + '{rightArrow}'.repeat(6), + { scrollBehavior: false } + ) + // TODO: Fix selector + cy.get('.review-tooltip-add-comment-button').as('add-comment-button') + }) - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('cancels adding comment', function () {}) + it('renders floating `add comment button`', function () { + cy.get('@add-comment-button').should('exist') + }) - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('adds comment', function () {}) + it('can add comment', function () { + cy.get('@add-comment-button').click({ scrollBehavior: false }) + cy.get('@review-panel').within(() => { + // TODO: Fix selector + cy.get('.review-panel-add-comment-textarea').type( + 'a new comment{enter}', + { + scrollBehavior: false, + } + ) + }) + cy.wait('@addNewComment') + // TODO : Figure out a way to plumb the websocket response back through + // to the test so we can verify the comment is added + // cy.findByText('a new comment').should('exist') + }) + + it('cancels adding comment', function () { + cy.get('@add-comment-button').click({ scrollBehavior: false }) + cy.get('@review-panel').within(() => { + cy.findByRole('button', { name: 'Cancel' }).click({ + scrollBehavior: false, + }) + }) + }) }) describe('bulk actions entry', function () { - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('renders the reject and accept all buttons`', function () {}) + beforeEach(function () { + // Select a deletion and an insertion + cy.findByText('\\maketitle').type( + '{home}{shift}' + '{downArrow}'.repeat(10), + { scrollBehavior: false } + ) + cy.findByLabelText('Accept selected changes').as( + 'accept-selected-changes' + ) + cy.findByLabelText('Reject selected changes').as( + 'reject-selected-changes' + ) + }) - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('accepts all changes', function () {}) + it('renders the reject and accept all buttons`', function () { + cy.get('@accept-selected-changes').should('exist') + cy.get('@reject-selected-changes').should('exist') + }) - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('rejects all changes', function () {}) + it('accepts all changes', function () { + cy.get('@accept-selected-changes').click({ scrollBehavior: false }) + cy.findByRole('dialog').within(() => { + cy.findByText( + 'Are you sure you want to accept the selected 2 changes?' + ).should('exist') + cy.findByRole('button', { name: 'OK' }).click({ + scrollBehavior: false, + }) + cy.wait('@acceptChange') + cy.get('@removeChangeIds').should('have.been.calledWith', [ + 'inserted-op-id', + 'deleted-op-id', + ]) + }) + }) + + it('rejects all changes', function () { + cy.get('@reject-selected-changes').click({ scrollBehavior: false }) + cy.findByRole('dialog').within(() => { + cy.findByText( + 'Are you sure you want to reject the selected 2 changes?' + ).should('exist') + cy.findByRole('button', { name: 'OK' }).click({ + scrollBehavior: false, + }) + cy.get('@getChanges').should('have.been.calledWith', [ + 'inserted-op-id', + 'deleted-op-id', + ]) + }) + }) }) describe('overview mode', function () { - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('shows list of files changed', function () {}) + beforeEach(function () { + cy.findByRole('tab', { name: /overview/i }).click() + }) + it('shows list of files changed', function () { + // TODO: Fix selector + cy.get('.collapsible-file-header').should('contain.text', 'test.tex') + }) - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('renders comments', function () {}) - }) + it('renders comments', function () { + cy.get('@review-panel').within(() => { + cy.findByText('unresolved comment text').should('exist') + cy.findByText('reply to thread').should('exist') + }) + }) - describe('in editor widgets', function () { - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('toggle review panel', function () {}) + it('renders changes', function () { + cy.get('@review-panel').within(() => { + cy.findByText('Added:').should('exist') + cy.findByText('introduction').should('exist') + cy.findByText('Deleted:').should('exist') + cy.findByText('beautiful').should('exist') + }) + }) - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('accepts all changes', function () {}) - - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('rejects all changes', function () {}) - - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('add comment', function () {}) - }) - - describe('upgrade track changes', function () { - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('renders modal', function () {}) - - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('closes modal', function () {}) - - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('opens subscription page after clicking on `upgrade`', function () {}) - - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('opens subscription page after clicking on `try it for free`', function () {}) - - // eslint-disable-next-line mocha/no-skipped-tests - it.skip('shows `ask project owner to upgrade` message', function () {}) + it('collapses the file entries when clicked', function () { + cy.findByText('test.tex').click() + cy.get('@review-panel').within(() => { + // TODO: Fix selector + cy.get('.review-panel-entry').should('not.exist') + }) + cy.findByText('test.tex').click() + cy.get('@review-panel').within(() => { + // TODO: Fix selector + cy.get('.review-panel-entry').should('exist') + }) + }) + }) +}) + +describe(' for free users', function () { + function mountEditor(ownerId = USER_ID) { + const scope = mockScope(undefined, { + permissions: { write: true, trackedWrite: false, comment: true }, + projectFeatures: { trackChanges: false }, + projectOwner: { + _id: ownerId, + }, + }) + + cy.wrap(scope).as('scope') + + cy.mount( + + + + + + ) + + cy.findByLabelText('Editing').click() + cy.findByRole('menu').within(() => { + cy.findByText(/Reviewing/).click() + }) + } + + beforeEach(function () { + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + cy.interceptEvents() + cy.intercept('GET', '/project/*/changes/users', []) + cy.intercept('GET', '/project/*/threads', {}) + }) + + it('renders modal', function () { + mountEditor() + cy.findByRole('dialog').within(() => { + cy.findByText('Upgrade to Review').should('exist') + }) + }) + + it('closes modal', function () { + mountEditor() + cy.findByRole('dialog').within(() => { + cy.findByText('Close').click() + }) + cy.findByRole('dialog').should('not.exist') + }) + + it('opens subscription page after clicking on `upgrade`', function () { + mountEditor() + cy.findByRole('dialog').within(() => { + // Verify the button exists. Clicking it will open a new window + cy.findByText('Upgrade').should('exist') + }) + }) + + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('opens subscription page after clicking on `try it for free`', function () {}) + + it('shows `ask project owner to upgrade` message', function () { + mountEditor('other-user-id') + cy.findByRole('dialog').within(() => { + cy.findByText( + 'Please ask the project owner to upgrade to use track changes' + ).should('exist') + }) }) }) diff --git a/services/web/test/frontend/features/settings/components/account-info-section.test.tsx b/services/web/test/frontend/features/settings/components/account-info-section.test.tsx index 112e7d90e7..e41cfe643f 100644 --- a/services/web/test/frontend/features/settings/components/account-info-section.test.tsx +++ b/services/web/test/frontend/features/settings/components/account-info-section.test.tsx @@ -47,7 +47,7 @@ describe('', function () { }) fireEvent.click( screen.getByRole('button', { - name: 'Update', + name: /update/i, }) ) expect(updateMock.callHistory.called()).to.be.true @@ -68,7 +68,7 @@ describe('', function () { target: { value: 'john' }, }) const button = screen.getByRole('button', { - name: 'Update', + name: /update/i, }) as HTMLButtonElement expect(button.disabled).to.be.true @@ -87,14 +87,14 @@ describe('', function () { fireEvent.click( screen.getByRole('button', { - name: 'Update', + name: /update/i, }) ) await screen.findByRole('button', { name: /saving/i }) finishUpdateCall(200) await screen.findByRole('button', { - name: 'Update', + name: /update/i, }) screen.getByText('Thanks, your settings have been updated.') }) @@ -105,7 +105,7 @@ describe('', function () { fireEvent.click( screen.getByRole('button', { - name: 'Update', + name: /update/i, }) ) await screen.findByText('Something went wrong. Please try again.') @@ -117,7 +117,7 @@ describe('', function () { fireEvent.click( screen.getByRole('button', { - name: 'Update', + name: /update/i, }) ) await screen.findByText( @@ -136,7 +136,7 @@ describe('', function () { fireEvent.click( screen.getByRole('button', { - name: 'Update', + name: /update/i, }) ) await screen.findByText('This email is already registered') @@ -153,7 +153,7 @@ describe('', function () { fireEvent.click( screen.getByRole('button', { - name: 'Update', + name: /update/i, }) ) expect( @@ -184,7 +184,7 @@ describe('', function () { fireEvent.click( screen.getByRole('button', { - name: 'Update', + name: /update account info/i, }) ) expect( @@ -212,7 +212,7 @@ describe('', function () { fireEvent.click( screen.getByRole('button', { - name: 'Update', + name: /update/i, }) ) expect( diff --git a/services/web/test/frontend/features/settings/components/emails/emails-row.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-row.test.tsx index 990d905ba8..f0d105d3e9 100644 --- a/services/web/test/frontend/features/settings/components/emails/emails-row.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/emails-row.test.tsx @@ -48,7 +48,7 @@ describe('', function () { it('renders actions', function () { renderEmailsRow(unconfirmedUserData) - screen.getByRole('button', { name: 'Make Primary' }) + screen.getByRole('button', { name: 'Make primary' }) }) }) @@ -96,7 +96,7 @@ describe('', function () { getByTextContent( 'You can now link your Overleaf account to your Overleaf institutional account.' ) - screen.getByRole('button', { name: 'Link Accounts' }) + screen.getByRole('button', { name: 'Link accounts' }) }) }) @@ -113,7 +113,7 @@ describe('', function () { getByTextContent( 'You can log in to Overleaf through your Overleaf institutional login.' ) - expect(screen.queryByRole('button', { name: 'Link Accounts' })).to.be + expect(screen.queryByRole('button', { name: 'Link accounts' })).to.be .null }) }) diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx index c556ac83a7..63021ca26e 100644 --- a/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx @@ -106,7 +106,7 @@ describe('', function () { }) fireEvent.click(button) - await screen.findByLabelText(/email/i) + await screen.findByLabelText(/email/i, { selector: 'input' }) }) it('renders "Start adding your address" until a valid email is typed', async function () { @@ -121,7 +121,7 @@ describe('', function () { }) fireEvent.click(button) - const input = screen.getByLabelText(/email/i) + const input = screen.getByLabelText(/email/i, { selector: 'input' }) // initially the text is displayed and the "add email" button disabled screen.getByText('Start by adding your email address.') @@ -200,7 +200,7 @@ describe('', function () { .post('/user/emails/confirm-secondary', 200) fireEvent.click(addAnotherEmailBtn) - const input = screen.getByLabelText(/email/i) + const input = screen.getByLabelText(/email/i, { selector: 'input' }) fireEvent.change(input, { target: { value: userEmailData.email }, @@ -242,7 +242,7 @@ describe('', function () { .post('/user/emails/secondary', 400) fireEvent.click(addAnotherEmailBtn) - const input = screen.getByLabelText(/email/i) + const input = screen.getByLabelText(/email/i, { selector: 'input' }) fireEvent.change(input, { target: { value: userEmailData.email }, @@ -279,12 +279,12 @@ describe('', function () { await userEvent.click(button) - const input = screen.getByLabelText(/email/i) + const input = screen.getByLabelText(/email/i, { selector: 'input' }) fireEvent.change(input, { target: { value: 'user@autocomplete.edu' }, }) - await screen.findByRole('button', { name: 'Link Accounts and Add Email' }) + await screen.findByRole('button', { name: 'Link accounts and add email' }) }) it('adds new email address with existing institution and custom departments', async function () { @@ -302,7 +302,10 @@ describe('', function () { await userEvent.click(button) - await userEvent.type(screen.getByLabelText(/email/i), userEmailData.email) + await userEvent.type( + screen.getByLabelText(/email/i, { selector: 'input' }), + userEmailData.email + ) await userEvent.click(screen.getByRole('button', { name: /let us know/i })) @@ -381,7 +384,7 @@ describe('', function () { department: customDepartment, }) - screen.getByText( + await screen.findByText( `Enter the 6-digit confirmation code sent to ${userEmailData.email}.` ) @@ -415,7 +418,10 @@ describe('', function () { // open "add new email" section and click "let us know" to open the Country/University form await userEvent.click(button) - await userEvent.type(screen.getByLabelText(/email/i), userEmailData.email) + await userEvent.type( + screen.getByLabelText(/email/i, { selector: 'input' }), + userEmailData.email + ) await userEvent.click(screen.getByRole('button', { name: /let us know/i })) // select a country @@ -457,7 +463,10 @@ describe('', function () { await userEvent.click(button) - await userEvent.type(screen.getByLabelText(/email/i), userEmailData.email) + await userEvent.type( + screen.getByLabelText(/email/i, { selector: 'input' }), + userEmailData.email + ) await userEvent.click(screen.getByRole('button', { name: /let us know/i })) @@ -574,7 +583,7 @@ describe('', function () { await userEvent.click(button) await userEvent.type( - screen.getByLabelText(/email/i), + screen.getByLabelText(/email/i, { selector: 'input' }), `user@${hostnameFirstChar}` ) @@ -647,7 +656,7 @@ describe('', function () { await userEvent.click(button) await userEvent.type( - screen.getByLabelText(/email/i), + screen.getByLabelText(/email/i, { selector: 'input' }), `user@${hostnameFirstChar}` ) diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx index 451a510855..e784f6aaac 100644 --- a/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx @@ -40,6 +40,9 @@ describe('', function () { screen.getByText(/add additional email addresses/i) screen.getByText(/to change your primary email/i) + screen.getByRole('link', { + name: /learn more about managing your Overleaf emails/i, + }) }) it('renders a loading message when loading', async function () { @@ -160,4 +163,56 @@ describe('', function () { screen.getByText(/sorry, something went wrong/i) screen.getByRole('button', { name: /resend confirmation code/i }) }) + + it('sorts emails with primary first, then confirmed, then unconfirmed', async function () { + const unconfirmedEmail = { ...unconfirmedUserData, email: 'b@example.com' } + const unconfirmedEmailTwo = { + ...unconfirmedUserData, + email: 'd@example.com', + } + const confirmedEmail = { + ...confirmedUserData, + email: 'a@example.com', + confirmedAt: new Date().toISOString(), + } + const confirmedEmailTwo = { + ...confirmedUserData, + email: 'e@example.com', + confirmedAt: new Date().toISOString(), + } + const primaryEmail = { + ...professionalUserData, + email: 'c@example.com', + default: true, + } + + const emails = [ + confirmedEmailTwo, + unconfirmedEmailTwo, + unconfirmedEmail, + confirmedEmail, + primaryEmail, + ] + + fetchMock.get('/user/emails?ensureAffiliation=true', emails) + render() + + await waitForElementToBeRemoved(() => screen.getByText(/loading/i)) + + const emailElements = screen.getAllByTestId(/email-row/i) + + // Primary should be first regardless of alphabetical order + expect(within(emailElements[0]).getByText('c@example.com')).to.exist + expect(within(emailElements[0]).getByText('Primary')).to.exist + + // Confirmed should be second in alphabetical order + expect(within(emailElements[1]).getByText('a@example.com')).to.exist + expect(within(emailElements[2]).getByText('e@example.com')).to.exist + + // Unconfirmed should be last in alphabetical order + expect(within(emailElements[3]).getByText('b@example.com')).to.exist + expect(within(emailElements[3]).getByText(/unconfirmed/i)).to.exist + expect(within(emailElements[4]).getByText('d@example.com')).to.exist + expect(within(emailElements[4]).getByText(/unconfirmed/i)).to.exist + }) }) diff --git a/services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx b/services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx index d18039a280..801b864cd4 100644 --- a/services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx @@ -97,7 +97,7 @@ describe('', function () { it('redirects to SAML flow', async function () { renderReconfirmationInfo(inReconfirmUserData) const confirmButton = screen.getByRole('button', { - name: 'Confirm Affiliation', + name: 'Confirm affiliation', }) as HTMLButtonElement await waitFor(() => { @@ -127,7 +127,7 @@ describe('', function () { it('sends and resends confirmation email', async function () { renderReconfirmationInfo(inReconfirmUserData) const confirmButton = (await screen.findByRole('button', { - name: 'Confirm Affiliation', + name: 'Confirm affiliation', })) as HTMLButtonElement await waitFor(() => { diff --git a/services/web/test/frontend/features/settings/components/leave-section.test.tsx b/services/web/test/frontend/features/settings/components/leave-section.test.tsx index 80d068a619..ddf67ac11f 100644 --- a/services/web/test/frontend/features/settings/components/leave-section.test.tsx +++ b/services/web/test/frontend/features/settings/components/leave-section.test.tsx @@ -23,7 +23,7 @@ describe('', function () { }) fireEvent.click(button) - await screen.findByText('Delete Account') + await screen.findByText('Delete account') }) it('closes modal', async function () { @@ -40,6 +40,6 @@ describe('', function () { fireEvent.click(cancelButton) - await waitForElementToBeRemoved(() => screen.getByText('Delete Account')) + await waitForElementToBeRemoved(() => screen.getByText('Delete account')) }) }) diff --git a/services/web/test/frontend/features/settings/components/linking-section.test.tsx b/services/web/test/frontend/features/settings/components/linking-section.test.tsx index d134ffeae6..92958f7a07 100644 --- a/services/web/test/frontend/features/settings/components/linking-section.test.tsx +++ b/services/web/test/frontend/features/settings/components/linking-section.test.tsx @@ -66,19 +66,21 @@ describe('', function () { it('lists SSO providers', async function () { renderSectionWithProviders() - screen.getByText('linked accounts') + screen.getByText('Linked accounts') screen.getByText('Google') screen.getByText('Log in with Google.') - screen.getByRole('button', { name: 'Unlink' }) + screen.getByRole('button', { name: /unlink/i }) screen.getByText('ORCID') screen.getByText( /Securely establish your identity by linking your ORCID iD/ ) - const helpLink = screen.getByRole('link', { name: 'Learn more' }) + const helpLink = screen.getByRole('link', { + name: /learn more about orcid/i, + }) expect(helpLink.getAttribute('href')).to.equal('/blog/434') - const linkButton = screen.getByRole('button', { name: 'Link' }) + const linkButton = screen.getByRole('link', { name: /link orcid/i }) expect(linkButton.getAttribute('href')).to.equal('/auth/orcid?intent=link') }) @@ -92,6 +94,6 @@ describe('', function () { window.metaAttributesCache.delete('ol-oauthProviders') renderSectionWithProviders() - expect(screen.queryByText('linked accounts')).to.not.exist + expect(screen.queryByText('Linked accounts')).to.not.exist }) }) diff --git a/services/web/test/frontend/features/settings/components/linking/integration-widget.test.tsx b/services/web/test/frontend/features/settings/components/linking/integration-widget.test.tsx index ed656056e3..1a623ba7b7 100644 --- a/services/web/test/frontend/features/settings/components/linking/integration-widget.test.tsx +++ b/services/web/test/frontend/features/settings/components/linking/integration-widget.test.tsx @@ -6,6 +6,7 @@ import * as eventTracking from '@/infrastructure/event-tracking' describe('', function () { const defaultProps = { + id: 'integration-widget-id', logo:
    , title: 'Integration', description: 'paragraph1', @@ -32,7 +33,7 @@ describe('', function () { }) it('should render an upgrade link and track clicks', function () { - const upgradeLink = screen.getByRole('button', { name: 'Upgrade' }) + const upgradeLink = screen.getByRole('link', { name: /upgrade/i }) expect(upgradeLink.getAttribute('href')).to.equal( '/user/subscription/plans' ) @@ -51,7 +52,7 @@ describe('', function () { it('should render a link to initiate integration linking', function () { expect( - screen.getByRole('button', { name: 'Link' }).getAttribute('href') + screen.getByRole('link', { name: 'Link' }).getAttribute('href') ).to.equal('/link') }) diff --git a/services/web/test/frontend/features/settings/components/linking/sso-widget.test.tsx b/services/web/test/frontend/features/settings/components/linking/sso-widget.test.tsx index d78de73ebc..2f02b7709b 100644 --- a/services/web/test/frontend/features/settings/components/linking/sso-widget.test.tsx +++ b/services/web/test/frontend/features/settings/components/linking/sso-widget.test.tsx @@ -25,7 +25,7 @@ describe('', function () { screen.getByText('integration') screen.getByText('integration description') expect( - screen.getByRole('link', { name: 'Learn more' }).getAttribute('href') + screen.getByRole('link', { name: /learn more/i }).getAttribute('href') ).to.equal('/help/integration') }) @@ -33,7 +33,7 @@ describe('', function () { it('should render a link to `linkPath`', function () { render() expect( - screen.getByRole('button', { name: 'Link' }).getAttribute('href') + screen.getByRole('link', { name: /link/i }).getAttribute('href') ).to.equal('/integration/link?intent=link') }) }) @@ -49,11 +49,11 @@ describe('', function () { }) it('should display an `unlink` button', function () { - screen.getByRole('button', { name: 'Unlink' }) + screen.getByRole('button', { name: /unlink/i }) }) it('should open a modal to confirm integration unlinking', function () { - fireEvent.click(screen.getByRole('button', { name: 'Unlink' })) + fireEvent.click(screen.getByRole('button', { name: /unlink/i })) screen.getByText('Unlink integration Account') screen.getByText( 'Warning: When you unlink your account from integration you will not be able to sign in using integration anymore.' @@ -61,7 +61,7 @@ describe('', function () { }) it('should cancel unlinking when clicking cancel in the confirmation modal', async function () { - fireEvent.click(screen.getByRole('button', { name: 'Unlink' })) + fireEvent.click(screen.getByRole('button', { name: /unlink/i })) const cancelBtn = screen.getByRole('button', { name: 'Cancel', hidden: false, @@ -80,9 +80,9 @@ describe('', function () { render( ) - fireEvent.click(screen.getByRole('button', { name: 'Unlink' })) + fireEvent.click(screen.getByRole('button', { name: /unlink/i })) confirmBtn = within(screen.getByRole('dialog')).getByRole('button', { - name: 'Unlink', + name: /unlink/i, hidden: false, }) }) @@ -114,11 +114,11 @@ describe('', function () { render( ) - fireEvent.click(screen.getByRole('button', { name: 'Unlink' })) + fireEvent.click(screen.getByRole('button', { name: /unlink/i })) const confirmBtn = within(screen.getByRole('dialog')).getByRole( 'button', { - name: 'Unlink', + name: /unlink/i, hidden: false, } ) @@ -130,7 +130,7 @@ describe('', function () { }) it('should display the unlink button ', async function () { - await screen.findByRole('button', { name: 'Unlink' }) + await screen.findByRole('button', { name: /unlink/i }) }) }) }) diff --git a/services/web/test/frontend/features/settings/components/password-section.test.tsx b/services/web/test/frontend/features/settings/components/password-section.test.tsx index 63f151a3df..5f4e0b01f5 100644 --- a/services/web/test/frontend/features/settings/components/password-section.test.tsx +++ b/services/web/test/frontend/features/settings/components/password-section.test.tsx @@ -186,7 +186,7 @@ describe('', function () { it('shows message when user cannot use password log in', async function () { window.metaAttributesCache.set('ol-cannot-change-password', true) render() - await screen.findByRole('heading', { name: 'Change Password' }) + await screen.findByRole('heading', { name: 'Change password' }) screen.getByText( 'You can’t add or change your password because your group or organization uses', { exact: false } diff --git a/services/web/test/frontend/features/settings/components/root.test.tsx b/services/web/test/frontend/features/settings/components/root.test.tsx index ea4de26aa1..f3f2f80639 100644 --- a/services/web/test/frontend/features/settings/components/root.test.tsx +++ b/services/web/test/frontend/features/settings/components/root.test.tsx @@ -36,11 +36,11 @@ describe('', function () { render() await waitFor(() => { - screen.getByText('Account Settings') + screen.getByText('Account settings') }) - screen.getByText('Emails and Affiliations') - screen.getByText('Update Account Info') - screen.getByText('Change Password') + screen.getByText('Emails and affiliations') + screen.getByText('Update account info') + screen.getByText('Change password') screen.getByText('Integrations') screen.getByText('Overleaf Beta Program') screen.getByText('Sessions') @@ -58,11 +58,11 @@ describe('', function () { render() await waitFor(() => { - screen.getByText('Account Settings') + screen.getByText('Account settings') }) - expect(screen.queryByText('Emails and Affiliations')).to.not.exist - screen.getByText('Update Account Info') - screen.getByText('Change Password') + expect(screen.queryByText('Emails and affiliations')).to.not.exist + screen.getByText('Update account info') + screen.getByText('Change password') screen.getByText('Integrations') expect(screen.queryByText('Overleaf Beta Program')).to.not.exist screen.getByText('Sessions') diff --git a/services/web/test/frontend/features/settings/components/security-section.test.tsx b/services/web/test/frontend/features/settings/components/security-section.test.tsx index 08aa89e9a4..e1cfd98c2f 100644 --- a/services/web/test/frontend/features/settings/components/security-section.test.tsx +++ b/services/web/test/frontend/features/settings/components/security-section.test.tsx @@ -22,7 +22,7 @@ describe('', function () { render() expect(screen.getAllByText('Single Sign-On (SSO)').length).to.equal(2) - const link = screen.getByRole('button', { + const link = screen.getByRole('link', { name: /Set up SSO/i, }) expect(link).to.exist diff --git a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx index cdeb0adb6f..88f3482c4b 100644 --- a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx +++ b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx @@ -94,7 +94,6 @@ describe('', function () { fetchMock.get('/user/contacts', { contacts }) window.metaAttributesCache.set('ol-user', { allowedFreeTrial: true }) window.metaAttributesCache.set('ol-showUpgradePrompt', true) - window.metaAttributesCache.set('ol-isReviewerRoleEnabled', true) window.metaAttributesCache.set('ol-preventCompileOnLoad', true) }) @@ -182,7 +181,7 @@ describe('', function () { await screen.findByText( 'This project is public and can be edited by anyone with the URL.' ) - await screen.findByRole('button', { name: 'Make Private' }) + await screen.findByRole('button', { name: 'Make private' }) }) it('handles legacy access level "readOnly"', async function () { @@ -193,7 +192,7 @@ describe('', function () { await screen.findByText( 'This project is public and can be viewed but not edited by anyone with the URL' ) - await screen.findByRole('button', { name: 'Make Private' }) + await screen.findByRole('button', { name: 'Make private' }) }) it('displays actions for project-owners', async function () { diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx index eda6dcec6b..0d80f9bde8 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx @@ -509,7 +509,7 @@ describe('', function () { \\end{{}figure}`, { delay: 0 } ) - cy.get('[aria-label="Edit figure"]').click() + cy.get('[aria-label="Edit figure"]').click({ force: true }) cy.findByRole('checkbox', { name: 'Include caption' }).should( 'be.checked' ) @@ -526,7 +526,7 @@ describe('', function () { \\end{{}figure}`, { delay: 0 } ) - cy.get('[aria-label="Edit figure"]').click() + cy.get('[aria-label="Edit figure"]').click({ force: true }) cy.get('[value="0.75"]').should('be.checked') }) @@ -539,7 +539,7 @@ describe('', function () { \\end{{}figure}`, { delay: 0 } ) - cy.get('[aria-label="Edit figure"]').click() + cy.get('[aria-label="Edit figure"]').click({ force: true }) cy.findByRole('checkbox', { name: 'Include label' }).click() cy.findByRole('checkbox', { name: 'Include label' }).should( 'not.be.checked' @@ -561,7 +561,7 @@ describe('', function () { \\end{{}figure}`, { delay: 0 } ) - cy.get('[aria-label="Edit figure"]').click() + cy.get('[aria-label="Edit figure"]').click({ force: true }) cy.findByRole('checkbox', { name: 'Include caption' }).click() cy.findByRole('checkbox', { name: 'Include caption' }).should( 'not.be.checked' @@ -585,7 +585,7 @@ describe('', function () { text below`, { delay: 0 } ) - cy.get('[aria-label="Edit figure"]').click() + cy.get('[aria-label="Edit figure"]').click({ force: true }) cy.findByRole('button', { name: 'Remove or replace figure' }).click() cy.findByText('Delete figure').click() cy.get('.cm-content').should('have.text', 'text abovetext below') diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx index ea7414ddab..34f26f02cb 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx @@ -217,6 +217,41 @@ cell 3 & cell 4 \\\\ ) }) + it('Correctly sets borders for booktabs', function () { + // Add a blank line above the table to allow room for the table toolbar + mountEditor(` + +\\begin{tabular}{c|c} + cell 1 & cell 2 \\\\ + cell 3 & cell 4 \\\\ + cell 5 & cell 6 \\\\ +\\end{tabular} +`) + checkBordersWithNoMultiColumn( + [false, false, false, false], + [false, true, false] + ) + cy.get('.table-generator-floating-toolbar').should('not.exist') + cy.get('.table-generator-cell').first().click() + cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist') + cy.get('@toolbar').findByText('Custom borders').click({ force: true }) + cy.get('.table-generator').findByText('Booktabs').click() + // The element is partially covered, but we can still click it + cy.get('.cm-line').first().click({ force: true }) + // Table should be unchanged + checkTable([ + ['cell 1', 'cell 2'], + ['cell 3', 'cell 4'], + ['cell 5', 'cell 6'], + ]) + checkBordersWithNoMultiColumn( + [true, true, false, true], + [false, false, false] + ) + cy.get('.table-generator-cell').first().click() + cy.get('@toolbar').findByText('Booktabs').should('exist') + }) + it('Changes the column alignment with dropdown buttons', function () { // Add a blank line above the table to allow room for the table toolbar mountEditor(` diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx index 450af37a5a..9220d6ccdf 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx @@ -227,6 +227,40 @@ describe(' paste HTML in Visual mode', function () { cy.get('.table-generator-cell[colspan="2"]').should('have.length', 2) }) + it('handles a pasted 1-row table with merged columns', function () { + mountEditor() + + const data = [ + ``, + ``, + `
    testtest
    `, + ].join('') + + const clipboardData = new DataTransfer() + clipboardData.setData('text/html', data) + cy.get('@content').trigger('paste', { clipboardData }) + + cy.get('@content').should('have.text', 'testtest' + menuIconsText) + cy.get('.table-generator-cell').should('have.length', 2) + cy.get('.table-generator-cell[colspan="2"]').should('have.length', 1) + }) + + it('handles a pasted table with a bordered merged column', function () { + mountEditor() + + const data = [ + ``, + ``, + `
    test
    `, + ].join('') + + const clipboardData = new DataTransfer() + clipboardData.setData('text/html', data) + cy.get('@content').trigger('paste', { clipboardData }) + cy.get('.table-generator-cell-border-right').should('have.length', 1) + cy.get('.table-generator-cell-border-left').should('have.length', 1) + }) + it('handles a pasted table with merged rows', function () { mountEditor() @@ -312,8 +346,8 @@ describe(' paste HTML in Visual mode', function () { cy.get('@content').should('have.text', 'foofoobarfoobar' + menuIconsText) cy.get('.table-generator-cell').should('have.length', 5) cy.get('.table-generator-cell[colspan="2"]').should('have.length', 1) - cy.get('.table-generator-cell-border-left').should('have.length', 2) - cy.get('.table-generator-cell-border-right').should('have.length', 4) + cy.get('.table-generator-cell-border-left').should('have.length', 3) + cy.get('.table-generator-cell-border-right').should('have.length', 5) cy.get('.table-generator-row-border-top').should('have.length', 5) cy.get('.table-generator-row-border-bottom').should('have.length', 2) }) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx index f3618af615..40693c2ec1 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx @@ -13,6 +13,8 @@ describe('', { scrollBehavior: false }, function () { beforeEach(function () { window.metaAttributesCache.set('ol-preventCompileOnLoad', true) cy.interceptEvents() + cy.intercept('GET', '/project/*/changes/users', []) + cy.intercept('GET', '/project/*/threads', {}) }) it('deletes selected text on Backspace', function () { @@ -519,6 +521,9 @@ describe('', { scrollBehavior: false }, function () { cy.findByLabelText('Within selection').as('within-selection-label') cy.findByRole('button', { name: 'Replace' }).as('replace') cy.findByRole('button', { name: 'Replace All' }).as('replace-all') + cy.findByRole('button', { name: 'Search all project files' }).as( + 'search-project' + ) cy.findByRole('button', { name: 'previous' }).as('find-previous') cy.findByRole('button', { name: 'next' }).as('find-next') cy.findByRole('button', { name: 'Close' }).as('close') @@ -532,6 +537,7 @@ describe('', { scrollBehavior: false }, function () { cy.get('@within-selection').should('be.focused').tab() cy.get('@find-previous').should('be.focused').tab() cy.get('@find-next').should('be.focused').tab() + cy.get('@search-project').should('be.focused').tab() cy.get('@replace').should('be.focused').tab() cy.get('@replace-all').should('be.focused').tab() @@ -539,6 +545,7 @@ describe('', { scrollBehavior: false }, function () { cy.get('@close').should('be.focused').tab({ shift: true }) cy.get('@replace-all').should('be.focused').tab({ shift: true }) cy.get('@replace').should('be.focused').tab({ shift: true }) + cy.get('@search-project').should('be.focused').tab({ shift: true }) cy.get('@find-next').should('be.focused').tab({ shift: true }) cy.get('@find-previous').should('be.focused').tab({ shift: true }) cy.get('@within-selection').should('be.focused').tab({ shift: true }) diff --git a/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts b/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts index 91f6cc6eff..4c239c1f60 100644 --- a/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts +++ b/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts @@ -53,7 +53,10 @@ class MockShareDoc extends EventEmitter { } } -export const mockDoc = (content = defaultContent) => { +export const mockDoc = ( + content = defaultContent, + { rangesOptions = {} } = {} +) => { const mockShareJSDoc: ShareDoc = new MockShareDoc(content) return { @@ -92,7 +95,10 @@ export const mockDoc = (content = defaultContent) => { }, }), resetDirtyState: () => {}, + removeCommentId: () => {}, + ...rangesOptions, }, + submitOp: (op: any) => {}, setTrackChangesIdSeeds: () => {}, getTrackingChanges: () => true, setTrackingChanges: () => {}, diff --git a/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts b/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts index 8eafae47c0..621bdecd3c 100644 --- a/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts +++ b/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts @@ -5,10 +5,18 @@ import { Folder } from '../../../../../types/folder' export const rootFolderId = '012345678901234567890123' export const figuresFolderId = '123456789012345678901234' export const figureId = '234567890123456789012345' -export const mockScope = (content?: string) => { +export const mockScope = ( + content?: string, + { + docOptions = {}, + projectFeatures = {}, + permissions = {}, + projectOwner = undefined, + }: any = {} +) => { return { editor: { - sharejs_doc: mockDoc(content), + sharejs_doc: mockDoc(content, docOptions), open_doc_name: 'test.tex', open_doc_id: docId, showVisual: false, @@ -61,14 +69,17 @@ export const mockScope = (content?: string) => { ] as Folder[], features: { trackChanges: true, + ...projectFeatures, }, trackChangesState: {}, members: [], + owner: projectOwner, }, permissions: { comment: true, trackedWrite: true, write: true, + ...permissions, }, ui: { reviewPanelOpen: false, diff --git a/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx index 065aeec79f..8edc881caa 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx @@ -37,13 +37,13 @@ describe('', function () { }) describe('custom subscription', function () { - it('displays contact support message', function () { + it('displays contact Support message', function () { renderWithSubscriptionDashContext(, { metaTags: [{ name: 'ol-subscription', value: customSubscription }], }) screen.getByText('Please', { exact: false }) - screen.getByText('contact support', { exact: false }) + screen.getByText('contact Support', { exact: false }) screen.getByText('to make changes to your plan', { exact: false }) }) }) @@ -82,7 +82,7 @@ describe('', function () { screen.getByText('No further payments will be taken.', { exact: false }) - screen.getByRole('button', { name: 'View your invoices' }) + screen.getByRole('link', { name: 'View your invoices' }) screen.getByRole('button', { name: 'Reactivate your subscription' }) }) diff --git a/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx index 15dd65b6ba..baada41976 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx @@ -102,7 +102,7 @@ describe('', function () { 'If you wish this change to apply before the end of your current billing period, please contact us.' ) - expect(screen.queryByRole('link', { name: 'contact support' })).to.be.null + expect(screen.queryByRole('link', { name: 'contact Support' })).to.be.null expect(screen.queryByText('if you wish to change your group subscription.')) .to.be.null }) @@ -470,10 +470,10 @@ describe('', function () { }) }) - it('does not show option to downgrade when not a collaborator plan', function () { - const trialPlan = cloneDeep(monthlyActiveCollaborator) - trialPlan.plan.planCode = 'anotherplan' - renderActiveSubscription(trialPlan) + it('does not show option to downgrade when plan is not eligible for downgrades', function () { + const ineligiblePlan = cloneDeep(monthlyActiveCollaborator) + ineligiblePlan.payment.isEligibleForDowngradeUpsell = false + renderActiveSubscription(ineligiblePlan) showConfirmCancelUI() expect( screen.queryByRole('button', { @@ -524,9 +524,9 @@ describe('', function () { expect(changePlan).to.be.null }) - it('shows contact support message for group plan change requests', function () { + it('shows contact Support message for group plan change requests', function () { renderActiveSubscription(groupActiveSubscription) - screen.getByRole('link', { name: 'contact support' }) + screen.getByRole('link', { name: 'contact Support' }) screen.getByText('if you wish to change your group subscription.', { exact: false, }) diff --git a/services/web/test/frontend/features/subscription/components/dashboard/subscription-dashboard.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/subscription-dashboard.test.tsx index 77d377cd60..2e44b4b6e4 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/subscription-dashboard.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/subscription-dashboard.test.tsx @@ -32,7 +32,7 @@ describe('', function () { }) it('renders the "Get the most from your subscription" text', function () { - screen.getByText(/get the most from your Overleaf subscription/i) + screen.getByText(/Get the most out of your subscription/i) }) }) @@ -42,13 +42,13 @@ describe('', function () { }) it('does not render the "Get the most out of your" subscription text', function () { - const text = screen.queryByText('Get the most out of your', { + const text = screen.queryByText('Get the most out of your subscription', { exact: false, }) expect(text).to.be.null }) - it('does not render the contact support message', function () { + it('does not render the contact Support message', function () { const text = screen.queryByText( `You’re on an Overleaf Paid plan. Contact`, { @@ -60,7 +60,7 @@ describe('', function () { }) describe('Custom subscription', function () { - it('renders the contact support message', function () { + it('renders the contact Support message', function () { renderWithSubscriptionDashContext(, { metaTags: [ { @@ -77,7 +77,7 @@ describe('', function () { screen.getByText(`You’re on an Overleaf Paid plan.`, { exact: false, }) - screen.getByText(`Contact support`, { + screen.getByText(`Contact Support`, { exact: false, }) }) diff --git a/services/web/test/frontend/features/subscription/components/group-invite/has-individual-recurly-subscription.test.tsx b/services/web/test/frontend/features/subscription/components/group-invite/has-individual-recurly-subscription.test.tsx index b5bf4ec97d..fb89b19bf1 100644 --- a/services/web/test/frontend/features/subscription/components/group-invite/has-individual-recurly-subscription.test.tsx +++ b/services/web/test/frontend/features/subscription/components/group-invite/has-individual-recurly-subscription.test.tsx @@ -40,7 +40,7 @@ describe('group invite', function () { fireEvent.click(button) await waitFor(() => { screen.getByText( - 'Something went wrong canceling your subscription. Please contact support.' + 'Something went wrong canceling your subscription. Please contact Support.' ) }) }) diff --git a/services/web/test/frontend/features/subscription/components/successful-subscription/successful-subscription.test.tsx b/services/web/test/frontend/features/subscription/components/successful-subscription/successful-subscription.test.tsx index 33979e6b9e..9259eaf259 100644 --- a/services/web/test/frontend/features/subscription/components/successful-subscription/successful-subscription.test.tsx +++ b/services/web/test/frontend/features/subscription/components/successful-subscription/successful-subscription.test.tsx @@ -4,21 +4,28 @@ import SuccessfulSubscription from '../../../../../../frontend/js/features/subsc import { renderWithSubscriptionDashContext } from '../../helpers/render-with-subscription-dash-context' import { annualActiveSubscription } from '../../fixtures/subscriptions' import { ExposedSettings } from '../../../../../../types/exposed-settings' +import { UserProvider } from '@/shared/context/user-context' describe('successful subscription page', function () { it('renders the invoices link', function () { const adminEmail = 'foo@example.com' - renderWithSubscriptionDashContext(, { - metaTags: [ - { - name: 'ol-ExposedSettings', - value: { - adminEmail, - } as ExposedSettings, - }, - { name: 'ol-subscription', value: annualActiveSubscription }, - ], - }) + renderWithSubscriptionDashContext( + + + , + + { + metaTags: [ + { + name: 'ol-ExposedSettings', + value: { + adminEmail, + } as ExposedSettings, + }, + { name: 'ol-subscription', value: annualActiveSubscription }, + ], + } + ) screen.getByRole('heading', { name: /thanks for subscribing/i }) const alert = screen.getByRole('alert') @@ -36,8 +43,8 @@ describe('successful subscription page', function () { screen.getByText( /it’s support from people like yourself that allows .* to continue to grow and improve/i ) - expect(screen.getByText(/get the most from your/i).textContent).to.match( - /get the most from your .* subscription\. discover premium features/i + expect(screen.getByText(/get the most out of your/i).textContent).to.match( + /get the most out of your subscription by checking out Overleaf’s features/i ) expect( screen @@ -66,7 +73,7 @@ describe('successful subscription page', function () { ) const helpLink = screen.getByRole('link', { - name: /discover premium features/i, + name: /Overleaf’s features/i, }) expect(helpLink.getAttribute('href')).to.equal( '/learn/how-to/Overleaf_premium_features' diff --git a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts index ebd741b240..08690742d3 100644 --- a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts +++ b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts @@ -54,6 +54,7 @@ export const annualActiveSubscription: PaidSubscription = { addOnDisplayPricesWithoutAdditionalLicense: {}, isEligibleForGroupPlan: true, isEligibleForPause: false, + isEligibleForDowngradeUpsell: false, }, } @@ -96,6 +97,7 @@ export const annualActiveSubscriptionEuro: PaidSubscription = { addOnDisplayPricesWithoutAdditionalLicense: {}, isEligibleForGroupPlan: true, isEligibleForPause: true, + isEligibleForDowngradeUpsell: false, }, } @@ -137,6 +139,7 @@ export const annualActiveSubscriptionPro: PaidSubscription = { addOnDisplayPricesWithoutAdditionalLicense: {}, isEligibleForGroupPlan: true, isEligibleForPause: true, + isEligibleForDowngradeUpsell: false, }, } @@ -179,6 +182,7 @@ export const pastDueExpiredSubscription: PaidSubscription = { addOnDisplayPricesWithoutAdditionalLicense: {}, isEligibleForGroupPlan: true, isEligibleForPause: true, + isEligibleForDowngradeUpsell: false, }, } @@ -221,6 +225,7 @@ export const canceledSubscription: PaidSubscription = { addOnDisplayPricesWithoutAdditionalLicense: {}, isEligibleForGroupPlan: true, isEligibleForPause: true, + isEligibleForDowngradeUpsell: false, }, } @@ -263,6 +268,7 @@ export const pendingSubscriptionChange: PaidSubscription = { addOnDisplayPricesWithoutAdditionalLicense: {}, isEligibleForGroupPlan: true, isEligibleForPause: false, + isEligibleForDowngradeUpsell: false, }, pendingPlan: { planCode: 'professional-annual', @@ -316,6 +322,7 @@ export const groupActiveSubscription: GroupSubscription = { addOnDisplayPricesWithoutAdditionalLicense: {}, isEligibleForGroupPlan: true, isEligibleForPause: false, + isEligibleForDowngradeUpsell: false, }, } @@ -365,6 +372,7 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription addOnDisplayPricesWithoutAdditionalLicense: {}, isEligibleForGroupPlan: true, isEligibleForPause: false, + isEligibleForDowngradeUpsell: false, }, pendingPlan: { planCode: 'group_collaborator_10_enterprise', @@ -417,6 +425,7 @@ export const trialSubscription: PaidSubscription = { addOnDisplayPricesWithoutAdditionalLicense: {}, isEligibleForGroupPlan: true, isEligibleForPause: false, + isEligibleForDowngradeUpsell: false, }, } @@ -480,6 +489,7 @@ export const trialCollaboratorSubscription: PaidSubscription = { addOnDisplayPricesWithoutAdditionalLicense: {}, isEligibleForGroupPlan: true, isEligibleForPause: true, + isEligibleForDowngradeUpsell: false, }, } @@ -521,5 +531,6 @@ export const monthlyActiveCollaborator: PaidSubscription = { addOnDisplayPricesWithoutAdditionalLicense: {}, isEligibleForGroupPlan: true, isEligibleForPause: true, + isEligibleForDowngradeUpsell: true, }, } diff --git a/services/web/test/frontend/features/subscription/util/is-monthly-collaborator-plan.test.ts b/services/web/test/frontend/features/subscription/util/is-monthly-collaborator-plan.test.ts deleted file mode 100644 index dd9c2a64b1..0000000000 --- a/services/web/test/frontend/features/subscription/util/is-monthly-collaborator-plan.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { expect } from 'chai' -import isMonthlyCollaboratorPlan from '../../../../../frontend/js/features/subscription/util/is-monthly-collaborator-plan' - -describe('isMonthlyCollaboratorPlan', function () { - it('returns false when a plan code without "collaborator" ', function () { - expect(isMonthlyCollaboratorPlan('test', false)).to.be.false - }) - it('returns false when on a plan with "collaborator" and "ann"', function () { - expect(isMonthlyCollaboratorPlan('collaborator-annual', false)).to.be.false - }) - it('returns false when on a plan with "collaborator" and without "ann" but is a group plan', function () { - expect(isMonthlyCollaboratorPlan('collaborator', true)).to.be.false - }) - it('returns true when on a plan with non-group "collaborator" monthly plan', function () { - expect(isMonthlyCollaboratorPlan('collaborator', false)).to.be.true - }) -}) diff --git a/services/web/test/frontend/features/subscription/util/show-downgrade-option.test.ts b/services/web/test/frontend/features/subscription/util/show-downgrade-option.test.ts deleted file mode 100644 index 8a464ac796..0000000000 --- a/services/web/test/frontend/features/subscription/util/show-downgrade-option.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { expect } from 'chai' -import showDowngradeOption from '../../../../../frontend/js/features/subscription/util/show-downgrade-option' -import dateformat from 'dateformat' - -describe('showDowngradeOption', function () { - const today = new Date() - const sevenDaysFromToday = new Date().setDate(today.getDate() + 7) - const sevenDaysFromTodayFormatted = dateformat( - sevenDaysFromToday, - 'dS mmmm yyyy' - ) - - it('returns false when no trial end date', function () { - expect(showDowngradeOption('collab')).to.be.false - }) - it('returns false when a plan code without "collaborator" ', function () { - expect(showDowngradeOption('test', false, sevenDaysFromTodayFormatted)).to - .be.false - }) - it('returns false when on a plan with trial date in future but has "collaborator" and "ann" in plan code', function () { - expect( - showDowngradeOption( - 'collaborator-annual', - false, - sevenDaysFromTodayFormatted - ) - ).to.be.false - }) - it('returns false when on a plan with trial date in future and plan code has "collaborator" and no "ann" but is a group plan', function () { - expect( - showDowngradeOption('collaborator', true, sevenDaysFromTodayFormatted) - ).to.be.false - }) - it('returns false when on a plan with "collaborator" and without "ann" and trial date in future', function () { - expect( - showDowngradeOption('collaborator', false, sevenDaysFromTodayFormatted) - ).to.be.false - }) - it('returns true when on a plan with "collaborator" and without "ann" and no trial date', function () { - expect(showDowngradeOption('collaborator', false)).to.be.true - }) - it('returns true when on a plan with "collaborator" and without "ann" and trial date is in the past', function () { - expect( - showDowngradeOption('collaborator', false, '2000-02-16T17:59:07.000Z') - ).to.be.true - }) - it('returns false when on a monthly collaborator plan with a pending pause', function () { - expect( - showDowngradeOption( - 'collaborator', - false, - null, - '2030-01-01T12:00:00.000Z' - ) - ).to.be.false - }) - it('returns false when on a monthly collaborator plan with an active pause', function () { - expect( - showDowngradeOption( - 'collaborator', - false, - null, - '2030-01-01T12:00:00.000Z', - 5 - ) - ).to.be.false - }) -}) diff --git a/services/web/test/frontend/features/word-count-modal/utils/count-words-in-file.test.ts b/services/web/test/frontend/features/word-count-modal/utils/count-words-in-file.test.ts new file mode 100644 index 0000000000..32c7b72c7d --- /dev/null +++ b/services/web/test/frontend/features/word-count-modal/utils/count-words-in-file.test.ts @@ -0,0 +1,72 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { countWordsInFile } from '@/features/word-count-modal/utils/count-words-in-file' +import { WordCountData } from '@/features/word-count-modal/components/word-count-data' +import { createSegmenters } from '@/features/word-count-modal/utils/segmenters' +import { expect } from 'chai' + +describe('word count', function () { + beforeEach(async function () { + this.data = { + encode: '', + textWords: 0, + headWords: 0, + outside: 0, + headers: 0, + elements: 0, + mathInline: 0, + mathDisplay: 0, + errors: 0, + messages: '', + textCharacters: 0, + headCharacters: 0, + captionWords: 0, + captionCharacters: 0, + footnoteWords: 0, + footnoteCharacters: 0, + abstractWords: 0, + abstractCharacters: 0, + otherWords: 0, + otherCharacters: 0, + } satisfies WordCountData + + const content = { + 'word-count.tex': await readFile( + path.join(__dirname, 'word-count.tex'), + 'utf-8' + ), + } + + this.projectSnapshot = { + getDocContents(path: keyof typeof content) { + return content[path] + }, + } + + this.segmenters = createSegmenters('en_US') + }) + + it('produces correct counts', function () { + countWordsInFile( + this.data, + this.projectSnapshot, + 'word-count.tex', + this.segmenters + ) + + expect(this.data).to.deep.include({ + abstractCharacters: 8, + abstractWords: 2, + captionCharacters: 16, + captionWords: 4, + footnoteCharacters: 8, + footnoteWords: 2, + headCharacters: 296, + headWords: 52, + otherCharacters: 10, + otherWords: 2, + textCharacters: 193, + textWords: 42, + }) + }) +}) diff --git a/services/web/test/frontend/features/word-count-modal/utils/word-count.tex b/services/web/test/frontend/features/word-count-modal/utils/word-count.tex new file mode 100644 index 0000000000..4522d6a3bc --- /dev/null +++ b/services/web/test/frontend/features/word-count-modal/utils/word-count.tex @@ -0,0 +1,118 @@ +\documentclass{article} +\usepackage{graphicx} +\usepackage{amsmath} + +\title{The Title} % 2 in headers +\author{An Author} % 0 +\date{May 2025} % 0 + +\begin{document} + +\maketitle % 0 + +\thanks{bleep bloop} + +\begin{abstract} +Word word % 2 in abstract +\end{abstract} + +\section{plain text} +Word word % 2 + +\section{accents} +w\'ard w\`erd w\"ird w\~ord w\.urd w\^ord % 6 + +\section{accents with groups} +% w\'{a}rd w\`{e}rd w\"{i}rd w\~{o}rd w\.{u}rd w\^{o}rd w\c{o}rd w\u{o}rd % 8 % TODO + +\section{grouped single character command} +% w{\o}rd w\~{\o}rd % 2 % TODO + +\section{commands that create characters} +\o\oe\aa\AE % 1 + +\subsection{with braces} +w\o{}rd w\oe{}rd w\aa{}rd w\AE{}rd % 4 + +\subsection{with spaces} +w\o rd w\oe rd w\ae rd w\AE rd % 4 + +\section{symbols} +\S{} \P{} % 0 + +\section{formatting} +\textit{italic} \textbf{bold} \textit{\textbf{bold italic}} % 4 +\texttt{teletype} \textsf{sans-serif} \textsc{small caps} % 4 +\textsf{-} % 0 + +\section{formatting inside word} +% wo\textit{italic}rd %1 % TODO + +\section{commands that create text} +\textbackslash{}word \LaTeX{} % 2 + +\section{special characters} +word\&word \$word word\% \#word wo\_rd \{word\} % 7 + +\section{footnote} +\footnote{word word} % 2 in footnote + +\section{headers} +\part{word} % 1 +\chapter{word} % 1 +\section{word} % 1 +\subsection{word} % 1 +\subsubsection{word} % 1 +\paragraph{word} % 1 + +\section{verbatim} +\verb|word word word| % 0 + +\section{list} +\begin{itemize} + \item word % 1 + \item word % 1 +\end{itemize} + +\section{figure} +\begin{figure} + \includegraphics[width=0.5\linewidth]{example.png} %0 + \caption{Word word} %2 in captions +\end{figure} + +\section{table} +\begin{table} + \begin{tabular}{c|c} + word & word \\ % 2 + word & word % 2 + \end{tabular} + \caption{Word word} % 2 in captions +\end{table} + +\section{line break} +word\\word % 2 + +\section{inline math} +$2+3=5$ % 0 + +\section{display math} +\[2+3=5\] + +\begin{equation} + 2+3=5 +\end{equation} + +\begin{equation*} + 2+3=5 +\end{equation*} + +\begin{align*} +2x - 5y &= 8 \\ +3x + 9y &= -12 +\end{align*} + +\section{text in math} + +$ 2+3 \text{ is equal to } 5 $ + +\end{document} diff --git a/services/web/test/frontend/infrastructure/project-snapshot.test.ts b/services/web/test/frontend/infrastructure/project-snapshot.test.ts index 313436a3f5..b3a78efed7 100644 --- a/services/web/test/frontend/infrastructure/project-snapshot.test.ts +++ b/services/web/test/frontend/infrastructure/project-snapshot.test.ts @@ -119,10 +119,14 @@ describe('ProjectSnapshot', function () { } function mockChanges() { - fetchMock.getOnce(`/project/${projectId}/changes?since=1`, changes, { - name: 'changes-1', - }) - fetchMock.get(`/project/${projectId}/changes?since=2`, [], { + fetchMock.getOnce( + `/project/${projectId}/changes?since=1&paginated=true`, + changes, + { + name: 'changes-1', + } + ) + fetchMock.get(`/project/${projectId}/changes?since=2&paginated=true`, [], { name: 'changes-2', }) } diff --git a/services/web/test/smoke/src/steps/100_loadProjectDashboard.js b/services/web/test/smoke/src/steps/100_loadProjectDashboard.js index cea96bfc02..2a60f8d4b3 100644 --- a/services/web/test/smoke/src/steps/100_loadProjectDashboard.js +++ b/services/web/test/smoke/src/steps/100_loadProjectDashboard.js @@ -1,4 +1,4 @@ -const TITLE_REGEX = /Your Projects - .*, Online LaTeX Editor<\/title>/ +const TITLE_REGEX = /<title>Your projects - .*, Online LaTeX Editor<\/title>/ async function run({ request, assertHasStatusCode }) { const response = await request('/project') diff --git a/services/web/test/unit/bootstrap.js b/services/web/test/unit/bootstrap.js index fa2a7a5b76..ee4a022c15 100644 --- a/services/web/test/unit/bootstrap.js +++ b/services/web/test/unit/bootstrap.js @@ -88,6 +88,7 @@ function getSandboxedModuleRequires() { 'sshpk', 'xml2js', 'mongodb', + 'mongodb-legacy', ] for (const modulePath of internalModules) { requires[Path.resolve(__dirname, modulePath)] = require(modulePath) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js b/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js index 7bfbc1c423..dda99e04f3 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js +++ b/services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js @@ -52,6 +52,7 @@ describe('CollaboratorsGetter', function () { this.UserGetter = { promises: { getUser: sinon.stub().resolves(null), + getUsers: sinon.stub().resolves([]), }, } this.ProjectMock = sinon.mock(Project) @@ -205,21 +206,13 @@ describe('CollaboratorsGetter', function () { describe('getInvitedMembersWithPrivilegeLevels', function () { beforeEach(function () { - this.UserGetter.promises.getUser - .withArgs(this.readOnlyRef1.toString()) - .resolves({ _id: this.readOnlyRef1 }) - this.UserGetter.promises.getUser - .withArgs(this.readOnlyTokenRef.toString()) - .resolves({ _id: this.readOnlyTokenRef }) - this.UserGetter.promises.getUser - .withArgs(this.readWriteRef2.toString()) - .resolves({ _id: this.readWriteRef2 }) - this.UserGetter.promises.getUser - .withArgs(this.readWriteTokenRef.toString()) - .resolves({ _id: this.readWriteTokenRef }) - this.UserGetter.promises.getUser - .withArgs(this.reviewer1Ref.toString()) - .resolves({ _id: this.reviewer1Ref }) + this.UserGetter.promises.getUsers.resolves([ + { _id: this.readOnlyRef1 }, + { _id: this.readOnlyTokenRef }, + { _id: this.readWriteRef2 }, + { _id: this.readWriteTokenRef }, + { _id: this.reviewer1Ref }, + ]) }) it('should return an array of invited members with their privilege levels', async function () { @@ -416,15 +409,11 @@ describe('CollaboratorsGetter', function () { { _id: this.reviewUser._id, email: this.reviewUser.email }, ], } - this.UserGetter.promises.getUser - .withArgs(this.owningUser._id.toString()) - .resolves(this.owningUser) - this.UserGetter.promises.getUser - .withArgs(this.readWriteUser._id.toString()) - .resolves(this.readWriteUser) - this.UserGetter.promises.getUser - .withArgs(this.reviewUser._id.toString()) - .resolves(this.reviewUser) + this.UserGetter.promises.getUsers.resolves([ + this.owningUser, + this.readWriteUser, + this.reviewUser, + ]) this.ProjectEditorHandler.buildOwnerAndMembersViews.returns(this.views) this.result = await this.CollaboratorsGetter.promises.getAllInvitedMembers( diff --git a/services/web/test/unit/src/Compile/ClsiCookieManagerTests.js b/services/web/test/unit/src/Compile/ClsiCookieManagerTests.js index b61991a100..082262c853 100644 --- a/services/web/test/unit/src/Compile/ClsiCookieManagerTests.js +++ b/services/web/test/unit/src/Compile/ClsiCookieManagerTests.js @@ -1,26 +1,22 @@ const sinon = require('sinon') -const { assert, expect } = require('chai') +const { expect } = require('chai') const modulePath = '../../../../app/src/Features/Compile/ClsiCookieManager.js' const SandboxedModule = require('sandboxed-module') -const realRequst = require('request') describe('ClsiCookieManager', function () { beforeEach(function () { this.redis = { auth() {}, get: sinon.stub(), - setex: sinon.stub().callsArg(3), + setex: sinon.stub().resolves(), } this.project_id = '123423431321-proj-id' this.user_id = 'abc-user-id' - this.request = { - post: sinon.stub(), - cookie: realRequst.cookie, - jar: realRequst.jar, - defaults: () => { - return this.request - }, + this.fetchUtils = { + fetchNothing: sinon.stub().returns(Promise.resolve()), + fetchStringWithResponse: sinon.stub().returns(Promise.resolve()), } + this.metrics = { inc: sinon.stub() } this.settings = { redis: { web: 'redis.something', @@ -41,7 +37,8 @@ describe('ClsiCookieManager', function () { client: () => this.redis, }), '@overleaf/settings': this.settings, - request: this.request, + '@overleaf/fetch-utils': this.fetchUtils, + '@overleaf/metrics': this.metrics, } this.ClsiCookieManager = SandboxedModule.require(modulePath, { requires: this.requires, @@ -49,74 +46,56 @@ describe('ClsiCookieManager', function () { }) describe('getServerId', function () { - it('should call get for the key', function (done) { - this.redis.get.callsArgWith(1, null, 'clsi-7') - this.ClsiCookieManager.getServerId( + it('should call get for the key', async function () { + this.redis.get.resolves('clsi-7') + const serverId = await this.ClsiCookieManager.promises.getServerId( this.project_id, this.user_id, '', - 'e2', - (err, serverId) => { - if (err) { - return done(err) - } - this.redis.get - .calledWith(`clsiserver:${this.project_id}:${this.user_id}`) - .should.equal(true) - serverId.should.equal('clsi-7') - done() - } + 'e2' ) + this.redis.get + .calledWith(`clsiserver:${this.project_id}:${this.user_id}`) + .should.equal(true) + serverId.should.equal('clsi-7') }) - it('should _populateServerIdViaRequest if no key is found', function (done) { - this.ClsiCookieManager._populateServerIdViaRequest = sinon + it('should _populateServerIdViaRequest if no key is found', async function () { + this.ClsiCookieManager.promises._populateServerIdViaRequest = sinon .stub() - .yields(null) - this.redis.get.callsArgWith(1, null) - this.ClsiCookieManager.getServerId( + .resolves() + this.redis.get.resolves(null) + await this.ClsiCookieManager.promises.getServerId( this.project_id, this.user_id, - '', - (err, serverId) => { - if (err) { - return done(err) - } - this.ClsiCookieManager._populateServerIdViaRequest - .calledWith(this.project_id, this.user_id) - .should.equal(true) - done() - } + '' ) + this.ClsiCookieManager.promises._populateServerIdViaRequest + .calledWith(this.project_id, this.user_id) + .should.equal(true) }) - it('should _populateServerIdViaRequest if no key is blank', function (done) { - this.ClsiCookieManager._populateServerIdViaRequest = sinon + it('should _populateServerIdViaRequest if no key is blank', async function () { + this.ClsiCookieManager.promises._populateServerIdViaRequest = sinon .stub() - .yields(null) - this.redis.get.callsArgWith(1, null, '') - this.ClsiCookieManager.getServerId( + .resolves(null) + this.redis.get.resolves('') + await this.ClsiCookieManager.promises.getServerId( this.project_id, this.user_id, '', - 'e2', - (err, serverId) => { - if (err) { - return done(err) - } - this.ClsiCookieManager._populateServerIdViaRequest - .calledWith(this.project_id, this.user_id) - .should.equal(true) - done() - } + 'e2' ) + this.ClsiCookieManager.promises._populateServerIdViaRequest + .calledWith(this.project_id, this.user_id) + .should.equal(true) }) }) describe('_populateServerIdViaRequest', function () { beforeEach(function () { this.clsiServerId = 'server-id' - this.ClsiCookieManager.setServerId = sinon.stub().yields() + this.ClsiCookieManager.promises.setServerId = sinon.stub().resolves() }) describe('with a server id in the response', function () { @@ -128,71 +107,54 @@ describe('ClsiCookieManager', function () { ], }, } - this.request.post.callsArgWith(1, null, this.response) + this.fetchUtils.fetchNothing.returns(this.response) }) - it('should make a request to the clsi', function (done) { - this.ClsiCookieManager._populateServerIdViaRequest( + it('should make a request to the clsi', async function () { + await this.ClsiCookieManager.promises._populateServerIdViaRequest( this.project_id, this.user_id, 'standard', - 'e2', - (err, serverId) => { - if (err) { - return done(err) - } - const args = this.ClsiCookieManager.setServerId.args[0] - args[0].should.equal(this.project_id) - args[1].should.equal(this.user_id) - args[2].should.equal('standard') - args[3].should.equal('e2') - args[4].should.deep.equal(this.clsiServerId) - done() - } + 'e2' ) + const args = this.ClsiCookieManager.promises.setServerId.args[0] + args[0].should.equal(this.project_id) + args[1].should.equal(this.user_id) + args[2].should.equal('standard') + args[3].should.equal('e2') + args[4].should.deep.equal(this.clsiServerId) }) - it('should return the server id', function (done) { - this.ClsiCookieManager._populateServerIdViaRequest( - this.project_id, - this.user_id, - '', - 'e2', - (err, serverId) => { - if (err) { - return done(err) - } - serverId.should.equal(this.clsiServerId) - done() - } - ) + it('should return the server id', async function () { + const serverId = + await this.ClsiCookieManager.promises._populateServerIdViaRequest( + this.project_id, + this.user_id, + '', + 'e2' + ) + serverId.should.equal(this.clsiServerId) }) }) describe('without a server id in the response', function () { beforeEach(function () { this.response = { headers: {} } - this.request.post.yields(null, this.response) + this.fetchUtils.fetchNothing.returns(this.response) }) - it('should not set the server id there is no server id in the response', function (done) { + it('should not set the server id there is no server id in the response', async function () { this.ClsiCookieManager._parseServerIdFromResponse = sinon .stub() .returns(null) - this.ClsiCookieManager.setServerId( + await this.ClsiCookieManager.promises.setServerId( this.project_id, this.user_id, 'standard', 'e2', this.clsiServerId, - null, - err => { - if (err) { - return done(err) - } - this.redis.setex.called.should.equal(false) - done() - } + null ) + this.redis.setex.called.should.equal(false) }) }) }) @@ -205,162 +167,148 @@ describe('ClsiCookieManager', function () { .returns('clsi-8') }) - it('should set the server id with a ttl', function (done) { - this.ClsiCookieManager.setServerId( + it('should set the server id with a ttl', async function () { + await this.ClsiCookieManager.promises.setServerId( this.project_id, this.user_id, 'standard', 'e2', this.clsiServerId, - null, - err => { - if (err) { - return done(err) - } - this.redis.setex.should.have.been.calledWith( - `clsiserver:${this.project_id}:${this.user_id}`, - this.settings.clsiCookie.ttlInSeconds, - this.clsiServerId - ) - done() - } + null + ) + this.redis.setex.should.have.been.calledWith( + `clsiserver:${this.project_id}:${this.user_id}`, + this.settings.clsiCookie.ttlInSeconds, + this.clsiServerId ) }) - it('should set the server id with the regular ttl for reg instance', function (done) { + it('should set the server id with the regular ttl for reg instance', async function () { this.clsiServerId = 'clsi-reg-8' - this.ClsiCookieManager.setServerId( + await this.ClsiCookieManager.promises.setServerId( this.project_id, this.user_id, 'standard', 'e2', this.clsiServerId, - null, - err => { - if (err) { - return done(err) - } - expect(this.redis.setex).to.have.been.calledWith( - `clsiserver:${this.project_id}:${this.user_id}`, - this.settings.clsiCookie.ttlInSecondsRegular, - this.clsiServerId - ) - done() - } + null + ) + expect(this.redis.setex).to.have.been.calledWith( + `clsiserver:${this.project_id}:${this.user_id}`, + this.settings.clsiCookie.ttlInSecondsRegular, + this.clsiServerId ) }) - it('should not set the server id if clsiCookies are not enabled', function (done) { + it('should not set the server id if clsiCookies are not enabled', async function () { delete this.settings.clsiCookie.key - this.ClsiCookieManager = SandboxedModule.require(modulePath, { + this.ClsiCookieManager2 = SandboxedModule.require(modulePath, { globals: { console, }, requires: this.requires, })() - this.ClsiCookieManager.setServerId( + await this.ClsiCookieManager2.promises.setServerId( this.project_id, this.user_id, 'standard', 'e2', this.clsiServerId, - null, - err => { - if (err) { - return done(err) - } - this.redis.setex.called.should.equal(false) - done() - } + null ) + this.redis.setex.called.should.equal(false) }) - it('should also set in the secondary if secondary redis is enabled', function (done) { - this.redis_secondary = { setex: sinon.stub().callsArg(3) } + it('should also set in the secondary if secondary redis is enabled', async function () { + this.redis_secondary = { setex: sinon.stub().resolves() } this.settings.redis.clsi_cookie_secondary = {} this.RedisWrapper.client = sinon.stub() this.RedisWrapper.client.withArgs('clsi_cookie').returns(this.redis) this.RedisWrapper.client .withArgs('clsi_cookie_secondary') .returns(this.redis_secondary) - this.ClsiCookieManager = SandboxedModule.require(modulePath, { + this.ClsiCookieManager2 = SandboxedModule.require(modulePath, { globals: { console, }, requires: this.requires, })() - this.ClsiCookieManager._parseServerIdFromResponse = sinon + this.ClsiCookieManager2._parseServerIdFromResponse = sinon .stub() .returns('clsi-8') - this.ClsiCookieManager.setServerId( + await this.ClsiCookieManager2.promises.setServerId( this.project_id, this.user_id, 'standard', 'e2', this.clsiServerId, - null, - err => { - if (err) { - return done(err) - } - this.redis_secondary.setex.should.have.been.calledWith( - `clsiserver:${this.project_id}:${this.user_id}`, - this.settings.clsiCookie.ttlInSeconds, - this.clsiServerId + null + ) + this.redis_secondary.setex.should.have.been.calledWith( + `clsiserver:${this.project_id}:${this.user_id}`, + this.settings.clsiCookie.ttlInSeconds, + this.clsiServerId + ) + }) + + describe('checkIsLoadSheddingEvent', function () { + beforeEach(function () { + this.fetchUtils.fetchStringWithResponse.reset() + this.call = async () => { + await this.ClsiCookieManager.promises.setServerId( + this.project_id, + this.user_id, + 'standard', + 'e2', + this.clsiServerId, + 'previous-clsi-server-id' + ) + expect( + this.fetchUtils.fetchStringWithResponse + ).to.have.been.calledWith( + `${this.settings.apis.clsi.url}/instance-state?clsiserverid=previous-clsi-server-id&compileGroup=standard&compileBackendClass=e2`, + { method: 'GET', signal: sinon.match.instanceOf(AbortSignal) } ) - done() } - ) - }) - }) + }) - describe('getCookieJar', function () { - beforeEach(function () { - this.ClsiCookieManager.getServerId = sinon.stub().yields(null, 'clsi-11') - }) + it('should report "load-shedding" when previous is UP', async function () { + this.fetchUtils.fetchStringWithResponse.resolves({ + response: { status: 200 }, + body: 'previous-clsi-server-id,UP\n', + }) + await this.call() + expect(this.metrics.inc).to.have.been.calledWith( + 'clsi-lb-switch-backend', + 1, + { status: 'load-shedding' } + ) + }) - it('should return a jar with the cookie set populated from redis', function (done) { - this.ClsiCookieManager.getCookieJar( - this.project_id, - this.user_id, - '', - 'e2', - (err, jar) => { - if (err) { - return done(err) - } - jar._jar.store.idx['clsi.example.com']['/'][ - this.settings.clsiCookie.key - ].key.should.equal - jar._jar.store.idx['clsi.example.com']['/'][ - this.settings.clsiCookie.key - ].value.should.equal('clsi-11') - done() - } - ) - }) + it('should report "cycle" when other is UP', async function () { + this.fetchUtils.fetchStringWithResponse.resolves({ + response: { status: 200 }, + body: 'other-clsi-server-id,UP\n', + }) + await this.call() + expect(this.metrics.inc).to.have.been.calledWith( + 'clsi-lb-switch-backend', + 1, + { status: 'cycle' } + ) + }) - it('should return empty cookie jar if clsiCookies are not enabled', function (done) { - delete this.settings.clsiCookie.key - this.ClsiCookieManager = SandboxedModule.require(modulePath, { - globals: { - console, - }, - requires: this.requires, - })() - this.ClsiCookieManager.getCookieJar( - this.project_id, - this.user_id, - '', - 'e2', - (err, jar) => { - if (err) { - return done(err) - } - assert.deepEqual(jar, realRequst.jar()) - done() - } - ) + it('should report "cycle" when previous is 404', async function () { + this.fetchUtils.fetchStringWithResponse.resolves({ + response: { status: 404 }, + }) + await this.call() + expect(this.metrics.inc).to.have.been.calledWith( + 'clsi-lb-switch-backend', + 1, + { status: 'cycle' } + ) + }) }) }) }) diff --git a/services/web/test/unit/src/Compile/CompileControllerTests.js b/services/web/test/unit/src/Compile/CompileControllerTests.js index aefa197a17..92bd21d176 100644 --- a/services/web/test/unit/src/Compile/CompileControllerTests.js +++ b/services/web/test/unit/src/Compile/CompileControllerTests.js @@ -1,4 +1,3 @@ -/* eslint-disable mocha/handle-done-callback */ const sinon = require('sinon') const { expect } = require('chai') const modulePath = '../../../../app/src/Features/Compile/CompileController.js' @@ -19,8 +18,15 @@ describe('CompileController', function () { compileTimeout: 100, }, } - this.CompileManager = { compile: sinon.stub() } - this.ClsiManager = {} + this.CompileManager = { + promises: { + compile: sinon.stub(), + getProjectCompileLimits: sinon.stub(), + }, + } + this.ClsiManager = { + promises: {}, + } this.UserGetter = { getUser: sinon.stub() } this.rateLimiter = { consume: sinon.stub().resolves(), @@ -47,10 +53,11 @@ describe('CompileController', function () { }, } this.ClsiCookieManager = { - getServerId: sinon.stub().yields(null, 'clsi-server-id-from-redis'), + promises: { + getServerId: sinon.stub().resolves('clsi-server-id-from-redis'), + }, } this.SessionManager = { - getLoggedInUser: sinon.stub().callsArgWith(1, null, this.user), getLoggedInUserId: sinon.stub().returns(this.user_id), getSessionUser: sinon.stub().returns(this.user), isUserLoggedIn: sinon.stub().returns(true), @@ -76,8 +83,9 @@ describe('CompileController', function () { 'stream/promises': { pipeline: this.pipeline }, '@overleaf/settings': this.settings, '@overleaf/fetch-utils': this.fetchUtils, - request: (this.request = sinon.stub()), - '../Project/ProjectGetter': (this.ProjectGetter = {}), + '../Project/ProjectGetter': (this.ProjectGetter = { + promises: {}, + }), '@overleaf/metrics': (this.Metrics = { inc: sinon.stub(), Timer: class { @@ -121,25 +129,23 @@ describe('CompileController', function () { beforeEach(function () { this.req.params = { Project_id: this.projectId } this.req.session = {} - this.CompileManager.compile = sinon.stub().callsArgWith( - 3, - null, - (this.status = 'success'), - (this.outputFiles = [ + this.CompileManager.promises.compile = sinon.stub().resolves({ + status: (this.status = 'success'), + outputFiles: (this.outputFiles = [ { path: 'output.pdf', url: `/project/${this.projectId}/user/${this.user_id}/build/id/output.pdf`, type: 'pdf', }, ]), - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - this.build_id - ) + clsiServerId: undefined, + limits: undefined, + validationProblems: undefined, + stats: undefined, + timings: undefined, + outputUrlPrefix: undefined, + buildId: this.build_id, + }) }) describe('pdfDownloadDomain', function () { @@ -148,9 +154,8 @@ describe('CompileController', function () { }) describe('when clsi does not emit zone prefix', function () { - beforeEach(function (done) { - this.res.callback = done - this.CompileController.compile(this.req, this.res, this.next) + beforeEach(async function () { + await this.CompileController.compile(this.req, this.res, this.next) }) it('should add domain verbatim', function () { @@ -177,28 +182,25 @@ describe('CompileController', function () { }) describe('when clsi emits a zone prefix', function () { - beforeEach(function (done) { - this.res.callback = done - this.CompileManager.compile = sinon.stub().callsArgWith( - 3, - null, - (this.status = 'success'), - (this.outputFiles = [ + beforeEach(async function () { + this.CompileManager.promises.compile = sinon.stub().resolves({ + status: (this.status = 'success'), + outputFiles: (this.outputFiles = [ { path: 'output.pdf', url: `/project/${this.projectId}/user/${this.user_id}/build/id/output.pdf`, type: 'pdf', }, ]), - undefined, // clsiServerId - undefined, // limits - undefined, // validationProblems - undefined, // stats - undefined, // timings - '/zone/b', - this.build_id - ) - this.CompileController.compile(this.req, this.res, this.next) + clsiServerId: undefined, + limits: undefined, + validationProblems: undefined, + stats: undefined, + timings: undefined, + outputUrlPrefix: '/zone/b', + buildId: this.build_id, + }) + await this.CompileController.compile(this.req, this.res, this.next) }) it('should add the zone prefix', function () { @@ -227,9 +229,8 @@ describe('CompileController', function () { }) describe('when not an auto compile', function () { - beforeEach(function (done) { - this.res.callback = done - this.CompileController.compile(this.req, this.res, this.next) + beforeEach(async function () { + await this.CompileController.compile(this.req, this.res, this.next) }) it('should look up the user id', function () { @@ -239,7 +240,7 @@ describe('CompileController', function () { }) it('should do the compile without the auto compile flag', function () { - this.CompileManager.compile.should.have.been.calledWith( + this.CompileManager.promises.compile.should.have.been.calledWith( this.projectId, this.user_id, { @@ -275,14 +276,13 @@ describe('CompileController', function () { }) describe('when an auto compile', function () { - beforeEach(function (done) { - this.res.callback = done + beforeEach(async function () { this.req.query = { auto_compile: 'true' } - this.CompileController.compile(this.req, this.res, this.next) + await this.CompileController.compile(this.req, this.res, this.next) }) it('should do the compile with the auto compile flag', function () { - this.CompileManager.compile.should.have.been.calledWith( + this.CompileManager.promises.compile.should.have.been.calledWith( this.projectId, this.user_id, { @@ -299,14 +299,13 @@ describe('CompileController', function () { }) describe('with the draft attribute', function () { - beforeEach(function (done) { - this.res.callback = done + beforeEach(async function () { this.req.body = { draft: true } - this.CompileController.compile(this.req, this.res, this.next) + await this.CompileController.compile(this.req, this.res, this.next) }) it('should do the compile without the draft compile flag', function () { - this.CompileManager.compile.should.have.been.calledWith( + this.CompileManager.promises.compile.should.have.been.calledWith( this.projectId, this.user_id, { @@ -324,14 +323,13 @@ describe('CompileController', function () { }) describe('with an editor id', function () { - beforeEach(function (done) { - this.res.callback = done + beforeEach(async function () { this.req.body = { editorId: 'the-editor-id' } - this.CompileController.compile(this.req, this.res, this.next) + await this.CompileController.compile(this.req, this.res, this.next) }) it('should pass the editor id to the compiler', function () { - this.CompileManager.compile.should.have.been.calledWith( + this.CompileManager.promises.compile.should.have.been.calledWith( this.projectId, this.user_id, { @@ -353,25 +351,29 @@ describe('CompileController', function () { this.submission_id = 'sub-1234' this.req.params = { submission_id: this.submission_id } this.req.body = {} - this.ClsiManager.sendExternalRequest = sinon - .stub() - .callsArgWith( - 3, - null, - (this.status = 'success'), - (this.outputFiles = ['mock-output-files']), - (this.clsiServerId = 'mock-server-id'), - (this.validationProblems = null) - ) + this.ClsiManager.promises.sendExternalRequest = sinon.stub().resolves({ + status: (this.status = 'success'), + outputFiles: (this.outputFiles = ['mock-output-files']), + clsiServerId: 'mock-server-id', + validationProblems: null, + }) }) - it('should set the content-type of the response to application/json', function () { - this.CompileController.compileSubmission(this.req, this.res, this.next) + it('should set the content-type of the response to application/json', async function () { + await this.CompileController.compileSubmission( + this.req, + this.res, + this.next + ) this.res.contentType.calledWith('application/json').should.equal(true) }) - it('should send a successful response reporting the status and files', function () { - this.CompileController.compileSubmission(this.req, this.res, this.next) + it('should send a successful response reporting the status and files', async function () { + await this.CompileController.compileSubmission( + this.req, + this.res, + this.next + ) this.res.statusCode.should.equal(200) this.res.body.should.equal( JSON.stringify({ @@ -393,7 +395,7 @@ describe('CompileController', function () { }) it('should use the supplied values', function () { - this.ClsiManager.sendExternalRequest.should.have.been.calledWith( + this.ClsiManager.promises.sendExternalRequest.should.have.been.calledWith( this.submission_id, { compileGroup: 'special', timeout: 600 }, { compileGroup: 'special', compileBackendClass: 'n2d', timeout: 600 } @@ -413,7 +415,7 @@ describe('CompileController', function () { }) it('should use the other options but default values for compileGroup and timeout', function () { - this.ClsiManager.sendExternalRequest.should.have.been.calledWith( + this.ClsiManager.promises.sendExternalRequest.should.have.been.calledWith( this.submission_id, { rootResourcePath: 'main.tex', @@ -437,24 +439,21 @@ describe('CompileController', function () { describe('downloadPdf', function () { beforeEach(function () { + this.CompileController._proxyToClsi = sinon.stub().resolves() this.req.params = { Project_id: this.projectId } - this.project = { name: 'test namè; 1' } - this.ProjectGetter.getProject = sinon + this.ProjectGetter.promises.getProject = sinon .stub() - .callsArgWith(2, null, this.project) + .resolves(this.project) }) describe('when downloading for embedding', function () { - beforeEach(function (done) { - this.CompileController.proxyToClsi = sinon - .stub() - .callsFake(() => done()) - this.CompileController.downloadPdf(this.req, this.res, this.next) + beforeEach(async function () { + await this.CompileController.downloadPdf(this.req, this.res, this.next) }) it('should look up the project', function () { - this.ProjectGetter.getProject + this.ProjectGetter.promises.getProject .calledWith(this.projectId, { name: 1 }) .should.equal(true) }) @@ -474,49 +473,72 @@ describe('CompileController', function () { }) it('should proxy the PDF from the CLSI', function () { - this.CompileController.proxyToClsi + this.CompileController._proxyToClsi .calledWith( this.projectId, 'output-file', `/project/${this.projectId}/user/${this.user_id}/output/output.pdf`, {}, this.req, - this.res, - this.next + this.res ) .should.equal(true) }) }) describe('when a build-id is provided', function () { - beforeEach(function (done) { + beforeEach(async function () { this.req.params.build_id = this.build_id - this.CompileController.proxyToClsi = sinon - .stub() - .callsFake(() => done()) - this.CompileController.downloadPdf(this.req, this.res, this.next) + await this.CompileController.downloadPdf(this.req, this.res, this.next) }) it('should proxy the PDF from the CLSI, with a build-id', function () { - this.CompileController.proxyToClsi + this.CompileController._proxyToClsi .calledWith( this.projectId, 'output-file', `/project/${this.projectId}/user/${this.user_id}/build/${this.build_id}/output/output.pdf`, {}, this.req, - this.res, - this.next + this.res ) .should.equal(true) }) }) + + describe('when rate-limited', function () { + beforeEach(async function () { + this.rateLimiter.consume.rejects({ + msBeforeNext: 250, + remainingPoints: 0, + consumedPoints: 5, + isFirstInDuration: false, + }) + }) + it('should return 500', async function () { + await this.CompileController.downloadPdf(this.req, this.res, this.next) + // should it be 429 instead? + this.res.sendStatus.calledWith(500).should.equal(true) + this.CompileController._proxyToClsi.should.not.have.been.called + }) + }) + + describe('when rate-limit errors', function () { + beforeEach(async function () { + this.rateLimiter.consume.rejects(new Error('uh oh')) + }) + it('should return 500', async function () { + await this.CompileController.downloadPdf(this.req, this.res, this.next) + this.res.sendStatus.calledWith(500).should.equal(true) + this.CompileController._proxyToClsi.should.not.have.been.called + }) + }) }) describe('getFileFromClsiWithoutUser', function () { beforeEach(function () { this.submission_id = 'sub-1234' - this.file = 'project.pdf' + this.file = 'output.pdf' this.req.params = { submission_id: this.submission_id, build_id: this.build_id, @@ -524,12 +546,12 @@ describe('CompileController', function () { } this.req.body = {} this.expected_url = `/project/${this.submission_id}/build/${this.build_id}/output/${this.file}` - this.CompileController.proxyToClsiWithLimits = sinon.stub() + this.CompileController._proxyToClsiWithLimits = sinon.stub() }) describe('without limits specified', function () { - beforeEach(function () { - this.CompileController.getFileFromClsiWithoutUser( + beforeEach(async function () { + await this.CompileController.getFileFromClsiWithoutUser( this.req, this.res, this.next @@ -537,15 +559,12 @@ describe('CompileController', function () { }) it('should proxy to CLSI with correct URL and default limits', function () { - this.CompileController.proxyToClsiWithLimits.should.have.been.calledWith( + this.CompileController._proxyToClsiWithLimits.should.have.been.calledWith( this.submission_id, 'output-file', this.expected_url, {}, - { - compileGroup: 'standard', - compileBackendClass: 'n2d', - } + { compileGroup: 'standard', compileBackendClass: 'n2d' } ) }) }) @@ -561,7 +580,7 @@ describe('CompileController', function () { }) it('should proxy to CLSI with correct URL and specified limits', function () { - this.CompileController.proxyToClsiWithLimits.should.have.been.calledWith( + this.CompileController._proxyToClsiWithLimits.should.have.been.calledWith( this.submission_id, 'output-file', this.expected_url, @@ -577,7 +596,7 @@ describe('CompileController', function () { describe('proxySyncCode', function () { let file, line, column, imageName, editorId, buildId - beforeEach(function (done) { + beforeEach(async function () { this.req.params = { Project_id: this.projectId } file = 'main.tex' line = String(Date.now()) @@ -587,17 +606,17 @@ describe('CompileController', function () { this.req.query = { file, line, column, editorId, buildId } imageName = 'foo/bar:tag-0' - this.ProjectGetter.getProject = sinon.stub().yields(null, { imageName }) + this.ProjectGetter.promises.getProject = sinon + .stub() + .resolves({ imageName }) - this.next.callsFake(done) - this.res.callback = done - this.CompileController.proxyToClsi = sinon.stub().callsFake(() => done()) + this.CompileController._proxyToClsi = sinon.stub().resolves() - this.CompileController.proxySyncCode(this.req, this.res, this.next) + await this.CompileController.proxySyncCode(this.req, this.res, this.next) }) it('should proxy the request with an imageName', function () { - expect(this.CompileController.proxyToClsi).to.have.been.calledWith( + expect(this.CompileController._proxyToClsi).to.have.been.calledWith( this.projectId, 'sync-to-code', `/project/${this.projectId}/user/${this.user_id}/sync/code`, @@ -611,8 +630,7 @@ describe('CompileController', function () { compileFromClsiCache: false, }, this.req, - this.res, - this.next + this.res ) }) }) @@ -620,7 +638,7 @@ describe('CompileController', function () { describe('proxySyncPdf', function () { let page, h, v, imageName, editorId, buildId - beforeEach(function (done) { + beforeEach(async function () { this.req.params = { Project_id: this.projectId } page = String(Date.now()) h = String(Math.random()) @@ -630,17 +648,17 @@ describe('CompileController', function () { this.req.query = { page, h, v, editorId, buildId } imageName = 'foo/bar:tag-1' - this.ProjectGetter.getProject = sinon.stub().yields(null, { imageName }) + this.ProjectGetter.promises.getProject = sinon + .stub() + .resolves({ imageName }) - this.next.callsFake(done) - this.res.callback = done - this.CompileController.proxyToClsi = sinon.stub().callsFake(() => done()) + this.CompileController._proxyToClsi = sinon.stub() - this.CompileController.proxySyncPdf(this.req, this.res, this.next) + await this.CompileController.proxySyncPdf(this.req, this.res, this.next) }) it('should proxy the request with an imageName', function () { - expect(this.CompileController.proxyToClsi).to.have.been.calledWith( + expect(this.CompileController._proxyToClsi).to.have.been.calledWith( this.projectId, 'sync-to-pdf', `/project/${this.projectId}/user/${this.user_id}/sync/pdf`, @@ -654,13 +672,12 @@ describe('CompileController', function () { compileFromClsiCache: false, }, this.req, - this.res, - this.next + this.res ) }) }) - describe('proxyToClsi', function () { + describe('_proxyToClsi', function () { beforeEach(function () { this.req.method = 'mock-method' this.req.headers = { @@ -673,15 +690,14 @@ describe('CompileController', function () { describe('old pdf viewer', function () { describe('user with standard priority', function () { - beforeEach(function (done) { - this.res.callback = done - this.CompileManager.getProjectCompileLimits = sinon + beforeEach(async function () { + this.CompileManager.promises.getProjectCompileLimits = sinon .stub() - .callsArgWith(1, null, { + .resolves({ compileGroup: 'standard', compileBackendClass: 'e2', }) - this.CompileController.proxyToClsi( + await this.CompileController._proxyToClsi( this.projectId, 'output-file', (this.url = '/test'), @@ -704,15 +720,14 @@ describe('CompileController', function () { }) describe('user with priority compile', function () { - beforeEach(function (done) { - this.res.callback = done - this.CompileManager.getProjectCompileLimits = sinon + beforeEach(async function () { + this.CompileManager.promises.getProjectCompileLimits = sinon .stub() - .callsArgWith(1, null, { + .resolves({ compileGroup: 'priority', compileBackendClass: 'c2d', }) - this.CompileController.proxyToClsi( + await this.CompileController._proxyToClsi( this.projectId, 'output-file', (this.url = '/test'), @@ -731,16 +746,15 @@ describe('CompileController', function () { }) describe('user with standard priority via query string', function () { - beforeEach(function (done) { - this.res.callback = done + beforeEach(async function () { this.req.query = { compileGroup: 'standard' } - this.CompileManager.getProjectCompileLimits = sinon + this.CompileManager.promises.getProjectCompileLimits = sinon .stub() - .callsArgWith(1, null, { + .resolves({ compileGroup: 'standard', compileBackendClass: 'e2', }) - this.CompileController.proxyToClsi( + await this.CompileController._proxyToClsi( this.projectId, 'output-file', (this.url = '/test'), @@ -763,16 +777,15 @@ describe('CompileController', function () { }) describe('user with non-existent priority via query string', function () { - beforeEach(function (done) { - this.res.callback = done + beforeEach(async function () { this.req.query = { compileGroup: 'foobar' } - this.CompileManager.getProjectCompileLimits = sinon + this.CompileManager.promises.getProjectCompileLimits = sinon .stub() - .callsArgWith(1, null, { + .resolves({ compileGroup: 'standard', compileBackendClass: 'e2', }) - this.CompileController.proxyToClsi( + await this.CompileController._proxyToClsi( this.projectId, 'output-file', (this.url = '/test'), @@ -791,16 +804,15 @@ describe('CompileController', function () { }) describe('user with build parameter via query string', function () { - beforeEach(function (done) { - this.res.callback = done - this.CompileManager.getProjectCompileLimits = sinon + beforeEach(async function () { + this.CompileManager.promises.getProjectCompileLimits = sinon .stub() - .callsArgWith(1, null, { + .resolves({ compileGroup: 'standard', compileBackendClass: 'e2', }) this.req.query = { build: 1234 } - this.CompileController.proxyToClsi( + await this.CompileController._proxyToClsi( this.projectId, 'output-file', (this.url = '/test'), @@ -821,16 +833,16 @@ describe('CompileController', function () { }) describe('deleteAuxFiles', function () { - beforeEach(function () { - this.CompileManager.deleteAuxFiles = sinon.stub().yields() + beforeEach(async function () { + this.CompileManager.promises.deleteAuxFiles = sinon.stub().resolves() this.req.params = { Project_id: this.projectId } this.req.query = { clsiserverid: 'node-1' } this.res.sendStatus = sinon.stub() - this.CompileController.deleteAuxFiles(this.req, this.res, this.next) + await this.CompileController.deleteAuxFiles(this.req, this.res, this.next) }) it('should proxy to the CLSI', function () { - this.CompileManager.deleteAuxFiles + this.CompileManager.promises.deleteAuxFiles .calledWith(this.projectId, this.user_id, 'node-1') .should.equal(true) }) @@ -848,26 +860,25 @@ describe('CompileController', function () { }, } this.downloadPath = `/project/${this.projectId}/build/123/output/output.pdf` - this.CompileManager.compile.callsArgWith(3, null, 'success', [ - { - path: 'output.pdf', - url: this.downloadPath, - }, - ]) - this.CompileController.proxyToClsi = sinon.stub() + this.CompileManager.promises.compile.resolves({ + status: 'success', + outputFiles: [{ path: 'output.pdf', url: this.downloadPath }], + }) + this.CompileController._proxyToClsi = sinon.stub() this.res = { send: () => {}, sendStatus: sinon.stub() } }) - it('should call compile in the compile manager', function (done) { - this.CompileController.compileAndDownloadPdf(this.req, this.res) - this.CompileManager.compile.calledWith(this.projectId).should.equal(true) - done() + it('should call compile in the compile manager', async function () { + await this.CompileController.compileAndDownloadPdf(this.req, this.res) + this.CompileManager.promises.compile + .calledWith(this.projectId) + .should.equal(true) }) - it('should proxy the res to the clsi with correct url', function (done) { - this.CompileController.compileAndDownloadPdf(this.req, this.res) + it('should proxy the res to the clsi with correct url', async function () { + await this.CompileController.compileAndDownloadPdf(this.req, this.res) sinon.assert.calledWith( - this.CompileController.proxyToClsi, + this.CompileController._proxyToClsi, this.projectId, 'output-file', this.downloadPath, @@ -876,7 +887,7 @@ describe('CompileController', function () { this.res ) - this.CompileController.proxyToClsi + this.CompileController._proxyToClsi .calledWith( this.projectId, 'output-file', @@ -886,38 +897,44 @@ describe('CompileController', function () { this.res ) .should.equal(true) - done() }) - it('should not download anything on compilation failures', function () { - this.CompileManager.compile.yields(new Error('failed')) - this.CompileController.compileAndDownloadPdf(this.req, this.res) + it('should not download anything on compilation failures', async function () { + this.CompileManager.promises.compile.rejects(new Error('failed')) + await this.CompileController.compileAndDownloadPdf( + this.req, + this.res, + this.next + ) this.res.sendStatus.should.have.been.calledWith(500) - this.CompileController.proxyToClsi.should.not.have.been.called + this.CompileController._proxyToClsi.should.not.have.been.called }) - it('should not download anything on missing pdf', function () { - this.CompileManager.compile.yields(null, 'success', []) - this.CompileController.compileAndDownloadPdf(this.req, this.res) + it('should not download anything on missing pdf', async function () { + this.CompileManager.promises.compile.resolves({ + status: 'success', + outputFiles: [], + }) + await this.CompileController.compileAndDownloadPdf(this.req, this.res) this.res.sendStatus.should.have.been.calledWith(500) - this.CompileController.proxyToClsi.should.not.have.been.called + this.CompileController._proxyToClsi.should.not.have.been.called }) }) describe('wordCount', function () { - beforeEach(function () { - this.CompileManager.wordCount = sinon + beforeEach(async function () { + this.CompileManager.promises.wordCount = sinon .stub() - .yields(null, { content: 'body' }) + .resolves({ content: 'body' }) this.req.params = { Project_id: this.projectId } this.req.query = { clsiserverid: 'node-42' } this.res.json = sinon.stub() this.res.contentType = sinon.stub() - this.CompileController.wordCount(this.req, this.res, this.next) + await this.CompileController.wordCount(this.req, this.res, this.next) }) it('should proxy to the CLSI', function () { - this.CompileManager.wordCount + this.CompileManager.promises.wordCount .calledWith(this.projectId, this.user_id, false, 'node-42') .should.equal(true) }) diff --git a/services/web/test/unit/src/Documents/DocumentControllerTests.mjs b/services/web/test/unit/src/Documents/DocumentControllerTests.mjs index 29d03f152c..813e8d65f3 100644 --- a/services/web/test/unit/src/Documents/DocumentControllerTests.mjs +++ b/services/web/test/unit/src/Documents/DocumentControllerTests.mjs @@ -126,6 +126,7 @@ describe('DocumentController', function () { projectHistoryType: 'project-history', resolvedCommentIds: ['comment2'], historyRangesSupport: false, + otMigrationStage: 0, }) }) }) diff --git a/services/web/test/unit/src/Email/EmailBuilderTests.js b/services/web/test/unit/src/Email/EmailBuilderTests.js index 1f7628bc3f..a8a0dc1ad5 100644 --- a/services/web/test/unit/src/Email/EmailBuilderTests.js +++ b/services/web/test/unit/src/Email/EmailBuilderTests.js @@ -229,7 +229,7 @@ describe('EmailBuilder', function () { describe('HTML email', function () { it('should include a CTA button and a fallback CTA link', function () { const dom = cheerio.load(this.email.html) - const buttonLink = dom('a:contains("Confirm Email")') + const buttonLink = dom('a:contains("Confirm email")') expect(buttonLink.length).to.equal(1) expect(buttonLink.attr('href')).to.equal(this.opts.confirmEmailUrl) const fallback = dom('.force-overleaf-style').last() @@ -592,7 +592,7 @@ describe('EmailBuilder', function () { describe('HTML email', function () { it('should include a CTA button and a fallback CTA link', function () { - const buttonLink = this.dom('a:contains("Confirm Email")') + const buttonLink = this.dom('a:contains("Confirm email")') expect(buttonLink.length).to.equal(1) expect(buttonLink.attr('href')).to.equal(this.opts.confirmEmailUrl) const fallback = this.dom('.force-overleaf-style').last() diff --git a/services/web/test/unit/src/History/HistoryManagerTests.js b/services/web/test/unit/src/History/HistoryManagerTests.js index bfb13feaf2..6b83d15ed0 100644 --- a/services/web/test/unit/src/History/HistoryManagerTests.js +++ b/services/web/test/unit/src/History/HistoryManagerTests.js @@ -2,10 +2,36 @@ const { expect } = require('chai') const sinon = require('sinon') const SandboxedModule = require('sandboxed-module') const { ObjectId } = require('mongodb-legacy') +const { + connectionPromise, + cleanupTestDatabase, + db, +} = require('../../../../app/src/infrastructure/mongodb') const MODULE_PATH = '../../../../app/src/Features/History/HistoryManager' +const GLOBAL_BLOBS = { + e69de29bb2d1d6434b8b29ae775ad8c2e48c5391: + 'e6/9d/e29bb2d1d6434b8b29ae775ad8c2e48c5391', + '02426c2b3a484003ca42ed52b374b7907b757d12': + '02/42/6c2b3a484003ca42ed52b374b7907b757d12', +} + describe('HistoryManager', function () { + before(async function () { + await connectionPromise + }) + before(cleanupTestDatabase) + before(async function () { + await db.projectHistoryGlobalBlobs.insertMany( + Object.keys(GLOBAL_BLOBS).map(sha => ({ + _id: sha, + byteLength: 0, + stringLength: 0, + })) + ) + }) + beforeEach(function () { this.user_id = 'user-id-123' this.historyId = new ObjectId().toString() @@ -29,6 +55,10 @@ describe('HistoryManager', function () { url: this.v1HistoryUrl, user: this.v1HistoryUser, pass: this.v1HistoryPassword, + buckets: { + globalBlobs: 'globalBlobs', + projectBlobs: 'projectBlobs', + }, }, }, } @@ -60,7 +90,7 @@ describe('HistoryManager', function () { this.HistoryManager = SandboxedModule.require(MODULE_PATH, { requires: { - '../../infrastructure/mongodb': { ObjectId }, + '../../infrastructure/mongodb': { ObjectId, db }, '@overleaf/fetch-utils': this.FetchUtils, '@overleaf/settings': this.settings, '../User/UserGetter': this.UserGetter, @@ -70,6 +100,28 @@ describe('HistoryManager', function () { }) }) + describe('getBlobLocation', function () { + beforeEach(async function () { + await this.HistoryManager.loadGlobalBlobsPromise + }) + it('should return a global blob location', function () { + for (const [sha, key] of Object.entries(GLOBAL_BLOBS)) { + expect(this.HistoryManager.getBlobLocation('42', sha)).to.deep.equal({ + bucket: this.settings.apis.v1_history.buckets.globalBlobs, + key, + }) + } + }) + it('should return a project blob location', function () { + const sha = '6ddfa0578a67fe5ad6623a8665ec9aafce1eb5ca' + const key = '240/000/000/6d/dfa0578a67fe5ad6623a8665ec9aafce1eb5ca' + expect(this.HistoryManager.getBlobLocation('42', sha)).to.deep.equal({ + bucket: this.settings.apis.v1_history.buckets.projectBlobs, + key, + }) + }) + }) + describe('initializeProject', function () { beforeEach(function () { this.settings.apis.project_history.initializeHistoryForNewProjects = true diff --git a/services/web/test/unit/src/Project/ProjectControllerTests.js b/services/web/test/unit/src/Project/ProjectControllerTests.js index 71de5023b1..46427171da 100644 --- a/services/web/test/unit/src/Project/ProjectControllerTests.js +++ b/services/web/test/unit/src/Project/ProjectControllerTests.js @@ -1121,44 +1121,6 @@ describe('ProjectController', function () { this.ProjectController.loadEditor(this.req, this.res) }) }) - - describe('when fetching the users featureSet', function () { - beforeEach(function () { - this.Modules.promises.hooks.fire = sinon.stub().resolves() - this.user.features = {} - }) - - it('should take into account features overrides from modules', function (done) { - // this case occurs when the user has bought the ai bundle on WF, which should include our error assistant - const bundleFeatures = { aiErrorAssistant: true } - this.user.features = { aiErrorAssistant: false } - this.Modules.promises.hooks.fire = sinon - .stub() - .resolves([bundleFeatures]) - this.res.render = (pageName, opts) => { - expect(opts.user.features).to.deep.equal(bundleFeatures) - this.Modules.promises.hooks.fire.should.have.been.calledWith( - 'getModuleProvidedFeatures', - this.user._id - ) - done() - } - this.ProjectController.loadEditor(this.req, this.res) - }) - - it('should handle modules not returning any features', function (done) { - this.Modules.promises.hooks.fire = sinon.stub().resolves([]) - this.res.render = (pageName, opts) => { - expect(opts.user.features).to.deep.equal({}) - this.Modules.promises.hooks.fire.should.have.been.calledWith( - 'getModuleProvidedFeatures', - this.user._id - ) - done() - } - this.ProjectController.loadEditor(this.req, this.res) - }) - }) }) describe('userProjectsJson', function () { diff --git a/services/web/test/unit/src/Project/ProjectEditorHandlerTests.js b/services/web/test/unit/src/Project/ProjectEditorHandlerTests.js index 8e78ea5168..0fb5b5fce4 100644 --- a/services/web/test/unit/src/Project/ProjectEditorHandlerTests.js +++ b/services/web/test/unit/src/Project/ProjectEditorHandlerTests.js @@ -42,13 +42,6 @@ describe('ProjectEditorHandler', function () { ], }, ], - deletedDocs: [ - { - _id: 'deleted-doc-id', - name: 'main.tex', - deletedAt: (this.deletedAt = new Date('2017-01-01')), - }, - ], } this.members = [ { @@ -95,9 +88,6 @@ describe('ProjectEditorHandler', function () { token: 'my-secret-token2', }, ] - this.deletedDocsFromDocstore = [ - { _id: 'deleted-doc-id-from-docstore', name: 'docstore.tex' }, - ] this.handler = SandboxedModule.require(modulePath) }) @@ -107,8 +97,7 @@ describe('ProjectEditorHandler', function () { this.result = this.handler.buildProjectModelView( this.project, this.members, - this.invites, - this.deletedDocsFromDocstore + this.invites ) }) @@ -141,18 +130,6 @@ describe('ProjectEditorHandler', function () { this.result.owner.privileges.should.equal('owner') }) - it('should include the deletedDocs', function () { - expect(this.result.deletedDocs).to.exist - this.result.deletedDocs.should.deep.equal([ - { - // omit deletedAt field - _id: this.project.deletedDocs[0]._id, - name: this.project.deletedDocs[0].name, - }, - this.deletedDocsFromDocstore[0], - ]) - }) - it('should gather readOnly_refs and collaberators_refs into a list of members', function () { const findMember = id => { for (const member of this.result.members) { @@ -231,33 +208,12 @@ describe('ProjectEditorHandler', function () { }) }) - describe('when docstore sends a deleted doc that is also present in the project', function () { - beforeEach(function () { - this.deletedDocsFromDocstore.push(this.project.deletedDocs[0]) - this.result = this.handler.buildProjectModelView( - this.project, - this.members, - this.invites, - this.deletedDocsFromDocstore - ) - }) - - it('should not send any duplicate', function () { - expect(this.result.deletedDocs).to.exist - this.result.deletedDocs.should.deep.equal([ - this.project.deletedDocs[0], - this.deletedDocsFromDocstore[0], - ]) - }) - }) - describe('deletedByExternalDataSource', function () { it('should set the deletedByExternalDataSource flag to false when it is not there', function () { delete this.project.deletedByExternalDataSource const result = this.handler.buildProjectModelView( this.project, this.members, - [], [] ) result.deletedByExternalDataSource.should.equal(false) @@ -267,7 +223,6 @@ describe('ProjectEditorHandler', function () { const result = this.handler.buildProjectModelView( this.project, this.members, - [], [] ) result.deletedByExternalDataSource.should.equal(false) @@ -278,7 +233,6 @@ describe('ProjectEditorHandler', function () { const result = this.handler.buildProjectModelView( this.project, this.members, - [], [] ) result.deletedByExternalDataSource.should.equal(true) @@ -296,7 +250,6 @@ describe('ProjectEditorHandler', function () { this.result = this.handler.buildProjectModelView( this.project, this.members, - [], [] ) }) @@ -326,7 +279,6 @@ describe('ProjectEditorHandler', function () { this.result = this.handler.buildProjectModelView( this.project, this.members, - [], [] ) }) @@ -351,7 +303,6 @@ describe('ProjectEditorHandler', function () { this.result = this.handler.buildProjectModelView( this.project, this.members, - [], [] ) }) diff --git a/services/web/test/unit/src/Project/ProjectListControllerTests.mjs b/services/web/test/unit/src/Project/ProjectListControllerTests.mjs index 40c6dfaa54..827d16b737 100644 --- a/services/web/test/unit/src/Project/ProjectListControllerTests.mjs +++ b/services/web/test/unit/src/Project/ProjectListControllerTests.mjs @@ -53,11 +53,6 @@ describe('ProjectListController', function () { this.settings = { siteUrl: 'https://overleaf.com', } - this.LimitationsManager = { - promises: { - userIsMemberOfGroupSubscription: sinon.stub().resolves(false), - }, - } this.TagsHandler = { promises: { getAllTags: sinon.stub().resolves(this.tags), @@ -113,7 +108,11 @@ describe('ProjectListController', function () { } this.SubscriptionViewModelBuilder = { promises: { - getBestSubscription: sinon.stub().resolves({ type: 'free' }), + getUsersSubscriptionDetails: sinon.stub().resolves({ + bestSubscription: { type: 'free' }, + individualSubscription: null, + memberGroupSubscriptions: [], + }), }, } this.SurveyHandler = { @@ -126,11 +125,6 @@ describe('ProjectListController', function () { ipMatcherAffiliation: sinon.stub().returns({ create: sinon.stub() }), }, } - this.SubscriptionLocator = { - promises: { - getUserSubscription: sinon.stub().resolves({}), - }, - } this.GeoIpLookup = { promises: { getCurrencyCode: sinon.stub().resolves({ @@ -161,8 +155,6 @@ describe('ProjectListController', function () { this.SplitTestSessionHandler, '../../../../app/src/Features/User/UserController': this.UserController, '../../../../app/src/Features/Project/ProjectHelper': this.ProjectHelper, - '../../../../app/src/Features/Subscription/LimitationsManager': - this.LimitationsManager, '../../../../app/src/Features/Tags/TagsHandler': this.TagsHandler, '../../../../app/src/Features/Notifications/NotificationsHandler': this.NotificationsHandler, @@ -180,8 +172,6 @@ describe('ProjectListController', function () { this.UserPrimaryEmailCheckHandler, '../../../../app/src/Features/Notifications/NotificationsBuilder': this.NotificationBuilder, - '../../../../app/src/Features/Subscription/SubscriptionLocator': - this.SubscriptionLocator, '../../../../app/src/infrastructure/GeoIpLookup': this.GeoIpLookup, '../../../../app/src/Features/Tutorial/TutorialHandler': this.TutorialHandler, @@ -345,9 +335,13 @@ describe('ProjectListController', function () { it('should show INR Banner for Indian users with free account', function (done) { // usersBestSubscription is only available when saas feature is present this.Features.hasFeature.withArgs('saas').returns(true) - this.SubscriptionViewModelBuilder.promises.getBestSubscription.resolves({ - type: 'free', - }) + this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( + { + bestSubscription: { + type: 'free', + }, + } + ) this.GeoIpLookup.promises.getCurrencyCode.resolves({ countryCode: 'IN', }) @@ -361,9 +355,13 @@ describe('ProjectListController', function () { it('should not show INR Banner for Indian users with premium account', function (done) { // usersBestSubscription is only available when saas feature is present this.Features.hasFeature.withArgs('saas').returns(true) - this.SubscriptionViewModelBuilder.promises.getBestSubscription.resolves({ - type: 'individual', - }) + this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( + { + bestSubscription: { + type: 'individual', + }, + } + ) this.GeoIpLookup.promises.getCurrencyCode.resolves({ countryCode: 'IN', }) @@ -616,8 +614,8 @@ describe('ProjectListController', function () { describe('enterprise banner', function () { beforeEach(function (done) { this.Features.hasFeature.withArgs('saas').returns(true) - this.LimitationsManager.promises.userIsMemberOfGroupSubscription.resolves( - { isMember: false } + this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( + { memberGroupSubscriptions: [] } ) this.UserGetter.promises.getUserFullEmails.resolves([ { @@ -660,8 +658,8 @@ describe('ProjectListController', function () { }) it('does not show banner if user is part of any group subscription', function () { - this.LimitationsManager.promises.userIsMemberOfGroupSubscription.resolves( - { isMember: true } + this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( + { memberGroupSubscriptions: [{}] } ) this.res.render = (pageName, opts) => { diff --git a/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js b/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js index 1a28130b94..c6593da28d 100644 --- a/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js +++ b/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js @@ -412,6 +412,7 @@ describe('PaymentProviderEntities', function () { periodStart: new Date(), periodEnd: new Date(), collectionMethod: 'automatic', + netTerms: 0, poNumber: '012345', termsAndConditions: 'T&C copy', }) diff --git a/services/web/test/unit/src/Subscription/PlansLocatorTests.js b/services/web/test/unit/src/Subscription/PlansLocatorTests.js index 9373c02b89..f705baa01c 100644 --- a/services/web/test/unit/src/Subscription/PlansLocatorTests.js +++ b/services/web/test/unit/src/Subscription/PlansLocatorTests.js @@ -120,7 +120,7 @@ describe('PlansLocator', function () { this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( 'collaborator' ) - expect(planType).to.equal('standard') + expect(planType).to.equal('individual') expect(period).to.equal('monthly') }) @@ -129,7 +129,7 @@ describe('PlansLocator', function () { this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( 'collaborator_free_trial_7_days' ) - expect(planType).to.equal('standard') + expect(planType).to.equal('individual') expect(period).to.equal('monthly') }) @@ -138,7 +138,7 @@ describe('PlansLocator', function () { this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( 'collaborator-annual' ) - expect(planType).to.equal('standard') + expect(planType).to.equal('individual') expect(period).to.equal('annual') }) @@ -147,7 +147,7 @@ describe('PlansLocator', function () { this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( 'professional' ) - expect(planType).to.equal('professional') + expect(planType).to.equal('individual') expect(period).to.equal('monthly') }) @@ -156,7 +156,7 @@ describe('PlansLocator', function () { this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( 'professional_free_trial_7_days' ) - expect(planType).to.equal('professional') + expect(planType).to.equal('individual') expect(period).to.equal('monthly') }) @@ -165,7 +165,7 @@ describe('PlansLocator', function () { this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( 'professional-annual' ) - expect(planType).to.equal('professional') + expect(planType).to.equal('individual') expect(period).to.equal('annual') }) diff --git a/services/web/test/unit/src/Subscription/RecurlyClientTests.js b/services/web/test/unit/src/Subscription/RecurlyClientTests.js index 4ae415dca5..97088e9944 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClientTests.js +++ b/services/web/test/unit/src/Subscription/RecurlyClientTests.js @@ -58,6 +58,7 @@ describe('RecurlyClient', function () { periodStart: new Date(), periodEnd: new Date(), collectionMethod: 'automatic', + netTerms: 0, poNumber: '', termsAndConditions: '', }) @@ -90,6 +91,7 @@ describe('RecurlyClient', function () { currentPeriodStartedAt: this.subscription.periodStart, currentPeriodEndsAt: this.subscription.periodEnd, collectionMethod: this.subscription.collectionMethod, + netTerms: this.subscription.netTerms, poNumber: this.subscription.poNumber, termsAndConditions: this.subscription.termsAndConditions, } @@ -690,29 +692,4 @@ describe('RecurlyClient', function () { ).to.be.rejectedWith(Error) }) }) - - describe('getCountryCode', function () { - it('should return the country code from the account info', async function () { - this.client.getAccount = sinon.stub().resolves({ - address: { - country: 'GB', - }, - }) - const countryCode = await this.RecurlyClient.promises.getCountryCode( - this.user._id - ) - expect(countryCode).to.equal('GB') - }) - - it('should throw if country code doesn’t exist', async function () { - this.client.getAccount = sinon.stub().resolves({ - address: { - country: '', - }, - }) - await expect( - this.RecurlyClient.promises.getCountryCode(this.user._id) - ).to.be.rejectedWith(Error, 'Country code not found') - }) - }) }) diff --git a/services/web/test/unit/src/Subscription/RecurlyEventHandlerTests.js b/services/web/test/unit/src/Subscription/RecurlyEventHandlerTests.js index 4928f81e0d..2528f0a451 100644 --- a/services/web/test/unit/src/Subscription/RecurlyEventHandlerTests.js +++ b/services/web/test/unit/src/Subscription/RecurlyEventHandlerTests.js @@ -62,6 +62,7 @@ describe('RecurlyEventHandler', function () { is_trial: true, has_ai_add_on: false, subscriptionId: this.eventData.subscription.uuid, + payment_provider: 'recurly', } ) sinon.assert.calledWith( @@ -116,6 +117,7 @@ describe('RecurlyEventHandler', function () { is_trial: false, has_ai_add_on: false, subscriptionId: this.eventData.subscription.uuid, + payment_provider: 'recurly', } ) sinon.assert.calledWith( @@ -149,6 +151,7 @@ describe('RecurlyEventHandler', function () { is_trial: true, has_ai_add_on: false, subscriptionId: this.eventData.subscription.uuid, + payment_provider: 'recurly', } ) sinon.assert.calledWith( @@ -187,6 +190,7 @@ describe('RecurlyEventHandler', function () { is_trial: true, has_ai_add_on: false, subscriptionId: this.eventData.subscription.uuid, + payment_provider: 'recurly', } ) sinon.assert.calledWith( @@ -219,6 +223,7 @@ describe('RecurlyEventHandler', function () { is_trial: true, has_ai_add_on: false, subscriptionId: this.eventData.subscription.uuid, + payment_provider: 'recurly', } ) sinon.assert.calledWith( @@ -256,6 +261,7 @@ describe('RecurlyEventHandler', function () { is_trial: true, has_ai_add_on: false, subscriptionId: this.eventData.subscription.uuid, + payment_provider: 'recurly', } ) }) @@ -274,6 +280,7 @@ describe('RecurlyEventHandler', function () { quantity: 1, has_ai_add_on: false, subscriptionId: this.eventData.subscription.uuid, + payment_provider: 'recurly', } ) }) @@ -313,6 +320,7 @@ describe('RecurlyEventHandler', function () { collectionMethod: invoice.collection_method, subscriptionId1: invoice.subscription_ids[0], subscriptionId2: invoice.subscription_ids[1], + payment_provider: 'recurly', } ) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index 27ba5bf85b..b3ae6610e1 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -127,6 +127,9 @@ describe('SubscriptionController', function () { getUser: sinon.stub().callsArgWith(2, null, this.user), promises: { getUser: sinon.stub().resolves(this.user), + getWritefullData: sinon + .stub() + .resolves({ isPremium: false, premiumSource: null }), }, } this.SplitTestV2Hander = { @@ -157,7 +160,7 @@ describe('SubscriptionController', function () { }, './FeaturesUpdater': (this.FeaturesUpdater = { promises: { - hasFeaturesViaWritefull: sinon.stub().resolves(false), + refreshFeatures: sinon.stub().resolves({ features: {} }), }, }), './GroupPlansData': (this.GroupPlansData = {}), @@ -186,6 +189,11 @@ describe('SubscriptionController', function () { '../../util/currency': (this.currency = { formatCurrency: sinon.stub(), }), + '../../models/User': { + User: { + findById: sinon.stub().resolves(this.user), + }, + }, }, }) @@ -221,7 +229,10 @@ describe('SubscriptionController', function () { title: 'thank_you', personalSubscription: 'foo', postCheckoutRedirect: undefined, - user: this.user, + user: { + _id: this.user._id, + features: this.user.features, + }, }) done() } diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs index 1d86199f9d..4376e752e7 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs @@ -67,6 +67,7 @@ describe('SubscriptionGroupController', function () { ensureSubscriptionIsActive: sinon.stub().resolves(), ensureSubscriptionCollectionMethodIsNotManual: sinon.stub().resolves(), ensureSubscriptionHasNoPendingChanges: sinon.stub().resolves(), + ensureSubscriptionHasNoPastDueInvoice: sinon.stub().resolves(), getGroupPlanUpgradePreview: sinon .stub() .resolves(this.previewSubscriptionChangeData), @@ -138,6 +139,7 @@ describe('SubscriptionGroupController', function () { PendingChangeError: class extends Error {}, InactiveError: class extends Error {}, SubtotalLimitExceededError: class extends Error {}, + HasPastDueInvoiceError: class extends Error {}, } this.Controller = await esmock.strict(modulePath, { @@ -370,6 +372,9 @@ describe('SubscriptionGroupController', function () { this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive .calledWith(this.subscription) .should.equal(true) + this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice + .calledWith(this.subscription) + .should.equal(true) this.SubscriptionGroupHandler.promises.checkBillingInfoExistence .calledWith(this.recurlySubscription, this.adminUserId) .should.equal(true) @@ -459,6 +464,20 @@ describe('SubscriptionGroupController', function () { this.Controller.addSeatsToGroupSubscription(this.req, res) }) + + it('should redirect to subscription page when subscription has pending invoice', function (done) { + this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice = + sinon.stub().rejects() + + const res = { + redirect: url => { + url.should.equal('/user/subscription') + done() + }, + } + + this.Controller.addSeatsToGroupSubscription(this.req, res) + }) }) describe('previewAddSeatsSubscriptionChange', function () { diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js index d9e42d645c..0c47db3e14 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js @@ -146,7 +146,6 @@ describe('SubscriptionGroupHandler', function () { applySubscriptionChangeRequest: sinon .stub() .resolves(this.applySubscriptionChange), - getCountryCode: sinon.stub().resolves('BG'), updateSubscriptionDetails: sinon.stub().resolves(), }, } @@ -198,6 +197,11 @@ describe('SubscriptionGroupHandler', function () { if (hookName === 'generateTermsAndConditions') { return Promise.resolve(['T&Cs']) } + if (hookName === 'getPaymentFromRecord') { + return Promise.resolve([ + { account: { hasPastDueInvoice: false } }, + ]) + } return Promise.resolve() }), }, @@ -504,9 +508,6 @@ describe('SubscriptionGroupHandler', function () { describe('updateSubscriptionPaymentTerms', function () { describe('accounts with PO number', function () { it('should update the subscription PO number and T&C', async function () { - this.RecurlyClient.promises.getCountryCode = sinon - .stub() - .resolves('GB') await this.Handler.promises.updateSubscriptionPaymentTerms( this.adminUser_id, this.recurlySubscription, @@ -526,9 +527,6 @@ describe('SubscriptionGroupHandler', function () { describe('accounts with no PO number', function () { it('should update the subscription T&C only', async function () { - this.RecurlyClient.promises.getCountryCode = sinon - .stub() - .resolves('GB') await this.Handler.promises.updateSubscriptionPaymentTerms( this.adminUser_id, this.recurlySubscription @@ -758,6 +756,27 @@ describe('SubscriptionGroupHandler', function () { }) }) + describe('ensureSubscriptionHasNoPastDueInvoice', function () { + it('should throw if the subscription has past due invoice', async function () { + this.Modules.promises.hooks.fire + .withArgs('getPaymentFromRecord') + .resolves([{ account: { hasPastDueInvoice: true } }]) + await expect( + this.Handler.promises.ensureSubscriptionHasNoPastDueInvoice( + this.subscription + ) + ).to.be.rejectedWith('This subscription has a past due invoice') + }) + + it('should not throw if the subscription has no past due invoice', async function () { + await expect( + this.Handler.promises.ensureSubscriptionHasNoPastDueInvoice( + this.subscription + ) + ).to.not.be.rejected + }) + }) + describe('upgradeGroupPlan', function () { it('should upgrade the subscription for flexible licensing group plans', async function () { this.SubscriptionLocator.promises.getUsersSubscription = sinon diff --git a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js index 0517e451d4..ed5ed2f6d1 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js @@ -4,7 +4,6 @@ const chai = require('chai') const { expect } = chai const { PaymentProviderSubscription, - PaymentProviderSubscriptionChangeRequest, } = require('../../../../app/src/Features/Subscription/PaymentProviderEntities') const MODULE_PATH = @@ -258,151 +257,107 @@ describe('SubscriptionHandler', function () { }) describe('updateSubscription', function () { - describe('with a user with a subscription', function () { - beforeEach(async function () { - this.user.id = this.activeRecurlySubscription.account.account_code - this.User.findById = (userId, projection) => ({ - exec: () => { - userId.should.equal(this.user.id) - return Promise.resolve(this.user) - }, - }) - this.plan_code = 'professional' - this.LimitationsManager.promises.userHasSubscription.resolves({ - hasSubscription: true, - subscription: this.subscription, - }) - await this.SubscriptionHandler.promises.updateSubscription( - this.user, - this.plan_code, - null - ) - }) - - it('should update the subscription', function () { - expect( - this.RecurlyClient.promises.applySubscriptionChangeRequest - ).to.have.been.calledWith( - new PaymentProviderSubscriptionChangeRequest({ - subscription: this.activeRecurlyClientSubscription, - timeframe: 'now', - planCode: this.plan_code, - }) - ) - }) - - it('should sync the new subscription to the user', function () { - expect(this.SubscriptionUpdater.promises.syncSubscription).to.have.been - .called - - this.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal( - this.activeRecurlySubscription - ) - this.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal( - this.user._id - ) + beforeEach(function () { + this.user.id = this.activeRecurlySubscription.account.account_code + this.User.findById = (userId, projection) => ({ + exec: () => { + userId.should.equal(this.user.id) + return Promise.resolve(this.user) + }, }) }) - describe('when plan(s) could not be located in settings', function () { - beforeEach(async function () { - this.user.id = this.activeRecurlySubscription.account.account_code - this.User.findById = (userId, projection) => ({ - exec: () => { - userId.should.equal(this.user.id) - return Promise.resolve(this.user) - }, - }) - - this.LimitationsManager.promises.userHasSubscription.resolves({ - hasSubscription: true, - subscription: this.subscription, - }) + it('should not fire updatePaidSubscription hook if user has no subscription', async function () { + this.LimitationsManager.promises.userHasSubscription.resolves({ + hasSubscription: false, + subscription: null, }) + await this.SubscriptionHandler.promises.updateSubscription( + this.user, + this.plan_code + ) + expect(this.Modules.promises.hooks.fire).to.not.have.been.calledWith( + 'updatePaidSubscription', + sinon.match.any, + sinon.match.any, + sinon.match.any + ) + }) - it('should be rejected and should not update the subscription', function () { - expect( - this.SubscriptionHandler.promises.updateSubscription( - this.user, - 'unknown-plan', - null - ) - ).to.be.rejected - this.RecurlyClient.promises.applySubscriptionChangeRequest.called.should.equal( - false - ) + it('should not fire updatePaidSubscription hook if user has custom subscription', async function () { + this.LimitationsManager.promises.userHasSubscription.resolves({ + hasSubscription: true, + subscription: { customAccount: true }, + }) + await this.SubscriptionHandler.promises.updateSubscription( + this.user, + this.plan_code + ) + expect(this.Modules.promises.hooks.fire).to.not.have.been.calledWith( + 'updatePaidSubscription', + sinon.match.any, + sinon.match.any, + sinon.match.any + ) + }) + + it('should fire updatePaidSubscription to update a valid subscription', async function () { + this.LimitationsManager.promises.userHasSubscription.resolves({ + hasSubscription: true, + subscription: this.subscription, + }) + await this.SubscriptionHandler.promises.updateSubscription( + this.user, + this.plan_code + ) + expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( + 'updatePaidSubscription', + this.subscription, + this.plan_code, + this.user._id + ) + }) + }) + + describe('cancelPendingSubscriptionChange', function () { + beforeEach(function () { + this.user.id = this.activeRecurlySubscription.account.account_code + this.User.findById = (userId, projection) => ({ + exec: () => { + userId.should.equal(this.user.id) + return Promise.resolve(this.user) + }, }) }) - describe('with a user without a subscription', function () { - beforeEach(async function () { - this.LimitationsManager.promises.userHasSubscription.resolves(false) - await this.SubscriptionHandler.promises.updateSubscription( - this.user, - this.plan_code, - null - ) - }) - - it('should redirect to the subscription dashboard', function () { - this.RecurlyClient.promises.applySubscriptionChangeRequest.called.should.equal( - false - ) - this.SubscriptionUpdater.promises.syncSubscription.called.should.equal( - false - ) + it('should not fire cancelPendingPaidSubscriptionChange hook if user has no subscription', async function () { + this.LimitationsManager.promises.userHasSubscription.resolves({ + hasSubscription: false, + subscription: null, }) + await this.SubscriptionHandler.promises.cancelPendingSubscriptionChange( + this.user, + this.plan_code + ) + expect(this.Modules.promises.hooks.fire).to.not.have.been.calledWith( + 'cancelPendingPaidSubscriptionChange', + sinon.match.any + ) }) - describe('with a coupon code', function () { - beforeEach(async function () { - this.user.id = this.activeRecurlySubscription.account.account_code - - this.User.findById = (userId, projection) => ({ - exec: () => { - userId.should.equal(this.user.id) - return Promise.resolve(this.user) - }, - }) - this.plan_code = 'collaborator' - this.coupon_code = '1231312' - this.LimitationsManager.promises.userHasSubscription.resolves({ - hasSubscription: true, - subscription: this.subscription, - }) - await this.SubscriptionHandler.promises.updateSubscription( - this.user, - this.plan_code, - this.coupon_code - ) - }) - - it('should get the users account', function () { - this.RecurlyWrapper.promises.getSubscription - .calledWith(this.activeRecurlySubscription.uuid) - .should.equal(true) - }) - - it('should redeem the coupon', function () { - this.RecurlyWrapper.promises.redeemCoupon - .calledWith( - this.activeRecurlySubscription.account.account_code, - this.coupon_code - ) - .should.equal(true) - }) - - it('should update the subscription', function () { - expect( - this.RecurlyClient.promises.applySubscriptionChangeRequest - ).to.be.calledWith( - new PaymentProviderSubscriptionChangeRequest({ - subscription: this.activeRecurlyClientSubscription, - timeframe: 'now', - planCode: this.plan_code, - }) - ) + it('should fire cancelPendingPaidSubscriptionChange to update a valid subscription', async function () { + this.LimitationsManager.promises.userHasSubscription.resolves({ + hasSubscription: true, + subscription: this.subscription, }) + await this.SubscriptionHandler.promises.cancelPendingSubscriptionChange( + this.user, + this.plan_code + ) + expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( + 'cancelPendingPaidSubscriptionChange', + this.subscription + ) }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js index e969cf381c..0f666b888a 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js @@ -25,6 +25,11 @@ describe('SubscriptionViewModelBuilder', function () { planCode: this.planCode, features: this.planFeatures, } + this.annualPlanCode = 'collaborator_annual' + this.annualPlan = { + planCode: this.annualPlanCode, + features: this.planFeatures, + } this.individualSubscription = { planCode: this.planCode, plan: this.plan, @@ -74,6 +79,7 @@ describe('SubscriptionViewModelBuilder', function () { features: this.groupPlanFeatures, membersLimit: 4, membersLimitAddOn: 'additional-license', + groupPlan: true, } this.groupSubscription = { planCode: this.groupPlanCode, @@ -166,6 +172,8 @@ describe('SubscriptionViewModelBuilder', function () { this.PlansLocator.findLocalPlanInSettings .withArgs(this.planCode) .returns(this.plan) + .withArgs(this.annualPlanCode) + .returns(this.annualPlan) .withArgs(this.groupPlanCode) .returns(this.groupPlan) .withArgs(this.commonsPlanCode) @@ -575,6 +583,7 @@ describe('SubscriptionViewModelBuilder', function () { }, isEligibleForGroupPlan: true, isEligibleForPause: false, + isEligibleForDowngradeUpsell: true, }) }) @@ -689,6 +698,96 @@ describe('SubscriptionViewModelBuilder', function () { }) }) + describe('isEligibleForDowngradeUpsell', function () { + it('is true for eligible individual subscriptions', async function () { + this.paymentRecord.pausePeriodStart = null + this.paymentRecord.remainingPauseCycles = null + this.paymentRecord.trialPeriodEnd = null + this.paymentRecord.service = 'recurly' + const result = + await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + this.user + ) + assert.isTrue( + result.personalSubscription.payment.isEligibleForDowngradeUpsell + ) + }) + + it('is false for group plans', async function () { + this.individualSubscription.planCode = this.groupPlanCode + this.paymentRecord.pausePeriodStart = null + this.paymentRecord.remainingPauseCycles = null + this.paymentRecord.trialPeriodEnd = null + this.paymentRecord.service = 'recurly' + const result = + await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + this.user + ) + assert.isFalse( + result.personalSubscription.payment.isEligibleForDowngradeUpsell + ) + }) + + it('is false for annual individual plans', async function () { + this.individualSubscription.planCode = this.annualPlanCode + this.paymentRecord.pausePeriodStart = null + this.paymentRecord.remainingPauseCycles = null + this.paymentRecord.trialPeriodEnd = null + this.paymentRecord.service = 'recurly' + const result = + await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + this.user + ) + assert.isFalse( + result.personalSubscription.payment.isEligibleForDowngradeUpsell + ) + }) + + it('is false for paused plans', async function () { + this.paymentRecord.pausePeriodStart = new Date() + this.paymentRecord.remainingPauseCycles = 1 + this.paymentRecord.trialPeriodEnd = null + this.paymentRecord.service = 'recurly' + const result = + await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + this.user + ) + assert.isFalse( + result.personalSubscription.payment.isEligibleForDowngradeUpsell + ) + }) + + it('is false for plans in free trial period', async function () { + this.paymentRecord.pausePeriodStart = null + this.paymentRecord.remainingPauseCycles = null + this.paymentRecord.trialPeriodEnd = new Date( + Date.now() + 24 * 60 * 60 * 1000 // tomorrow + ) + this.paymentRecord.service = 'recurly' + const result = + await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + this.user + ) + assert.isFalse( + result.personalSubscription.payment.isEligibleForDowngradeUpsell + ) + }) + + it('is false for Stripe subscriptions', async function () { + this.paymentRecord.pausePeriodStart = null + this.paymentRecord.remainingPauseCycles = null + this.paymentRecord.trialPeriodEnd = null + this.paymentRecord.service = 'stripe' + const result = + await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + this.user + ) + assert.isFalse( + result.personalSubscription.payment.isEligibleForDowngradeUpsell + ) + }) + }) + it('includes pending changes', async function () { this.paymentRecord.pendingChange = new PaymentProviderSubscriptionChange({ diff --git a/services/web/test/unit/src/User/UserGetterTests.js b/services/web/test/unit/src/User/UserGetterTests.js index 91df5d8c6d..0e0c170fd6 100644 --- a/services/web/test/unit/src/User/UserGetterTests.js +++ b/services/web/test/unit/src/User/UserGetterTests.js @@ -19,7 +19,7 @@ describe('UserGetter', function () { beforeEach(function () { const confirmedAt = new Date() this.fakeUser = { - _id: '12390i', + _id: new ObjectId(), email: 'email2@foo.bar', emails: [ { @@ -45,6 +45,10 @@ describe('UserGetter', function () { } this.getUserAffiliations = sinon.stub().resolves([]) + this.Modules = { + promises: { hooks: { fire: sinon.stub().resolves() } }, + } + this.UserGetter = SandboxedModule.require(modulePath, { requires: { '../Helpers/Mongo': { normalizeQuery, normalizeMultiQuery }, @@ -63,6 +67,7 @@ describe('UserGetter', function () { '../../models/User': { User: (this.User = {}), }, + '../../infrastructure/Modules': this.Modules, }, }) }) @@ -1259,4 +1264,56 @@ describe('UserGetter', function () { }) }) }) + + describe('getUserFeatures', function () { + beforeEach(function () { + this.Modules.promises.hooks.fire = sinon.stub().resolves() + this.fakeUser.features = {} + }) + + it('should return user features', function (done) { + this.fakeUser.features = { feature1: true, feature2: false } + this.UserGetter.getUserFeatures(new ObjectId(), (error, features) => { + expect(error).to.not.exist + expect(features).to.deep.equal(this.fakeUser.features) + done() + }) + }) + + it('should return user features when using promises', async function () { + this.fakeUser.features = { feature1: true, feature2: false } + const features = await this.UserGetter.promises.getUserFeatures( + this.fakeUser._id + ) + expect(features).to.deep.equal(this.fakeUser.features) + }) + + it('should take into account features overrides from modules', async function () { + // this case occurs when the user has bought the ai bundle on WF, which should include our error assistant + const bundleFeatures = { aiErrorAssistant: true } + this.fakeUser.features = { aiErrorAssistant: false } + this.Modules.promises.hooks.fire = sinon.stub().resolves([bundleFeatures]) + const features = await this.UserGetter.promises.getUserFeatures( + this.fakeUser._id + ) + expect(features).to.deep.equal(bundleFeatures) + this.Modules.promises.hooks.fire.should.have.been.calledWith( + 'getModuleProvidedFeatures', + this.fakeUser._id + ) + }) + + it('should handle modules not returning any features', async function () { + this.Modules.promises.hooks.fire = sinon.stub().resolves([]) + this.fakeUser.features = { test: true } + const features = await this.UserGetter.promises.getUserFeatures( + this.fakeUser._id + ) + expect(features).to.deep.equal({ test: true }) + this.Modules.promises.hooks.fire.should.have.been.calledWith( + 'getModuleProvidedFeatures', + this.fakeUser._id + ) + }) + }) }) diff --git a/services/web/test/unit/src/User/UserInfoControllerTests.js b/services/web/test/unit/src/User/UserInfoControllerTests.js index 022dbd2d7f..dd90ac3b00 100644 --- a/services/web/test/unit/src/User/UserInfoControllerTests.js +++ b/services/web/test/unit/src/User/UserInfoControllerTests.js @@ -10,7 +10,11 @@ describe('UserInfoController', function () { beforeEach(function () { this.UserDeleter = { deleteUser: sinon.stub().callsArgWith(1) } this.UserUpdater = { updatePersonalInfo: sinon.stub() } - this.UserGetter = {} + this.UserGetter = { + promises: { + getUserFeatures: sinon.stub(), + }, + } this.UserInfoController = SandboxedModule.require(modulePath, { requires: { @@ -148,4 +152,74 @@ describe('UserInfoController', function () { }) }) }) + + describe('getUserFeatures', function () { + describe('when the user is logged in', function () { + beforeEach(async function () { + this.user_id = new ObjectId().toString() + this.features = { + collaborators: 10, + trackChanges: true, + references: true, + } + this.SessionManager.getLoggedInUserId.returns(this.user_id) + this.UserGetter.promises.getUserFeatures.resolves(this.features) + await this.UserInfoController.getUserFeatures( + this.req, + this.res, + this.next + ) + }) + + it('should fetch the user features', function () { + expect(this.UserGetter.promises.getUserFeatures.callCount).to.equal(1) + expect( + this.UserGetter.promises.getUserFeatures.calledWith(this.user_id) + ).to.equal(true) + }) + + it('should return the features as JSON', function () { + expect(this.res.json.callCount).to.equal(1) + expect(this.res.json.calledWith(this.features)).to.equal(true) + }) + }) + + describe('when the user is not logged in', function () { + beforeEach(async function () { + this.SessionManager.getLoggedInUserId.returns(null) + await this.UserInfoController.getUserFeatures( + this.req, + this.res, + this.next + ) + }) + + it('should call next with an error', function () { + expect(this.next.callCount).to.equal(1) + expect(this.next.firstCall.args[0]).to.be.an.instanceof(Error) + expect(this.next.firstCall.args[0].message).to.equal( + 'User is not logged in' + ) + }) + }) + + describe('when fetching features fails', function () { + beforeEach(async function () { + this.user_id = new ObjectId().toString() + this.error = new Error('something went wrong') + this.SessionManager.getLoggedInUserId.returns(this.user_id) + this.UserGetter.promises.getUserFeatures.rejects(this.error) + await this.UserInfoController.getUserFeatures( + this.req, + this.res, + this.next + ) + }) + + it('should call next with the error', function () { + expect(this.next.callCount).to.equal(1) + expect(this.next.firstCall.args[0]).to.equal(this.error) + }) + }) + }) }) diff --git a/services/web/types/cms.ts b/services/web/types/cms.ts index 6930603c83..1f9e4beebc 100644 --- a/services/web/types/cms.ts +++ b/services/web/types/cms.ts @@ -12,14 +12,17 @@ export type IconElementSticker = | 'sticker | globe | green' | 'sticker | house-tree | grey | large' | 'sticker | hub | tangerine' + | 'sticker | journal | grey' | 'sticker | lock | grey | medium' | 'sticker | lightning | yellow' | 'sticker | overleaf | green | medium' | 'sticker | pen | purple' | 'sticker | pen | tangerine' + | 'sticker | pen | yellow' | 'sticker | pi | tangerine' | 'sticker | rocket | yellow' | 'sticker | rocket | yellow | medium' + | 'sticker | support | green' | 'sticker | support | tangerine' | 'sticker | support | yellow' | 'sticker | waving-hand | purple | medium' diff --git a/services/web/types/cobranding.ts b/services/web/types/cobranding.ts new file mode 100644 index 0000000000..dc6822be84 --- /dev/null +++ b/services/web/types/cobranding.ts @@ -0,0 +1,11 @@ +export type Cobranding = { + logoImgUrl: string + brandVariationName: string + brandVariationId: number + brandId: number + brandVariationHomeUrl: string + publishGuideHtml?: string + partner?: string + brandedMenu?: boolean + submitBtnHtml?: string +} diff --git a/services/web/types/compile.ts b/services/web/types/compile.ts index 541d03149c..3038893529 100644 --- a/services/web/types/compile.ts +++ b/services/web/types/compile.ts @@ -23,6 +23,7 @@ export type CompileResponseData = { outputFiles: CompileOutputFile[] compileGroup?: string clsiServerId?: string + clsiCacheShard?: string pdfDownloadDomain?: string pdfCachingMinChunkSize: number validationProblems: any diff --git a/services/web/types/stripe/webhook-event.ts b/services/web/types/stripe/webhook-event.ts new file mode 100644 index 0000000000..f6e36b8b0a --- /dev/null +++ b/services/web/types/stripe/webhook-event.ts @@ -0,0 +1,65 @@ +import Stripe from 'stripe' + +export type CustomerSubscriptionUpdatedWebhookEvent = { + type: 'customer.subscription.updated' + data: { + object: Stripe.Subscription & { + metadata: { + adminUserId?: string + } + } + // https://docs.stripe.com/api/events/object?api-version=2025-04-30.basil#event_object-data-previous_attributes + previous_attributes: { + cancel_at_period_end?: boolean // will only be present if the subscription was cancelled or reactivated + items?: { + // will be present if the subscription was downgraded, upgraded, or renewed + data: [ + { + price: { + id: string + } + quantity: number + }, + ] + } + } + } +} + +export type CustomerSubscriptionCreatedWebhookEvent = { + type: 'customer.subscription.created' + data: { + object: Stripe.Subscription & { + metadata: { + adminUserId?: string + } + } + } +} + +export type CustomerSubscriptionsDeletedWebhookEvent = { + type: 'customer.subscription.deleted' + data: { + object: Stripe.Subscription & { + metadata: { + adminUserId?: string + } + } + } +} + +export type InvoicePaidWebhookEvent = { + type: 'invoice.paid' + data: { + object: Stripe.Invoice + } +} + +export type CustomerSubscriptionWebhookEvent = + | CustomerSubscriptionUpdatedWebhookEvent + | CustomerSubscriptionCreatedWebhookEvent + | CustomerSubscriptionsDeletedWebhookEvent + +export type WebhookEvent = + | CustomerSubscriptionWebhookEvent + | InvoicePaidWebhookEvent diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts index 1272f33eb0..a1ee934423 100644 --- a/services/web/types/subscription/dashboard/subscription.ts +++ b/services/web/types/subscription/dashboard/subscription.ts @@ -46,6 +46,7 @@ type PaymentProviderRecord = { remainingPauseCycles?: Nullable<number> isEligibleForPause: boolean isEligibleForGroupPlan: boolean + isEligibleForDowngradeUpsell: boolean } export type GroupPolicy = { @@ -111,3 +112,8 @@ export type PaymentProvider = { trialStartedAt?: Nullable<Date> trialEndsAt?: Nullable<Date> } + +export type SubscriptionRequesterData = { + id?: string + ip?: string +} diff --git a/services/web/types/subscription/payment-context-value.tsx b/services/web/types/subscription/payment-context-value.tsx index 496f7e027b..2df1265270 100644 --- a/services/web/types/subscription/payment-context-value.tsx +++ b/services/web/types/subscription/payment-context-value.tsx @@ -73,5 +73,4 @@ export type PaymentContextValue = { React.SetStateAction<PaymentContextValue['studentConfirmationChecked']> > updatePlan: (newPlanCode: string) => void - showNudgeToAnnualText: boolean } diff --git a/services/web/types/subscription/plan.ts b/services/web/types/subscription/plan.ts index a1b0f7b5d6..c5e8f7e820 100644 --- a/services/web/types/subscription/plan.ts +++ b/services/web/types/subscription/plan.ts @@ -81,11 +81,19 @@ export type RecurlyPlanCode = | 'student' | 'student-annual' | 'student_free_trial_7_days' + | 'group_professional' + | 'group_professional_educational' + | 'group_collaborator' + | 'group_collaborator_educational' export type StripeLookupKey = - | 'collaborator_monthly' - | 'collaborator_annual' + | 'standard_monthly' + | 'standard_annual' | 'professional_monthly' | 'professional_annual' | 'student_monthly' | 'student_annual' + | 'group_standard_enterprise' + | 'group_professional_enterprise' + | 'group_standard_educational' + | 'group_professional_educational' diff --git a/services/web/types/subscription/subscription-change-preview.ts b/services/web/types/subscription/subscription-change-preview.ts index 096820a2f6..5152b80e6e 100644 --- a/services/web/types/subscription/subscription-change-preview.ts +++ b/services/web/types/subscription/subscription-change-preview.ts @@ -2,6 +2,7 @@ export type SubscriptionChangePreview = { change: SubscriptionChangeDescription currency: string paymentMethod: string | undefined + netTerms: number nextPlan: { annual: boolean } diff --git a/services/web/types/user.ts b/services/web/types/user.ts index 0c6c45facf..8d00ea803f 100644 --- a/services/web/types/user.ts +++ b/services/web/types/user.ts @@ -52,6 +52,7 @@ export type User = { enabled: boolean autoCreatedAccount: boolean firstAutoLoad: boolean + premiumSource: string } aiErrorAssistant?: { enabled: boolean
  • diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/toolbar.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/toolbar.tsx index ed1b2509ff..56c597451e 100644 --- a/services/web/frontend/js/features/ide-redesign/components/toolbar/toolbar.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/toolbar/toolbar.tsx @@ -11,6 +11,7 @@ import { useLayoutContext } from '@/shared/context/layout-context' import BackToEditorButton from '@/features/editor-navigation-toolbar/components/back-to-editor-button' import { useCallback } from 'react' import * as eventTracking from '../../../../infrastructure/event-tracking' +import OLTooltip from '@/features/ui/components/ol/ol-tooltip' export const Toolbar = () => { const { view, setView } = useLayoutContext() @@ -45,12 +46,18 @@ const ToolbarMenus = () => { const { t } = useTranslation() return ( ) diff --git a/services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx b/services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx index 51c797fa1d..c02d17fb9b 100644 --- a/services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx +++ b/services/web/frontend/js/features/ide-redesign/contexts/rail-context.tsx @@ -1,4 +1,5 @@ import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel' +import useEventListener from '@/shared/hooks/use-event-listener' import { createContext, Dispatch, @@ -77,6 +78,17 @@ export const RailProvider: FC = ({ children }) => { [setIsOpen, setSelectedTab] ) + useEventListener( + 'ui.toggle-review-panel', + useCallback(() => { + if (isOpen && selectedTab === 'review-panel') { + handlePaneCollapse() + } else { + openTab('review-panel') + } + }, [handlePaneCollapse, selectedTab, isOpen, openTab]) + ) + const value = useMemo( () => ({ selectedTab, diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.tsx index 4a000e9a41..b5b9c8609c 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.tsx @@ -9,7 +9,7 @@ import PdfHybridDownloadButton from './pdf-hybrid-download-button' import PdfHybridCodeCheckButton from './pdf-hybrid-code-check-button' import PdfOrphanRefreshButton from './pdf-orphan-refresh-button' import { DetachedSynctexControl } from './detach-synctex-control' -import { Spinner } from 'react-bootstrap-5' +import { Spinner } from 'react-bootstrap' const ORPHAN_UI_TIMEOUT_MS = 5000 diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.tsx index 8133ff31ee..7bbecbc327 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.tsx @@ -1,4 +1,4 @@ -import { memo, Suspense } from 'react' +import { ElementType, memo, Suspense } from 'react' import classNames from 'classnames' import PdfLogsViewer from './pdf-logs-viewer' import PdfViewer from './pdf-viewer' @@ -11,6 +11,7 @@ import { PdfPreviewProvider } from './pdf-preview-provider' import PdfPreviewHybridToolbarNew from '@/features/ide-redesign/components/pdf-preview/pdf-preview-hybrid-toolbar' import PdfErrorState from '@/features/ide-redesign/components/pdf-preview/pdf-error-state' import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils' +import importOverleafModules from '../../../../macros/import-overleaf-module.macro' function PdfPreviewPane() { const { pdfUrl, hasShortCompileTimeout } = useCompileContext() @@ -18,6 +19,10 @@ function PdfPreviewPane() { 'pdf-empty': !pdfUrl, }) const newEditor = useIsNewEditorEnabled() + const pdfPromotions = importOverleafModules('pdfPreviewPromotions') as { + import: { default: ElementType } + path: string + }[] return (
    @@ -36,6 +41,9 @@ function PdfPreviewPane() {
    {newEditor ? : } + {pdfPromotions.map(({ import: { default: Component }, path }) => ( + + ))}