From dc157392ae7513675930d42fbb174972b770e29c Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Fri, 10 Jan 2025 09:14:03 +0000 Subject: [PATCH 0001/1724] Merge pull request #22765 from overleaf/ar-convert-final-acceptance-tests-to-es-modules [web] convert final acceptance tests to es modules GitOrigin-RevId: d0d0cd3dfedbe494ce51dd6f8c180dff02429ad8 --- .../test/acceptance/src/HistoryTests.mjs | 4 +-- .../history-v1/test/acceptance/src/Init.mjs | 12 +++---- .../test/acceptance/src/LabelsTests.mjs | 4 +-- .../acceptance/src/ProjectStructureTests.mjs | 4 +-- .../acceptance/src/RestoringFilesTest.mjs | 8 ++--- .../test/acceptance/src/LaunchpadTests.mjs | 2 +- .../test/acceptance/src/Init.mjs | 8 ++--- .../acceptance/src/ServerCEScriptsTests.mjs | 2 +- .../acceptance/src/ActiveUsersMetricTests.mjs | 6 ++-- .../acceptance/src/AddSecondaryEmailTests.mjs | 2 +- .../test/acceptance/src/AdminEmailTests.mjs | 2 +- .../acceptance/src/AdminOnlyLoginTests.mjs | 2 +- .../src/AdminPrivilegeAvailableTests.mjs | 2 +- .../acceptance/src/AuthenticationTests.mjs | 2 +- .../acceptance/src/AuthorizationTests.mjs | 4 +-- .../src/BackFillDeletedFilesTests.mjs | 6 ++-- .../BackFillDocNameForDeletedDocsTests.mjs | 6 ++-- .../acceptance/src/BackFillDocRevTests.mjs | 4 +-- .../src/BackFillDummyDocMetaTests.mjs | 4 +-- .../acceptance/src/BatchedUpdateTests.mjs | 2 +- .../test/acceptance/src/BetaProgramTests.mjs | 2 +- .../test/acceptance/src/CDNMigrationTests.mjs | 4 +-- .../web/test/acceptance/src/CaptchaTests.mjs | 2 +- .../ClearSessionsSetMustReconfirmTests.mjs | 6 ++-- .../acceptance/src/ConvertArchivedState.mjs | 4 +-- .../acceptance/src/CookieMetricsTests.mjs | 4 +-- .../DeleteOrphanedDocsOnlineCheckTests.mjs | 4 +-- .../web/test/acceptance/src/DeletionTests.mjs | 6 ++-- .../test/acceptance/src/DocUpdateTests.mjs | 2 +- .../src/EditorHttpControllerTests.mjs | 2 +- .../acceptance/src/HaveIBeenPwnedApiTests.mjs | 4 +-- .../src/HealthCheckControllerTests.mjs | 2 +- .../web/test/acceptance/src/HistoryTests.mjs | 8 ++--- services/web/test/acceptance/src/Init.mjs | 12 +++---- .../web/test/acceptance/src/LearnTest.mjs | 2 +- .../test/acceptance/src/LinkedFilesTests.mjs | 4 +-- .../web/test/acceptance/src/MongoHelper.mjs | 2 +- .../acceptance/src/PasswordResetTests.mjs | 2 +- .../acceptance/src/PasswordUpdateTests.mjs | 2 +- .../acceptance/src/PrimaryEmailCheckTests.mjs | 4 +-- .../test/acceptance/src/ProjectCRUDTests.mjs | 2 +- .../src/ProjectDuplicateNameTests.mjs | 8 ++--- .../acceptance/src/ProjectFeaturesTests.mjs | 2 +- .../acceptance/src/ProjectInviteTests.mjs | 2 +- .../src/ProjectOwnershipTransferTests.mjs | 2 +- .../acceptance/src/ProjectStructureTests.mjs | 6 ++-- .../RegenerateDuplicateReferralIdsTests.mjs | 4 +-- .../test/acceptance/src/RegistrationTests.mjs | 4 +-- ...veDeletedUsersFromTokenAccessRefsTests.mjs | 4 +-- .../acceptance/src/SecurityHeadersTests.mjs | 2 +- .../test/acceptance/src/ServerCrashTests.mjs | 8 ++--- .../web/test/acceptance/src/SessionTests.mjs | 2 +- .../web/test/acceptance/src/SettingsTests.mjs | 2 +- .../web/test/acceptance/src/SharingTests.mjs | 2 +- .../web/test/acceptance/src/TagsTests.mjs | 4 +-- .../test/acceptance/src/TokenAccessTests.mjs | 4 +-- .../test/acceptance/src/TpdsUpdateTests.mjs | 2 +- .../src/UnsupportedBrowserTests.mjs | 2 +- .../test/acceptance/src/UserHelperTests.mjs | 2 +- .../src/UserMembershipAuthorizationTests.mjs | 4 +-- .../acceptance/src/UserReconfirmTests.mjs | 2 +- .../acceptance/src/helpers/MongoHelper.mjs | 2 +- .../helpers/{Publisher.js => Publisher.mjs} | 10 +++--- .../src/helpers/RecurlySubscription.mjs | 2 +- .../helpers/{SAMLHelper.js => SAMLHelper.mjs} | 19 +++++----- ...SplitTestHelper.js => SplitTestHelper.mjs} | 6 ++-- .../src/helpers/{User.js => User.mjs} | 26 +++++++------- .../helpers/{UserHelper.js => UserHelper.mjs} | 35 +++++++++---------- ...rorResponse.js => expectErrorResponse.mjs} | 4 +-- .../test/acceptance/src/helpers/groupSSO.mjs | 10 +++--- .../src/helpers/{metrics.js => metrics.mjs} | 8 ++--- ...AbstractMockApi.js => AbstractMockApi.mjs} | 8 ++--- .../acceptance/src/mocks/MockAnalyticsApi.mjs | 2 +- .../test/acceptance/src/mocks/MockChatApi.mjs | 2 +- .../test/acceptance/src/mocks/MockClsiApi.mjs | 2 +- ...DocUpdaterApi.js => MockDocUpdaterApi.mjs} | 4 +-- ...MockDocstoreApi.js => MockDocstoreApi.mjs} | 6 ++-- ...ckFilestoreApi.js => MockFilestoreApi.mjs} | 4 +-- .../acceptance/src/mocks/MockGitBridgeApi.mjs | 2 +- ...ogleOauthApi.js => MockGoogleOauthApi.mjs} | 4 +-- .../src/mocks/MockHaveIBeenPwnedApi.mjs | 2 +- .../mocks/MockHistoryBackupDeletionApi.mjs | 2 +- .../src/mocks/MockNotificationsApi.mjs | 2 +- ...istoryApi.js => MockProjectHistoryApi.mjs} | 14 ++++---- .../acceptance/src/mocks/MockReCaptchaApi.mjs | 2 +- .../{MockRecurlyApi.js => MockRecurlyApi.mjs} | 8 ++--- .../acceptance/src/mocks/MockSpellingApi.mjs | 2 +- .../src/mocks/MockThirdPartyDataStoreApi.mjs | 2 +- .../src/mocks/{MockV1Api.js => MockV1Api.mjs} | 8 ++--- ...ckV1HistoryApi.js => MockV1HistoryApi.mjs} | 12 +++---- 90 files changed, 225 insertions(+), 219 deletions(-) rename services/web/test/acceptance/src/helpers/{Publisher.js => Publisher.mjs} (76%) rename services/web/test/acceptance/src/helpers/{SAMLHelper.js => SAMLHelper.mjs} (95%) rename services/web/test/acceptance/src/helpers/{SplitTestHelper.js => SplitTestHelper.mjs} (93%) rename services/web/test/acceptance/src/helpers/{User.js => User.mjs} (97%) rename services/web/test/acceptance/src/helpers/{UserHelper.js => UserHelper.mjs} (94%) rename services/web/test/acceptance/src/helpers/{expectErrorResponse.js => expectErrorResponse.mjs} (90%) rename services/web/test/acceptance/src/helpers/{metrics.js => metrics.mjs} (77%) rename services/web/test/acceptance/src/mocks/{AbstractMockApi.js => AbstractMockApi.mjs} (97%) rename services/web/test/acceptance/src/mocks/{MockDocUpdaterApi.js => MockDocUpdaterApi.mjs} (94%) rename services/web/test/acceptance/src/mocks/{MockDocstoreApi.js => MockDocstoreApi.mjs} (94%) rename services/web/test/acceptance/src/mocks/{MockFilestoreApi.js => MockFilestoreApi.mjs} (96%) rename services/web/test/acceptance/src/mocks/{MockGoogleOauthApi.js => MockGoogleOauthApi.mjs} (91%) rename services/web/test/acceptance/src/mocks/{MockProjectHistoryApi.js => MockProjectHistoryApi.mjs} (93%) rename services/web/test/acceptance/src/mocks/{MockRecurlyApi.js => MockRecurlyApi.mjs} (93%) rename services/web/test/acceptance/src/mocks/{MockV1Api.js => MockV1Api.mjs} (98%) rename services/web/test/acceptance/src/mocks/{MockV1HistoryApi.js => MockV1HistoryApi.mjs} (94%) diff --git a/services/web/modules/history-v1/test/acceptance/src/HistoryTests.mjs b/services/web/modules/history-v1/test/acceptance/src/HistoryTests.mjs index 88c2b1239d..583f79aefb 100644 --- a/services/web/modules/history-v1/test/acceptance/src/HistoryTests.mjs +++ b/services/web/modules/history-v1/test/acceptance/src/HistoryTests.mjs @@ -2,8 +2,8 @@ import { expect } from 'chai' import _ from 'lodash' import { db, ObjectId } from '../../../../../app/src/infrastructure/mongodb.js' -import User from '../../../../../test/acceptance/src/helpers/User.js' -import MockV1HistoryApiClass from '../../../../../test/acceptance/src/mocks/MockV1HistoryApi.js' +import User from '../../../../../test/acceptance/src/helpers/User.mjs' +import MockV1HistoryApiClass from '../../../../../test/acceptance/src/mocks/MockV1HistoryApi.mjs' let MockV1HistoryApi diff --git a/services/web/modules/history-v1/test/acceptance/src/Init.mjs b/services/web/modules/history-v1/test/acceptance/src/Init.mjs index 2a4d0d05f6..bd54ed3dca 100644 --- a/services/web/modules/history-v1/test/acceptance/src/Init.mjs +++ b/services/web/modules/history-v1/test/acceptance/src/Init.mjs @@ -1,12 +1,12 @@ import '../../../../../test/acceptance/src/helpers/InitApp.mjs' -import MockDocstoreApi from '../../../../../test/acceptance/src/mocks/MockDocstoreApi.js' -import MockDocUpdaterApi from '../../../../../test/acceptance/src/mocks/MockDocUpdaterApi.js' -import MockFilestoreApi from '../../../../../test/acceptance/src/mocks/MockFilestoreApi.js' +import MockDocstoreApi from '../../../../../test/acceptance/src/mocks/MockDocstoreApi.mjs' +import MockDocUpdaterApi from '../../../../../test/acceptance/src/mocks/MockDocUpdaterApi.mjs' +import MockFilestoreApi from '../../../../../test/acceptance/src/mocks/MockFilestoreApi.mjs' import MockNotificationsApi from '../../../../../test/acceptance/src/mocks/MockNotificationsApi.mjs' -import MockProjectHistoryApi from '../../../../../test/acceptance/src/mocks/MockProjectHistoryApi.js' +import MockProjectHistoryApi from '../../../../../test/acceptance/src/mocks/MockProjectHistoryApi.mjs' import MockSpellingApi from '../../../../../test/acceptance/src/mocks/MockSpellingApi.mjs' -import MockV1Api from '../../../../../test/acceptance/src/mocks/MockV1Api.js' -import MockV1HistoryApi from '../../../../../test/acceptance/src/mocks/MockV1HistoryApi.js' +import MockV1Api from '../../../../../test/acceptance/src/mocks/MockV1Api.mjs' +import MockV1HistoryApi from '../../../../../test/acceptance/src/mocks/MockV1HistoryApi.mjs' const mockOpts = { debug: ['1', 'true', 'TRUE'].includes(process.env.DEBUG_MOCKS), diff --git a/services/web/modules/history-v1/test/acceptance/src/LabelsTests.mjs b/services/web/modules/history-v1/test/acceptance/src/LabelsTests.mjs index c197614cc0..4013e8d538 100644 --- a/services/web/modules/history-v1/test/acceptance/src/LabelsTests.mjs +++ b/services/web/modules/history-v1/test/acceptance/src/LabelsTests.mjs @@ -1,7 +1,7 @@ import { expect } from 'chai' import mongodb from 'mongodb-legacy' -import User from '../../../../../test/acceptance/src/helpers/User.js' -import MockProjectHistoryApiClass from '../../../../../test/acceptance/src/mocks/MockProjectHistoryApi.js' +import User from '../../../../../test/acceptance/src/helpers/User.mjs' +import MockProjectHistoryApiClass from '../../../../../test/acceptance/src/mocks/MockProjectHistoryApi.mjs' const { ObjectId } = mongodb diff --git a/services/web/modules/history-v1/test/acceptance/src/ProjectStructureTests.mjs b/services/web/modules/history-v1/test/acceptance/src/ProjectStructureTests.mjs index 2978f99df9..9e2e91c1de 100644 --- a/services/web/modules/history-v1/test/acceptance/src/ProjectStructureTests.mjs +++ b/services/web/modules/history-v1/test/acceptance/src/ProjectStructureTests.mjs @@ -6,8 +6,8 @@ import fs from 'node:fs' import Settings from '@overleaf/settings' import _ from 'lodash' import ProjectGetter from '../../../../../app/src/Features/Project/ProjectGetter.js' -import User from '../../../../../test/acceptance/src/helpers/User.js' -import MockDocUpdaterApiClass from '../../../../../test/acceptance/src/mocks/MockDocUpdaterApi.js' +import User from '../../../../../test/acceptance/src/helpers/User.mjs' +import MockDocUpdaterApiClass from '../../../../../test/acceptance/src/mocks/MockDocUpdaterApi.mjs' import Features from '../../../../../app/src/infrastructure/Features.js' const { ObjectId } = mongodb diff --git a/services/web/modules/history-v1/test/acceptance/src/RestoringFilesTest.mjs b/services/web/modules/history-v1/test/acceptance/src/RestoringFilesTest.mjs index 6bb8d28564..fc02d5aed1 100644 --- a/services/web/modules/history-v1/test/acceptance/src/RestoringFilesTest.mjs +++ b/services/web/modules/history-v1/test/acceptance/src/RestoringFilesTest.mjs @@ -4,10 +4,10 @@ import _ from 'lodash' import fs from 'node:fs' import { fileURLToPath } from 'node:url' import Path from 'node:path' -import User from '../../../../../test/acceptance/src/helpers/User.js' -import MockProjectHistoryApiClass from '../../../../../test/acceptance/src/mocks/MockProjectHistoryApi.js' -import MockDocstoreApiClass from '../../../../../test/acceptance/src/mocks/MockDocstoreApi.js' -import MockFilestoreApiClass from '../../../../../test/acceptance/src/mocks/MockFilestoreApi.js' +import User from '../../../../../test/acceptance/src/helpers/User.mjs' +import MockProjectHistoryApiClass from '../../../../../test/acceptance/src/mocks/MockProjectHistoryApi.mjs' +import MockDocstoreApiClass from '../../../../../test/acceptance/src/mocks/MockDocstoreApi.mjs' +import MockFilestoreApiClass from '../../../../../test/acceptance/src/mocks/MockFilestoreApi.mjs' let MockProjectHistoryApi, MockDocstoreApi, MockFilestoreApi diff --git a/services/web/modules/launchpad/test/acceptance/src/LaunchpadTests.mjs b/services/web/modules/launchpad/test/acceptance/src/LaunchpadTests.mjs index 3f9f47e1f5..e04d02cd80 100644 --- a/services/web/modules/launchpad/test/acceptance/src/LaunchpadTests.mjs +++ b/services/web/modules/launchpad/test/acceptance/src/LaunchpadTests.mjs @@ -1,6 +1,6 @@ import { expect } from 'chai' import cheerio from 'cheerio' -import UserHelper from '../../../../../test/acceptance/src/helpers/UserHelper.js' +import UserHelper from '../../../../../test/acceptance/src/helpers/UserHelper.mjs' describe('Launchpad', function () { const adminEmail = 'admin@example.com' diff --git a/services/web/modules/server-ce-scripts/test/acceptance/src/Init.mjs b/services/web/modules/server-ce-scripts/test/acceptance/src/Init.mjs index b8513314ec..1872687e32 100644 --- a/services/web/modules/server-ce-scripts/test/acceptance/src/Init.mjs +++ b/services/web/modules/server-ce-scripts/test/acceptance/src/Init.mjs @@ -1,8 +1,8 @@ import '../../../../../test/acceptance/src/helpers/InitApp.mjs' -import MockProjectHistoryApi from '../../../../../test/acceptance/src/mocks/MockProjectHistoryApi.js' -import MockDocstoreApi from '../../../../../test/acceptance/src/mocks/MockDocstoreApi.js' -import MockDocUpdaterApi from '../../../../../test/acceptance/src/mocks/MockDocUpdaterApi.js' -import MockV1Api from '../../../../admin-panel/test/acceptance/src/mocks/MockV1Api.js' +import MockProjectHistoryApi from '../../../../../test/acceptance/src/mocks/MockProjectHistoryApi.mjs' +import MockDocstoreApi from '../../../../../test/acceptance/src/mocks/MockDocstoreApi.mjs' +import MockDocUpdaterApi from '../../../../../test/acceptance/src/mocks/MockDocUpdaterApi.mjs' +import MockV1Api from '../../../../admin-panel/test/acceptance/src/mocks/MockV1Api.mjs' const mockOpts = { debug: ['1', 'true', 'TRUE'].includes(process.env.DEBUG_MOCKS), diff --git a/services/web/modules/server-ce-scripts/test/acceptance/src/ServerCEScriptsTests.mjs b/services/web/modules/server-ce-scripts/test/acceptance/src/ServerCEScriptsTests.mjs index abefd3fecf..afa6ec4137 100644 --- a/services/web/modules/server-ce-scripts/test/acceptance/src/ServerCEScriptsTests.mjs +++ b/services/web/modules/server-ce-scripts/test/acceptance/src/ServerCEScriptsTests.mjs @@ -3,7 +3,7 @@ import fs from 'node:fs' import Settings from '@overleaf/settings' import { expect } from 'chai' import { db } from '../../../../../app/src/infrastructure/mongodb.js' -import UserHelper from '../../../../../test/acceptance/src/helpers/User.js' +import UserHelper from '../../../../../test/acceptance/src/helpers/User.mjs' const { promises: User } = UserHelper diff --git a/services/web/test/acceptance/src/ActiveUsersMetricTests.mjs b/services/web/test/acceptance/src/ActiveUsersMetricTests.mjs index 99148904ff..8fbb661148 100644 --- a/services/web/test/acceptance/src/ActiveUsersMetricTests.mjs +++ b/services/web/test/acceptance/src/ActiveUsersMetricTests.mjs @@ -1,8 +1,8 @@ -import { promisify } from 'util' +import { promisify } from 'node:util' import { expect } from 'chai' import Features from '../../../app/src/infrastructure/Features.js' -import MetricsHelper from './helpers/metrics.js' -import UserHelper from './helpers/User.js' +import MetricsHelper from './helpers/metrics.mjs' +import UserHelper from './helpers/User.mjs' const sleep = promisify(setTimeout) const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/AddSecondaryEmailTests.mjs b/services/web/test/acceptance/src/AddSecondaryEmailTests.mjs index 858cd43e4c..2fdc7705fe 100644 --- a/services/web/test/acceptance/src/AddSecondaryEmailTests.mjs +++ b/services/web/test/acceptance/src/AddSecondaryEmailTests.mjs @@ -1,5 +1,5 @@ import { expect } from 'chai' -import UserHelper from './helpers/User.js' +import UserHelper from './helpers/User.mjs' import logger from '@overleaf/logger' import sinon from 'sinon' import { db } from '../../../app/src/infrastructure/mongodb.js' diff --git a/services/web/test/acceptance/src/AdminEmailTests.mjs b/services/web/test/acceptance/src/AdminEmailTests.mjs index 89e36c75c4..a1204f548f 100644 --- a/services/web/test/acceptance/src/AdminEmailTests.mjs +++ b/services/web/test/acceptance/src/AdminEmailTests.mjs @@ -1,7 +1,7 @@ import OError from '@overleaf/o-error' import { expect } from 'chai' import async from 'async' -import User from './helpers/User.js' +import User from './helpers/User.mjs' describe('AdminEmails', function () { beforeEach(function (done) { diff --git a/services/web/test/acceptance/src/AdminOnlyLoginTests.mjs b/services/web/test/acceptance/src/AdminOnlyLoginTests.mjs index c080b119dd..c0fce7fc4c 100644 --- a/services/web/test/acceptance/src/AdminOnlyLoginTests.mjs +++ b/services/web/test/acceptance/src/AdminOnlyLoginTests.mjs @@ -1,6 +1,6 @@ import Settings from '@overleaf/settings' import { expect } from 'chai' -import UserHelper from './helpers/User.js' +import UserHelper from './helpers/User.mjs' const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/AdminPrivilegeAvailableTests.mjs b/services/web/test/acceptance/src/AdminPrivilegeAvailableTests.mjs index a48f7248cc..93465bb0bf 100644 --- a/services/web/test/acceptance/src/AdminPrivilegeAvailableTests.mjs +++ b/services/web/test/acceptance/src/AdminPrivilegeAvailableTests.mjs @@ -1,6 +1,6 @@ import Settings from '@overleaf/settings' import { expect } from 'chai' -import UserHelper from './helpers/User.js' +import UserHelper from './helpers/User.mjs' import { getSafeAdminDomainRedirect } from '../../../app/src/Features/Helpers/UrlHelper.js' const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/AuthenticationTests.mjs b/services/web/test/acceptance/src/AuthenticationTests.mjs index dfa6e9c9cf..aea57f3ea6 100644 --- a/services/web/test/acceptance/src/AuthenticationTests.mjs +++ b/services/web/test/acceptance/src/AuthenticationTests.mjs @@ -1,7 +1,7 @@ import { expect } from 'chai' import mongodb from 'mongodb-legacy' import Settings from '@overleaf/settings' -import UserHelper from './helpers/User.js' +import UserHelper from './helpers/User.mjs' const ObjectId = mongodb.ObjectId diff --git a/services/web/test/acceptance/src/AuthorizationTests.mjs b/services/web/test/acceptance/src/AuthorizationTests.mjs index da9eb23e67..0fa61d3295 100644 --- a/services/web/test/acceptance/src/AuthorizationTests.mjs +++ b/services/web/test/acceptance/src/AuthorizationTests.mjs @@ -1,10 +1,10 @@ import { expect } from 'chai' import async from 'async' -import User from './helpers/User.js' +import User from './helpers/User.mjs' import request from './helpers/request.js' import settings from '@overleaf/settings' import Features from '../../../app/src/infrastructure/Features.js' -import expectErrorResponse from './helpers/expectErrorResponse.js' +import expectErrorResponse from './helpers/expectErrorResponse.mjs' function tryReadAccess(user, projectId, test, callback) { async.series( diff --git a/services/web/test/acceptance/src/BackFillDeletedFilesTests.mjs b/services/web/test/acceptance/src/BackFillDeletedFilesTests.mjs index 516d21341a..7c49d973ba 100644 --- a/services/web/test/acceptance/src/BackFillDeletedFilesTests.mjs +++ b/services/web/test/acceptance/src/BackFillDeletedFilesTests.mjs @@ -1,9 +1,9 @@ -import { exec } from 'child_process' -import { promisify } from 'util' +import { exec } from 'node:child_process' +import { promisify } from 'node:util' import { expect } from 'chai' import logger from '@overleaf/logger' import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js' -import UserHelper from './helpers/User.js' +import UserHelper from './helpers/User.mjs' const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/BackFillDocNameForDeletedDocsTests.mjs b/services/web/test/acceptance/src/BackFillDocNameForDeletedDocsTests.mjs index 9fd4073eaa..97e1e1f54d 100644 --- a/services/web/test/acceptance/src/BackFillDocNameForDeletedDocsTests.mjs +++ b/services/web/test/acceptance/src/BackFillDocNameForDeletedDocsTests.mjs @@ -1,9 +1,9 @@ -import { exec } from 'child_process' -import { promisify } from 'util' +import { exec } from 'node:child_process' +import { promisify } from 'node:util' import { expect } from 'chai' import logger from '@overleaf/logger' import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js' -import UserHelper from './helpers/User.js' +import UserHelper from './helpers/User.mjs' import { renderObjectId } from '@overleaf/mongo-utils/batchedUpdate.js' const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/BackFillDocRevTests.mjs b/services/web/test/acceptance/src/BackFillDocRevTests.mjs index 1fc3134ca4..df62d1276f 100644 --- a/services/web/test/acceptance/src/BackFillDocRevTests.mjs +++ b/services/web/test/acceptance/src/BackFillDocRevTests.mjs @@ -1,6 +1,6 @@ import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js' -import { promisify } from 'util' -import { exec } from 'child_process' +import { promisify } from 'node:util' +import { exec } from 'node:child_process' import logger from '@overleaf/logger' import { expect } from 'chai' diff --git a/services/web/test/acceptance/src/BackFillDummyDocMetaTests.mjs b/services/web/test/acceptance/src/BackFillDummyDocMetaTests.mjs index 6874154eb5..852e19ec2c 100644 --- a/services/web/test/acceptance/src/BackFillDummyDocMetaTests.mjs +++ b/services/web/test/acceptance/src/BackFillDummyDocMetaTests.mjs @@ -1,5 +1,5 @@ -import { exec } from 'child_process' -import { promisify } from 'util' +import { exec } from 'node:child_process' +import { promisify } from 'node:util' import { expect } from 'chai' import logger from '@overleaf/logger' import { filterOutput } from './helpers/settings.mjs' diff --git a/services/web/test/acceptance/src/BatchedUpdateTests.mjs b/services/web/test/acceptance/src/BatchedUpdateTests.mjs index c73b91c5d2..e93881dcf6 100644 --- a/services/web/test/acceptance/src/BatchedUpdateTests.mjs +++ b/services/web/test/acceptance/src/BatchedUpdateTests.mjs @@ -1,4 +1,4 @@ -import { spawnSync } from 'child_process' +import { spawnSync } from 'node:child_process' import { expect } from 'chai' import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js' diff --git a/services/web/test/acceptance/src/BetaProgramTests.mjs b/services/web/test/acceptance/src/BetaProgramTests.mjs index b780ce976f..a84f6ae5ff 100644 --- a/services/web/test/acceptance/src/BetaProgramTests.mjs +++ b/services/web/test/acceptance/src/BetaProgramTests.mjs @@ -1,5 +1,5 @@ import { expect } from 'chai' -import UserHelper from '../src/helpers/UserHelper.js' +import UserHelper from '../src/helpers/UserHelper.mjs' describe('BetaProgram', function () { let email, userHelper diff --git a/services/web/test/acceptance/src/CDNMigrationTests.mjs b/services/web/test/acceptance/src/CDNMigrationTests.mjs index 60e3ecdc1e..b837b16bf6 100644 --- a/services/web/test/acceptance/src/CDNMigrationTests.mjs +++ b/services/web/test/acceptance/src/CDNMigrationTests.mjs @@ -1,6 +1,6 @@ import { expect } from 'chai' -import UserHelper from './helpers/User.js' -import MetricsHelper from './helpers/metrics.js' +import UserHelper from './helpers/User.mjs' +import MetricsHelper from './helpers/metrics.mjs' const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/CaptchaTests.mjs b/services/web/test/acceptance/src/CaptchaTests.mjs index 9a647b2a45..10fdcf1626 100644 --- a/services/web/test/acceptance/src/CaptchaTests.mjs +++ b/services/web/test/acceptance/src/CaptchaTests.mjs @@ -1,7 +1,7 @@ import { db } from '../../../app/src/infrastructure/mongodb.js' import { expect } from 'chai' import Settings from '@overleaf/settings' -import UserHelper from './helpers/User.js' +import UserHelper from './helpers/User.mjs' import MockHaveIBeenPwnedApiClass from './mocks/MockHaveIBeenPwnedApi.mjs' const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/ClearSessionsSetMustReconfirmTests.mjs b/services/web/test/acceptance/src/ClearSessionsSetMustReconfirmTests.mjs index 379d231b7a..93269c800d 100644 --- a/services/web/test/acceptance/src/ClearSessionsSetMustReconfirmTests.mjs +++ b/services/web/test/acceptance/src/ClearSessionsSetMustReconfirmTests.mjs @@ -1,10 +1,10 @@ import { exec } from 'node:child_process' -import { promisify } from 'util' +import { promisify } from 'node:util' import { expect } from 'chai' import logger from '@overleaf/logger' import { ObjectId, db } from '../../../app/src/infrastructure/mongodb.js' -import fs from 'fs/promises' -import UserHelper from './helpers/User.js' +import fs from 'node:fs/promises' +import UserHelper from './helpers/User.mjs' import UserGetter from '../../../app/src/Features/User/UserGetter.js' const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/ConvertArchivedState.mjs b/services/web/test/acceptance/src/ConvertArchivedState.mjs index 06de502d6d..53e2278974 100644 --- a/services/web/test/acceptance/src/ConvertArchivedState.mjs +++ b/services/web/test/acceptance/src/ConvertArchivedState.mjs @@ -1,7 +1,7 @@ import { expect } from 'chai' -import { exec } from 'child_process' +import { exec } from 'node:child_process' import mongodb from 'mongodb-legacy' -import UserHelper from './helpers/User.js' +import UserHelper from './helpers/User.mjs' const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/CookieMetricsTests.mjs b/services/web/test/acceptance/src/CookieMetricsTests.mjs index ca1d1ddcbc..4cab8cb1d4 100644 --- a/services/web/test/acceptance/src/CookieMetricsTests.mjs +++ b/services/web/test/acceptance/src/CookieMetricsTests.mjs @@ -1,7 +1,7 @@ import Settings from '@overleaf/settings' import { expect } from 'chai' -import UserHelper from './helpers/User.js' -import MetricsHelper from './helpers/metrics.js' +import UserHelper from './helpers/User.mjs' +import MetricsHelper from './helpers/metrics.mjs' import cookieSignature from 'cookie-signature' const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/DeleteOrphanedDocsOnlineCheckTests.mjs b/services/web/test/acceptance/src/DeleteOrphanedDocsOnlineCheckTests.mjs index 5f5a72a51b..748561613a 100644 --- a/services/web/test/acceptance/src/DeleteOrphanedDocsOnlineCheckTests.mjs +++ b/services/web/test/acceptance/src/DeleteOrphanedDocsOnlineCheckTests.mjs @@ -1,5 +1,5 @@ -import { exec } from 'child_process' -import { promisify } from 'util' +import { exec } from 'node:child_process' +import { promisify } from 'node:util' import { expect } from 'chai' import logger from '@overleaf/logger' import { filterOutput } from './helpers/settings.mjs' diff --git a/services/web/test/acceptance/src/DeletionTests.mjs b/services/web/test/acceptance/src/DeletionTests.mjs index 3058e76173..da26d8ace7 100644 --- a/services/web/test/acceptance/src/DeletionTests.mjs +++ b/services/web/test/acceptance/src/DeletionTests.mjs @@ -1,4 +1,4 @@ -import User from './helpers/User.js' +import User from './helpers/User.mjs' import Subscription from './helpers/Subscription.mjs' import request from './helpers/request.js' import async from 'async' @@ -6,8 +6,8 @@ import { expect } from 'chai' import settings from '@overleaf/settings' import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js' import Features from '../../../app/src/infrastructure/Features.js' -import MockDocstoreApiClass from './mocks/MockDocstoreApi.js' -import MockFilestoreApiClass from './mocks/MockFilestoreApi.js' +import MockDocstoreApiClass from './mocks/MockDocstoreApi.mjs' +import MockFilestoreApiClass from './mocks/MockFilestoreApi.mjs' import MockChatApiClass from './mocks/MockChatApi.mjs' import MockGitBridgeApiClass from './mocks/MockGitBridgeApi.mjs' import MockHistoryBackupDeletionApiClass from './mocks/MockHistoryBackupDeletionApi.mjs' diff --git a/services/web/test/acceptance/src/DocUpdateTests.mjs b/services/web/test/acceptance/src/DocUpdateTests.mjs index b6b161cf62..7f11b855f5 100644 --- a/services/web/test/acceptance/src/DocUpdateTests.mjs +++ b/services/web/test/acceptance/src/DocUpdateTests.mjs @@ -1,4 +1,4 @@ -import User from './helpers/User.js' +import User from './helpers/User.mjs' import request from './helpers/request.js' import { expect } from 'chai' import settings from '@overleaf/settings' diff --git a/services/web/test/acceptance/src/EditorHttpControllerTests.mjs b/services/web/test/acceptance/src/EditorHttpControllerTests.mjs index 651b644643..de38f79be4 100644 --- a/services/web/test/acceptance/src/EditorHttpControllerTests.mjs +++ b/services/web/test/acceptance/src/EditorHttpControllerTests.mjs @@ -1,4 +1,4 @@ -import User from './helpers/User.js' +import User from './helpers/User.mjs' import { expect } from 'chai' describe('EditorHttpController', function () { diff --git a/services/web/test/acceptance/src/HaveIBeenPwnedApiTests.mjs b/services/web/test/acceptance/src/HaveIBeenPwnedApiTests.mjs index 3db3ed6681..ffa8eb7618 100644 --- a/services/web/test/acceptance/src/HaveIBeenPwnedApiTests.mjs +++ b/services/web/test/acceptance/src/HaveIBeenPwnedApiTests.mjs @@ -1,9 +1,9 @@ import Settings from '@overleaf/settings' import { expect } from 'chai' -import UserHelper from './helpers/User.js' +import UserHelper from './helpers/User.mjs' import MockHaveIBeenPwnedApiClass from './mocks/MockHaveIBeenPwnedApi.mjs' import { db } from '../../../app/src/infrastructure/mongodb.js' -import MetricsHelper from './helpers/metrics.js' +import MetricsHelper from './helpers/metrics.mjs' const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/HealthCheckControllerTests.mjs b/services/web/test/acceptance/src/HealthCheckControllerTests.mjs index fd677caa0d..a05b7aef21 100644 --- a/services/web/test/acceptance/src/HealthCheckControllerTests.mjs +++ b/services/web/test/acceptance/src/HealthCheckControllerTests.mjs @@ -1,6 +1,6 @@ import { expect } from 'chai' import Settings from '@overleaf/settings' -import UserHelper from './helpers/User.js' +import UserHelper from './helpers/User.mjs' const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/HistoryTests.mjs b/services/web/test/acceptance/src/HistoryTests.mjs index a87b98d158..4d4623bbfa 100644 --- a/services/web/test/acceptance/src/HistoryTests.mjs +++ b/services/web/test/acceptance/src/HistoryTests.mjs @@ -1,14 +1,14 @@ import fs from 'node:fs' import Path from 'node:path' import { expect } from 'chai' -import UserHelper from './helpers/User.js' -import MockV1HistoryApiClass from './mocks/MockV1HistoryApi.js' +import UserHelper from './helpers/User.mjs' +import MockV1HistoryApiClass from './mocks/MockV1HistoryApi.mjs' import ProjectGetter from '../../../app/src/Features/Project/ProjectGetter.js' -import MockFilestoreApiClass from './mocks/MockFilestoreApi.js' +import MockFilestoreApiClass from './mocks/MockFilestoreApi.mjs' import { fileURLToPath } from 'node:url' import sinon from 'sinon' import logger from '@overleaf/logger' -import Metrics from './helpers/metrics.js' +import Metrics from './helpers/metrics.mjs' import Features from '../../../app/src/infrastructure/Features.js' const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/Init.mjs b/services/web/test/acceptance/src/Init.mjs index 678083848e..c52962cd7c 100644 --- a/services/web/test/acceptance/src/Init.mjs +++ b/services/web/test/acceptance/src/Init.mjs @@ -4,15 +4,15 @@ import Features from '../../../app/src/infrastructure/Features.js' import MockAnalyticsApi from './mocks/MockAnalyticsApi.mjs' import MockChatApi from './mocks/MockChatApi.mjs' import MockClsiApi from './mocks/MockClsiApi.mjs' -import MockDocstoreApi from './mocks/MockDocstoreApi.js' -import MockDocUpdaterApi from './mocks/MockDocUpdaterApi.js' -import MockFilestoreApi from './mocks/MockFilestoreApi.js' +import MockDocstoreApi from './mocks/MockDocstoreApi.mjs' +import MockDocUpdaterApi from './mocks/MockDocUpdaterApi.mjs' +import MockFilestoreApi from './mocks/MockFilestoreApi.mjs' import MockGitBridgeApi from './mocks/MockGitBridgeApi.mjs' import MockNotificationsApi from './mocks/MockNotificationsApi.mjs' -import MockProjectHistoryApi from './mocks/MockProjectHistoryApi.js' +import MockProjectHistoryApi from './mocks/MockProjectHistoryApi.mjs' import MockSpellingApi from './mocks/MockSpellingApi.mjs' -import MockV1Api from './mocks/MockV1Api.js' -import MockV1HistoryApi from './mocks/MockV1HistoryApi.js' +import MockV1Api from './mocks/MockV1Api.mjs' +import MockV1HistoryApi from './mocks/MockV1HistoryApi.mjs' import MockHaveIBeenPwnedApi from './mocks/MockHaveIBeenPwnedApi.mjs' import MockThirdPartyDataStoreApi from './mocks/MockThirdPartyDataStoreApi.mjs' import MockHistoryBackupDeletionApi from './mocks/MockHistoryBackupDeletionApi.mjs' diff --git a/services/web/test/acceptance/src/LearnTest.mjs b/services/web/test/acceptance/src/LearnTest.mjs index aca991739b..665f1d63cd 100644 --- a/services/web/test/acceptance/src/LearnTest.mjs +++ b/services/web/test/acceptance/src/LearnTest.mjs @@ -1,6 +1,6 @@ import { expect } from 'chai' import cheerio from 'cheerio' -import UserHelper from './helpers/User.js' +import UserHelper from './helpers/User.mjs' const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/LinkedFilesTests.mjs b/services/web/test/acceptance/src/LinkedFilesTests.mjs index a66d3b8eaa..b625ba1627 100644 --- a/services/web/test/acceptance/src/LinkedFilesTests.mjs +++ b/services/web/test/acceptance/src/LinkedFilesTests.mjs @@ -1,9 +1,9 @@ import { expect } from 'chai' import _ from 'lodash' -import fs from 'fs' +import fs from 'node:fs' import timekeeper from 'timekeeper' import Settings from '@overleaf/settings' -import UserHelper from './helpers/User.js' +import UserHelper from './helpers/User.mjs' import express from 'express' import { plainTextResponse } from '../../../app/src/infrastructure/Response.js' diff --git a/services/web/test/acceptance/src/MongoHelper.mjs b/services/web/test/acceptance/src/MongoHelper.mjs index d737fe9c7c..269939e8ff 100644 --- a/services/web/test/acceptance/src/MongoHelper.mjs +++ b/services/web/test/acceptance/src/MongoHelper.mjs @@ -7,7 +7,7 @@ import { normalizeQuery, normalizeMultiQuery, } from '../../../app/src/Features/Helpers/Mongo.js' -import UserHelper from './helpers/User.js' +import UserHelper from './helpers/User.mjs' const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/PasswordResetTests.mjs b/services/web/test/acceptance/src/PasswordResetTests.mjs index 14b98a2d5d..5b040e7e0e 100644 --- a/services/web/test/acceptance/src/PasswordResetTests.mjs +++ b/services/web/test/acceptance/src/PasswordResetTests.mjs @@ -1,5 +1,5 @@ import { expect } from 'chai' -import UserHelper from './helpers/UserHelper.js' +import UserHelper from './helpers/UserHelper.mjs' import { db } from '../../../app/src/infrastructure/mongodb.js' describe('PasswordReset', function () { diff --git a/services/web/test/acceptance/src/PasswordUpdateTests.mjs b/services/web/test/acceptance/src/PasswordUpdateTests.mjs index 3c1a6be091..d65100b1c6 100644 --- a/services/web/test/acceptance/src/PasswordUpdateTests.mjs +++ b/services/web/test/acceptance/src/PasswordUpdateTests.mjs @@ -1,6 +1,6 @@ import { expect } from 'chai' import PasswordResetRouter from '../../../app/src/Features/PasswordReset/PasswordResetRouter.mjs' -import UserHelper from './helpers/UserHelper.js' +import UserHelper from './helpers/UserHelper.mjs' describe('PasswordUpdate', function () { let email, password, response, user, userHelper diff --git a/services/web/test/acceptance/src/PrimaryEmailCheckTests.mjs b/services/web/test/acceptance/src/PrimaryEmailCheckTests.mjs index 92d097950f..1598373f34 100644 --- a/services/web/test/acceptance/src/PrimaryEmailCheckTests.mjs +++ b/services/web/test/acceptance/src/PrimaryEmailCheckTests.mjs @@ -1,8 +1,8 @@ -import UserHelper from './helpers/UserHelper.js' +import UserHelper from './helpers/UserHelper.mjs' import Settings from '@overleaf/settings' import { expect } from 'chai' import Features from '../../../app/src/infrastructure/Features.js' -import MockV1ApiClass from './mocks/MockV1Api.js' +import MockV1ApiClass from './mocks/MockV1Api.mjs' import SubscriptionHelper from './helpers/Subscription.mjs' const Subscription = SubscriptionHelper.promises diff --git a/services/web/test/acceptance/src/ProjectCRUDTests.mjs b/services/web/test/acceptance/src/ProjectCRUDTests.mjs index 6eba6acaf6..2212dc8e8b 100644 --- a/services/web/test/acceptance/src/ProjectCRUDTests.mjs +++ b/services/web/test/acceptance/src/ProjectCRUDTests.mjs @@ -1,5 +1,5 @@ import { expect } from 'chai' -import UserHelper from './helpers/User.js' +import UserHelper from './helpers/User.mjs' import { Project } from '../../../app/src/models/Project.js' import mongodb from 'mongodb-legacy' import cheerio from 'cheerio' diff --git a/services/web/test/acceptance/src/ProjectDuplicateNameTests.mjs b/services/web/test/acceptance/src/ProjectDuplicateNameTests.mjs index 61828963ab..ed6d8efe22 100644 --- a/services/web/test/acceptance/src/ProjectDuplicateNameTests.mjs +++ b/services/web/test/acceptance/src/ProjectDuplicateNameTests.mjs @@ -3,10 +3,10 @@ import sinon from 'sinon' import Path from 'node:path' import fs from 'node:fs' import _ from 'lodash' -import User from './helpers/User.js' -import UserHelper from './helpers/UserHelper.js' -import MockDocstoreApiClass from './mocks/MockDocstoreApi.js' -import MockFilestoreApiClass from './mocks/MockFilestoreApi.js' +import User from './helpers/User.mjs' +import UserHelper from './helpers/UserHelper.mjs' +import MockDocstoreApiClass from './mocks/MockDocstoreApi.mjs' +import MockFilestoreApiClass from './mocks/MockFilestoreApi.mjs' import { fileURLToPath } from 'node:url' let MockDocstoreApi, MockFilestoreApi diff --git a/services/web/test/acceptance/src/ProjectFeaturesTests.mjs b/services/web/test/acceptance/src/ProjectFeaturesTests.mjs index 1f65bf2203..c7bd2a0a38 100644 --- a/services/web/test/acceptance/src/ProjectFeaturesTests.mjs +++ b/services/web/test/acceptance/src/ProjectFeaturesTests.mjs @@ -13,7 +13,7 @@ import { expect } from 'chai' import async from 'async' -import User from './helpers/User.js' +import User from './helpers/User.mjs' import request from './helpers/request.js' import settings from '@overleaf/settings' diff --git a/services/web/test/acceptance/src/ProjectInviteTests.mjs b/services/web/test/acceptance/src/ProjectInviteTests.mjs index 520d1fd7ee..48473687a1 100644 --- a/services/web/test/acceptance/src/ProjectInviteTests.mjs +++ b/services/web/test/acceptance/src/ProjectInviteTests.mjs @@ -1,6 +1,6 @@ import { expect } from 'chai' import Async from 'async' -import User from './helpers/User.js' +import User from './helpers/User.mjs' import settings from '@overleaf/settings' import CollaboratorsEmailHandler from '../../../app/src/Features/Collaborators/CollaboratorsEmailHandler.mjs' import CollaboratorsInviteHelper from '../../../app/src/Features/Collaborators/CollaboratorsInviteHelper.js' diff --git a/services/web/test/acceptance/src/ProjectOwnershipTransferTests.mjs b/services/web/test/acceptance/src/ProjectOwnershipTransferTests.mjs index 6256221dfe..9d01efd7bc 100644 --- a/services/web/test/acceptance/src/ProjectOwnershipTransferTests.mjs +++ b/services/web/test/acceptance/src/ProjectOwnershipTransferTests.mjs @@ -1,5 +1,5 @@ import { expect } from 'chai' -import UserHelper from './helpers/User.js' +import UserHelper from './helpers/User.mjs' const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/ProjectStructureTests.mjs b/services/web/test/acceptance/src/ProjectStructureTests.mjs index 6f6f89ce86..022c16c294 100644 --- a/services/web/test/acceptance/src/ProjectStructureTests.mjs +++ b/services/web/test/acceptance/src/ProjectStructureTests.mjs @@ -4,9 +4,9 @@ import Path from 'node:path' import fs from 'node:fs' import { Project } from '../../../app/src/models/Project.js' import ProjectGetter from '../../../app/src/Features/Project/ProjectGetter.js' -import UserHelper from './helpers/User.js' -import MockDocStoreApiClass from './mocks/MockDocstoreApi.js' -import MockDocUpdaterApiClass from './mocks/MockDocUpdaterApi.js' +import UserHelper from './helpers/User.mjs' +import MockDocStoreApiClass from './mocks/MockDocstoreApi.mjs' +import MockDocUpdaterApiClass from './mocks/MockDocUpdaterApi.mjs' import { fileURLToPath } from 'node:url' const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/RegenerateDuplicateReferralIdsTests.mjs b/services/web/test/acceptance/src/RegenerateDuplicateReferralIdsTests.mjs index ebee8a3ae6..2f54a03043 100644 --- a/services/web/test/acceptance/src/RegenerateDuplicateReferralIdsTests.mjs +++ b/services/web/test/acceptance/src/RegenerateDuplicateReferralIdsTests.mjs @@ -1,5 +1,5 @@ -import { exec } from 'child_process' -import { promisify } from 'util' +import { exec } from 'node:child_process' +import { promisify } from 'node:util' import { expect } from 'chai' import logger from '@overleaf/logger' import { filterOutput } from './helpers/settings.mjs' diff --git a/services/web/test/acceptance/src/RegistrationTests.mjs b/services/web/test/acceptance/src/RegistrationTests.mjs index bbbe21c3b3..f062d3d314 100644 --- a/services/web/test/acceptance/src/RegistrationTests.mjs +++ b/services/web/test/acceptance/src/RegistrationTests.mjs @@ -1,7 +1,7 @@ import { expect } from 'chai' import async from 'async' -import metrics from './helpers/metrics.js' -import User from './helpers/User.js' +import metrics from './helpers/metrics.mjs' +import User from './helpers/User.mjs' import redis from './helpers/redis.mjs' import Features from '../../../app/src/infrastructure/Features.js' diff --git a/services/web/test/acceptance/src/RemoveDeletedUsersFromTokenAccessRefsTests.mjs b/services/web/test/acceptance/src/RemoveDeletedUsersFromTokenAccessRefsTests.mjs index d7b0aa8895..ce596a1837 100644 --- a/services/web/test/acceptance/src/RemoveDeletedUsersFromTokenAccessRefsTests.mjs +++ b/services/web/test/acceptance/src/RemoveDeletedUsersFromTokenAccessRefsTests.mjs @@ -1,6 +1,6 @@ import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js' -import { promisify } from 'util' -import { exec } from 'child_process' +import { promisify } from 'node:util' +import { exec } from 'node:child_process' import logger from '@overleaf/logger' import { expect } from 'chai' diff --git a/services/web/test/acceptance/src/SecurityHeadersTests.mjs b/services/web/test/acceptance/src/SecurityHeadersTests.mjs index fe0b1135cd..6b11fee6e9 100644 --- a/services/web/test/acceptance/src/SecurityHeadersTests.mjs +++ b/services/web/test/acceptance/src/SecurityHeadersTests.mjs @@ -14,7 +14,7 @@ import { assert } from 'chai' import async from 'async' -import User from './helpers/User.js' +import User from './helpers/User.mjs' import request from './helpers/request.js' import ProjectGetter from '../../../app/src/Features/Project/ProjectGetter.js' diff --git a/services/web/test/acceptance/src/ServerCrashTests.mjs b/services/web/test/acceptance/src/ServerCrashTests.mjs index fb89c416d5..765a3b437f 100644 --- a/services/web/test/acceptance/src/ServerCrashTests.mjs +++ b/services/web/test/acceptance/src/ServerCrashTests.mjs @@ -1,10 +1,10 @@ import { expect } from 'chai' -import fs from 'fs' -import Path from 'path' +import fs from 'node:fs' +import Path from 'node:path' import fetch from 'node-fetch' -import UserHelper from './helpers/UserHelper.js' +import UserHelper from './helpers/UserHelper.mjs' import glob from 'glob' -import { fileURLToPath } from 'url' +import { fileURLToPath } from 'node:url' const BASE_URL = UserHelper.baseUrl() diff --git a/services/web/test/acceptance/src/SessionTests.mjs b/services/web/test/acceptance/src/SessionTests.mjs index 09ddeda0d6..d55bece093 100644 --- a/services/web/test/acceptance/src/SessionTests.mjs +++ b/services/web/test/acceptance/src/SessionTests.mjs @@ -1,6 +1,6 @@ import { expect } from 'chai' import async from 'async' -import UserHelper from './helpers/User.js' +import UserHelper from './helpers/User.mjs' import redis from './helpers/redis.mjs' import UserSessionsRedis from '../../../app/src/Features/User/UserSessionsRedis.js' const rclient = UserSessionsRedis.client() diff --git a/services/web/test/acceptance/src/SettingsTests.mjs b/services/web/test/acceptance/src/SettingsTests.mjs index 87a425fb9b..3d0c015bb3 100644 --- a/services/web/test/acceptance/src/SettingsTests.mjs +++ b/services/web/test/acceptance/src/SettingsTests.mjs @@ -11,7 +11,7 @@ import { expect } from 'chai' import async from 'async' -import User from './helpers/User.js' +import User from './helpers/User.mjs' import Features from '../../../app/src/infrastructure/Features.js' describe('SettingsPage', function () { diff --git a/services/web/test/acceptance/src/SharingTests.mjs b/services/web/test/acceptance/src/SharingTests.mjs index f644c39180..d70c47aad6 100644 --- a/services/web/test/acceptance/src/SharingTests.mjs +++ b/services/web/test/acceptance/src/SharingTests.mjs @@ -1,5 +1,5 @@ import { expect } from 'chai' -import UserHelper from './helpers/User.js' +import UserHelper from './helpers/User.mjs' const User = UserHelper.promises diff --git a/services/web/test/acceptance/src/TagsTests.mjs b/services/web/test/acceptance/src/TagsTests.mjs index a2c99ba23d..7c17353833 100644 --- a/services/web/test/acceptance/src/TagsTests.mjs +++ b/services/web/test/acceptance/src/TagsTests.mjs @@ -1,9 +1,9 @@ -import User from './helpers/User.js' +import User from './helpers/User.mjs' import async from 'async' import { expect } from 'chai' import _ from 'lodash' import request from './helpers/request.js' -import expectErrorResponse from './helpers/expectErrorResponse.js' +import expectErrorResponse from './helpers/expectErrorResponse.mjs' const _initUser = (user, callback) => { async.series([cb => user.login(cb), cb => user.getCsrfToken(cb)], callback) diff --git a/services/web/test/acceptance/src/TokenAccessTests.mjs b/services/web/test/acceptance/src/TokenAccessTests.mjs index ba4d2c09d0..96d10c6414 100644 --- a/services/web/test/acceptance/src/TokenAccessTests.mjs +++ b/services/web/test/acceptance/src/TokenAccessTests.mjs @@ -1,10 +1,10 @@ import { expect } from 'chai' import async from 'async' -import User from './helpers/User.js' +import User from './helpers/User.mjs' import request from './helpers/request.js' import settings from '@overleaf/settings' import { db } from '../../../app/src/infrastructure/mongodb.js' -import expectErrorResponse from './helpers/expectErrorResponse.js' +import expectErrorResponse from './helpers/expectErrorResponse.mjs' import SplitTestHandler from '../../../app/src/Features/SplitTests/SplitTestHandler.js' import sinon from 'sinon' diff --git a/services/web/test/acceptance/src/TpdsUpdateTests.mjs b/services/web/test/acceptance/src/TpdsUpdateTests.mjs index 4f1307a208..3515e03823 100644 --- a/services/web/test/acceptance/src/TpdsUpdateTests.mjs +++ b/services/web/test/acceptance/src/TpdsUpdateTests.mjs @@ -1,7 +1,7 @@ import { expect } from 'chai' import ProjectGetter from '../../../app/src/Features/Project/ProjectGetter.js' import request from './helpers/request.js' -import User from './helpers/User.js' +import User from './helpers/User.mjs' describe('TpdsUpdateTests', function () { beforeEach(function (done) { diff --git a/services/web/test/acceptance/src/UnsupportedBrowserTests.mjs b/services/web/test/acceptance/src/UnsupportedBrowserTests.mjs index 6f9cc5a730..335a9cd3dd 100644 --- a/services/web/test/acceptance/src/UnsupportedBrowserTests.mjs +++ b/services/web/test/acceptance/src/UnsupportedBrowserTests.mjs @@ -1,5 +1,5 @@ import { expect } from 'chai' -import User from './helpers/User.js' +import User from './helpers/User.mjs' const botUserAgents = new Map([ [ diff --git a/services/web/test/acceptance/src/UserHelperTests.mjs b/services/web/test/acceptance/src/UserHelperTests.mjs index 391b18154a..bde8429622 100644 --- a/services/web/test/acceptance/src/UserHelperTests.mjs +++ b/services/web/test/acceptance/src/UserHelperTests.mjs @@ -1,5 +1,5 @@ import AuthenticationManager from '../../../app/src/Features/Authentication/AuthenticationManager.js' -import UserHelper from './helpers/UserHelper.js' +import UserHelper from './helpers/UserHelper.mjs' import Features from '../../../app/src/infrastructure/Features.js' import { expect } from 'chai' diff --git a/services/web/test/acceptance/src/UserMembershipAuthorizationTests.mjs b/services/web/test/acceptance/src/UserMembershipAuthorizationTests.mjs index 906d154e42..0b98ac5f8d 100644 --- a/services/web/test/acceptance/src/UserMembershipAuthorizationTests.mjs +++ b/services/web/test/acceptance/src/UserMembershipAuthorizationTests.mjs @@ -1,9 +1,9 @@ import { expect } from 'chai' import async from 'async' -import User from './helpers/User.js' +import User from './helpers/User.mjs' import Institution from './helpers/Institution.mjs' import Subscription from './helpers/Subscription.mjs' -import Publisher from './helpers/Publisher.js' +import Publisher from './helpers/Publisher.mjs' describe('UserMembershipAuthorization', function () { beforeEach(function (done) { diff --git a/services/web/test/acceptance/src/UserReconfirmTests.mjs b/services/web/test/acceptance/src/UserReconfirmTests.mjs index a132da0f71..abce68c08f 100644 --- a/services/web/test/acceptance/src/UserReconfirmTests.mjs +++ b/services/web/test/acceptance/src/UserReconfirmTests.mjs @@ -14,7 +14,7 @@ import { expect } from 'chai' import async from 'async' -import User from './helpers/User.js' +import User from './helpers/User.mjs' describe('User Must Reconfirm', function () { beforeEach(function (done) { diff --git a/services/web/test/acceptance/src/helpers/MongoHelper.mjs b/services/web/test/acceptance/src/helpers/MongoHelper.mjs index 45c1feb081..fc4a0feda2 100644 --- a/services/web/test/acceptance/src/helpers/MongoHelper.mjs +++ b/services/web/test/acceptance/src/helpers/MongoHelper.mjs @@ -1,4 +1,4 @@ -import { execFile } from 'child_process' +import { execFile } from 'node:child_process' import { connectionPromise, cleanupTestDatabase, diff --git a/services/web/test/acceptance/src/helpers/Publisher.js b/services/web/test/acceptance/src/helpers/Publisher.mjs similarity index 76% rename from services/web/test/acceptance/src/helpers/Publisher.js rename to services/web/test/acceptance/src/helpers/Publisher.mjs index 43a29ff29e..8300157cc2 100644 --- a/services/web/test/acceptance/src/helpers/Publisher.js +++ b/services/web/test/acceptance/src/helpers/Publisher.mjs @@ -1,6 +1,8 @@ -const { ObjectId } = require('mongodb-legacy') -const PublisherModel = require('../../../../app/src/models/Publisher').Publisher -const { callbackifyClass } = require('@overleaf/promise-utils') +import mongodb from 'mongodb-legacy' +import { Publisher as PublisherModel } from '../../../../app/src/models/Publisher.js' +import { callbackifyClass } from '@overleaf/promise-utils' + +const { ObjectId } = mongodb let count = parseInt(Math.random() * 999999) @@ -35,4 +37,4 @@ class PromisifiedPublisher { const Publisher = callbackifyClass(PromisifiedPublisher) Publisher.promises = class extends PromisifiedPublisher {} -module.exports = Publisher +export default Publisher diff --git a/services/web/test/acceptance/src/helpers/RecurlySubscription.mjs b/services/web/test/acceptance/src/helpers/RecurlySubscription.mjs index 1e337d7f32..09340451be 100644 --- a/services/web/test/acceptance/src/helpers/RecurlySubscription.mjs +++ b/services/web/test/acceptance/src/helpers/RecurlySubscription.mjs @@ -1,6 +1,6 @@ import mongodb from 'mongodb-legacy' import Subscription from './Subscription.mjs' -import MockRecurlyApiClass from '../mocks/MockRecurlyApi.js' +import MockRecurlyApiClass from '../mocks/MockRecurlyApi.mjs' import RecurlyWrapper from '../../../../app/src/Features/Subscription/RecurlyWrapper.js' import { promisifyClass } from '@overleaf/promise-utils' diff --git a/services/web/test/acceptance/src/helpers/SAMLHelper.js b/services/web/test/acceptance/src/helpers/SAMLHelper.mjs similarity index 95% rename from services/web/test/acceptance/src/helpers/SAMLHelper.js rename to services/web/test/acceptance/src/helpers/SAMLHelper.mjs index d18342f206..76265e1774 100644 --- a/services/web/test/acceptance/src/helpers/SAMLHelper.js +++ b/services/web/test/acceptance/src/helpers/SAMLHelper.mjs @@ -1,10 +1,13 @@ -const fs = require('fs') -const path = require('path') -const SignedXml = require('xml-crypto').SignedXml -const { SamlLog } = require('../../../../app/src/models/SamlLog') -const { expect } = require('chai') -const zlib = require('zlib') -const xml2js = require('xml2js') +import fs from 'node:fs' +import path from 'node:path' +import { SignedXml } from 'xml-crypto' +import { SamlLog } from '../../../../app/src/models/SamlLog.js' +import { expect } from 'chai' +import zlib from 'node:zlib' +import { fileURLToPath } from 'node:url' +import xml2js from 'xml2js' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) const samlDataDefaults = { firstName: 'first-name', @@ -246,4 +249,4 @@ const SAMLHelper = { getRequestId, } -module.exports = SAMLHelper +export default SAMLHelper diff --git a/services/web/test/acceptance/src/helpers/SplitTestHelper.js b/services/web/test/acceptance/src/helpers/SplitTestHelper.mjs similarity index 93% rename from services/web/test/acceptance/src/helpers/SplitTestHelper.js rename to services/web/test/acceptance/src/helpers/SplitTestHelper.mjs index f29ddc0f16..600c5b8082 100644 --- a/services/web/test/acceptance/src/helpers/SplitTestHelper.js +++ b/services/web/test/acceptance/src/helpers/SplitTestHelper.mjs @@ -1,5 +1,5 @@ -const { assert } = require('chai') -const { CacheFlow } = require('cache-flow') +import { assert } from 'chai' +import { CacheFlow } from 'cache-flow' const sendStaffRequest = async function ( staffUser, @@ -52,7 +52,7 @@ const expectResponse = async function ( } } -module.exports = { +export default { sendStaffRequest, createTest, updateTestConfig, diff --git a/services/web/test/acceptance/src/helpers/User.js b/services/web/test/acceptance/src/helpers/User.mjs similarity index 97% rename from services/web/test/acceptance/src/helpers/User.js rename to services/web/test/acceptance/src/helpers/User.mjs index 75a8d287ac..f91f428d6d 100644 --- a/services/web/test/acceptance/src/helpers/User.js +++ b/services/web/test/acceptance/src/helpers/User.mjs @@ -1,14 +1,16 @@ -const OError = require('@overleaf/o-error') -const request = require('./request') -const settings = require('@overleaf/settings') -const { db, ObjectId } = require('../../../../app/src/infrastructure/mongodb') -const UserModel = require('../../../../app/src/models/User').User -const UserUpdater = require('../../../../app/src/Features/User/UserUpdater') -const AuthenticationManager = require('../../../../app/src/Features/Authentication/AuthenticationManager') -const { promisifyClass } = require('@overleaf/promise-utils') -const fs = require('fs') -const Path = require('path') -const { Cookie } = require('tough-cookie') +import OError from '@overleaf/o-error' +import request from './request.js' +import settings from '@overleaf/settings' +import { db, ObjectId } from '../../../../app/src/infrastructure/mongodb.js' +import { User as UserModel } from '../../../../app/src/models/User.js' +import UserUpdater from '../../../../app/src/Features/User/UserUpdater.js' +import AuthenticationManager from '../../../../app/src/Features/Authentication/AuthenticationManager.js' +import { promisifyClass } from '@overleaf/promise-utils' +import fs from 'node:fs' +import Path from 'node:path' +import { fileURLToPath } from 'node:url' +import { Cookie } from 'tough-cookie' +const __dirname = fileURLToPath(new URL('.', import.meta.url)) const COOKIE_DOMAIN = settings.cookieDomain // The cookie domain has a leading '.' but the cookie jar stores it without. const DEFAULT_COOKIE_URL = `https://${COOKIE_DOMAIN.replace(/^\./, '')}/` @@ -1296,4 +1298,4 @@ User.promises.prototype.doRequest = async function (method, params) { }) } -module.exports = User +export default User diff --git a/services/web/test/acceptance/src/helpers/UserHelper.js b/services/web/test/acceptance/src/helpers/UserHelper.mjs similarity index 94% rename from services/web/test/acceptance/src/helpers/UserHelper.js rename to services/web/test/acceptance/src/helpers/UserHelper.mjs index a561e02f47..cfeafed47c 100644 --- a/services/web/test/acceptance/src/helpers/UserHelper.js +++ b/services/web/test/acceptance/src/helpers/UserHelper.mjs @@ -1,23 +1,22 @@ -const { CookieJar } = require('tough-cookie') -const AuthenticationManager = require('../../../../app/src/Features/Authentication/AuthenticationManager') -const Settings = require('@overleaf/settings') -const InstitutionsAPI = require('../../../../app/src/Features/Institutions/InstitutionsAPI') -const UserCreator = require('../../../../app/src/Features/User/UserCreator') -const UserGetter = require('../../../../app/src/Features/User/UserGetter') -const UserUpdater = require('../../../../app/src/Features/User/UserUpdater') -const moment = require('moment') -const fetch = require('node-fetch') -const { db } = require('../../../../app/src/infrastructure/mongodb') -const { ObjectId } = require('mongodb-legacy') -const { - UserAuditLogEntry, -} = require('../../../../app/src/models/UserAuditLogEntry') +import { CookieJar } from 'tough-cookie' +import AuthenticationManager from '../../../../app/src/Features/Authentication/AuthenticationManager.js' +import Settings from '@overleaf/settings' +import InstitutionsAPI from '../../../../app/src/Features/Institutions/InstitutionsAPI.js' +import UserCreator from '../../../../app/src/Features/User/UserCreator.js' +import UserGetter from '../../../../app/src/Features/User/UserGetter.js' +import UserUpdater from '../../../../app/src/Features/User/UserUpdater.js' +import moment from 'moment' +import fetch from 'node-fetch' +import { db } from '../../../../app/src/infrastructure/mongodb.js' +import mongodb from 'mongodb-legacy' + +import { UserAuditLogEntry } from '../../../../app/src/models/UserAuditLogEntry.js' // Import the rate limiter so we can clear it between tests -const { - RateLimiter, -} = require('../../../../app/src/infrastructure/RateLimiter') +import { RateLimiter } from '../../../../app/src/infrastructure/RateLimiter.js' + +const { ObjectId } = mongodb const rateLimiters = { resendConfirmation: new RateLimiter('resend-confirmation'), @@ -557,4 +556,4 @@ class UserHelper { } } -module.exports = UserHelper +export default UserHelper diff --git a/services/web/test/acceptance/src/helpers/expectErrorResponse.js b/services/web/test/acceptance/src/helpers/expectErrorResponse.mjs similarity index 90% rename from services/web/test/acceptance/src/helpers/expectErrorResponse.js rename to services/web/test/acceptance/src/helpers/expectErrorResponse.mjs index 87de023e7b..e79bfd5b2e 100644 --- a/services/web/test/acceptance/src/helpers/expectErrorResponse.js +++ b/services/web/test/acceptance/src/helpers/expectErrorResponse.mjs @@ -1,6 +1,6 @@ -const { expect } = require('chai') +import { expect } from 'chai' -module.exports = { +export default { requireLogin: { json(response, body) { expect(response.statusCode).to.equal(401) diff --git a/services/web/test/acceptance/src/helpers/groupSSO.mjs b/services/web/test/acceptance/src/helpers/groupSSO.mjs index 5f2de9cfd4..f7efeb9e63 100644 --- a/services/web/test/acceptance/src/helpers/groupSSO.mjs +++ b/services/web/test/acceptance/src/helpers/groupSSO.mjs @@ -1,10 +1,10 @@ -import fs from 'fs' -import Path from 'path' -import UserModule from './User.js' +import fs from 'node:fs' +import Path from 'node:path' +import UserModule from './User.mjs' import SubscriptionHelper from './Subscription.mjs' import { SSOConfig } from '../../../../app/src/models/SSOConfig.js' -import UserHelper from './UserHelper.js' -import SAMLHelper from './SAMLHelper.js' +import UserHelper from './UserHelper.mjs' +import SAMLHelper from './SAMLHelper.mjs' import Settings from '@overleaf/settings' import { getProviderId } from '../../../../app/src/Features/Subscription/GroupUtils.js' import UserGetter from '../../../../app/src/Features/User/UserGetter.js' diff --git a/services/web/test/acceptance/src/helpers/metrics.js b/services/web/test/acceptance/src/helpers/metrics.mjs similarity index 77% rename from services/web/test/acceptance/src/helpers/metrics.js rename to services/web/test/acceptance/src/helpers/metrics.mjs index 6cc4dc2d55..cb47f3d91d 100644 --- a/services/web/test/acceptance/src/helpers/metrics.js +++ b/services/web/test/acceptance/src/helpers/metrics.mjs @@ -1,6 +1,6 @@ -const { callbackify } = require('util') -const request = require('./request') -const metrics = require('@overleaf/metrics') +import { callbackify } from 'node:util' +import request from './request.js' +import metrics from '@overleaf/metrics' async function getMetric(matcher) { const { body } = await request.promises.request('/metrics') @@ -16,7 +16,7 @@ function resetMetrics() { metrics.register.resetMetrics() } -module.exports = { +export default { getMetric: callbackify(getMetric), resetMetrics, promises: { diff --git a/services/web/test/acceptance/src/mocks/AbstractMockApi.js b/services/web/test/acceptance/src/mocks/AbstractMockApi.mjs similarity index 97% rename from services/web/test/acceptance/src/mocks/AbstractMockApi.js rename to services/web/test/acceptance/src/mocks/AbstractMockApi.mjs index 780a49bb5f..37f5cd3b24 100644 --- a/services/web/test/acceptance/src/mocks/AbstractMockApi.js +++ b/services/web/test/acceptance/src/mocks/AbstractMockApi.mjs @@ -1,6 +1,6 @@ -const OError = require('@overleaf/o-error') -const express = require('express') -const bodyParser = require('body-parser') +import OError from '@overleaf/o-error' +import express from 'express' +import bodyParser from 'body-parser' /** * Abstract class for running a mock API via Express. Handles setting up of @@ -190,4 +190,4 @@ class AbstractMockApi { } } -module.exports = AbstractMockApi +export default AbstractMockApi diff --git a/services/web/test/acceptance/src/mocks/MockAnalyticsApi.mjs b/services/web/test/acceptance/src/mocks/MockAnalyticsApi.mjs index 6ebbf994a3..b5aaa45b27 100644 --- a/services/web/test/acceptance/src/mocks/MockAnalyticsApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockAnalyticsApi.mjs @@ -1,4 +1,4 @@ -import AbstractMockApi from './AbstractMockApi.js' +import AbstractMockApi from './AbstractMockApi.mjs' class MockAnalyticsApi extends AbstractMockApi { reset() { diff --git a/services/web/test/acceptance/src/mocks/MockChatApi.mjs b/services/web/test/acceptance/src/mocks/MockChatApi.mjs index 7a67c0f8e0..ee3346597d 100644 --- a/services/web/test/acceptance/src/mocks/MockChatApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockChatApi.mjs @@ -1,4 +1,4 @@ -import AbstractMockApi from './AbstractMockApi.js' +import AbstractMockApi from './AbstractMockApi.mjs' class MockChatApi extends AbstractMockApi { reset() { diff --git a/services/web/test/acceptance/src/mocks/MockClsiApi.mjs b/services/web/test/acceptance/src/mocks/MockClsiApi.mjs index d59be62d9f..102b75b0d3 100644 --- a/services/web/test/acceptance/src/mocks/MockClsiApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockClsiApi.mjs @@ -1,4 +1,4 @@ -import AbstractMockApi from './AbstractMockApi.js' +import AbstractMockApi from './AbstractMockApi.mjs' import { plainTextResponse } from '../../../../app/src/infrastructure/Response.js' class MockClsiApi extends AbstractMockApi { diff --git a/services/web/test/acceptance/src/mocks/MockDocUpdaterApi.js b/services/web/test/acceptance/src/mocks/MockDocUpdaterApi.mjs similarity index 94% rename from services/web/test/acceptance/src/mocks/MockDocUpdaterApi.js rename to services/web/test/acceptance/src/mocks/MockDocUpdaterApi.mjs index f254473175..d94c8eb32a 100644 --- a/services/web/test/acceptance/src/mocks/MockDocUpdaterApi.js +++ b/services/web/test/acceptance/src/mocks/MockDocUpdaterApi.mjs @@ -1,4 +1,4 @@ -const AbstractMockApi = require('./AbstractMockApi') +import AbstractMockApi from './AbstractMockApi.mjs' class MockDocUpdaterApi extends AbstractMockApi { reset() { @@ -56,7 +56,7 @@ class MockDocUpdaterApi extends AbstractMockApi { } } -module.exports = MockDocUpdaterApi +export default MockDocUpdaterApi // type hint for the inherited `instance` method /** diff --git a/services/web/test/acceptance/src/mocks/MockDocstoreApi.js b/services/web/test/acceptance/src/mocks/MockDocstoreApi.mjs similarity index 94% rename from services/web/test/acceptance/src/mocks/MockDocstoreApi.js rename to services/web/test/acceptance/src/mocks/MockDocstoreApi.mjs index da479ca30a..212603596c 100644 --- a/services/web/test/acceptance/src/mocks/MockDocstoreApi.js +++ b/services/web/test/acceptance/src/mocks/MockDocstoreApi.mjs @@ -1,5 +1,5 @@ -const { db, ObjectId } = require('../../../../app/src/infrastructure/mongodb') -const AbstractMockApi = require('./AbstractMockApi') +import { db, ObjectId } from '../../../../app/src/infrastructure/mongodb.js' +import AbstractMockApi from './AbstractMockApi.mjs' class MockDocstoreApi extends AbstractMockApi { reset() { @@ -97,7 +97,7 @@ class MockDocstoreApi extends AbstractMockApi { } } -module.exports = MockDocstoreApi +export default MockDocstoreApi // type hint for the inherited `instance` method /** diff --git a/services/web/test/acceptance/src/mocks/MockFilestoreApi.js b/services/web/test/acceptance/src/mocks/MockFilestoreApi.mjs similarity index 96% rename from services/web/test/acceptance/src/mocks/MockFilestoreApi.js rename to services/web/test/acceptance/src/mocks/MockFilestoreApi.mjs index f2a44d5a37..bfb4e9e04b 100644 --- a/services/web/test/acceptance/src/mocks/MockFilestoreApi.js +++ b/services/web/test/acceptance/src/mocks/MockFilestoreApi.mjs @@ -1,4 +1,4 @@ -const AbstractMockApi = require('./AbstractMockApi') +import AbstractMockApi from './AbstractMockApi.mjs' class MockFilestoreApi extends AbstractMockApi { reset() { @@ -70,7 +70,7 @@ class MockFilestoreApi extends AbstractMockApi { } } -module.exports = MockFilestoreApi +export default MockFilestoreApi // type hint for the inherited `instance` method /** diff --git a/services/web/test/acceptance/src/mocks/MockGitBridgeApi.mjs b/services/web/test/acceptance/src/mocks/MockGitBridgeApi.mjs index da20860f8e..4927814b9a 100644 --- a/services/web/test/acceptance/src/mocks/MockGitBridgeApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockGitBridgeApi.mjs @@ -1,4 +1,4 @@ -import AbstractMockApi from './AbstractMockApi.js' +import AbstractMockApi from './AbstractMockApi.mjs' class MockGitBridgeApi extends AbstractMockApi { reset() { diff --git a/services/web/test/acceptance/src/mocks/MockGoogleOauthApi.js b/services/web/test/acceptance/src/mocks/MockGoogleOauthApi.mjs similarity index 91% rename from services/web/test/acceptance/src/mocks/MockGoogleOauthApi.js rename to services/web/test/acceptance/src/mocks/MockGoogleOauthApi.mjs index 744cb6b6ee..5661f665f6 100644 --- a/services/web/test/acceptance/src/mocks/MockGoogleOauthApi.js +++ b/services/web/test/acceptance/src/mocks/MockGoogleOauthApi.mjs @@ -1,4 +1,4 @@ -const AbstractMockApi = require('./AbstractMockApi') +import AbstractMockApi from './AbstractMockApi.mjs' class MockGoogleOauthApi extends AbstractMockApi { reset() { @@ -35,7 +35,7 @@ class MockGoogleOauthApi extends AbstractMockApi { } } -module.exports = MockGoogleOauthApi +export default MockGoogleOauthApi // type hint for the inherited `instance` method /** diff --git a/services/web/test/acceptance/src/mocks/MockHaveIBeenPwnedApi.mjs b/services/web/test/acceptance/src/mocks/MockHaveIBeenPwnedApi.mjs index e7c92b9db4..2aa47f83f7 100644 --- a/services/web/test/acceptance/src/mocks/MockHaveIBeenPwnedApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockHaveIBeenPwnedApi.mjs @@ -1,4 +1,4 @@ -import AbstractMockApi from './AbstractMockApi.js' +import AbstractMockApi from './AbstractMockApi.mjs' import { plainTextResponse } from '../../../../app/src/infrastructure/Response.js' class MockHaveIBeenPwnedApi extends AbstractMockApi { diff --git a/services/web/test/acceptance/src/mocks/MockHistoryBackupDeletionApi.mjs b/services/web/test/acceptance/src/mocks/MockHistoryBackupDeletionApi.mjs index 069a0e6a26..e16ddf923f 100644 --- a/services/web/test/acceptance/src/mocks/MockHistoryBackupDeletionApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockHistoryBackupDeletionApi.mjs @@ -1,4 +1,4 @@ -import AbstractMockApi from './AbstractMockApi.js' +import AbstractMockApi from './AbstractMockApi.mjs' class MockHistoryBackupDeletionApi extends AbstractMockApi { reset() { diff --git a/services/web/test/acceptance/src/mocks/MockNotificationsApi.mjs b/services/web/test/acceptance/src/mocks/MockNotificationsApi.mjs index 356069c4cf..2e8893977a 100644 --- a/services/web/test/acceptance/src/mocks/MockNotificationsApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockNotificationsApi.mjs @@ -1,4 +1,4 @@ -import AbstractMockApi from './AbstractMockApi.js' +import AbstractMockApi from './AbstractMockApi.mjs' // Currently there is nothing implemented here as we have no acceptance tests // for the notifications API. This does however stop errors appearing in the diff --git a/services/web/test/acceptance/src/mocks/MockProjectHistoryApi.js b/services/web/test/acceptance/src/mocks/MockProjectHistoryApi.mjs similarity index 93% rename from services/web/test/acceptance/src/mocks/MockProjectHistoryApi.js rename to services/web/test/acceptance/src/mocks/MockProjectHistoryApi.mjs index 4f53277923..82908dc948 100644 --- a/services/web/test/acceptance/src/mocks/MockProjectHistoryApi.js +++ b/services/web/test/acceptance/src/mocks/MockProjectHistoryApi.mjs @@ -1,9 +1,9 @@ -const AbstractMockApi = require('./AbstractMockApi') -const _ = require('lodash') -const { ObjectId } = require('mongodb-legacy') -const { - plainTextResponse, -} = require('../../../../app/src/infrastructure/Response') +import AbstractMockApi from './AbstractMockApi.mjs' +import _ from 'lodash' +import mongodb from 'mongodb-legacy' +import { plainTextResponse } from '../../../../app/src/infrastructure/Response.js' + +const { ObjectId } = mongodb class MockProjectHistoryApi extends AbstractMockApi { reset() { @@ -148,7 +148,7 @@ class MockProjectHistoryApi extends AbstractMockApi { } } -module.exports = MockProjectHistoryApi +export default MockProjectHistoryApi // type hint for the inherited `instance` method /** diff --git a/services/web/test/acceptance/src/mocks/MockReCaptchaApi.mjs b/services/web/test/acceptance/src/mocks/MockReCaptchaApi.mjs index 52804cf3e4..cb12e8bb1a 100644 --- a/services/web/test/acceptance/src/mocks/MockReCaptchaApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockReCaptchaApi.mjs @@ -1,4 +1,4 @@ -import AbstractMockApi from './AbstractMockApi.js' +import AbstractMockApi from './AbstractMockApi.mjs' class MockReCaptchaApi extends AbstractMockApi { applyRoutes() { diff --git a/services/web/test/acceptance/src/mocks/MockRecurlyApi.js b/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs similarity index 93% rename from services/web/test/acceptance/src/mocks/MockRecurlyApi.js rename to services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs index 81cc4927e9..c1e7c2aa8b 100644 --- a/services/web/test/acceptance/src/mocks/MockRecurlyApi.js +++ b/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs @@ -1,6 +1,6 @@ -const AbstractMockApi = require('./AbstractMockApi') -const SubscriptionController = require('../../../../app/src/Features/Subscription/SubscriptionController') -const { xmlResponse } = require('../../../../app/src/infrastructure/Response') +import AbstractMockApi from './AbstractMockApi.mjs' +import SubscriptionController from '../../../../app/src/Features/Subscription/SubscriptionController.js' +import { xmlResponse } from '../../../../app/src/infrastructure/Response.js' class MockRecurlyApi extends AbstractMockApi { reset() { @@ -132,7 +132,7 @@ class MockRecurlyApi extends AbstractMockApi { } } -module.exports = MockRecurlyApi +export default MockRecurlyApi // type hint for the inherited `instance` method /** diff --git a/services/web/test/acceptance/src/mocks/MockSpellingApi.mjs b/services/web/test/acceptance/src/mocks/MockSpellingApi.mjs index 09689a2184..55a31c4e72 100644 --- a/services/web/test/acceptance/src/mocks/MockSpellingApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockSpellingApi.mjs @@ -1,4 +1,4 @@ -import AbstractMockApi from './AbstractMockApi.js' +import AbstractMockApi from './AbstractMockApi.mjs' class MockSpellingApi extends AbstractMockApi { reset() { diff --git a/services/web/test/acceptance/src/mocks/MockThirdPartyDataStoreApi.mjs b/services/web/test/acceptance/src/mocks/MockThirdPartyDataStoreApi.mjs index 5c4cc10984..cc597db598 100644 --- a/services/web/test/acceptance/src/mocks/MockThirdPartyDataStoreApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockThirdPartyDataStoreApi.mjs @@ -1,4 +1,4 @@ -import AbstractMockApi from './AbstractMockApi.js' +import AbstractMockApi from './AbstractMockApi.mjs' class MockThirdPartyDataStoreApi extends AbstractMockApi { reset() {} diff --git a/services/web/test/acceptance/src/mocks/MockV1Api.js b/services/web/test/acceptance/src/mocks/MockV1Api.mjs similarity index 98% rename from services/web/test/acceptance/src/mocks/MockV1Api.js rename to services/web/test/acceptance/src/mocks/MockV1Api.mjs index 8d4d53ab70..70740d656e 100644 --- a/services/web/test/acceptance/src/mocks/MockV1Api.js +++ b/services/web/test/acceptance/src/mocks/MockV1Api.mjs @@ -1,6 +1,6 @@ -const AbstractMockApi = require('./AbstractMockApi') -const moment = require('moment') -const sinon = require('sinon') +import AbstractMockApi from './AbstractMockApi.mjs' +import moment from 'moment' +import sinon from 'sinon' class MockV1Api extends AbstractMockApi { reset() { @@ -462,7 +462,7 @@ class MockV1Api extends AbstractMockApi { } } -module.exports = MockV1Api +export default MockV1Api // type hint for the inherited `instance` method /** diff --git a/services/web/test/acceptance/src/mocks/MockV1HistoryApi.js b/services/web/test/acceptance/src/mocks/MockV1HistoryApi.mjs similarity index 94% rename from services/web/test/acceptance/src/mocks/MockV1HistoryApi.js rename to services/web/test/acceptance/src/mocks/MockV1HistoryApi.mjs index 1a109c43ac..d190f61398 100644 --- a/services/web/test/acceptance/src/mocks/MockV1HistoryApi.js +++ b/services/web/test/acceptance/src/mocks/MockV1HistoryApi.mjs @@ -1,10 +1,10 @@ -const AbstractMockApi = require('./AbstractMockApi') -const { EventEmitter } = require('events') -const { +import AbstractMockApi from './AbstractMockApi.mjs' +import { EventEmitter } from 'node:events' +import { zipAttachment, prepareZipAttachment, -} = require('../../../../app/src/infrastructure/Response') -const Joi = require('joi') +} from '../../../../app/src/infrastructure/Response.js' +import Joi from 'joi' class MockV1HistoryApi extends AbstractMockApi { reset() { @@ -120,7 +120,7 @@ class MockV1HistoryApi extends AbstractMockApi { } } -module.exports = MockV1HistoryApi +export default MockV1HistoryApi // type hint for the inherited `instance` method /** From 182e9deada8cdf8ebc3f7f58b36a5a9979b982e4 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Fri, 10 Jan 2025 10:07:14 +0000 Subject: [PATCH 0002/1724] Merge pull request #22768 from overleaf/mj-ide-source-editor [web] Add editor to editor redesign GitOrigin-RevId: cdda3d5391866b882d6696ba833316aa91cf2856 --- .../ide-redesign/components/editor.tsx | 73 +++++++++++++++++++ .../ide-redesign/components/main-layout.tsx | 5 +- .../pages/editor/ide-redesign.scss | 9 +++ 3 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 services/web/frontend/js/features/ide-redesign/components/editor.tsx diff --git a/services/web/frontend/js/features/ide-redesign/components/editor.tsx b/services/web/frontend/js/features/ide-redesign/components/editor.tsx new file mode 100644 index 0000000000..12e6e3b4fb --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/editor.tsx @@ -0,0 +1,73 @@ +import { LoadingPane } from '@/features/ide-react/components/editor/loading-pane' +import { + EditorScopeValue, + useEditorManagerContext, +} from '@/features/ide-react/context/editor-manager-context' +import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context' +import useScopeValue from '@/shared/hooks/use-scope-value' +import classNames from 'classnames' +import SourceEditor from '@/features/source-editor/components/source-editor' +import { useProjectContext } from '@/shared/context/project-context' +import { useFileTreeData } from '@/shared/context/file-tree-data-context' +import { useEffect, useRef } from 'react' +import { findInTree } from '@/features/file-tree/util/find-in-tree' + +// FIXME: This is only needed until we have a working file tree. This hook does +// the minimal amount of work to load the initial document. +const useWorkaroundForOpeningInitialDocument = () => { + const { _id: projectId } = useProjectContext() + const { fileTreeData, setSelectedEntities } = useFileTreeData() + const isReady = Boolean(projectId && fileTreeData) + const { handleFileTreeInit, handleFileTreeSelect } = useFileTreeOpenContext() + const { currentDocumentId } = useEditorManagerContext() + + useEffect(() => { + if (isReady) handleFileTreeInit() + }, [isReady, handleFileTreeInit]) + + const alreadyOpenedFile = useRef(false) + useEffect(() => { + if (isReady && currentDocumentId && !alreadyOpenedFile.current) { + alreadyOpenedFile.current = true + const doc = findInTree(fileTreeData, currentDocumentId) + if (doc) { + handleFileTreeSelect([doc]) + setSelectedEntities([doc]) + } + } + }, [ + isReady, + currentDocumentId, + fileTreeData, + handleFileTreeSelect, + setSelectedEntities, + ]) +} + +export const Editor = () => { + const [editor] = useScopeValue('editor') + useWorkaroundForOpeningInitialDocument() + const { selectedEntityCount, openEntity } = useFileTreeOpenContext() + const { currentDocumentId } = useEditorManagerContext() + + if (!currentDocumentId) { + return null + } + + const isLoading = Boolean( + (!editor.sharejs_doc || editor.opening) && + !editor.error_state && + editor.open_doc_id + ) + + return ( +
+ + {isLoading && } +
+ ) +} 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 553fe95ca6..fb8e6b870c 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 @@ -2,6 +2,7 @@ import { Panel, PanelGroup } from 'react-resizable-panels' import classNames from 'classnames' import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle' import PdfPreview from '@/features/pdf-preview/components/pdf-preview' +import { Editor } from './editor' export default function MainLayout() { return ( @@ -34,8 +35,8 @@ export default function MainLayout() { hitAreaMargins={{ coarse: 0, fine: 0 }} /> -
- Editor +
+
Date: Fri, 10 Jan 2025 12:23:23 +0100 Subject: [PATCH 0003/1724] Merge pull request #22252 from overleaf/ab-gradual-rollout-continuity [web] Ensure continuity for gradual rollouts GitOrigin-RevId: c5bada71ae476862c782dc669024944f12d77097 --- .../Features/SplitTests/SplitTestHandler.js | 74 ++++++++----------- .../Features/SplitTests/SplitTestManager.js | 23 +++++- .../src/Editor/EditorHttpControllerTests.js | 3 - 3 files changed, 51 insertions(+), 49 deletions(-) diff --git a/services/web/app/src/Features/SplitTests/SplitTestHandler.js b/services/web/app/src/Features/SplitTests/SplitTestHandler.js index bc80bd0d6a..94fc036457 100644 --- a/services/web/app/src/Features/SplitTests/SplitTestHandler.js +++ b/services/web/app/src/Features/SplitTests/SplitTestHandler.js @@ -127,42 +127,6 @@ async function getAssignmentForUser( } } -/** - * Get the assignment of a user to a split test by their pre-fetched mongo doc. - * - * Warning: this does not support query parameters override, nor makes the assignment and split test info available to - * the frontend through locals. Wherever possible, `getAssignment` should be used instead. - * - * @param user the user - * @param splitTestName the unique name of the split test - * @param options {Object} - for test purposes only, to force the synchronous update of the user's profile - * @returns {Promise} - */ -async function getAssignmentForMongoUser( - user, - splitTestName, - { sync = false } = {} -) { - try { - if (!Features.hasFeature('saas')) { - return _getNonSaasAssignment(splitTestName) - } - - return _getAssignment(splitTestName, { - analyticsId: await UserAnalyticsIdCache.get(user._id), - sync, - user, - userId: user._id.toString(), - }) - } catch (error) { - logger.error( - { err: error }, - 'Failed to get split test assignment for mongo user' - ) - return DEFAULT_ASSIGNMENT - } -} - /** * Get a mapping of the active split test assignments for the given user */ @@ -238,8 +202,7 @@ async function getOneTimeAssignment(splitTestName) { variant: selectedVariantName, currentVersion, isFirstNonDefaultAssignment: - selectedVariantName !== DEFAULT_VARIANT && - currentVersion.analyticsEnabled, + selectedVariantName !== DEFAULT_VARIANT && _isSplitTest(splitTest), }) } catch (error) { logger.error({ err: error }, 'Failed to get one time split test assignment') @@ -344,7 +307,7 @@ async function _getAssignment( } if (activeForUser) { - if (currentVersion.analyticsEnabled) { + if (_isSplitTest(splitTest)) { // if the user is logged in, persist the assignment if (userId) { const assignmentData = { @@ -424,7 +387,25 @@ async function _getAssignment( async function _getAssignmentMetadata(analyticsId, user, splitTest) { const currentVersion = SplitTestUtils.getCurrentVersion(splitTest) + const versionNumber = currentVersion.versionNumber const phase = currentVersion.phase + + // For continuity on phase rollout for gradual rollouts, we keep all users from the previous phase enrolled to the variant. + // In beta, all alpha users are cohorted to the variant, and the same in release phase all alpha & beta users. + if ( + _isGradualRollout(splitTest) && + ((phase === BETA_PHASE && user?.alphaProgram) || + (phase === RELEASE_PHASE && (user?.alphaProgram || user?.betaProgram))) + ) { + return { + activeForUser: true, + selectedVariantName: currentVersion.variants[0].name, + phase, + versionNumber, + isFirstNonDefaultAssignment: false, + } + } + if ( (phase === ALPHA_PHASE && !user?.alphaProgram) || (phase === BETA_PHASE && !user?.betaProgram) @@ -433,6 +414,7 @@ async function _getAssignmentMetadata(analyticsId, user, splitTest) { activeForUser: false, } } + const userId = user?._id.toString() const percentile = getPercentile(analyticsId || userId, splitTest.name, phase) const selectedVariantName = @@ -442,10 +424,10 @@ async function _getAssignmentMetadata(analyticsId, user, splitTest) { activeForUser: true, selectedVariantName, phase, - versionNumber: currentVersion.versionNumber, + versionNumber, isFirstNonDefaultAssignment: selectedVariantName !== DEFAULT_VARIANT && - currentVersion.analyticsEnabled && + _isSplitTest(splitTest) && (!Array.isArray(user?.splitTests?.[splitTest.name]) || !user?.splitTests?.[splitTest.name]?.some( assignment => assignment.variantName !== DEFAULT_VARIANT @@ -582,10 +564,17 @@ async function _getSplitTest(name) { } } +function _isSplitTest(featureFlag) { + return SplitTestUtils.getCurrentVersion(featureFlag).analyticsEnabled +} + +function _isGradualRollout(featureFlag) { + return !SplitTestUtils.getCurrentVersion(featureFlag).analyticsEnabled +} + module.exports = { getPercentile, getAssignment: callbackify(getAssignment), - getAssignmentForMongoUser: callbackify(getAssignmentForMongoUser), getAssignmentForUser: callbackify(getAssignmentForUser), getOneTimeAssignment: callbackify(getOneTimeAssignment), getActiveAssignmentsForUser: callbackify(getActiveAssignmentsForUser), @@ -593,7 +582,6 @@ module.exports = { clearOverridesInSession, promises: { getAssignment, - getAssignmentForMongoUser, getAssignmentForUser, getOneTimeAssignment, getActiveAssignmentsForUser, diff --git a/services/web/app/src/Features/SplitTests/SplitTestManager.js b/services/web/app/src/Features/SplitTests/SplitTestManager.js index 579fe5991c..16120f56b0 100644 --- a/services/web/app/src/Features/SplitTests/SplitTestManager.js +++ b/services/web/app/src/Features/SplitTests/SplitTestManager.js @@ -83,7 +83,12 @@ async function createSplitTest( ) { const stripedVariants = [] let stripeStart = 0 - _checkNewVariantsConfiguration([], configuration.variants) + + _checkNewVariantsConfiguration( + [], + configuration.variants, + configuration.analyticsEnabled + ) for (const variant of configuration.variants) { stripedVariants.push({ name: (variant.name || '').trim(), @@ -139,7 +144,11 @@ async function updateSplitTestConfig({ name, configuration, comment }, userId) { `Cannot update with different phase - use /switch-to-next-phase endpoint instead` ) } - _checkNewVariantsConfiguration(lastVersion.variants, configuration.variants) + _checkNewVariantsConfiguration( + lastVersion.variants, + configuration.variants, + configuration.analyticsEnabled + ) const updatedVariants = _updateVariantsWithNewConfiguration( lastVersion.variants, configuration.variants @@ -320,7 +329,15 @@ async function clearCache() { await CacheFlow.reset('split-test') } -function _checkNewVariantsConfiguration(variants, newVariantsConfiguration) { +function _checkNewVariantsConfiguration( + variants, + newVariantsConfiguration, + analyticsEnabled +) { + if (newVariantsConfiguration?.length > 1 && !analyticsEnabled) { + throw new OError(`Gradual rollouts can only have a single variant`) + } + const totalRolloutPercentage = _getTotalRolloutPercentage( newVariantsConfiguration ) diff --git a/services/web/test/unit/src/Editor/EditorHttpControllerTests.js b/services/web/test/unit/src/Editor/EditorHttpControllerTests.js index ede797fd88..dffa2d21ff 100644 --- a/services/web/test/unit/src/Editor/EditorHttpControllerTests.js +++ b/services/web/test/unit/src/Editor/EditorHttpControllerTests.js @@ -132,9 +132,6 @@ describe('EditorHttpController', function () { this.SplitTestHandler = { promises: { getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }), - getAssignmentForMongoUser: sinon - .stub() - .resolves({ variant: 'default' }), }, } this.UserGetter = { promises: { getUser: sinon.stub().resolves(null, {}) } } From a343c010c1337fba44606be0f7fc0bd4b06bf2a9 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Fri, 10 Jan 2025 11:42:15 +0000 Subject: [PATCH 0004/1724] Merge pull request #22800 from overleaf/jpa-fix-ce [server-ce] fix mongo replica setup in public monorepo GitOrigin-RevId: 98e0d3337c34b389b499520e85c1b72e0e91e07a --- docker-compose.yml | 2 +- server-ce/server-ce | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 120000 server-ce/server-ce diff --git a/docker-compose.yml b/docker-compose.yml index 10a74780e3..fb85b988ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -107,7 +107,7 @@ services: command: '--replSet overleaf' volumes: - ~/mongo_data:/data/db - - ./mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js + - ./server-ce/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js environment: MONGO_INITDB_DATABASE: sharelatex extra_hosts: diff --git a/server-ce/server-ce b/server-ce/server-ce new file mode 120000 index 0000000000..945c9b46d6 --- /dev/null +++ b/server-ce/server-ce @@ -0,0 +1 @@ +. \ No newline at end of file From 8f29870334fd733a9b74222b709e7e02e704e069 Mon Sep 17 00:00:00 2001 From: Tim Down <158919+timdown@users.noreply.github.com> Date: Fri, 10 Jan 2025 15:27:51 +0000 Subject: [PATCH 0005/1724] Merge pull request #22818 from overleaf/td-storybook-overall-editor-theme Make theme switcher work in BS5 editor Storybook stories GitOrigin-RevId: ff9a9a14c2e9bdaccefab2652fbfbd54c544635e --- services/web/.storybook/preview.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/services/web/.storybook/preview.tsx b/services/web/.storybook/preview.tsx index 8707cde5f3..e998af1647 100644 --- a/services/web/.storybook/preview.tsx +++ b/services/web/.storybook/preview.tsx @@ -139,7 +139,6 @@ const preview: Preview = { items: [ { value: 'main-', title: 'Default' }, { value: 'main-light-', title: 'Light' }, - { value: 'main-ieee-', title: 'IEEE' }, ], }, }, @@ -154,10 +153,10 @@ const preview: Preview = { bootstrap3Style: await import( `!!to-string-loader!css-loader!less-loader!../../../services/web/frontend/stylesheets/${theme}style.less` ), - // NOTE: this uses `${theme}style.scss` rather than `${theme}.scss` - // so that webpack only bundles files ending with "style.scss" + // Themes are applied differently in Bootstrap 5 code bootstrap5Style: await import( - `!!to-string-loader!css-loader!resolve-url-loader!sass-loader!../../../services/web/frontend/stylesheets/bootstrap-5/${theme}style.scss` + // @ts-ignore + `!!to-string-loader!css-loader!resolve-url-loader!sass-loader!../../../services/web/frontend/stylesheets/bootstrap-5/main-style.scss` ), } }, @@ -175,14 +174,18 @@ const preview: Preview = { resetMeta(bootstrapVersion) return ( - <> +
{activeStyle && } - +
) }, ], From 62119de408388fd28f9ce65fbee15d9423f771a9 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Fri, 10 Jan 2025 17:17:55 +0000 Subject: [PATCH 0006/1724] Merge pull request #22815 from overleaf/jpa-docker-compose-update [server-ce] upgrade mongo and cleanup temporary env var GitOrigin-RevId: 94be18c7d3074b5f707ad696384a583224b2f8a4 --- docker-compose.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index fb85b988ec..d7dc69cc23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,10 +40,6 @@ services: # Disables email confirmation requirement EMAIL_CONFIRMATION_DISABLED: 'true' - # temporary fix for LuaLaTex compiles - # see https://github.com/overleaf/overleaf/issues/695 - TEXMFVAR: /var/lib/overleaf/tmp/texmf-var - ## Set for SSL via nginx-proxy #VIRTUAL_HOST: 103.112.212.22 @@ -102,7 +98,7 @@ services: mongo: restart: always - image: mongo:5.0 + image: mongo:6.0 container_name: mongo command: '--replSet overleaf' volumes: From db78629e5c67ba20a1e3c9821058db3c3bd8ba7d Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Mon, 13 Jan 2025 10:49:57 +0000 Subject: [PATCH 0007/1724] Remove deprecated apple-mobile-web-app-* meta tags (#22734) GitOrigin-RevId: b146d74e94a215a222d403bd852e9b21b74614b1 --- services/web/app/views/_metadata.pug | 4 ---- 1 file changed, 4 deletions(-) diff --git a/services/web/app/views/_metadata.pug b/services/web/app/views/_metadata.pug index bef8ac3ddd..a784860095 100644 --- a/services/web/app/views/_metadata.pug +++ b/services/web/app/views/_metadata.pug @@ -107,10 +107,6 @@ if !metadata || metadata.viewport !== false if settings.robotsNoindex meta(name="robots" content="noindex") -//- Apple devices -meta(name="apple-mobile-web-app-capable" content="yes") -meta(name="apple-mobile-web-app-status-bar-style" content="black-translucent") - //- Icons link(rel="icon", sizes="32x32", href="/favicon-32x32.png") link(rel="icon", sizes="16x16", href="/favicon-16x16.png") From 003fa536df1b6ad03d5c117f3da8dff6f8359637 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Mon, 13 Jan 2025 10:50:53 +0000 Subject: [PATCH 0008/1724] Convert Chat components to TypeScript (#22672) GitOrigin-RevId: b47a7fc3f77055335990ee0215bd32ae65b1ebfe --- ...back-error.jsx => chat-fallback-error.tsx} | 11 ++- ...nfinite-scroll.jsx => infinite-scroll.tsx} | 67 ++++++++++--------- .../{message-list.jsx => message-list.tsx} | 36 +++++----- .../components/{message.jsx => message.tsx} | 30 ++++----- .../js/features/chat/context/chat-context.tsx | 4 +- 5 files changed, 77 insertions(+), 71 deletions(-) rename services/web/frontend/js/features/chat/components/{chat-fallback-error.jsx => chat-fallback-error.tsx} (81%) rename services/web/frontend/js/features/chat/components/{infinite-scroll.jsx => infinite-scroll.tsx} (56%) rename services/web/frontend/js/features/chat/components/{message-list.jsx => message-list.tsx} (67%) rename services/web/frontend/js/features/chat/components/{message.jsx => message.tsx} (68%) diff --git a/services/web/frontend/js/features/chat/components/chat-fallback-error.jsx b/services/web/frontend/js/features/chat/components/chat-fallback-error.tsx similarity index 81% rename from services/web/frontend/js/features/chat/components/chat-fallback-error.jsx rename to services/web/frontend/js/features/chat/components/chat-fallback-error.tsx index 7cf990c787..b572722c9f 100644 --- a/services/web/frontend/js/features/chat/components/chat-fallback-error.jsx +++ b/services/web/frontend/js/features/chat/components/chat-fallback-error.tsx @@ -1,9 +1,12 @@ -import PropTypes from 'prop-types' import { useTranslation } from 'react-i18next' import OLNotification from '@/features/ui/components/ol/ol-notification' import OLButton from '@/features/ui/components/ol/ol-button' -function ChatFallbackError({ reconnect }) { +interface ChatFallbackErrorProps { + reconnect?: () => void +} + +function ChatFallbackError({ reconnect }: ChatFallbackErrorProps) { const { t } = useTranslation() return ( @@ -22,8 +25,4 @@ function ChatFallbackError({ reconnect }) { ) } -ChatFallbackError.propTypes = { - reconnect: PropTypes.any, -} - export default ChatFallbackError diff --git a/services/web/frontend/js/features/chat/components/infinite-scroll.jsx b/services/web/frontend/js/features/chat/components/infinite-scroll.tsx similarity index 56% rename from services/web/frontend/js/features/chat/components/infinite-scroll.jsx rename to services/web/frontend/js/features/chat/components/infinite-scroll.tsx index 361fec55d4..9de0ed9380 100644 --- a/services/web/frontend/js/features/chat/components/infinite-scroll.jsx +++ b/services/web/frontend/js/features/chat/components/infinite-scroll.tsx @@ -1,9 +1,17 @@ import { useRef, useEffect, useLayoutEffect } from 'react' -import PropTypes from 'prop-types' import _ from 'lodash' const SCROLL_END_OFFSET = 30 +interface InfiniteScrollProps { + atEnd?: boolean + children: React.ReactElement + className?: string + fetchData(): void + itemCount: number + isLoading?: boolean +} + function InfiniteScroll({ atEnd, children, @@ -11,20 +19,22 @@ function InfiniteScroll({ fetchData, itemCount, isLoading, -}) { - const root = useRef(null) +}: InfiniteScrollProps) { + const root = useRef(null) // we keep the value in a Ref instead of state so it can be safely used in effects const scrollBottomRef = useRef(0) - function setScrollBottom(value) { + function setScrollBottom(value: number) { scrollBottomRef.current = value } function updateScrollPosition() { - root.current.scrollTop = - root.current.scrollHeight - - root.current.clientHeight - - scrollBottomRef.current + if (root.current) { + root.current.scrollTop = + root.current.scrollHeight - + root.current.clientHeight - + scrollBottomRef.current + } } // Repositions the scroll after new items are loaded @@ -39,23 +49,29 @@ function InfiniteScroll({ } }, []) - function onScrollHandler(event) { - setScrollBottom( - root.current.scrollHeight - - root.current.scrollTop - - root.current.clientHeight - ) - if (event.target !== event.currentTarget) { - // Ignore scroll events on nested divs - // (this check won't be necessary in React 17: https://github.com/facebook/react/issues/15723 - return - } - if (shouldFetchData()) { - fetchData() + function onScrollHandler(event: React.UIEvent) { + if (root.current) { + setScrollBottom( + root.current.scrollHeight - + root.current.scrollTop - + root.current.clientHeight + ) + + if (event.target !== event.currentTarget) { + // Ignore scroll events on nested divs + // (this check won't be necessary in React 17: https://github.com/facebook/react/issues/15723 + return + } + if (shouldFetchData()) { + fetchData() + } } } function shouldFetchData() { + if (!root.current) { + return false + } const containerIsLargerThanContent = root.current.children[0].clientHeight < root.current.clientHeight if (atEnd || isLoading || containerIsLargerThanContent) { @@ -76,13 +92,4 @@ function InfiniteScroll({ ) } -InfiniteScroll.propTypes = { - atEnd: PropTypes.bool, - children: PropTypes.element.isRequired, - className: PropTypes.string, - fetchData: PropTypes.func.isRequired, - itemCount: PropTypes.number.isRequired, - isLoading: PropTypes.bool, -} - export default InfiniteScroll diff --git a/services/web/frontend/js/features/chat/components/message-list.jsx b/services/web/frontend/js/features/chat/components/message-list.tsx similarity index 67% rename from services/web/frontend/js/features/chat/components/message-list.jsx rename to services/web/frontend/js/features/chat/components/message-list.tsx index 173e86c66f..410cd255f2 100644 --- a/services/web/frontend/js/features/chat/components/message-list.jsx +++ b/services/web/frontend/js/features/chat/components/message-list.tsx @@ -1,10 +1,11 @@ -import PropTypes from 'prop-types' import moment from 'moment' import Message from './message' +import { UserId } from '../../../../../types/user' +import type { Message as MessageType } from '@/features/chat/context/chat-context' const FIVE_MINUTES = 5 * 60 * 1000 -function formatTimestamp(date) { +function formatTimestamp(date: moment.MomentInput) { if (!date) { return 'N/A' } else { @@ -12,14 +13,28 @@ function formatTimestamp(date) { } } -function MessageList({ messages, resetUnreadMessages, userId }) { - function shouldRenderDate(messageIndex) { +interface MessageListProps { + messages: MessageType[] + resetUnreadMessages(...args: unknown[]): unknown + userId: UserId | null +} + +function MessageList({ + messages, + resetUnreadMessages, + userId, +}: MessageListProps) { + function shouldRenderDate(messageIndex: number) { if (messageIndex === 0) { return true } else { const message = messages[messageIndex] const previousMessage = messages[messageIndex - 1] - return message.timestamp - previousMessage.timestamp > FIVE_MINUTES + return ( + message.timestamp && + previousMessage.timestamp && + message.timestamp - previousMessage.timestamp > FIVE_MINUTES + ) } } @@ -53,15 +68,4 @@ function MessageList({ messages, resetUnreadMessages, userId }) { ) } -MessageList.propTypes = { - messages: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - timestamp: PropTypes.number, - }) - ).isRequired, - resetUnreadMessages: PropTypes.func.isRequired, - userId: PropTypes.string, -} - export default MessageList diff --git a/services/web/frontend/js/features/chat/components/message.jsx b/services/web/frontend/js/features/chat/components/message.tsx similarity index 68% rename from services/web/frontend/js/features/chat/components/message.jsx rename to services/web/frontend/js/features/chat/components/message.tsx index 380b2fd1cc..87fbb8dbd3 100644 --- a/services/web/frontend/js/features/chat/components/message.jsx +++ b/services/web/frontend/js/features/chat/components/message.tsx @@ -1,20 +1,26 @@ -import PropTypes from 'prop-types' import { getHueForUserId } from '../../../shared/utils/colors' import MessageContent from './message-content' +import type { Message as MessageType } from '@/features/chat/context/chat-context' +import { User } from '../../../../../types/user' -function Message({ message, userId }) { - function hue(user) { +interface MessageProps { + message: MessageType + userId: string | null +} + +function Message({ message, userId }: MessageProps) { + function hue(user?: User) { return user ? getHueForUserId(user.id, userId) : 0 } - function getMessageStyle(user) { + function getMessageStyle(user?: User) { return { borderColor: `hsl(${hue(user)}, 85%, 40%)`, backgroundColor: `hsl(${hue(user)}, 85%, 40%`, } } - function getArrowStyle(user) { + function getArrowStyle(user?: User) { return { borderColor: `hsl(${hue(user)}, 85%, 40%)`, } @@ -24,7 +30,7 @@ function Message({ message, userId }) { return (
- {!isMessageFromSelf && ( + {!isMessageFromSelf && message.user.id && (
{message.user.first_name || message.user.email}
@@ -43,16 +49,4 @@ function Message({ message, userId }) { ) } -Message.propTypes = { - message: PropTypes.shape({ - contents: PropTypes.arrayOf(PropTypes.string).isRequired, - user: PropTypes.shape({ - id: PropTypes.string, - email: PropTypes.string, - first_name: PropTypes.string, - }), - }), - userId: PropTypes.string, -} - export default Message 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 715c178d22..d2b078138d 100644 --- a/services/web/frontend/js/features/chat/context/chat-context.tsx +++ b/services/web/frontend/js/features/chat/context/chat-context.tsx @@ -19,13 +19,15 @@ import { useLayoutContext } from '../../../shared/context/layout-context' import { useIdeContext } from '@/shared/context/ide-context' import getMeta from '@/utils/meta' import { debugConsole } from '@/utils/debugging' +import { User } from '../../../../../types/user' const PAGE_SIZE = 50 export type Message = { id: string timestamp: number - contents: string + contents: string[] + user: User } type State = { From a8a61db23e615abc45c9daeabad150c68a8c9734 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Mon, 13 Jan 2025 10:52:11 +0000 Subject: [PATCH 0009/1724] Convert shared utils modules to TypeScript (#22665) GitOrigin-RevId: de40a0aaba35336ec59499a047356b0b9d161b38 --- .../js/shared/utils/{colors.js => colors.ts} | 4 +- .../utils/{formatDate.js => formatDate.ts} | 2 +- .../utils/{grammarly.js => grammarly.ts} | 0 .../utils/{url-helper.js => url-helper.ts} | 7 +++- .../test/frontend/shared/utils/colors.test.js | 2 +- .../frontend/shared/utils/url-helper.test.js | 38 ++++++++----------- 6 files changed, 24 insertions(+), 29 deletions(-) rename services/web/frontend/js/shared/utils/{colors.js => colors.ts} (90%) rename services/web/frontend/js/shared/utils/{formatDate.js => formatDate.ts} (71%) rename services/web/frontend/js/shared/utils/{grammarly.js => grammarly.ts} (100%) rename services/web/frontend/js/shared/utils/{url-helper.js => url-helper.ts} (53%) diff --git a/services/web/frontend/js/shared/utils/colors.js b/services/web/frontend/js/shared/utils/colors.ts similarity index 90% rename from services/web/frontend/js/shared/utils/colors.js rename to services/web/frontend/js/shared/utils/colors.ts index 90ed30cda3..615dc546c6 100644 --- a/services/web/frontend/js/shared/utils/colors.js +++ b/services/web/frontend/js/shared/utils/colors.ts @@ -5,7 +5,7 @@ const OWN_HUE = 200 // We will always appear as this color to ourselves const OWN_HUE_BLOCKED_SIZE = 20 // no other user should have a HUE in this range const TOTAL_HUES = 360 // actually 361, but 360 for legacy reasons -export function getHueForUserId(userId, currentUserId) { +export function getHueForUserId(userId: string, currentUserId: string) { if (userId == null || userId === 'anonymous-user') { return ANONYMOUS_HUE } @@ -29,7 +29,7 @@ export function getHueForUserId(userId, currentUserId) { return hue } -function getHueForId(id) { +function getHueForId(id: string) { const hash = generateMD5Hash(id) const hue = parseInt(hash.toString().slice(0, 8), 16) % diff --git a/services/web/frontend/js/shared/utils/formatDate.js b/services/web/frontend/js/shared/utils/formatDate.ts similarity index 71% rename from services/web/frontend/js/shared/utils/formatDate.js rename to services/web/frontend/js/shared/utils/formatDate.ts index e224ae6126..774b823136 100644 --- a/services/web/frontend/js/shared/utils/formatDate.js +++ b/services/web/frontend/js/shared/utils/formatDate.ts @@ -1,6 +1,6 @@ import moment from 'moment' -export function formatUtcDate(date) { +export function formatUtcDate(date: moment.MomentInput) { if (date) { return moment(date).utc().format('D MMM YYYY, HH:mm:ss') + ' UTC' } else { diff --git a/services/web/frontend/js/shared/utils/grammarly.js b/services/web/frontend/js/shared/utils/grammarly.ts similarity index 100% rename from services/web/frontend/js/shared/utils/grammarly.js rename to services/web/frontend/js/shared/utils/grammarly.ts diff --git a/services/web/frontend/js/shared/utils/url-helper.js b/services/web/frontend/js/shared/utils/url-helper.ts similarity index 53% rename from services/web/frontend/js/shared/utils/url-helper.js rename to services/web/frontend/js/shared/utils/url-helper.ts index 0b3770bcc9..60e33085d1 100644 --- a/services/web/frontend/js/shared/utils/url-helper.js +++ b/services/web/frontend/js/shared/utils/url-helper.ts @@ -1,5 +1,8 @@ -export function buildUrlWithDetachRole(mode) { - const url = new URL(window.location) +export function buildUrlWithDetachRole(mode: string | null) { + return cleanURL(new URL(window.location.href), mode) +} + +export function cleanURL(url: URL, mode: string | null) { let cleanPathname = url.pathname .replace(/\/(detached|detacher)\/?$/, '') .replace(/\/$/, '') diff --git a/services/web/test/frontend/shared/utils/colors.test.js b/services/web/test/frontend/shared/utils/colors.test.js index bf952dae79..896c9d5c8f 100644 --- a/services/web/test/frontend/shared/utils/colors.test.js +++ b/services/web/test/frontend/shared/utils/colors.test.js @@ -1,6 +1,6 @@ import { expect } from 'chai' -import { getHueForUserId } from '../../../../frontend/js/shared/utils/colors' +import { getHueForUserId } from '@/shared/utils/colors' describe('colors', function () { const currentUser = '5bf7dab7a18b0b7a1cf6738c' diff --git a/services/web/test/frontend/shared/utils/url-helper.test.js b/services/web/test/frontend/shared/utils/url-helper.test.js index 24c1241734..72571a172b 100644 --- a/services/web/test/frontend/shared/utils/url-helper.test.js +++ b/services/web/test/frontend/shared/utils/url-helper.test.js @@ -1,34 +1,26 @@ import { expect } from 'chai' -import sinon from 'sinon' -import { buildUrlWithDetachRole } from '../../../../frontend/js/shared/utils/url-helper' +import { cleanURL } from '@/shared/utils/url-helper' describe('url-helper', function () { - let locationStub - describe('buildUrlWithDetachRole', function () { - beforeEach(function () { - locationStub = sinon.stub(window, 'location') - }) - - afterEach(function () { - locationStub.restore() - }) - + describe('cleanURL', function () { describe('without mode', function () { it('removes trailing slash', function () { - locationStub.value('https://www.ovelreaf.com/project/1abc/') - expect(buildUrlWithDetachRole().href).to.equal( + const url = new URL('https://www.ovelreaf.com/project/1abc/') + expect(cleanURL(url).href).to.equal( 'https://www.ovelreaf.com/project/1abc' ) }) - it('clears the mode from the current URL', function () { - locationStub.value('https://www.ovelreaf.com/project/2abc/detached') - expect(buildUrlWithDetachRole().href).to.equal( + it('clears the mode from the detached URL', function () { + const url = new URL('https://www.ovelreaf.com/project/2abc/detached') + expect(cleanURL(url).href).to.equal( 'https://www.ovelreaf.com/project/2abc' ) + }) - locationStub.value('https://www.ovelreaf.com/project/2abc/detacher/') - expect(buildUrlWithDetachRole().href).to.equal( + it('clears the mode from the detacher URL', function () { + const url = new URL('https://www.ovelreaf.com/project/2abc/detacher/') + expect(cleanURL(url).href).to.equal( 'https://www.ovelreaf.com/project/2abc' ) }) @@ -36,15 +28,15 @@ describe('url-helper', function () { describe('with mode', function () { it('handles with trailing slash', function () { - locationStub.value('https://www.ovelreaf.com/project/3abc/') - expect(buildUrlWithDetachRole('detacher').href).to.equal( + const url = new URL('https://www.ovelreaf.com/project/3abc/') + expect(cleanURL(url, 'detacher').href).to.equal( 'https://www.ovelreaf.com/project/3abc/detacher' ) }) it('handles without trailing slash', function () { - locationStub.value('https://www.ovelreaf.com/project/4abc') - expect(buildUrlWithDetachRole('detached').href).to.equal( + const url = new URL('https://www.ovelreaf.com/project/4abc') + expect(cleanURL(url, 'detached').href).to.equal( 'https://www.ovelreaf.com/project/4abc/detached' ) }) From f79aac8d0141639c00ad7c71127bf93e7248e6c1 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Mon, 13 Jan 2025 10:52:51 +0000 Subject: [PATCH 0010/1724] Convert endpoints to TypeScript (#22664) GitOrigin-RevId: 159f0f38333ad2944c3f25c5076432e5f5dc7ba3 --- services/web/.storybook/preview.tsx | 2 +- services/web/cypress/support/component.ts | 2 +- services/web/frontend/js/{dev-toolbar.js => dev-toolbar.ts} | 0 services/web/frontend/js/{i18n.js => i18n.ts} | 0 .../web/frontend/js/{ide-detached.js => ide-detached.ts} | 0 services/web/frontend/js/{marketing.js => marketing.ts} | 0 services/web/frontend/js/pages/project-list.tsx | 2 +- services/web/frontend/js/pages/sharing-updates.tsx | 2 +- services/web/frontend/js/pages/token-access.tsx | 2 +- services/web/frontend/js/pages/user/settings.jsx | 2 +- services/web/frontend/js/pages/user/subscription/base.js | 2 +- services/web/frontend/js/shared/hooks/use-wait-for-i18n.ts | 2 +- .../web/modules/launchpad/frontend/js/pages/launchpad.js | 2 +- .../user-activate/frontend/js/pages/user-activate-page.jsx | 2 +- services/web/webpack.config.js | 6 +++--- 15 files changed, 13 insertions(+), 13 deletions(-) rename services/web/frontend/js/{dev-toolbar.js => dev-toolbar.ts} (100%) rename services/web/frontend/js/{i18n.js => i18n.ts} (100%) rename services/web/frontend/js/{ide-detached.js => ide-detached.ts} (100%) rename services/web/frontend/js/{marketing.js => marketing.ts} (100%) diff --git a/services/web/.storybook/preview.tsx b/services/web/.storybook/preview.tsx index e998af1647..0ed624e087 100644 --- a/services/web/.storybook/preview.tsx +++ b/services/web/.storybook/preview.tsx @@ -1,7 +1,7 @@ import type { Preview } from '@storybook/react' // Storybook does not (currently) support async loading of "stories". Therefore -// the strategy in frontend/js/i18n.js does not work (because we cannot wait on +// the strategy in frontend/js/i18n.ts does not work (because we cannot wait on // the promise to resolve). // Therefore we have to use the synchronous method for configuring // react-i18next. Because this, we can only hard-code a single language. diff --git a/services/web/cypress/support/component.ts b/services/web/cypress/support/component.ts index 8fd40b0d99..f658b7d2e9 100644 --- a/services/web/cypress/support/component.ts +++ b/services/web/cypress/support/component.ts @@ -1,6 +1,6 @@ import 'cypress-plugin-tab' import { resetMeta } from './ct/window' // needs to be before i18n -import '../../frontend/js/i18n' +import '@/i18n' import './shared/commands' import './shared/exceptions' import './ct/commands' diff --git a/services/web/frontend/js/dev-toolbar.js b/services/web/frontend/js/dev-toolbar.ts similarity index 100% rename from services/web/frontend/js/dev-toolbar.js rename to services/web/frontend/js/dev-toolbar.ts diff --git a/services/web/frontend/js/i18n.js b/services/web/frontend/js/i18n.ts similarity index 100% rename from services/web/frontend/js/i18n.js rename to services/web/frontend/js/i18n.ts diff --git a/services/web/frontend/js/ide-detached.js b/services/web/frontend/js/ide-detached.ts similarity index 100% rename from services/web/frontend/js/ide-detached.js rename to services/web/frontend/js/ide-detached.ts diff --git a/services/web/frontend/js/marketing.js b/services/web/frontend/js/marketing.ts similarity index 100% rename from services/web/frontend/js/marketing.js rename to services/web/frontend/js/marketing.ts diff --git a/services/web/frontend/js/pages/project-list.tsx b/services/web/frontend/js/pages/project-list.tsx index 3886db545e..60fcda413c 100644 --- a/services/web/frontend/js/pages/project-list.tsx +++ b/services/web/frontend/js/pages/project-list.tsx @@ -1,7 +1,7 @@ import './../utils/meta' import './../utils/webpack-public-path' import './../infrastructure/error-reporter' -import './../i18n' +import '@/i18n' import '../features/event-tracking' import '../features/cookie-banner' import '../features/link-helpers/slow-link' diff --git a/services/web/frontend/js/pages/sharing-updates.tsx b/services/web/frontend/js/pages/sharing-updates.tsx index d75069129e..429bc8f57c 100644 --- a/services/web/frontend/js/pages/sharing-updates.tsx +++ b/services/web/frontend/js/pages/sharing-updates.tsx @@ -1,7 +1,7 @@ import './../utils/meta' import './../utils/webpack-public-path' import './../infrastructure/error-reporter' -import './../i18n' +import '@/i18n' import ReactDOM from 'react-dom' import SharingUpdatesRoot from '../features/token-access/components/sharing-updates-root' diff --git a/services/web/frontend/js/pages/token-access.tsx b/services/web/frontend/js/pages/token-access.tsx index 0ce94520ae..f18cd1e541 100644 --- a/services/web/frontend/js/pages/token-access.tsx +++ b/services/web/frontend/js/pages/token-access.tsx @@ -1,7 +1,7 @@ import './../utils/meta' import './../utils/webpack-public-path' import './../infrastructure/error-reporter' -import './../i18n' +import '@/i18n' import ReactDOM from 'react-dom' import TokenAccessRoot from '../features/token-access/components/token-access-root' diff --git a/services/web/frontend/js/pages/user/settings.jsx b/services/web/frontend/js/pages/user/settings.jsx index 15c1c21eb7..b31d1c4d5c 100644 --- a/services/web/frontend/js/pages/user/settings.jsx +++ b/services/web/frontend/js/pages/user/settings.jsx @@ -2,7 +2,7 @@ import '../../marketing' import './../../utils/meta' import './../../utils/webpack-public-path' import './../../infrastructure/error-reporter' -import './../../i18n' +import '@/i18n' import '../../features/settings/components/root' import ReactDOM from 'react-dom' import SettingsPageRoot from '../../features/settings/components/root.tsx' diff --git a/services/web/frontend/js/pages/user/subscription/base.js b/services/web/frontend/js/pages/user/subscription/base.js index fb28e97f95..7773ac9bb2 100644 --- a/services/web/frontend/js/pages/user/subscription/base.js +++ b/services/web/frontend/js/pages/user/subscription/base.js @@ -1,7 +1,7 @@ import './../../../utils/meta' import './../../../utils/webpack-public-path' import './../../../infrastructure/error-reporter' -import './../../../i18n' +import '@/i18n' import '../../../features/event-tracking' import '../../../features/cookie-banner' import '../../../features/link-helpers/slow-link' diff --git a/services/web/frontend/js/shared/hooks/use-wait-for-i18n.ts b/services/web/frontend/js/shared/hooks/use-wait-for-i18n.ts index b81b5ce363..ffed558373 100644 --- a/services/web/frontend/js/shared/hooks/use-wait-for-i18n.ts +++ b/services/web/frontend/js/shared/hooks/use-wait-for-i18n.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import i18n from '../../../js/i18n' +import i18n from '@/i18n' import { useTranslation } from 'react-i18next' function useWaitForI18n() { diff --git a/services/web/modules/launchpad/frontend/js/pages/launchpad.js b/services/web/modules/launchpad/frontend/js/pages/launchpad.js index 861b49d9fb..0c6b0b8cac 100644 --- a/services/web/modules/launchpad/frontend/js/pages/launchpad.js +++ b/services/web/modules/launchpad/frontend/js/pages/launchpad.js @@ -1,5 +1,5 @@ /* global io */ -import '../../../../../frontend/js/marketing' +import '@/marketing' import { inflightHelper, toggleDisplay, diff --git a/services/web/modules/user-activate/frontend/js/pages/user-activate-page.jsx b/services/web/modules/user-activate/frontend/js/pages/user-activate-page.jsx index b7d9a69959..22174de921 100644 --- a/services/web/modules/user-activate/frontend/js/pages/user-activate-page.jsx +++ b/services/web/modules/user-activate/frontend/js/pages/user-activate-page.jsx @@ -1,4 +1,4 @@ -import '../../../../../frontend/js/marketing' +import '@/marketing' import ReactDOM from 'react-dom' import UserActivateRegister from '../components/user-activate-register' diff --git a/services/web/webpack.config.js b/services/web/webpack.config.js index 4047bbbc6c..9f82eec062 100644 --- a/services/web/webpack.config.js +++ b/services/web/webpack.config.js @@ -19,9 +19,9 @@ const entryPoints = { tracing: './frontend/js/tracing.js', 'bootstrap-3': './frontend/js/bootstrap-3.ts', 'bootstrap-5': './frontend/js/bootstrap-5.ts', - devToolbar: './frontend/js/dev-toolbar.js', - 'ide-detached': './frontend/js/ide-detached.js', - marketing: './frontend/js/marketing.js', + devToolbar: './frontend/js/dev-toolbar.ts', + 'ide-detached': './frontend/js/ide-detached.ts', + marketing: './frontend/js/marketing.ts', 'main-style': './frontend/stylesheets/main-style.less', 'main-ieee-style': './frontend/stylesheets/main-ieee-style.less', 'main-light-style': './frontend/stylesheets/main-light-style.less', From 47af13c8a893c4020aa94104485c2e0846682490 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Mon, 13 Jan 2025 10:53:01 +0000 Subject: [PATCH 0011/1724] Delete unused iconTypeFromName file (#22662) GitOrigin-RevId: 3c768cacebcf229d932a7b03a4068291e3cf60b1 --- .../js/ide/file-tree/util/iconTypeFromName.js | 34 ------------------- 1 file changed, 34 deletions(-) delete mode 100644 services/web/frontend/js/ide/file-tree/util/iconTypeFromName.js diff --git a/services/web/frontend/js/ide/file-tree/util/iconTypeFromName.js b/services/web/frontend/js/ide/file-tree/util/iconTypeFromName.js deleted file mode 100644 index 6ad913c8d4..0000000000 --- a/services/web/frontend/js/ide/file-tree/util/iconTypeFromName.js +++ /dev/null @@ -1,34 +0,0 @@ -/* eslint-disable - max-len, - no-return-assign, - no-unused-vars, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS103: Rewrite code to no longer use __guard__ - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -let iconTypeFromName - -export default iconTypeFromName = function (name) { - const ext = __guard__(name.split('.').pop(), x => x.toLowerCase()) - if (['png', 'pdf', 'jpg', 'jpeg', 'gif'].includes(ext)) { - return 'image' - } else if (['csv', 'xls', 'xlsx'].includes(ext)) { - return 'table' - } else if (['py', 'r'].includes(ext)) { - return 'file-text' - } else if (['bib'].includes(ext)) { - return 'book' - } else { - return 'file' - } -} - -function __guard__(value, transform) { - return typeof value !== 'undefined' && value !== null - ? transform(value) - : undefined -} From cffa9c1a28f5c2c9862876e28e02a6466c8668fe Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Mon, 13 Jan 2025 10:59:35 +0000 Subject: [PATCH 0012/1724] Improve spell check when dictionary is edited (#22635) GitOrigin-RevId: 20d36cb987d014809423240a46c7c577781dfde6 --- .../components/dictionary-modal-content.tsx | 26 ++-- .../js/features/dictionary/ignored-words.ts | 42 +----- .../extensions/spelling/backend.ts | 13 -- .../extensions/spelling/cache.ts | 27 ++-- .../extensions/spelling/context-menu.tsx | 22 ++-- .../extensions/spelling/ignored-words.ts | 31 ----- .../extensions/spelling/index.ts | 75 +++-------- .../extensions/spelling/learned-words.ts | 24 ++++ .../extensions/spelling/misspelled-words.ts | 13 +- .../extensions/spelling/spellchecker.ts | 121 +++++++++--------- .../spelling/spelling-suggestions.tsx | 40 +++--- .../hooks/use-codemirror-scope.ts | 40 +----- .../source-editor/hooks/use-hunspell.ts | 7 +- .../dictionary-modal-content.spec.jsx | 30 +++-- .../extensions/spelling/cache.test.ts | 24 ++-- .../extensions/spelling/spellchecker.test.ts | 58 +++------ 16 files changed, 218 insertions(+), 375 deletions(-) delete mode 100644 services/web/frontend/js/features/source-editor/extensions/spelling/backend.ts delete mode 100644 services/web/frontend/js/features/source-editor/extensions/spelling/ignored-words.ts create mode 100644 services/web/frontend/js/features/source-editor/extensions/spelling/learned-words.ts diff --git a/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx b/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx index 0a0fabf242..90369d2467 100644 --- a/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx +++ b/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx @@ -2,7 +2,6 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import useAsync from '../../../shared/hooks/use-async' import { postJSON } from '../../../infrastructure/fetch-json' -import ignoredWords from '../ignored-words' import { debugConsole } from '@/utils/debugging' import { OLModalBody, @@ -15,6 +14,7 @@ import OLNotification from '@/features/ui/components/ol/ol-notification' import OLButton from '@/features/ui/components/ol/ol-button' import OLIconButton from '@/features/ui/components/ol/ol-icon-button' import { bsVersion } from '@/features/utils/bootstrap-5' +import { learnedWords as initialLearnedWords } from '@/features/source-editor/extensions/spelling/learned-words' type DictionaryModalContentProps = { handleHide: () => void @@ -26,22 +26,24 @@ export default function DictionaryModalContent({ handleHide, }: DictionaryModalContentProps) { const { t } = useTranslation() - const [learnedWords, setLearnedWords] = useState(ignoredWords.learnedWords) + + const [learnedWords, setLearnedWords] = useState>( + initialLearnedWords.global + ) const { isError, runAsync } = useAsync() const handleRemove = useCallback( word => { - runAsync( - postJSON('/spelling/unlearn', { - body: { - word, - }, - }) - ) + runAsync(postJSON('/spelling/unlearn', { body: { word } })) .then(() => { - ignoredWords.remove(word) - setLearnedWords(new Set(ignoredWords.learnedWords)) + setLearnedWords(value => { + value.delete(word) + return new Set(value) + }) + window.dispatchEvent( + new CustomEvent('editor:remove-learned-word', { detail: word }) + ) }) .catch(debugConsole.error) }, @@ -62,7 +64,7 @@ export default function DictionaryModalContent({ /> ) : null} - {learnedWords?.size > 0 ? ( + {learnedWords.size > 0 ? (
    {[...learnedWords].sort(wordsSortFunction).map(learnedWord => (
  • diff --git a/services/web/frontend/js/features/dictionary/ignored-words.ts b/services/web/frontend/js/features/dictionary/ignored-words.ts index 90230eefd2..da79ab327c 100644 --- a/services/web/frontend/js/features/dictionary/ignored-words.ts +++ b/services/web/frontend/js/features/dictionary/ignored-words.ts @@ -1,6 +1,4 @@ -import getMeta from '../../utils/meta' - -export const globalLearnedWords = new Set([ +export const globalIgnoredWords = new Set([ 'Overleaf', 'overleaf', 'ShareLaTeX', @@ -22,41 +20,3 @@ export const globalLearnedWords = new Set([ 'Coronavirus', 'coronavirus', ]) - -export class IgnoredWords { - public learnedWords!: Set - private readonly ignoredMisspellings: Set - - constructor() { - this.reset() - this.ignoredMisspellings = globalLearnedWords - window.addEventListener('learnedWords:doreset', () => this.reset()) // for tests - } - - reset() { - this.learnedWords = new Set(getMeta('ol-learnedWords')) - window.dispatchEvent(new CustomEvent('learnedWords:reset')) - } - - add(wordText: string) { - this.learnedWords.add(wordText) - window.dispatchEvent( - new CustomEvent('learnedWords:add', { detail: wordText }) - ) - } - - remove(wordText: string) { - this.learnedWords.delete(wordText) - window.dispatchEvent( - new CustomEvent('learnedWords:remove', { detail: wordText }) - ) - } - - has(wordText: string) { - return ( - this.ignoredMisspellings.has(wordText) || this.learnedWords.has(wordText) - ) - } -} - -export default new IgnoredWords() diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/backend.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/backend.ts deleted file mode 100644 index 934858c623..0000000000 --- a/services/web/frontend/js/features/source-editor/extensions/spelling/backend.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { postJSON } from '@/infrastructure/fetch-json' -import { Word } from './spellchecker' - -export async function learnWordRequest(word?: Word) { - if (!word || !word.text) { - throw new Error(`Invalid word supplied: ${word}`) - } - return await postJSON('/spelling/learn', { - body: { - word: word.text, - }, - }) -} diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/cache.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/cache.ts index c83505df92..44edcf15d6 100644 --- a/services/web/frontend/js/features/source-editor/extensions/spelling/cache.ts +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/cache.ts @@ -7,16 +7,14 @@ export const cacheKey = (lang: string, wordText: string) => { return `${lang}:${wordText}` } -export type WordCacheValue = string[] | boolean | number - export class WordCache { - private _cache: LRU + private _cache: LRU constructor() { this._cache = new LRU({ max: CACHE_MAX }) } - set(lang: string, wordText: string, value: WordCacheValue) { + set(lang: string, wordText: string, value: boolean) { const key = cacheKey(lang, wordText) this._cache.set(key, value) } @@ -31,6 +29,10 @@ export class WordCache { this._cache.delete(key) } + reset() { + this._cache = new LRU({ max: CACHE_MAX }) + } + /* * Given a language and a list of words, * check the cache and sort the words into two categories: @@ -44,23 +46,20 @@ export class WordCache { knownMisspelledWords: Word[] unknownWords: Word[] } { - const knownMisspelledWords = [] - const unknownWords = [] - const seen: Record = {} + const knownMisspelledWords: Word[] = [] + const unknownWords: Word[] = [] + const seen: Record = {} for (const word of wordsToCheck) { const wordText = word.text - if (seen[wordText] == null) { + if (seen[wordText] === undefined) { seen[wordText] = this.get(lang, wordText) } const cached = seen[wordText] - if (cached == null) { + if (cached === undefined) { // Word is not known unknownWords.push(word) - } else if (cached === true) { - // Word is known to be correct - } else { + } else if (!cached) { // Word is known to be misspelled - word.suggestions = cached knownMisspelledWords.push(word) } } @@ -74,7 +73,7 @@ export class WordCache { export const addWordToCache = StateEffect.define<{ lang: string wordText: string - value: string[] | boolean + value: boolean }>() export const removeWordFromCache = StateEffect.define<{ diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx b/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx index 5771630fbb..b1b88a01cc 100644 --- a/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx @@ -5,8 +5,6 @@ import { Prec, } from '@codemirror/state' import { EditorView, showTooltip, Tooltip, keymap } from '@codemirror/view' -import { addIgnoredWord } from './ignored-words' -import { learnWordRequest } from './backend' import { Word, Mark, getMarkAtPosition } from './spellchecker' import { debugConsole } from '@/utils/debugging' import { @@ -17,6 +15,8 @@ import { sendMB } from '@/infrastructure/event-tracking' import ReactDOM from 'react-dom' import { SpellingSuggestions } from '@/features/source-editor/extensions/spelling/spelling-suggestions' import { SplitTestProvider } from '@/shared/context/split-test-context' +import { addLearnedWord } from '@/features/source-editor/extensions/spelling/learned-words' +import { postJSON } from '@/infrastructure/fetch-json' /* * The time until which a click event will be ignored, so it doesn't immediately close the spelling menu. @@ -176,10 +176,14 @@ const createSpellingSuggestionList = (word: Word) => (view: EditorView) => { } }} handleLearnWord={() => { - learnWordRequest(word) + postJSON('/spelling/learn', { + body: { + word: word.text, + }, + }) .then(() => { - view.dispatch({ - effects: [addIgnoredWord.of(word), hideSpellingMenu.of(null)], + view.dispatch(addLearnedWord(word.text), { + effects: hideSpellingMenu.of(null), }) sendMB('spelling-word-added', { language: getSpellCheckLanguage(view.state), @@ -203,9 +207,11 @@ const createSpellingSuggestionList = (word: Word) => (view: EditorView) => { return } - view.dispatch({ - changes: [{ from: tooltip.pos, to: tooltip.end, insert: text }], - effects: [hideSpellingMenu.of(null)], + window.setTimeout(() => { + view.dispatch({ + changes: [{ from: tooltip.pos, to: tooltip.end, insert: text }], + effects: [hideSpellingMenu.of(null)], + }) }) view.focus() diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/ignored-words.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/ignored-words.ts deleted file mode 100644 index 1a17451d7d..0000000000 --- a/services/web/frontend/js/features/source-editor/extensions/spelling/ignored-words.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { StateField, StateEffect } from '@codemirror/state' -import ignoredWords, { IgnoredWords } from '../../../dictionary/ignored-words' - -export const ignoredWordsField = StateField.define({ - create() { - return ignoredWords - }, - update(ignoredWords, transaction) { - for (const effect of transaction.effects) { - if (effect.is(addIgnoredWord)) { - const newWord = effect.value - ignoredWords.add(newWord.text) - } - } - return ignoredWords - }, -}) - -export const addIgnoredWord = StateEffect.define<{ - text: string -}>() - -export const removeIgnoredWord = StateEffect.define<{ - text: string -}>() - -export const updateAfterAddingIgnoredWord = StateEffect.define() - -export const updateAfterRemovingIgnoredWord = StateEffect.define() - -export const resetSpellChecker = StateEffect.define() diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/index.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/index.ts index 02799b449c..e95026b139 100644 --- a/services/web/frontend/js/features/source-editor/extensions/spelling/index.ts +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/index.ts @@ -6,20 +6,12 @@ import { TransactionSpec, } from '@codemirror/state' import { misspelledWordsField } from './misspelled-words' -import { - addIgnoredWord, - ignoredWordsField, - removeIgnoredWord, - resetSpellChecker, - updateAfterAddingIgnoredWord, -} from './ignored-words' -import { addWordToCache, cacheField, removeWordFromCache } from './cache' +import { removeLearnedWord } from './learned-words' +import { cacheField } from './cache' import { hideSpellingMenu, spellingMenuField } from './context-menu' import { SpellChecker } from './spellchecker' import { parserWatcher } from '../wait-for-parser' import type { HunspellManager } from '@/features/source-editor/hunspell/HunspellManager' -import { debugConsole } from '@/utils/debugging' -import { captureException } from '@/infrastructure/error-reporter' type Options = { spellCheckLanguage?: string @@ -44,12 +36,26 @@ export const spelling = ({ spellCheckLanguage, hunspellManager }: Options) => { : null ), misspelledWordsField, - ignoredWordsField, cacheField, spellingMenuField, + dictionary, ] } +const dictionary = ViewPlugin.define(view => { + const listener = (event: Event) => { + view.dispatch(removeLearnedWord((event as CustomEvent).detail)) + } + + window.addEventListener('editor:remove-learned-word', listener) + + return { + destroy() { + window.removeEventListener('editor:remove-learned-word', listener) + }, + } +}) + const spellingTheme = EditorView.baseTheme({ '.ol-cm-spelling-error': { textDecorationColor: 'red', @@ -82,16 +88,6 @@ const spellCheckerField = StateField.define({ effect.value.hunspellManager ) : null - } else if (effect.is(addIgnoredWord)) { - value?.addWord(effect.value.text).catch(error => { - captureException(error) - debugConsole.error(error) - }) - } else if (effect.is(removeIgnoredWord)) { - value?.removeWord(effect.value.text).catch(error => { - captureException(error) - debugConsole.error(error) - }) } } return value @@ -157,40 +153,3 @@ export const setSpellCheckLanguage = ({ ], } } - -export const addLearnedWord = ( - spellCheckLanguage: string, - word: string -): TransactionSpec => { - return { - effects: [ - addWordToCache.of({ - lang: spellCheckLanguage, - wordText: word, - value: true, - }), - updateAfterAddingIgnoredWord.of(word), - ], - } -} - -export const removeLearnedWord = ( - spellCheckLanguage: string, - word: string -): TransactionSpec => { - return { - effects: [ - removeWordFromCache.of({ - lang: spellCheckLanguage, - wordText: word, - }), - removeIgnoredWord.of({ text: word }), - ], - } -} - -export const resetLearnedWords = (): TransactionSpec => { - return { - effects: [resetSpellChecker.of(null)], - } -} diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/learned-words.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/learned-words.ts new file mode 100644 index 0000000000..8582131aca --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/learned-words.ts @@ -0,0 +1,24 @@ +import { StateEffect } from '@codemirror/state' +import getMeta from '@/utils/meta' + +export const addLearnedWordEffect = StateEffect.define() + +export const removeLearnedWordEffect = StateEffect.define() + +export const learnedWords = { + global: new Set(getMeta('ol-learnedWords')), +} + +export const addLearnedWord = (text: string) => { + learnedWords.global.add(text) + return { + effects: addLearnedWordEffect.of(text), + } +} + +export const removeLearnedWord = (text: string) => { + learnedWords.global.delete(text) + return { + effects: removeLearnedWordEffect.of(text), + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/misspelled-words.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/misspelled-words.ts index 7767402d8c..ecda168d64 100644 --- a/services/web/frontend/js/features/source-editor/extensions/spelling/misspelled-words.ts +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/misspelled-words.ts @@ -1,6 +1,6 @@ import { StateField, StateEffect, Line } from '@codemirror/state' import { EditorView, Decoration, DecorationSet } from '@codemirror/view' -import { updateAfterAddingIgnoredWord } from './ignored-words' +import { addLearnedWordEffect } from './learned-words' import { Word } from './spellchecker' import { setSpellCheckLanguageEffect } from '@/features/source-editor/extensions/spelling/index' @@ -59,11 +59,15 @@ export const misspelledWordsField = StateField.define({ add: effect.value.map(word => createMark(word)), sort: true, }) - } else if (effect.is(updateAfterAddingIgnoredWord)) { + } else if (effect.is(addLearnedWordEffect)) { + const word = effect.value // Remove existing marks matching the text of a supplied word marks = marks.update({ filter(_from, _to, mark) { - return mark.spec.word.text !== effect.value + return ( + mark.spec.word.text !== word && + mark.spec.word.text !== capitaliseWord(word) + ) }, }) } else if (effect.is(setSpellCheckLanguageEffect)) { @@ -76,3 +80,6 @@ export const misspelledWordsField = StateField.define({ return EditorView.decorations.from(field) }, }) + +const capitaliseWord = (word: string) => + word.charAt(0).toUpperCase() + word.substring(1) diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts index db5b10c63a..eecccf5787 100644 --- a/services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts @@ -1,11 +1,10 @@ import { addMisspelledWords, misspelledWordsField } from './misspelled-words' -import { ignoredWordsField, resetSpellChecker } from './ignored-words' -import { cacheField, addWordToCache, WordCacheValue } from './cache' +import { addLearnedWordEffect, removeLearnedWordEffect } from './learned-words' +import { cacheField, addWordToCache } from './cache' import { WORD_REGEX } from './helpers' import OError from '@overleaf/o-error' import { EditorView, ViewUpdate } from '@codemirror/view' import { ChangeSet, Line, Range, RangeValue } from '@codemirror/state' -import { IgnoredWords } from '../../../dictionary/ignored-words' import { getNormalTextSpansFromLine } from '../../utils/tree-query' import { waitForParser } from '../wait-for-parser' import { debugConsole } from '@/utils/debugging' @@ -77,14 +76,6 @@ export class SpellChecker { } else if (update.viewportChanged) { this.trackedChanges = ChangeSet.empty(0) this.scheduleSpellCheck(update.view) - } else if ( - update.transactions.some(tr => { - return tr.effects.some(effect => effect.is(resetSpellChecker)) - }) - ) { - // for tests - this.trackedChanges = ChangeSet.empty(0) - this.spellCheckAsap(update.view) } // At the point that the spellchecker is initialized, the editor may not // yet be editable, and the parser may not be ready. Therefore, to do the @@ -100,6 +91,34 @@ export class SpellChecker { ) { this.firstCheckPending = true this.spellCheckAsap(update.view) + } else { + for (const tr of update.transactions) { + for (const effect of tr.effects) { + if (effect.is(addLearnedWordEffect)) { + this.addWord(effect.value) + .then(() => { + update.view.state.field(cacheField, false)?.reset() + this.trackedChanges = ChangeSet.empty(0) + this.spellCheckAsap(update.view) + }) + .catch(error => { + captureException(error) + debugConsole.error(error) + }) + } else if (effect.is(removeLearnedWordEffect)) { + this.removeWord(effect.value) + .then(() => { + update.view.state.field(cacheField, false)?.reset() + this.trackedChanges = ChangeSet.empty(0) + this.spellCheckAsap(update.view) + }) + .catch(error => { + captureException(error) + debugConsole.error(error) + }) + } + } + } } } @@ -114,22 +133,29 @@ export class SpellChecker { this.language, wordsToCheck ) - const processResult = ( - misspellings: { index: number; suggestions?: string[] }[] - ) => { + const processResult = (misspellings: { index: number }[]) => { this.trackedChanges = ChangeSet.empty(0) if (this.firstCheck) { this.firstCheck = false this.firstCheckPending = false } - const result = buildSpellCheckResult( + const { misspelledWords, cacheAdditions } = buildSpellCheckResult( knownMisspelledWords, unknownWords, misspellings ) view.dispatch({ - effects: compileEffects(result), + effects: [ + addMisspelledWords.of(misspelledWords), + ...cacheAdditions.map(([word, value]) => { + return addWordToCache.of({ + lang: word.lang, + wordText: word.text, + value, + }) + }), + ], }) } if (unknownWords.length === 0) { @@ -264,19 +290,10 @@ export class SpellChecker { } } - const ignoredWords = this.hunspellManager - ? null - : view.state.field(ignoredWordsField) for (const i of changedLineNumbers) { const line = view.state.doc.line(i) wordsToCheck.push( - ...getWordsFromLine( - view, - line, - ignoredWords, - this.language, - this.segmenter - ) + ...getWordsFromLine(view, line, this.language, this.segmenter) ) } @@ -290,7 +307,6 @@ export class Word { public to: number public lineNumber: number public lang: string - public suggestions?: WordCacheValue constructor(options: { text: string @@ -320,25 +336,26 @@ export class Word { export const buildSpellCheckResult = ( knownMisspelledWords: Word[], unknownWords: Word[], - misspellings: { index: number; suggestions?: string[] }[] + misspellings: { index: number }[] ) => { - const cacheAdditions: [Word, string[] | boolean][] = [] + const cacheAdditions: [Word, boolean][] = [] // Put known misspellings into cache const misspelledWords = misspellings.map(item => { const word = { ...unknownWords[item.index], } - word.suggestions = item.suggestions - if (word.suggestions) { - cacheAdditions.push([word, word.suggestions]) - } + cacheAdditions.push([word, false]) return word }) + const misspelledWordsSet = new Set( + misspelledWords.map(word => word.text) + ) + // if word was not misspelled, put it in the cache for (const word of unknownWords) { - if (!misspelledWords.find(mw => mw.text === word.text)) { + if (!misspelledWordsSet.has(word.text)) { cacheAdditions.push([word, true]) } } @@ -349,34 +366,16 @@ export const buildSpellCheckResult = ( } } -export const compileEffects = (results: { - cacheAdditions: [Word, string[] | boolean][] - misspelledWords: Word[] -}) => { - const { cacheAdditions, misspelledWords } = results - return [ - addMisspelledWords.of(misspelledWords), - ...cacheAdditions.map(([word, value]) => { - return addWordToCache.of({ - lang: word.lang, - wordText: word.text, - value, - }) - }), - ] -} - export function* getWordsFromLine( view: EditorView, line: Line, - ignoredWords: IgnoredWords | null, lang: string, segmenter?: Intl.Segmenter ) { for (const span of getNormalTextSpansFromLine(view, line)) { if (segmenter) { for (const value of segmenter.segment(span.text)) { - if (value.isWordLike && !ignoredWords?.has(value.segment)) { + if (value.isWordLike) { const word = value.segment const from = span.from + value.index yield new Word({ @@ -396,15 +395,13 @@ export function* getWordsFromLine( word = word.slice(1) from++ } - if (!ignoredWords?.has(word)) { - yield new Word({ - text: word, - from, - to: from + word.length, - lineNumber: line.number, - lang, - }) - } + yield new Word({ + text: word, + from, + to: from + word.length, + lineNumber: line.number, + lang, + }) } } } diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/spelling-suggestions.tsx b/services/web/frontend/js/features/source-editor/extensions/spelling/spelling-suggestions.tsx index c0cc7a3496..ba649a06c7 100644 --- a/services/web/frontend/js/features/source-editor/extensions/spelling/spelling-suggestions.tsx +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/spelling-suggestions.tsx @@ -41,34 +41,28 @@ export const SpellingSuggestions: FC = ({ handleLearnWord, handleCorrectWord, }) => { - const [suggestions, setSuggestions] = useState(() => - Array.isArray(word.suggestions) - ? word.suggestions.slice(0, ITEMS_TO_SHOW) - : [] - ) + const [suggestions, setSuggestions] = useState([]) - const [waiting, setWaiting] = useState(!word.suggestions) + const [waiting, setWaiting] = useState(true) useEffect(() => { - if (!word.suggestions) { - spellChecker - ?.suggest(word.text) - .then(result => { - setSuggestions(result.suggestions.slice(0, ITEMS_TO_SHOW)) - setWaiting(false) - sendMB('spelling-suggestion-shown', { - language: spellCheckLanguage, - count: result.suggestions.length, - // word: transaction.state.sliceDoc(mark.from, mark.to), - }) + spellChecker + ?.suggest(word.text) + .then(result => { + setSuggestions(result.suggestions.slice(0, ITEMS_TO_SHOW)) + setWaiting(false) + sendMB('spelling-suggestion-shown', { + language: spellCheckLanguage, + count: result.suggestions.length, + // word: transaction.state.sliceDoc(mark.from, mark.to), }) - .catch(error => { - captureException(error, { - tags: { ol_spell_check_language: spellCheckLanguage }, - }) - debugConsole.error(error) + }) + .catch(error => { + captureException(error, { + tags: { ol_spell_check_language: spellCheckLanguage }, }) - } + debugConsole.error(error) + }) }, [word, spellChecker, spellCheckLanguage]) const language = useMemo(() => { diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index eef644a813..1965724654 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts @@ -33,12 +33,7 @@ import { setAutoPair } from '../extensions/auto-pair' import { setAutoComplete } from '../extensions/auto-complete' import { usePhrases } from './use-phrases' import { setPhrases } from '../extensions/phrases' -import { - addLearnedWord, - removeLearnedWord, - resetLearnedWords, - setSpellCheckLanguage, -} from '../extensions/spelling' +import { setSpellCheckLanguage } from '../extensions/spelling' import { createChangeManager, dispatchEditorEvent, @@ -566,39 +561,6 @@ function useCodeMirrorScope(view: EditorView) { } }, [view, cursorHighlights, currentDoc]) - const handleAddLearnedWords = useCallback( - (event: CustomEvent) => { - // If the word addition is from adding the word to the dictionary via the - // editor, there will be a transaction running now so wait for that to - // finish before starting a new one - window.setTimeout(() => { - view.dispatch(addLearnedWord(spellCheckLanguage, event.detail)) - }, 0) - }, - [spellCheckLanguage, view] - ) - - useEventListener('learnedWords:add', handleAddLearnedWords) - - const handleRemoveLearnedWords = useCallback( - (event: CustomEvent) => { - window.setTimeout(() => { - view.dispatch(removeLearnedWord(spellCheckLanguage, event.detail)) - }) - }, - [spellCheckLanguage, view] - ) - - useEventListener('learnedWords:remove', handleRemoveLearnedWords) - - const handleResetLearnedWords = useCallback(() => { - window.setTimeout(() => { - view.dispatch(resetLearnedWords()) - }) - }, [view]) - - useEventListener('learnedWords:reset', handleResetLearnedWords) - useEventListener( 'editor:focus', useCallback(() => { diff --git a/services/web/frontend/js/features/source-editor/hooks/use-hunspell.ts b/services/web/frontend/js/features/source-editor/hooks/use-hunspell.ts index 027f3291d5..9aaf9324f9 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-hunspell.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-hunspell.ts @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react' import getMeta from '@/utils/meta' -import { globalLearnedWords } from '@/features/dictionary/ignored-words' +import { globalIgnoredWords } from '@/features/dictionary/ignored-words' import { HunspellManager } from '@/features/source-editor/hunspell/HunspellManager' import { debugConsole } from '@/utils/debugging' +import { learnedWords } from '@/features/source-editor/extensions/spelling/learned-words' export const useHunspell = (spellCheckLanguage: string | null) => { const [hunspellManager, setHunspellManager] = useState() @@ -14,8 +15,8 @@ export const useHunspell = (spellCheckLanguage: string | null) => { ) if (lang?.dic) { const hunspellManager = new HunspellManager(lang.dic, [ - ...globalLearnedWords, - ...getMeta('ol-learnedWords'), + ...globalIgnoredWords, + ...learnedWords.global, ]) setHunspellManager(hunspellManager) debugConsole.log(spellCheckLanguage, hunspellManager) diff --git a/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.spec.jsx b/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.spec.jsx index 37875012a5..c28eef66ef 100644 --- a/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.spec.jsx +++ b/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.spec.jsx @@ -1,21 +1,26 @@ import DictionaryModal from '@/features/dictionary/components/dictionary-modal' import { EditorProviders } from '../../../helpers/editor-providers' +import { learnedWords } from '@/features/source-editor/extensions/spelling/learned-words' describe('', function () { + let originalLearnedWords + beforeEach(function () { + cy.then(() => { + originalLearnedWords = learnedWords.global + }) cy.interceptCompile() }) afterEach(function () { - cy.window().then(win => { - win.dispatchEvent(new CustomEvent('learnedWords:doreset')) + cy.then(() => { + learnedWords.global = originalLearnedWords }) }) it('list words', function () { - cy.window().then(win => { - win.metaAttributesCache.set('ol-learnedWords', ['foo', 'bar']) - win.dispatchEvent(new CustomEvent('learnedWords:doreset')) + cy.then(win => { + learnedWords.global = new Set(['foo', 'bar']) }) cy.mount( @@ -29,9 +34,8 @@ describe('', function () { }) it('shows message when empty', function () { - cy.window().then(win => { - win.metaAttributesCache.set('ol-learnedWords', []) - win.dispatchEvent(new CustomEvent('learnedWords:doreset')) + cy.then(win => { + learnedWords.global = new Set([]) }) cy.mount( @@ -46,9 +50,8 @@ describe('', function () { it('removes words', function () { cy.intercept('/spelling/unlearn', { statusCode: 200 }) - cy.window().then(win => { - win.metaAttributesCache.set('ol-learnedWords', ['Foo', 'bar']) - win.dispatchEvent(new CustomEvent('learnedWords:doreset')) + cy.then(win => { + learnedWords.global = new Set(['Foo', 'bar']) }) cy.mount( @@ -73,9 +76,8 @@ describe('', function () { it('handles errors', function () { cy.intercept('/spelling/unlearn', { statusCode: 500 }).as('unlearn') - cy.window().then(win => { - win.metaAttributesCache.set('ol-learnedWords', ['foo']) - win.dispatchEvent(new CustomEvent('learnedWords:doreset')) + cy.then(win => { + learnedWords.global = new Set(['foo']) }) cy.mount( diff --git a/services/web/test/frontend/features/source-editor/extensions/spelling/cache.test.ts b/services/web/test/frontend/features/source-editor/extensions/spelling/cache.test.ts index 97ab853944..c4c98027b3 100644 --- a/services/web/test/frontend/features/source-editor/extensions/spelling/cache.test.ts +++ b/services/web/test/frontend/features/source-editor/extensions/spelling/cache.test.ts @@ -1,6 +1,6 @@ -import { WordCache } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/cache' +import { WordCache } from '@/features/source-editor/extensions/spelling/cache' import { expect } from 'chai' -import { Word } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/spellchecker' +import { Word } from '@/features/source-editor/extensions/spelling/spellchecker' describe('WordCache', function () { describe('basic operations', function () { @@ -18,25 +18,25 @@ describe('WordCache', function () { word = 'bar' expect(cache.get(lang, word)).to.not.exist - cache.set(lang, word, ['a', 'b']) - expect(cache.get(lang, word)).to.deep.equal(['a', 'b']) + cache.set(lang, word, false) + expect(cache.get(lang, word)).to.equal(false) }) it('should store words in separate languages', function () { const word = 'foo' const otherLang = 'zz' - cache.set(lang, word, 101) - expect(cache.get(lang, word)).to.equal(101) + cache.set(lang, word, true) + expect(cache.get(lang, word)).to.equal(true) expect(cache.get(otherLang, word)).to.not.exist - cache.set(otherLang, word, 202) - expect(cache.get(lang, word)).to.equal(101) - expect(cache.get(otherLang, word)).to.equal(202) + cache.set(otherLang, word, false) + expect(cache.get(lang, word)).to.equal(true) + expect(cache.get(otherLang, word)).to.equal(false) }) it('should check words against cache', function () { - cache.set(lang, 'foo', ['a', 'b']) + cache.set(lang, 'foo', false) cache.set(lang, 'bar', true) cache.set(lang, 'baz', true) const wordsToCheck = [ @@ -49,8 +49,8 @@ describe('WordCache', function () { const result = cache.checkWords(lang, wordsToCheck) expect(result).to.have.keys('knownMisspelledWords', 'unknownWords') expect(result.knownMisspelledWords).to.deep.equal([ - { text: 'foo', suggestions: ['a', 'b'], from: 0 }, - { text: 'foo', suggestions: ['a', 'b'], from: 3 }, + { text: 'foo', from: 0 }, + { text: 'foo', from: 3 }, ]) expect(result.unknownWords).to.deep.equal([ { text: 'quux', from: 2 }, diff --git a/services/web/test/frontend/features/source-editor/extensions/spelling/spellchecker.test.ts b/services/web/test/frontend/features/source-editor/extensions/spelling/spellchecker.test.ts index e3083d1c6f..7500a671ae 100644 --- a/services/web/test/frontend/features/source-editor/extensions/spelling/spellchecker.test.ts +++ b/services/web/test/frontend/features/source-editor/extensions/spelling/spellchecker.test.ts @@ -5,7 +5,6 @@ import { } from '@/features/source-editor/extensions/spelling/spellchecker' import { expect } from 'chai' import { EditorView } from '@codemirror/view' -import { IgnoredWords } from '@/features/dictionary/ignored-words' import { LaTeXLanguage } from '@/features/source-editor/languages/latex/latex-language' import { LanguageSupport } from '@codemirror/language' @@ -13,11 +12,10 @@ const extensions = [new LanguageSupport(LaTeXLanguage)] describe('SpellChecker', function () { describe('getWordsFromLine', function () { - let lang: string, ignoredWords: IgnoredWords + let lang: string beforeEach(function () { /* Note: ignore the word 'test' */ lang = 'en' - ignoredWords = new Set([]) as unknown as IgnoredWords }) it('should get words from a line', function () { @@ -26,7 +24,7 @@ describe('SpellChecker', function () { extensions, }) const line = view.state.doc.line(1) - const words = Array.from(getWordsFromLine(view, line, ignoredWords, lang)) + const words = Array.from(getWordsFromLine(view, line, lang)) expect(words).to.deep.equal([ { text: 'Hello', from: 0, to: 5, lineNumber: 1, lang: 'en' }, { text: 'test', from: 6, to: 10, lineNumber: 1, lang: 'en' }, @@ -35,28 +33,13 @@ describe('SpellChecker', function () { ]) }) - it('should ignore words in ignoredWords', function () { - ignoredWords = new Set(['test']) as unknown as IgnoredWords - const view = new EditorView({ - doc: 'Hello test one two', - extensions, - }) - const line = view.state.doc.line(1) - const words = Array.from(getWordsFromLine(view, line, ignoredWords, lang)) - expect(words).to.deep.equal([ - { text: 'Hello', from: 0, to: 5, lineNumber: 1, lang: 'en' }, - { text: 'one', from: 11, to: 14, lineNumber: 1, lang: 'en' }, - { text: 'two', from: 15, to: 18, lineNumber: 1, lang: 'en' }, - ]) - }) - it('should get no words from an empty line', function () { const view = new EditorView({ doc: ' ', extensions, }) const line = view.state.doc.line(1) - const words = Array.from(getWordsFromLine(view, line, ignoredWords, lang)) + const words = Array.from(getWordsFromLine(view, line, lang)) expect(words).to.deep.equal([]) }) @@ -66,7 +49,7 @@ describe('SpellChecker', function () { extensions, }) const line = view.state.doc.line(1) - const words = Array.from(getWordsFromLine(view, line, ignoredWords, lang)) + const words = Array.from(getWordsFromLine(view, line, lang)) expect(words).to.deep.equal([ { text: 'seven', from: 24, to: 29, lineNumber: 1, lang: 'en' }, { text: 'eight', from: 30, to: 35, lineNumber: 1, lang: 'en' }, @@ -79,7 +62,7 @@ describe('SpellChecker', function () { extensions, }) const line = view.state.doc.line(1) - const words = Array.from(getWordsFromLine(view, line, ignoredWords, lang)) + const words = Array.from(getWordsFromLine(view, line, lang)) expect(words).to.deep.equal([ { text: 'nine', from: 5, to: 9, lineNumber: 1, lang: 'en' }, { text: 'ten', from: 15, to: 18, lineNumber: 1, lang: 'en' }, @@ -91,7 +74,7 @@ describe('SpellChecker', function () { it('should build an empty result', function () { const knownMisspelledWords: Word[] = [] const unknownWords: Word[] = [] - const misspellings: { index: number; suggestions: string[] }[] = [] + const misspellings: { index: number }[] = [] const result = buildSpellCheckResult( knownMisspelledWords, unknownWords, @@ -103,21 +86,17 @@ describe('SpellChecker', function () { }) }) it('should build a realistic result', function () { - const _makeWord = (text: string, suggestions?: string[]) => { - const word = new Word({ + const _makeWord = (text: string) => { + return new Word({ text, from: 0, to: 0, lineNumber: 0, lang: 'xx', }) - if (suggestions != null) { - word.suggestions = suggestions - } - return word } // We know this word is misspelled - const knownMisspelledWords = [_makeWord('fff', ['food', 'fleece'])] + const knownMisspelledWords = [_makeWord('fff')] // These words we didn't know const unknownWords = [ _makeWord('aaa'), @@ -126,10 +105,7 @@ describe('SpellChecker', function () { _makeWord('ddd'), ] // These are the suggestions we got back from the backend - const misspellings = [ - { index: 1, suggestions: ['box', 'bass'] }, - { index: 3, suggestions: ['docs', 'dance'] }, - ] + const misspellings = [{ index: 1 }, { index: 3 }] // Build the result structure const result = buildSpellCheckResult( knownMisspelledWords, @@ -140,21 +116,19 @@ describe('SpellChecker', function () { // Check cache additions expect(result.cacheAdditions.map(([k, v]) => [k.text, v])).to.deep.equal([ // Put these in cache as known misspellings - ['bbb', ['box', 'bass']], - ['ddd', ['docs', 'dance']], + ['bbb', false], + ['ddd', false], // Put these in cache as known-correct ['aaa', true], ['ccc', true], ]) // Check misspellings - expect( - result.misspelledWords.map(w => [w.text, w.suggestions]) - ).to.deep.equal([ + expect(result.misspelledWords.map(w => w.text)).to.deep.equal([ // Words in the payload that we now know were misspelled - ['bbb', ['box', 'bass']], - ['ddd', ['docs', 'dance']], + 'bbb', + 'ddd', // Word we already knew was misspelled, preserved here - ['fff', ['food', 'fleece']], + 'fff', ]) }) }) From 19ee929d656d098fe447e6d9f4c192a3f74df386 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Mon, 13 Jan 2025 11:17:01 +0000 Subject: [PATCH 0013/1724] Allow currentUserId to be null when calculating user colour (#22830) GitOrigin-RevId: 70ef0c5a7319fa952690b5e23fae7aef9703eed9 --- services/web/frontend/js/shared/utils/colors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/frontend/js/shared/utils/colors.ts b/services/web/frontend/js/shared/utils/colors.ts index 615dc546c6..27fb1c9203 100644 --- a/services/web/frontend/js/shared/utils/colors.ts +++ b/services/web/frontend/js/shared/utils/colors.ts @@ -5,7 +5,7 @@ const OWN_HUE = 200 // We will always appear as this color to ourselves const OWN_HUE_BLOCKED_SIZE = 20 // no other user should have a HUE in this range const TOTAL_HUES = 360 // actually 361, but 360 for legacy reasons -export function getHueForUserId(userId: string, currentUserId: string) { +export function getHueForUserId(userId: string, currentUserId: string | null) { if (userId == null || userId === 'anonymous-user') { return ANONYMOUS_HUE } From 30ebad91b75acb77ee60f373fbae00135dc31cbe Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Mon, 13 Jan 2025 14:26:13 +0100 Subject: [PATCH 0014/1724] Allow reviewers to resolve their own comments (#22582) * Allow reviewers to resolve their own comments * check if reviewer is comment author * add missing translation * add CommentsController tests * added DocumentManagerTests * added HttpControllerTests * Add AuthorizationManagerTests * added AuthorizationMiddlewareTests * added DocumentUpdaterHandler test * fix test descriptions * remove returns from CommentsControllerTests * use ensureUserCanResolveThread in authorizationMiddleware * move canResolveThread to AuthorizationManager * commentId as param in NotFoundError * refactor canUserResolveThread GitOrigin-RevId: 131c3d1eb9ac916eaaa9221d351a92bc07b80cdc --- services/document-updater/app.js | 4 + .../app/js/DocumentManager.js | 26 +++++ .../document-updater/app/js/HttpController.js | 24 +++++ .../DocumentManager/DocumentManagerTests.js | 71 ++++++++++++ .../js/HttpController/HttpControllerTests.js | 59 ++++++++++ .../Authorization/AuthorizationManager.js | 44 ++++++++ .../Authorization/AuthorizationMiddleware.js | 49 +++++++++ .../DocumentUpdater/DocumentUpdaterHandler.js | 19 ++++ services/web/locales/en.json | 4 +- .../AuthorizationManagerTests.js | 102 ++++++++++++++++++ .../AuthorizationMiddlewareTests.js | 51 +++++++++ .../DocumentUpdaterHandlerTests.js | 34 ++++++ 12 files changed, 486 insertions(+), 1 deletion(-) diff --git a/services/document-updater/app.js b/services/document-updater/app.js index 9466c188ac..2932bba87d 100644 --- a/services/document-updater/app.js +++ b/services/document-updater/app.js @@ -135,6 +135,10 @@ app.use((req, res, next) => { }) app.get('/project/:project_id/doc/:doc_id', HttpController.getDoc) +app.get( + '/project/:project_id/doc/:doc_id/comment/:comment_id', + HttpController.getComment +) app.get('/project/:project_id/doc/:doc_id/peek', HttpController.peekDoc) // temporarily keep the GET method for backwards compatibility app.get('/project/:project_id/doc', HttpController.getProjectDocsAndFlushIfOld) diff --git a/services/document-updater/app/js/DocumentManager.js b/services/document-updater/app/js/DocumentManager.js index 1b5598aab8..540a8a254c 100644 --- a/services/document-updater/app/js/DocumentManager.js +++ b/services/document-updater/app/js/DocumentManager.js @@ -367,6 +367,21 @@ const DocumentManager = { } }, + async getComment(projectId, docId, commentId) { + const { ranges } = await DocumentManager.getDoc(projectId, docId) + + const comment = ranges?.comments?.find(comment => comment.id === commentId) + + if (!comment) { + throw new Errors.NotFoundError({ + message: 'comment not found', + info: { commentId }, + }) + } + + return { comment } + }, + async deleteComment(projectId, docId, commentId, userId) { const { lines, version, ranges, pathname, historyRangesSupport } = await DocumentManager.getDoc(projectId, docId) @@ -500,6 +515,16 @@ const DocumentManager = { ) }, + async getCommentWithLock(projectId, docId, commentId) { + const UpdateManager = require('./UpdateManager') + return await UpdateManager.promises.lockUpdatesAndDo( + DocumentManager.getComment, + projectId, + docId, + commentId + ) + }, + async getDocAndRecentOpsWithLock(projectId, docId, fromVersion) { const UpdateManager = require('./UpdateManager') return await UpdateManager.promises.lockUpdatesAndDo( @@ -676,6 +701,7 @@ module.exports = { 'pathname', 'projectHistoryId', ], + getCommentWithLock: ['comment'], }, }), promises: DocumentManager, diff --git a/services/document-updater/app/js/HttpController.js b/services/document-updater/app/js/HttpController.js index 2d6e81eebb..d8f2d0c5d3 100644 --- a/services/document-updater/app/js/HttpController.js +++ b/services/document-updater/app/js/HttpController.js @@ -49,6 +49,29 @@ function getDoc(req, res, next) { ) } +function getComment(req, res, next) { + const docId = req.params.doc_id + const projectId = req.params.project_id + const commentId = req.params.comment_id + + logger.debug({ projectId, docId, commentId }, 'getting comment via http') + + DocumentManager.getCommentWithLock( + projectId, + docId, + commentId, + (error, comment) => { + if (error) { + return next(error) + } + if (comment == null) { + return next(new Errors.NotFoundError('comment not found')) + } + res.json(comment) + } + ) +} + // return the doc from redis if present, but don't load it from mongo function peekDoc(req, res, next) { const docId = req.params.doc_id @@ -506,4 +529,5 @@ module.exports = { flushQueuedProjects, blockProject, unblockProject, + getComment, } diff --git a/services/document-updater/test/unit/js/DocumentManager/DocumentManagerTests.js b/services/document-updater/test/unit/js/DocumentManager/DocumentManagerTests.js index 5dc3d1c88f..e9d68ee414 100644 --- a/services/document-updater/test/unit/js/DocumentManager/DocumentManagerTests.js +++ b/services/document-updater/test/unit/js/DocumentManager/DocumentManagerTests.js @@ -835,6 +835,77 @@ describe('DocumentManager', function () { }) }) + describe('getComment', function () { + beforeEach(function () { + this.ranges.comments = [ + { + id: 'mock-comment-id-1', + }, + { + id: 'mock-comment-id-2', + }, + ] + this.DocumentManager.promises.getDoc = sinon.stub().resolves({ + lines: this.lines, + version: this.version, + ranges: this.ranges, + }) + }) + + describe('when comment exists', function () { + beforeEach(async function () { + await expect( + this.DocumentManager.promises.getComment( + this.project_id, + this.doc_id, + 'mock-comment-id-1' + ) + ).to.eventually.deep.equal({ + comment: { id: 'mock-comment-id-1' }, + }) + }) + + it("should get the document's current ranges", function () { + this.DocumentManager.promises.getDoc + .calledWith(this.project_id, this.doc_id) + .should.equal(true) + }) + }) + + describe('when comment doesnt exists', function () { + beforeEach(async function () { + await expect( + this.DocumentManager.promises.getComment( + this.project_id, + this.doc_id, + 'mock-comment-id-x' + ) + ).to.be.rejectedWith(Errors.NotFoundError) + }) + + it("should get the document's current ranges", function () { + this.DocumentManager.promises.getDoc + .calledWith(this.project_id, this.doc_id) + .should.equal(true) + }) + }) + + describe('when the doc is not found', function () { + beforeEach(async function () { + this.DocumentManager.promises.getDoc = sinon + .stub() + .resolves({ lines: null, version: null, ranges: null }) + await expect( + this.DocumentManager.promises.acceptChanges( + this.project_id, + this.doc_id, + [this.change_id] + ) + ).to.be.rejectedWith(Errors.NotFoundError) + }) + }) + }) + describe('deleteComment', function () { beforeEach(function () { this.comment_id = 'mock-comment-id' diff --git a/services/document-updater/test/unit/js/HttpController/HttpControllerTests.js b/services/document-updater/test/unit/js/HttpController/HttpControllerTests.js index d6aa03ab52..2b8d288ef8 100644 --- a/services/document-updater/test/unit/js/HttpController/HttpControllerTests.js +++ b/services/document-updater/test/unit/js/HttpController/HttpControllerTests.js @@ -184,6 +184,65 @@ describe('HttpController', function () { }) }) + describe('getComment', function () { + beforeEach(function () { + this.ranges = { + changes: 'mock', + comments: [ + { + id: 'comment-id-1', + }, + { + id: 'comment-id-2', + }, + ], + } + this.req = { + params: { + project_id: this.project_id, + doc_id: this.doc_id, + comment_id: this.comment_id, + }, + query: {}, + body: {}, + } + }) + + beforeEach(function () { + this.DocumentManager.getCommentWithLock = sinon + .stub() + .callsArgWith(3, null, this.ranges.comments[0]) + this.HttpController.getComment(this.req, this.res, this.next) + }) + + it('should get the comment', function () { + this.DocumentManager.getCommentWithLock + .calledWith(this.project_id, this.doc_id, this.comment_id) + .should.equal(true) + }) + + it('should return the comment as JSON', function () { + this.res.json + .calledWith({ + id: 'comment-id-1', + }) + .should.equal(true) + }) + + it('should log the request', function () { + this.logger.debug + .calledWith( + { + projectId: this.project_id, + docId: this.doc_id, + commentId: this.comment_id, + }, + 'getting comment via http' + ) + .should.equal(true) + }) + }) + describe('setDoc', function () { beforeEach(function () { this.lines = ['one', 'two', 'three'] diff --git a/services/web/app/src/Features/Authorization/AuthorizationManager.js b/services/web/app/src/Features/Authorization/AuthorizationManager.js index 565788c3ba..c28ddf363d 100644 --- a/services/web/app/src/Features/Authorization/AuthorizationManager.js +++ b/services/web/app/src/Features/Authorization/AuthorizationManager.js @@ -10,6 +10,7 @@ const PublicAccessLevels = require('./PublicAccessLevels') const Errors = require('../Errors/Errors') const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper') const Settings = require('@overleaf/settings') +const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler') function isRestrictedUser( userId, @@ -190,6 +191,19 @@ async function canUserReadProject(userId, projectId, token) { ].includes(privilegeLevel) } +async function canUserReviewProjectContent(userId, projectId, token) { + const privilegeLevel = await getPrivilegeLevelForProject( + userId, + projectId, + token + ) + return [ + PrivilegeLevels.OWNER, + PrivilegeLevels.READ_AND_WRITE, + PrivilegeLevels.REVIEW, + ].includes(privilegeLevel) +} + async function canUserWriteProjectContent(userId, projectId, token) { const privilegeLevel = await getPrivilegeLevelForProject( userId, @@ -240,9 +254,37 @@ async function isUserSiteAdmin(userId) { return hasAdminAccess(user) } +async function canUserResolveThread(userId, projectId, docId, threadId, token) { + const privilegeLevel = await getPrivilegeLevelForProject( + userId, + projectId, + token, + { ignorePublicAccess: true } + ) + if ( + privilegeLevel === PrivilegeLevels.OWNER || + privilegeLevel === PrivilegeLevels.READ_AND_WRITE + ) { + return true + } + + if (privilegeLevel !== PrivilegeLevels.REVIEW) { + return false + } + + const comment = await DocumentUpdaterHandler.promises.getComment( + projectId, + docId, + threadId + ) + return comment.metadata.user_id === userId +} + module.exports = { canUserReadProject: callbackify(canUserReadProject), canUserWriteProjectContent: callbackify(canUserWriteProjectContent), + canUserReviewProjectContent: callbackify(canUserReviewProjectContent), + canUserResolveThread: callbackify(canUserResolveThread), canUserWriteProjectSettings: callbackify(canUserWriteProjectSettings), canUserRenameProject: callbackify(canUserRenameProject), canUserAdminProject: callbackify(canUserAdminProject), @@ -253,6 +295,8 @@ module.exports = { promises: { canUserReadProject, canUserWriteProjectContent, + canUserReviewProjectContent, + canUserResolveThread, canUserWriteProjectSettings, canUserRenameProject, canUserAdminProject, diff --git a/services/web/app/src/Features/Authorization/AuthorizationMiddleware.js b/services/web/app/src/Features/Authorization/AuthorizationMiddleware.js index 6b2d2ab920..93db5af1c1 100644 --- a/services/web/app/src/Features/Authorization/AuthorizationMiddleware.js +++ b/services/web/app/src/Features/Authorization/AuthorizationMiddleware.js @@ -103,6 +103,32 @@ async function ensureUserCanWriteProjectSettings(req, res, next) { next() } +async function ensureUserCanResolveThread(req, res, next) { + const projectId = _getProjectId(req) + const docId = _getDocId(req) + const threadId = _getThreadId(req) + const userId = _getUserId(req) + const token = TokenAccessHandler.getRequestToken(req, projectId) + const canResolveThread = + await AuthorizationManager.promises.canUserResolveThread( + userId, + projectId, + docId, + threadId, + token + ) + if (canResolveThread) { + logger.debug({ userId, projectId }, 'allowing user resolve comment thread') + return next() + } + + logger.debug( + { userId, projectId, threadId }, + 'denying user to resolve comment thread' + ) + return HttpErrorHandler.forbidden(req, res) +} + async function ensureUserCanWriteProjectContent(req, res, next) { const projectId = _getProjectId(req) const userId = _getUserId(req) @@ -166,6 +192,28 @@ function _getProjectId(req) { return projectId } +function _getDocId(req) { + const docId = req.params.doc_id + if (!docId) { + throw new Error('Expected doc_id in request parameters') + } + if (!ObjectId.isValid(docId)) { + throw new Errors.NotFoundError(`invalid docId: ${docId}`) + } + return docId +} + +function _getThreadId(req) { + const threadId = req.params.thread_id + if (!threadId) { + throw new Error('Expected thread_id in request parameters') + } + if (!ObjectId.isValid(threadId)) { + throw new Errors.NotFoundError(`invalid threadId: ${threadId}`) + } + return threadId +} + function _getUserId(req) { return ( SessionManager.getLoggedInUserId(req.session) || @@ -200,6 +248,7 @@ module.exports = { ensureUserCanWriteProjectSettings: expressify( ensureUserCanWriteProjectSettings ), + ensureUserCanResolveThread: expressify(ensureUserCanResolveThread), ensureUserCanWriteProjectContent: expressify( ensureUserCanWriteProjectContent ), diff --git a/services/web/app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js b/services/web/app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js index dbb8ce9c74..70e6770053 100644 --- a/services/web/app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js +++ b/services/web/app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js @@ -80,6 +80,23 @@ function deleteDoc(projectId, docId, ignoreFlushErrors, callback) { ) } +function getComment(projectId, docId, commentId, callback) { + _makeRequest( + { + path: `/project/${projectId}/doc/${docId}/comment/${commentId}`, + json: true, + }, + projectId, + 'get-comment', + function (error, comment) { + if (error) { + return callback(error) + } + callback(null, comment) + } + ) +} + function getDocument(projectId, docId, fromVersion, callback) { _makeRequest( { @@ -548,6 +565,7 @@ module.exports = { flushProjectToMongoAndDelete, flushDocToMongo, deleteDoc, + getComment, getDocument, setDocument, appendToDocument, @@ -567,6 +585,7 @@ module.exports = { flushProjectToMongoAndDelete: promisify(flushProjectToMongoAndDelete), flushDocToMongo: promisify(flushDocToMongo), deleteDoc: promisify(deleteDoc), + getComment: promisify(getComment), getDocument: promisifyMultiResult(getDocument, [ 'lines', 'version', diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 422526725f..3969d99f57 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -573,7 +573,7 @@ "easily_manage_your_project_files_everywhere": "Easily manage your project files, everywhere", "easy_collaboration_for_students": "Easy collaboration for students. Supports longer or more complex projects.", "edit": "Edit", - "edit_comment_error_message": "There was an error editing your comment. Please try again in a few moments.", + "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_dictionary": "Edit Dictionary", "edit_dictionary_empty": "Your custom dictionary is empty.", @@ -1813,6 +1813,8 @@ "resize": "Resize", "resolve": "Resolve", "resolve_comment": "Resolve comment", + "resolve_comment_error_message": "There was an error resolving the comment. Please try again in a few moments.", + "resolve_comment_error_title": "Resolve Comment Error", "resolved_comments": "Resolved comments", "restore": "Restore", "restore_file": "Restore file", diff --git a/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js b/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js index baa48001ea..4c272387d2 100644 --- a/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js +++ b/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js @@ -12,6 +12,8 @@ describe('AuthorizationManager', function () { beforeEach(function () { this.user = { _id: new ObjectId() } this.project = { _id: new ObjectId() } + this.doc = { _id: new ObjectId() } + this.thread = { _id: new ObjectId() } this.token = 'some-token' this.ProjectGetter = { @@ -46,6 +48,14 @@ describe('AuthorizationManager', function () { }, } + this.DocumentUpdaterHandler = { + promises: { + getComment: sinon + .stub() + .resolves({ metadata: { user_id: new ObjectId() } }), + }, + } + this.AuthorizationManager = SandboxedModule.require(modulePath, { requires: { 'mongodb-legacy': { ObjectId }, @@ -54,6 +64,8 @@ describe('AuthorizationManager', function () { '../Project/ProjectGetter': this.ProjectGetter, '../../models/User': { User: this.User }, '../TokenAccess/TokenAccessHandler': this.TokenAccessHandler, + '../DocumentUpdater/DocumentUpdaterHandler': + this.DocumentUpdaterHandler, '@overleaf/settings': { passwordStrengthOptions: {}, adminPrivilegeAvailable: true, @@ -70,6 +82,7 @@ describe('AuthorizationManager', function () { ['id', 'readAndWrite', true, true], ['id', 'readOnly', false, false], ['id', 'readOnly', false, true], + ['id', 'review', false, true], ] const restrictedScenarios = [ [null, 'readOnly', false, false], @@ -432,6 +445,7 @@ describe('AuthorizationManager', function () { siteAdmin: true, owner: true, readAndWrite: true, + review: true, readOnly: true, publicReadAndWrite: true, publicReadOnly: true, @@ -439,6 +453,15 @@ describe('AuthorizationManager', function () { tokenReadOnly: true, }) + testPermission('canUserReviewProjectContent', { + siteAdmin: true, + owner: true, + readAndWrite: true, + review: true, + publicReadAndWrite: true, + tokenReadAndWrite: true, + }) + testPermission('canUserWriteProjectContent', { siteAdmin: true, owner: true, @@ -503,6 +526,80 @@ describe('AuthorizationManager', function () { }) }) }) + + describe('canUserReviewThread', function () { + it('should return true when user has write permissions', async function () { + this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel + .withArgs(this.user._id, this.project._id) + .resolves(PrivilegeLevels.READ_AND_WRITE) + + const canResolve = + await this.AuthorizationManager.promises.canUserResolveThread( + this.user._id, + this.project._id, + this.doc._id, + this.thread._id, + this.token + ) + + expect(canResolve).to.equal(true) + }) + + it('should return false when user has read permission', async function () { + this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel + .withArgs(this.user._id, this.project._id) + .resolves(PrivilegeLevels.READ_ONLY) + + const canResolve = + await this.AuthorizationManager.promises.canUserResolveThread( + this.user._id, + this.project._id, + this.doc._id, + this.thread._id, + this.token + ) + + expect(canResolve).to.equal(false) + }) + + describe('when user has review permission', function () { + beforeEach(function () { + this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel + .withArgs(this.user._id, this.project._id) + .resolves(PrivilegeLevels.REVIEW) + }) + + it('should return false when user is not the comment author', async function () { + const canResolve = + await this.AuthorizationManager.promises.canUserResolveThread( + this.user._id, + this.project._id, + this.doc._id, + this.thread._id, + this.token + ) + + expect(canResolve).to.equal(false) + }) + + it('should return true when user is the comment author', async function () { + this.DocumentUpdaterHandler.promises.getComment + .withArgs(this.project._id, this.doc._id, this.thread._id) + .resolves({ metadata: { user_id: this.user._id } }) + + const canResolve = + await this.AuthorizationManager.promises.canUserResolveThread( + this.user._id, + this.project._id, + this.doc._id, + this.thread._id, + this.token + ) + + expect(canResolve).to.equal(true) + }) + }) + }) }) function testPermission(permission, privilegeLevels) { @@ -525,6 +622,11 @@ function testPermission(permission, privilegeLevels) { expectPermission(permission, privilegeLevels.readAndWrite || false) }) + describe('when user has review access', function () { + setupUserPrivilegeLevel(PrivilegeLevels.REVIEW) + expectPermission(permission, privilegeLevels.review || false) + }) + describe('when user has read-only access', function () { setupUserPrivilegeLevel(PrivilegeLevels.READ_ONLY) expectPermission(permission, privilegeLevels.readOnly || false) diff --git a/services/web/test/unit/src/Authorization/AuthorizationMiddlewareTests.js b/services/web/test/unit/src/Authorization/AuthorizationMiddlewareTests.js index 6ae930048c..43c5d4bf0c 100644 --- a/services/web/test/unit/src/Authorization/AuthorizationMiddlewareTests.js +++ b/services/web/test/unit/src/Authorization/AuthorizationMiddlewareTests.js @@ -11,6 +11,8 @@ describe('AuthorizationMiddleware', function () { beforeEach(function () { this.userId = new ObjectId().toString() this.project_id = new ObjectId().toString() + this.doc_id = new ObjectId().toString() + this.thread_id = new ObjectId().toString() this.token = 'some-token' this.AuthenticationController = {} this.SessionManager = { @@ -23,8 +25,10 @@ describe('AuthorizationMiddleware', function () { canUserReadProject: sinon.stub(), canUserWriteProjectSettings: sinon.stub(), canUserWriteProjectContent: sinon.stub(), + canUserResolveThread: sinon.stub(), canUserAdminProject: sinon.stub(), canUserRenameProject: sinon.stub(), + canUserReviewProjectContent: sinon.stub(), isUserSiteAdmin: sinon.stub(), isRestrictedUserForProject: sinon.stub(), }, @@ -35,6 +39,11 @@ describe('AuthorizationMiddleware', function () { this.TokenAccessHandler = { getRequestToken: sinon.stub().returns(this.token), } + this.DocumentUpdaterHandler = { + promises: { + getComment: sinon.stub().resolves(), + }, + } this.AuthorizationMiddleware = SandboxedModule.require(MODULE_PATH, { requires: { './AuthorizationManager': this.AuthorizationManager, @@ -47,6 +56,8 @@ describe('AuthorizationMiddleware', function () { '../Helpers/AdminAuthorizationHelper': { canRedirectToAdminDomain: sinon.stub().returns(false), }, + '../DocumentUpdater/DocumentUpdaterHandler': + this.DocumentUpdaterHandler, }, }) this.req = { @@ -75,6 +86,46 @@ describe('AuthorizationMiddleware', function () { ) }) + describe('ensureUserCanResolveThread', function () { + beforeEach(function () { + this.req.params.doc_id = this.doc_id + this.req.params.thread_id = this.thread_id + }) + describe('when user has permission', function () { + beforeEach(function () { + this.AuthorizationManager.promises.canUserResolveThread + .withArgs( + this.userId, + this.project_id, + this.doc_id, + this.thread_id, + this.token + ) + .resolves(true) + }) + + invokeMiddleware('ensureUserCanResolveThread') + expectNext() + }) + + describe("when user doesn't have permission", function () { + beforeEach(function () { + this.AuthorizationManager.promises.canUserResolveThread + .withArgs( + this.userId, + this.project_id, + this.doc_id, + this.thread_id, + this.token + ) + .resolves(false) + }) + + invokeMiddleware('ensureUserCanResolveThread') + expectForbidden() + }) + }) + describe('ensureUserCanWriteProjectSettings', function () { describe('when renaming a project', function () { beforeEach(function () { diff --git a/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterHandlerTests.js b/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterHandlerTests.js index b215d6b42c..3e9d2ee9d9 100644 --- a/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterHandlerTests.js +++ b/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterHandlerTests.js @@ -459,6 +459,40 @@ describe('DocumentUpdaterHandler', function () { }) }) + describe('getComment', function () { + describe('successfully', function () { + beforeEach(function () { + this.comment = { + id: 'mock-comment-id-1', + } + this.body = this.comment + this.request.callsArgWith(1, null, { statusCode: 200 }, this.body) + this.handler.getComment( + this.project_id, + this.doc_id, + this.comment.id, + this.callback + ) + }) + + it('should get the comment from the document updater', function () { + const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}/comment/${this.comment.id}` + this.request + .calledWith({ + url, + method: 'GET', + json: true, + timeout: 30 * 1000, + }) + .should.equal(true) + }) + + it('should call the callback with the comment', function () { + this.callback.calledWithExactly(null, this.comment).should.equal(true) + }) + }) + }) + describe('getDocument', function () { describe('successfully', function () { beforeEach(function () { From 1f23b78de2db7207aa07a5126e3c52064b211087 Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Mon, 13 Jan 2025 14:26:37 +0100 Subject: [PATCH 0015/1724] Fixed equation preview overflow (#22769) * Fixed equation preview overflow * Decrease max-height to 200px GitOrigin-RevId: 4a733d25e86b0d4adfb2dc697bc251ad244949b4 --- .../js/features/source-editor/extensions/math-preview.ts | 4 ++-- .../frontend/stylesheets/app/editor/math-preview.less | 9 ++++++--- .../bootstrap-5/pages/editor/math-preview.scss | 9 ++++++--- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/services/web/frontend/js/features/source-editor/extensions/math-preview.ts b/services/web/frontend/js/features/source-editor/extensions/math-preview.ts index a28a1c9508..6d7191f036 100644 --- a/services/web/frontend/js/features/source-editor/extensions/math-preview.ts +++ b/services/web/frontend/js/features/source-editor/extensions/math-preview.ts @@ -235,12 +235,12 @@ const buildTooltipContent = ( * Styles for the preview tooltip */ const mathPreviewTheme = EditorView.baseTheme({ - '&light .ol-cm-math-tooltip-container': { + '&light .ol-cm-math-tooltip': { boxShadow: '0px 2px 4px 0px #1e253029', border: '1px solid #e7e9ee !important', backgroundColor: 'white !important', }, - '&dark .ol-cm-math-tooltip-container': { + '&dark .ol-cm-math-tooltip': { boxShadow: '0px 2px 4px 0px #1e253029', border: '1px solid #2f3a4c !important', backgroundColor: '#1b222c !important', diff --git a/services/web/frontend/stylesheets/app/editor/math-preview.less b/services/web/frontend/stylesheets/app/editor/math-preview.less index 5b158499c4..b42eb74aab 100644 --- a/services/web/frontend/stylesheets/app/editor/math-preview.less +++ b/services/web/frontend/stylesheets/app/editor/math-preview.less @@ -1,9 +1,8 @@ .ol-cm-math-tooltip-container { position: relative; - border-radius: 4px; - max-height: 400px; - max-width: 800px; overflow: visible; + border: 0px !important; + background-color: transparent !important; } .ol-cm-math-tooltip { @@ -11,6 +10,10 @@ gap: 8px; overflow: auto; padding: 8px; + border-radius: 4px; + max-height: 200px; + max-width: 800px; + margin-top: 10px; .dropdown { position: static; diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/math-preview.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/math-preview.scss index 04c38c8e2a..fd2bfc92f7 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/math-preview.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/math-preview.scss @@ -1,9 +1,8 @@ .ol-cm-math-tooltip-container { position: relative; - border-radius: var(--border-radius-base); - max-height: 400px; - max-width: 800px; overflow: visible; + border: 0 !important; + background-color: transparent !important; } .ol-cm-math-tooltip { @@ -11,6 +10,10 @@ gap: var(--spacing-04); overflow: auto; padding: var(--spacing-04); + border-radius: var(--border-radius-base); + max-height: 200px; + max-width: 800px; + margin-top: 10px; .dropdown { position: static; From c0ccb57100e3f18e7959a8c631e9ecf17e453a30 Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:32:10 +0000 Subject: [PATCH 0016/1724] Merge pull request #22756 from overleaf/dp-preload-pdf-presentation Load full pdf document in background when entering presentation mode GitOrigin-RevId: 514ef838b155a7f13f3bc12690aef45fd3f2c3fc --- .../js/features/pdf-preview/hooks/use-presentation-mode.ts | 2 ++ .../frontend/js/features/pdf-preview/util/pdf-js-wrapper.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/services/web/frontend/js/features/pdf-preview/hooks/use-presentation-mode.ts b/services/web/frontend/js/features/pdf-preview/hooks/use-presentation-mode.ts index 3b4a9be364..7c76c6b17d 100644 --- a/services/web/frontend/js/features/pdf-preview/hooks/use-presentation-mode.ts +++ b/services/web/frontend/js/features/pdf-preview/hooks/use-presentation-mode.ts @@ -133,6 +133,8 @@ export default function usePresentationMode( pdfJsWrapper.viewer.scrollMode = 3 // page pdfJsWrapper.viewer.spreadMode = 0 // none + pdfJsWrapper.fetchAllData() + setPresentationMode(true) } }, [pdfJsWrapper, setScale, scale]) diff --git a/services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.ts b/services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.ts index ca1ff2ea0e..7053342e73 100644 --- a/services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.ts +++ b/services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.ts @@ -99,6 +99,10 @@ export default class PDFJSWrapper { } } + async fetchAllData() { + await this.viewer.pdfDocument?.getData() + } + // update the current scale value if the container size changes updateOnResize() { if (!this.isVisible()) { From 39d1ba7fe0a4a4c5bea54fc4479abe1fe0c31c8a Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Mon, 13 Jan 2025 14:32:23 +0100 Subject: [PATCH 0017/1724] Allow rejecting reviewers own track changes (#22793) * Allow rejecting reviewers own track changes * reject option only for change authors GitOrigin-RevId: ecbc5ee9dfe6c468a5df3c1ce7b147561802a8c0 --- .../components/review-panel-change.tsx | 91 ++++++++++--------- 1 file changed, 50 insertions(+), 41 deletions(-) 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 b316898eba..2c1f9175be 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 @@ -16,6 +16,7 @@ import { ReviewPanelChangeUser } from './review-panel-change-user' 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' export const ReviewPanelChange = memo<{ change: Change @@ -44,6 +45,7 @@ export const ReviewPanelChange = memo<{ const permissions = usePermissionsContext() const changesUsers = useChangesUsersContext() const { showGenericMessageModal } = useModalsContext() + const user = useUserContext() const [accepting, setAccepting] = useState(false) @@ -70,6 +72,8 @@ export const ReviewPanelChange = memo<{ return null } + const isChangeAuthor = change.metadata?.user_id === user.id + return (
- {editable && permissions.write && ( + {editable && (
- - - + + + )} - - - + + + )}
)}
From e9c1c0f9c81f2678a7c7c9b1e31a4cec1b04874f Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Mon, 13 Jan 2025 09:20:57 -0500 Subject: [PATCH 0018/1724] Merge pull request #22650 from overleaf/em-tracked-deletes-at-same-position Handle multiple tracked deletes at same position GitOrigin-RevId: 3cbf1c418bcd50cf08e1b90ce6ba3bc480236079 --- libraries/ranges-tracker/index.cjs | 32 +++- .../test/unit/ranges-tracker-test.js | 173 ++++++++++++++++++ .../document-updater/app/js/RangesManager.js | 21 ++- .../js/RangesManager/RangesManagerTests.js | 38 ++++ .../web/frontend/js/vendor/libs/sharejs.js | 3 + 5 files changed, 261 insertions(+), 6 deletions(-) diff --git a/libraries/ranges-tracker/index.cjs b/libraries/ranges-tracker/index.cjs index 8d3cb840c0..71eb40f14b 100644 --- a/libraries/ranges-tracker/index.cjs +++ b/libraries/ranges-tracker/index.cjs @@ -316,7 +316,7 @@ class RangesTracker { const movedChanges = [] const removeChanges = [] const newChanges = [] - + const trackedDeletesAtOpPosition = [] for (let i = 0; i < this.changes.length; i++) { change = this.changes[i] const changeStart = change.op.p @@ -327,13 +327,15 @@ class RangesTracker { change.op.p += opLength movedChanges.push(change) } else if (opStart === changeStart) { - // If we are undoing, then we want to cancel any existing delete ranges if we can. - // Check if the insert matches the start of the delete, and just remove it from the delete instead if so. if ( + !(op.orderedRejections && alreadyMerged) && undoing && change.op.d.length >= op.i.length && change.op.d.slice(0, op.i.length) === op.i ) { + // If we are undoing, then we want to reject any existing tracked delete if we can. + // Check if the insert matches the start of the delete, and just + // remove it from the delete instead if so. change.op.d = change.op.d.slice(op.i.length) change.op.p += op.i.length if (change.op.d === '') { @@ -342,9 +344,27 @@ class RangesTracker { movedChanges.push(change) } alreadyMerged = true + + if (op.orderedRejections) { + // Any tracked delete that came before this tracked delete + // rejection was moved after the incoming insert. Move them back + // so that they appear before the tracked delete rejection. + for (const trackedDelete of trackedDeletesAtOpPosition) { + trackedDelete.op.p -= opLength + } + } } else { + // We're not rejecting that tracked delete. Move it after the + // insert. change.op.p += opLength movedChanges.push(change) + + // Keep track of tracked deletes that are at the same position as the + // insert. If we find a tracked delete to reject, we'll want to + // reposition them. + if (!alreadyMerged) { + trackedDeletesAtOpPosition.push(change) + } } } } else if (change.op.i != null) { @@ -624,9 +644,13 @@ class RangesTracker { } _addOp(op, metadata) { + // Don't take a reference to the existing op since we'll modify this in place with future changes + op = this._clone(op) + // TODO: Remove this when the orderedRejections transition is over + delete op.orderedRejections const change = { id: this.newId(), - op: this._clone(op), // Don't take a reference to the existing op since we'll modify this in place with future changes + op, metadata: this._clone(metadata), } this.changes.push(change) diff --git a/libraries/ranges-tracker/test/unit/ranges-tracker-test.js b/libraries/ranges-tracker/test/unit/ranges-tracker-test.js index 75d7024d09..f5945c7aa1 100644 --- a/libraries/ranges-tracker/test/unit/ranges-tracker-test.js +++ b/libraries/ranges-tracker/test/unit/ranges-tracker-test.js @@ -4,6 +4,7 @@ const RangesTracker = require('../..') describe('RangesTracker', function () { describe('with duplicate change ids', function () { beforeEach(function () { + this.comments = [] this.changes = [ { id: 'id1', op: { p: 1, i: 'hello' } }, { id: 'id2', op: { p: 10, i: 'world' } }, @@ -26,4 +27,176 @@ describe('RangesTracker', function () { expect(this.rangesTracker.changes).to.deep.equal([this.changes[2]]) }) }) + + describe('with multiple tracked deletes at the same position', function () { + beforeEach(function () { + this.comments = [] + this.changes = [ + { id: 'id1', op: { p: 33, d: 'before' } }, + { id: 'id2', op: { p: 50, d: 'right before' } }, + { id: 'id3', op: { p: 50, d: 'this one' } }, + { id: 'id4', op: { p: 50, d: 'right after' } }, + { id: 'id5', op: { p: 75, d: 'long after' } }, + ] + this.rangesTracker = new RangesTracker(this.changes, this.comments) + }) + + describe('with the orderedRejections flag', function () { + it('preserves the text order when rejecting changes', function () { + this.rangesTracker.applyOp( + { p: 50, i: 'this one', u: true, orderedRejections: true }, + { user_id: 'user-id' } + ) + expect(this.rangesTracker.changes).to.deep.equal([ + { id: 'id1', op: { p: 33, d: 'before' } }, + { id: 'id2', op: { p: 50, d: 'right before' } }, + { id: 'id4', op: { p: 58, d: 'right after' } }, + { id: 'id5', op: { p: 83, d: 'long after' } }, + ]) + }) + + it('moves all tracked deletes after the insert if not rejecting changes', function () { + this.rangesTracker.applyOp( + { p: 50, i: 'some other text', u: true, orderedRejections: true }, + { user_id: 'user-id' } + ) + expect(this.rangesTracker.changes).to.deep.equal([ + { id: 'id1', op: { p: 33, d: 'before' } }, + { id: 'id2', op: { p: 65, d: 'right before' } }, + { id: 'id3', op: { p: 65, d: 'this one' } }, + { id: 'id4', op: { p: 65, d: 'right after' } }, + { id: 'id5', op: { p: 90, d: 'long after' } }, + ]) + }) + }) + + describe('without the orderedRejections flag', function () { + it('puts the insert before tracked deletes when rejecting changes', function () { + this.rangesTracker.applyOp( + { p: 50, i: 'this one', u: true }, + { user_id: 'user-id' } + ) + expect(this.rangesTracker.changes).to.deep.equal([ + { id: 'id1', op: { p: 33, d: 'before' } }, + { id: 'id2', op: { p: 58, d: 'right before' } }, + { id: 'id4', op: { p: 58, d: 'right after' } }, + { id: 'id5', op: { p: 83, d: 'long after' } }, + ]) + }) + + it('moves all tracked deletes after the insert if not rejecting changes', function () { + this.rangesTracker.applyOp( + { p: 50, i: 'some other text', u: true }, + { user_id: 'user-id' } + ) + expect(this.rangesTracker.changes).to.deep.equal([ + { id: 'id1', op: { p: 33, d: 'before' } }, + { id: 'id2', op: { p: 65, d: 'right before' } }, + { id: 'id3', op: { p: 65, d: 'this one' } }, + { id: 'id4', op: { p: 65, d: 'right after' } }, + { id: 'id5', op: { p: 90, d: 'long after' } }, + ]) + }) + }) + }) + + describe('with multiple tracked deletes at the same position with the same content', function () { + beforeEach(function () { + this.comments = [] + this.changes = [ + { id: 'id1', op: { p: 10, d: 'cat' } }, + { id: 'id2', op: { p: 10, d: 'giraffe' } }, + { id: 'id3', op: { p: 10, d: 'cat' } }, + { id: 'id4', op: { p: 10, d: 'giraffe' } }, + ] + this.rangesTracker = new RangesTracker(this.changes, this.comments) + }) + + describe('with the orderedRejections flag', function () { + it('removes only the first matching tracked delete', function () { + this.rangesTracker.applyOp( + { p: 10, i: 'giraffe', u: true, orderedRejections: true }, + { user_id: 'user-id' } + ) + expect(this.rangesTracker.changes).to.deep.equal([ + { id: 'id1', op: { p: 10, d: 'cat' } }, + { id: 'id3', op: { p: 17, d: 'cat' } }, + { id: 'id4', op: { p: 17, d: 'giraffe' } }, + ]) + }) + }) + + describe('without the orderedRejections flag', function () { + it('removes all matching tracked delete', function () { + this.rangesTracker.applyOp( + { p: 10, i: 'giraffe', u: true }, + { user_id: 'user-id' } + ) + expect(this.rangesTracker.changes).to.deep.equal([ + { id: 'id1', op: { p: 17, d: 'cat' } }, + { id: 'id3', op: { p: 17, d: 'cat' } }, + ]) + }) + }) + }) + + describe('with a tracked insert at the same position as a tracked delete', function () { + beforeEach(function () { + this.comments = [] + this.changes = [ + { + id: 'id1', + op: { p: 5, d: 'before' }, + metadata: { user_id: 'user-id' }, + }, + { + id: 'id2', + op: { p: 10, d: 'delete' }, + metadata: { user_id: 'user-id' }, + }, + { + id: 'id3', + op: { p: 10, i: 'insert' }, + metadata: { user_id: 'user-id' }, + }, + ] + this.rangesTracker = new RangesTracker(this.changes, this.comments) + }) + + describe('with the orderedRejections flag', function () { + it('places a tracked insert at the same position before both the delete and the insert', function () { + this.rangesTracker.track_changes = true + this.rangesTracker.applyOp( + { p: 10, i: 'incoming', orderedRejections: true }, + { user_id: 'user-id' } + ) + expect( + this.rangesTracker.changes.map(change => change.op) + ).to.deep.equal([ + { p: 5, d: 'before' }, + { p: 10, i: 'incoming' }, + { p: 18, d: 'delete' }, + { p: 18, i: 'insert' }, + ]) + }) + }) + + describe('without the orderedRejections flag', function () { + it('places a tracked insert at the same position before both the delete and the insert', function () { + this.rangesTracker.track_changes = true + this.rangesTracker.applyOp( + { p: 10, i: 'incoming' }, + { user_id: 'user-id' } + ) + expect( + this.rangesTracker.changes.map(change => change.op) + ).to.deep.equal([ + { p: 5, d: 'before' }, + { p: 10, i: 'incoming' }, + { p: 18, d: 'delete' }, + { p: 18, i: 'insert' }, + ]) + }) + }) + }) }) diff --git a/services/document-updater/app/js/RangesManager.js b/services/document-updater/app/js/RangesManager.js index 9b8a50c526..37006ac1d0 100644 --- a/services/document-updater/app/js/RangesManager.js +++ b/services/document-updater/app/js/RangesManager.js @@ -352,6 +352,12 @@ function getHistoryOpForInsert(op, comments, changes) { } } + // If it's determined that the op is a tracked delete rejection, we have to + // calculate its proper history position. If multiple tracked deletes are + // found at the same position as the insert, the tracked deletes that come + // before the tracked delete that was actually rejected offset the history + // position. + let trackedDeleteRejectionOffset = 0 for (const change of changes) { if (!isDelete(change.op)) { // We're only interested in tracked deletes @@ -362,14 +368,25 @@ function getHistoryOpForInsert(op, comments, changes) { // Tracked delete is before the op. Move the op forward. hpos += change.op.d.length } else if (change.op.p === op.p) { - // Tracked delete is at the same position as the op. The insert comes before - // the tracked delete so it doesn't move. + // Tracked delete is at the same position as the op. if (op.u && change.op.d.startsWith(op.i)) { // We're undoing and the insert matches the start of the tracked // delete. RangesManager treats this as a tracked delete rejection. We // will note this in the op so that project-history can take the // appropriate action. trackedDeleteRejection = true + + // The history must be updated to take into account all preceding + // tracked deletes at the same position + hpos += trackedDeleteRejectionOffset + + // No need to continue. All subsequent tracked deletes are after the + // insert. + break + } else { + // This tracked delete does not match the insert. Note its length in + // case we find a tracked delete that matches later. + trackedDeleteRejectionOffset += change.op.d.length } } else { // Tracked delete is after the insert. Tracked deletes are ordered, so diff --git a/services/document-updater/test/unit/js/RangesManager/RangesManagerTests.js b/services/document-updater/test/unit/js/RangesManager/RangesManagerTests.js index ec1c8703e9..4053aafb01 100644 --- a/services/document-updater/test/unit/js/RangesManager/RangesManagerTests.js +++ b/services/document-updater/test/unit/js/RangesManager/RangesManagerTests.js @@ -323,6 +323,44 @@ describe('RangesManager', function () { }) }) + describe('tracked delete rejections with multiple tracked deletes at the same position', function () { + beforeEach(function () { + // original text is "one [two ][three ][four ]five" + // [] denotes tracked deletes + this.ranges = { + changes: makeRanges([ + { d: 'two ', p: 4 }, + { d: 'three ', p: 4 }, + { d: 'four ', p: 4 }, + ]), + } + this.updates = makeUpdates([{ i: 'three ', p: 4, u: true }]) + this.newDocLines = ['one three five'] + this.result = this.RangesManager.applyUpdate( + this.project_id, + this.doc_id, + this.ranges, + this.updates, + this.newDocLines, + { historyRangesSupport: true } + ) + }) + + it('should insert the text at the right history position', function () { + expect(this.result.historyUpdates.map(x => x.op)).to.deep.equal([ + [ + { + i: 'three ', + p: 4, + hpos: 8, + u: true, + trackedDeleteRejection: true, + }, + ], + ]) + }) + }) + describe('deletes over tracked changes', function () { beforeEach(function () { // original text is "on[1]e [22](three) f[333]ou[4444]r [55555]five" diff --git a/services/web/frontend/js/vendor/libs/sharejs.js b/services/web/frontend/js/vendor/libs/sharejs.js index 6b9ea123d7..0765813a18 100644 --- a/services/web/frontend/js/vendor/libs/sharejs.js +++ b/services/web/frontend/js/vendor/libs/sharejs.js @@ -695,6 +695,9 @@ export const { Doc } = (() => { var op = { p: pos, i: text }; if (fromUndo) { op.u = true; + // TODO: This flag is temporary. It is only necessary while we change + // the behaviour of tracked delete rejections in RangesTracker + op.orderedRejections = true; } op = [op]; From 1b5fb1ef9e8ae077abc707b1236ba8955e7f036d Mon Sep 17 00:00:00 2001 From: Jimmy Domagala-Tang Date: Mon, 13 Jan 2025 10:24:08 -0500 Subject: [PATCH 0019/1724] Merge pull request #22702 from overleaf/jdt-assistant-button-shrinking Refactor AI error assistant ctas to reduce visual space GitOrigin-RevId: 33f85849d250368e7ff53242b1d155573b0a1a43 --- services/web/frontend/extracted-translations.json | 1 - services/web/locales/en.json | 1 - 2 files changed, 2 deletions(-) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 1aed113d50..130a748b0c 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -831,7 +831,6 @@ "let_us_know": "", "let_us_know_how_we_can_help": "", "let_us_know_what_you_think": "", - "lets_fix_your_errors": "", "library": "", "license_for_educational_purposes": "", "license_for_educational_purposes_2025": "", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 3969d99f57..82aebaf28f 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1171,7 +1171,6 @@ "let_us_know": "Let us know", "let_us_know_how_we_can_help": "Let us know how we can help", "let_us_know_what_you_think": "Let us know what you think", - "lets_fix_your_errors": "Let’s fix your errors", "libraries": "Libraries", "library": "Library", "license": "License", From bf789a2635df83c66293705e02efea336340e05b Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Mon, 13 Jan 2025 15:29:33 +0000 Subject: [PATCH 0020/1724] Merge pull request #22799 from overleaf/dp-new-pdf-toolbar Add toolbar styles and update pdf toolbar to match new editor designs GitOrigin-RevId: 4d5d9c6fa3353c10dd135aa35440c8512a5d3226 --- .../components/pdf-compile-button.tsx | 33 +++++++++++++++++++ .../components/pdf-preview-hybrid-toolbar.jsx | 24 ++++++++++++++ .../pdf-preview/components/pdf-js-viewer.tsx | 1 + .../components/pdf-preview-pane.tsx | 10 +++++- .../pdf-viewer-controls-menu-button.tsx | 3 ++ .../pdf-viewer-controls-toolbar.tsx | 7 ++++ .../bootstrap-5/pages/editor/pdf.scss | 13 ++++++++ .../bootstrap-5/pages/editor/toolbar.scss | 12 +++++++ 8 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 services/web/frontend/js/features/ide-redesign/components/pdf-compile-button.tsx create mode 100644 services/web/frontend/js/features/ide-redesign/components/pdf-preview-hybrid-toolbar.jsx diff --git a/services/web/frontend/js/features/ide-redesign/components/pdf-compile-button.tsx b/services/web/frontend/js/features/ide-redesign/components/pdf-compile-button.tsx new file mode 100644 index 0000000000..e0fa76828b --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/pdf-compile-button.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from 'react-i18next' +import { memo } from 'react' +import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' +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' + +function PdfCompileButton() { + const { compiling, startCompile } = useCompileContext() + const { t } = useTranslation() + + return ( + + {/* TODO: add some indicator that changes have been made */} + + + + + ) +} + +export default memo(PdfCompileButton) diff --git a/services/web/frontend/js/features/ide-redesign/components/pdf-preview-hybrid-toolbar.jsx b/services/web/frontend/js/features/ide-redesign/components/pdf-preview-hybrid-toolbar.jsx new file mode 100644 index 0000000000..771ebad313 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/pdf-preview-hybrid-toolbar.jsx @@ -0,0 +1,24 @@ +import { memo } from 'react' +import OlButtonToolbar from '@/features/ui/components/ol/ol-button-toolbar' +import PdfCompileButton from './pdf-compile-button' +import PdfHybridLogsButton from '@/features/pdf-preview/components/pdf-hybrid-logs-button' +import PdfHybridDownloadButton from '@/features/pdf-preview/components/pdf-hybrid-download-button' + +function PdfPreviewHybridToolbar() { + // TODO: add detached pdf logic + return ( + +
+ + + +
+
+
+ {/* TODO: should we have switch to editor/code check/synctex buttons? */} +
+ + ) +} + +export default memo(PdfPreviewHybridToolbar) diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx index 6600e096d5..04332d2485 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx @@ -501,6 +501,7 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) { setPage={handlePageChange} page={page} totalPages={totalPages} + pdfContainer={pdfJsWrapper?.container} /> )}
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 bbbcaafe14..2488b23ef8 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 @@ -9,6 +9,8 @@ import { PdfPreviewMessages } from './pdf-preview-messages' import CompileTimeWarningUpgradePrompt from './compile-time-warning-upgrade-prompt' import { PdfPreviewProvider } from './pdf-preview-provider' import importOverleafModules from '../../../../macros/import-overleaf-module.macro' +import { useFeatureFlag } from '@/shared/context/split-test-context' +import PdfPreviewHybridToolbarNew from '@/features/ide-redesign/components/pdf-preview-hybrid-toolbar' const pdfPreviewPromotions = importOverleafModules('pdfPreviewPromotions') as { import: { default: ElementType } @@ -20,10 +22,16 @@ function PdfPreviewPane() { const classes = classNames('pdf', 'full-size', { 'pdf-empty': !pdfUrl, }) + const newEditor = useFeatureFlag('editor-redesign') + return (
- + {newEditor ? ( + + ) : ( + + )} {pdfPreviewPromotions.map( ({ import: { default: Component }, path }) => ( diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-viewer-controls-menu-button.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-viewer-controls-menu-button.tsx index c339a9e2c5..681c38c43f 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-viewer-controls-menu-button.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-viewer-controls-menu-button.tsx @@ -15,6 +15,7 @@ type PdfViewerControlsMenuButtonProps = { setPage: (page: number) => void page: number totalPages: number + pdfContainer?: HTMLDivElement } export default function PdfViewerControlsMenuButton({ @@ -22,6 +23,7 @@ export default function PdfViewerControlsMenuButton({ setPage, page, totalPages, + pdfContainer, }: PdfViewerControlsMenuButtonProps) { const { t } = useTranslation() @@ -54,6 +56,7 @@ export default function PdfViewerControlsMenuButton({ show={popoverOpen} target={targetRef.current} placement="bottom" + container={pdfContainer} containerPadding={0} transition rootClose 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 4c87f9efe7..0b44ef5b5d 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 @@ -14,6 +14,7 @@ type PdfViewerControlsToolbarProps = { setPage: (page: number) => void page: number totalPages: number + pdfContainer?: HTMLDivElement } function PdfViewerControlsToolbar({ @@ -23,6 +24,7 @@ function PdfViewerControlsToolbar({ setPage, page, totalPages, + pdfContainer, }: PdfViewerControlsToolbarProps) { const { showLogs } = useCompileContext() @@ -61,6 +63,7 @@ function PdfViewerControlsToolbar({ setPage={setPage} page={page} totalPages={totalPages} + pdfContainer={pdfContainer} />
, @@ -75,6 +78,8 @@ type InnerControlsProps = { setPage: (page: number) => void page: number totalPages: number + // eslint-disable-next-line react/no-unused-prop-types + pdfContainer?: HTMLDivElement } function PdfViewerControlsToolbarFull({ @@ -111,6 +116,7 @@ function PdfViewerControlsToolbarSmall({ setPage, page, totalPages, + pdfContainer, }: InnerControlsProps) { return (
@@ -124,6 +130,7 @@ function PdfViewerControlsToolbarSmall({ setPage={setPage} page={page} totalPages={totalPages} + pdfContainer={pdfContainer} />
) 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 68b40e2e31..632f9a1796 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/pdf.scss @@ -154,6 +154,19 @@ top: var(--toolbar-small-height); } +.ide-redesign-main { + .pdf-viewer { + .pdfjs-viewer { + .page { + box-shadow: + 0 5px 5px 0 #23282f0d, + 0 3px 14px 0 #23282f08, + 0 8px 10px 0 #23282f14; + } + } + } +} + .pdf-viewer { iframe { width: 100%; diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss index e38a908c79..464ad52df0 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss @@ -45,6 +45,18 @@ --editor-toolbar-bg: var(--white); } +.ide-redesign-main { + --toolbar-alt-bg-color: var(--bg-light-secondary); + --toolbar-btn-color: var(--content-primary); + --toolbar-btn-hover-bg-color: var(--neutral-80); + --toolbar-btn-hover-color: var(--white); + --editor-toolbar-bg: var(--white); + + .toolbar { + border-bottom: none; + } +} + .toolbar { display: flex; align-items: center; From 2ef5db2938a2ec816692651c047c0b8b4886908a Mon Sep 17 00:00:00 2001 From: M Fahru Date: Mon, 13 Jan 2025 09:45:31 -0700 Subject: [PATCH 0021/1724] Merge pull request #22340 from overleaf/mf-clean-up-currency-format-test [web] Clean up localized currency format test (`local-ccy-format-v2`) GitOrigin-RevId: 30d671479522b87ee9205994508b745d2b0ae4c3 --- .../web/.storybook/utils/with-split-tests.tsx | 7 +- .../Subscription/SubscriptionController.js | 63 ++-------- .../Subscription/SubscriptionFormatters.js | 56 +-------- .../Subscription/SubscriptionHelper.js | 109 +----------------- .../SubscriptionViewModelBuilder.js | 45 ++++---- services/web/app/src/util/currency.js | 6 +- .../subscriptions/plans-light-design.pug | 1 - .../web/app/views/subscriptions/plans.pug | 1 - .../components/add-seats/cost-summary.tsx | 10 +- .../upgrade-subscription-plan-details.tsx | 4 +- .../upgrade-subscription-upgrade-summary.tsx | 10 +- .../features/plans/group-plan-modal/index.js | 13 +-- .../plans/utils/group-plan-pricing.js | 46 +------- .../preview-subscription-change/root.tsx | 19 ++- .../subscription-dashboard-context.tsx | 22 +--- .../subscription/util/recurly-pricing.ts | 46 ++------ .../plans-v2/plans-v2-group-plan.js | 17 +-- .../plans-v2/plans-v2-tracking.ts | 5 - .../web/frontend/js/shared/utils/currency.ts | 2 +- .../active/change-plan/change-plan.test.tsx | 16 +-- .../subscription/util/recurly-pricing.test.ts | 25 +--- .../shared/utils/group-plan-pricing.test.js | 9 -- .../SubscriptionControllerTests.js | 46 ++------ .../Subscription/SubscriptionHelperTests.js | 26 +---- services/web/types/currency-code.ts | 1 - services/web/types/subscription/currency.ts | 1 - .../subscription/payment-context-value.tsx | 3 +- 27 files changed, 105 insertions(+), 504 deletions(-) delete mode 100644 services/web/types/currency-code.ts diff --git a/services/web/.storybook/utils/with-split-tests.tsx b/services/web/.storybook/utils/with-split-tests.tsx index fa8b4bad52..0a7ee0bd72 100644 --- a/services/web/.storybook/utils/with-split-tests.tsx +++ b/services/web/.storybook/utils/with-split-tests.tsx @@ -3,11 +3,8 @@ import _ from 'lodash' import { SplitTestContext } from '../../frontend/js/shared/context/split-test-context' export const splitTestsArgTypes = { - 'local-ccy-format-v2': { - description: 'Use local currency formatting', - control: { type: 'radio' as const }, - options: ['default', 'enabled'], - }, + // to be able to use this utility, you need to add the argTypes for each split test in this object + // Check the original implementation for an example: https://github.com/overleaf/internal/pull/17809 } export const withSplitTests = ( diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index ecac7dea0c..dbf0ed1f14 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -23,8 +23,7 @@ const SubscriptionHelper = require('./SubscriptionHelper') const AuthorizationManager = require('../Authorization/AuthorizationManager') const Modules = require('../../infrastructure/Modules') const async = require('async') -const { formatCurrencyLocalized } = require('../../util/currency') -const SubscriptionFormatters = require('./SubscriptionFormatters') +const { formatCurrency } = require('../../util/currency') const HttpErrorHandler = require('../Errors/HttpErrorHandler') const { URLSearchParams } = require('url') const RecurlyClient = require('./RecurlyClient') @@ -113,16 +112,6 @@ async function plansPage(req, res) { const { showLATAMBanner, showInrGeoBanner, showBrlGeoBanner } = _plansBanners(countryCode) - const localCcyAssignment = await SplitTestHandler.promises.getAssignment( - req, - res, - 'local-ccy-format-v2' - ) - const formatCurrency = - localCcyAssignment.variant === 'enabled' - ? formatCurrencyLocalized - : SubscriptionHelper.formatCurrencyDefault - const shouldLoadHotjar = await getShouldLoadHotjar(req, res) res.render('subscriptions/plans', { @@ -142,8 +131,7 @@ async function plansPage(req, res) { initialLocalizedGroupPrice: SubscriptionHelper.generateInitialLocalizedGroupPrice( currency ?? 'USD', - language, - formatCurrency + language ), showInrGeoBanner, showBrlGeoBanner, @@ -163,16 +151,6 @@ async function plansPageLightDesign(req, res) { const plans = SubscriptionViewModelBuilder.buildPlansList() const groupPlanModalDefaults = _getGroupPlanModalDefaults(req, currency) - const localCcyAssignment = await SplitTestHandler.promises.getAssignment( - req, - res, - 'local-ccy-format-v2' - ) - const formatCurrency = - localCcyAssignment.variant === 'enabled' - ? formatCurrencyLocalized - : SubscriptionHelper.formatCurrencyDefault - const { showLATAMBanner, showInrGeoBanner, showBrlGeoBanner } = _plansBanners(countryCode) @@ -197,8 +175,7 @@ async function plansPageLightDesign(req, res) { initialLocalizedGroupPrice: SubscriptionHelper.generateInitialLocalizedGroupPrice( currency ?? 'USD', - language, - formatCurrency + language ), showLATAMBanner, showInrGeoBanner, @@ -222,11 +199,6 @@ function formatGroupPlansDataForDash() { async function userSubscriptionPage(req, res) { const user = SessionManager.getSessionUser(req.session) - const localCcyAssignment = await SplitTestHandler.promises.getAssignment( - req, - res, - 'local-ccy-format-v2' - ) await SplitTestHandler.promises.getAssignment(req, res, 'ai-add-on') // Populates splitTestVariants with a value for the split test name and allows @@ -241,10 +213,7 @@ async function userSubscriptionPage(req, res) { const results = await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( user, - req.i18n.language, - localCcyAssignment.variant === 'enabled' - ? SubscriptionFormatters.formatPriceLocalized - : SubscriptionFormatters.formatPriceDefault + req.i18n.language ) const { personalSubscription, @@ -364,12 +333,6 @@ async function interstitialPaymentPage(req, res) { const { showLATAMBanner, showInrGeoBanner, showBrlGeoBanner } = _plansBanners(countryCode) - const localCcyAssignment = await SplitTestHandler.promises.getAssignment( - req, - res, - 'local-ccy-format-v2' - ) - const shouldLoadHotjar = await getShouldLoadHotjar(req, res) res.render(template, { @@ -380,11 +343,8 @@ async function interstitialPaymentPage(req, res) { recommendedCurrency, interstitialPaymentConfig, showSkipLink, - formatCurrency: - localCcyAssignment.variant === 'enabled' - ? formatCurrencyLocalized - : SubscriptionHelper.formatCurrencyDefault, - showCurrencyAndPaymentMethods: localCcyAssignment.variant === 'enabled', + formatCurrency, + showCurrencyAndPaymentMethods: true, // TODO: remove hardcode showInrGeoBanner, showBrlGeoBanner, showLATAMBanner, @@ -399,18 +359,11 @@ async function interstitialPaymentPage(req, res) { async function successfulSubscription(req, res) { const user = SessionManager.getSessionUser(req.session) - const localCcyAssignment = await SplitTestHandler.promises.getAssignment( - req, - res, - 'local-ccy-format-v2' - ) + const { personalSubscription } = await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( user, - req.i18n.language, - localCcyAssignment.variant === 'enabled' - ? SubscriptionFormatters.formatPriceLocalized - : SubscriptionFormatters.formatPriceDefault + req.i18n.language ) const postCheckoutRedirect = req.session?.postCheckoutRedirect diff --git a/services/web/app/src/Features/Subscription/SubscriptionFormatters.js b/services/web/app/src/Features/Subscription/SubscriptionFormatters.js index 71df8c69b1..8bdfc2a060 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionFormatters.js +++ b/services/web/app/src/Features/Subscription/SubscriptionFormatters.js @@ -1,56 +1,5 @@ const dateformat = require('dateformat') -const { formatCurrencyLocalized } = require('../../util/currency') - -/** - * @import { CurrencyCode } from '../../../../types/currency-code' - */ - -const currencySymbols = { - EUR: '€', - USD: '$', - GBP: '£', - SEK: 'kr', - CAD: '$', - NOK: 'kr', - DKK: 'kr', - AUD: '$', - NZD: '$', - CHF: 'Fr', - SGD: '$', - INR: '₹', - BRL: 'R$', - MXN: '$', - COP: '$', - CLP: '$', - PEN: 'S/', -} - -function formatPriceDefault(priceInCents, currency) { - if (!currency) { - currency = 'USD' - } else if (currency === 'CLP') { - // CLP doesn't have minor units, recurly stores the whole major unit without cents - return priceInCents.toLocaleString('es-CL', { - style: 'currency', - currency, - minimumFractionDigits: 0, - }) - } - let string = String(Math.round(priceInCents)) - if (string.length === 2) { - string = `0${string}` - } - if (string.length === 1) { - string = `00${string}` - } - if (string.length === 0) { - string = '000' - } - const cents = string.slice(-2) - const dollars = string.slice(0, -2) - const symbol = currencySymbols[currency] - return `${symbol}${dollars}.${cents}` -} +const { formatCurrency } = require('../../util/currency') /** * @param {number} priceInCents - price in the smallest currency unit (e.g. dollar cents, CLP units, ...) @@ -65,7 +14,7 @@ function formatPriceLocalized(priceInCents, currency = 'USD', locale) { ? priceInCents : priceInCents / 100 - return formatCurrencyLocalized(priceInCurrencyUnit, currency, locale) + return formatCurrency(priceInCurrencyUnit, currency, locale) } function formatDateTime(date) { @@ -83,7 +32,6 @@ function formatDate(date) { } module.exports = { - formatPriceDefault, formatPriceLocalized, formatDateTime, formatDate, diff --git a/services/web/app/src/Features/Subscription/SubscriptionHelper.js b/services/web/app/src/Features/Subscription/SubscriptionHelper.js index 23220185df..1ee6ebcefa 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHelper.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHelper.js @@ -1,3 +1,4 @@ +const { formatCurrency } = require('../../util/currency') const GroupPlansData = require('./GroupPlansData') /** @@ -9,20 +10,15 @@ function shouldPlanChangeAtTermEnd(oldPlan, newPlan) { } /** - * @import { CurrencyCode } from '../../../../types/currency-code' + * @import { CurrencyCode } from '../../../../types/subscription/currency' */ /** * @param {CurrencyCode} recommendedCurrency * @param {string} locale - * @param {(amount: number, currency: CurrencyCode, locale: string, stripIfInteger: boolean) => string} formatCurrency * @returns {{ price: { collaborator: string, professional: string }, pricePerUser: { collaborator: string, professional: string } }} - localized group price */ -function generateInitialLocalizedGroupPrice( - recommendedCurrency, - locale, - formatCurrency -) { +function generateInitialLocalizedGroupPrice(recommendedCurrency, locale) { const INITIAL_LICENSE_SIZE = 2 // the price is in cents, so divide by 100 to get the value @@ -56,106 +52,7 @@ function generateInitialLocalizedGroupPrice( } } -const currencies = { - USD: { - symbol: '$', - placement: 'before', - }, - EUR: { - symbol: '€', - placement: 'before', - }, - GBP: { - symbol: '£', - placement: 'before', - }, - SEK: { - symbol: ' kr', - placement: 'after', - }, - CAD: { - symbol: '$', - placement: 'before', - }, - NOK: { - symbol: ' kr', - placement: 'after', - }, - DKK: { - symbol: ' kr', - placement: 'after', - }, - AUD: { - symbol: '$', - placement: 'before', - }, - NZD: { - symbol: '$', - placement: 'before', - }, - CHF: { - symbol: 'Fr ', - placement: 'before', - }, - SGD: { - symbol: '$', - placement: 'before', - }, - INR: { - symbol: '₹', - placement: 'before', - }, - BRL: { - code: 'BRL', - locale: 'pt-BR', - symbol: 'R$ ', - placement: 'before', - }, - MXN: { - code: 'MXN', - locale: 'es-MX', - symbol: '$ ', - placement: 'before', - }, - COP: { - code: 'COP', - locale: 'es-CO', - symbol: '$ ', - placement: 'before', - }, - CLP: { - code: 'CLP', - locale: 'es-CL', - symbol: '$ ', - placement: 'before', - }, - PEN: { - code: 'PEN', - locale: 'es-PE', - symbol: 'S/ ', - placement: 'before', - }, -} - -function formatCurrencyDefault(amount, recommendedCurrency) { - const currency = currencies[recommendedCurrency] - - // Test using toLocaleString to format currencies for new LATAM regions - if (currency.locale && currency.code) { - return amount.toLocaleString(currency.locale, { - style: 'currency', - currency: currency.code, - minimumFractionDigits: 0, - }) - } - - return currency.placement === 'before' - ? `${currency.symbol}${amount}` - : `${amount}${currency.symbol}` -} - module.exports = { - formatCurrencyDefault, shouldPlanChangeAtTermEnd, generateInitialLocalizedGroupPrice, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js index c3928d36b2..c4b5dcaf29 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js +++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js @@ -69,11 +69,7 @@ async function getRedirectToHostedPage(userId, pageType) { ].join('') } -async function buildUsersSubscriptionViewModel( - user, - locale = 'en', - formatPrice = SubscriptionFormatters.formatPriceDefault -) { +async function buildUsersSubscriptionViewModel(user, locale = 'en') { let { personalSubscription, memberGroupSubscriptions, @@ -313,19 +309,21 @@ async function buildUsersSubscriptionViewModel( const pendingSubscriptionTax = personalSubscription.recurly.taxRate * recurlySubscription.pending_subscription.unit_amount_in_cents - personalSubscription.recurly.displayPrice = formatPrice( - recurlySubscription.pending_subscription.unit_amount_in_cents + - pendingAddOnPrice + - pendingAddOnTax + - pendingSubscriptionTax, - recurlySubscription.currency, - locale - ) - personalSubscription.recurly.currentPlanDisplayPrice = formatPrice( - recurlySubscription.unit_amount_in_cents + addOnPrice + tax, - recurlySubscription.currency, - locale - ) + personalSubscription.recurly.displayPrice = + SubscriptionFormatters.formatPriceLocalized( + recurlySubscription.pending_subscription.unit_amount_in_cents + + pendingAddOnPrice + + pendingAddOnTax + + pendingSubscriptionTax, + recurlySubscription.currency, + locale + ) + personalSubscription.recurly.currentPlanDisplayPrice = + SubscriptionFormatters.formatPriceLocalized( + recurlySubscription.unit_amount_in_cents + addOnPrice + tax, + recurlySubscription.currency, + locale + ) const pendingTotalLicenses = (pendingPlan.membersLimit || 0) + pendingAdditionalLicenses personalSubscription.recurly.pendingAdditionalLicenses = @@ -333,11 +331,12 @@ async function buildUsersSubscriptionViewModel( personalSubscription.recurly.pendingTotalLicenses = pendingTotalLicenses personalSubscription.pendingPlan = pendingPlan } else { - personalSubscription.recurly.displayPrice = formatPrice( - recurlySubscription.unit_amount_in_cents + addOnPrice + tax, - recurlySubscription.currency, - locale - ) + personalSubscription.recurly.displayPrice = + SubscriptionFormatters.formatPriceLocalized( + recurlySubscription.unit_amount_in_cents + addOnPrice + tax, + recurlySubscription.currency, + locale + ) } } diff --git a/services/web/app/src/util/currency.js b/services/web/app/src/util/currency.js index 774651cfa0..ac6667976a 100644 --- a/services/web/app/src/util/currency.js +++ b/services/web/app/src/util/currency.js @@ -3,7 +3,7 @@ */ /** - * @import { CurrencyCode } from '../../../types/currency-code' + * @import { CurrencyCode } from '../../../types/subscription/currency' */ /** @@ -13,7 +13,7 @@ * @param {boolean} stripIfInteger * @returns {string} */ -function formatCurrencyLocalized(amount, currency, locale, stripIfInteger) { +function formatCurrency(amount, currency, locale, stripIfInteger) { const options = { style: 'currency', currency } if (stripIfInteger && Number.isInteger(amount)) { options.minimumFractionDigits = 0 @@ -34,5 +34,5 @@ function formatCurrencyLocalized(amount, currency, locale, stripIfInteger) { } module.exports = { - formatCurrencyLocalized, + formatCurrency, } diff --git a/services/web/app/views/subscriptions/plans-light-design.pug b/services/web/app/views/subscriptions/plans-light-design.pug index ef569a1062..5a8cb2cf54 100644 --- a/services/web/app/views/subscriptions/plans-light-design.pug +++ b/services/web/app/views/subscriptions/plans-light-design.pug @@ -10,7 +10,6 @@ block vars block append meta meta(name="ol-recommendedCurrency" content=recommendedCurrency) meta(name="ol-groupPlans" data-type="json" content=groupPlans) - meta(name="ol-currencySymbols" data-type="json" content=groupPlanModalOptions.currencySymbols) meta(name="ol-itm_content" content=itm_content) meta(name="ol-currentView" content=currentView) meta(name="ol-countryCode" content=countryCode) diff --git a/services/web/app/views/subscriptions/plans.pug b/services/web/app/views/subscriptions/plans.pug index 4c8c59961d..66cdc1ad5f 100644 --- a/services/web/app/views/subscriptions/plans.pug +++ b/services/web/app/views/subscriptions/plans.pug @@ -6,7 +6,6 @@ block entrypointVar block append meta meta(name="ol-recommendedCurrency" content=recommendedCurrency) meta(name="ol-groupPlans" data-type="json" content=groupPlans) - meta(name="ol-currencySymbols" data-type="json" content=groupPlanModalOptions.currencySymbols) meta(name="ol-itm_content" content=itm_content) meta(name="ol-currentView" content=currentView) meta(name="ol-countryCode" content=countryCode) diff --git a/services/web/frontend/js/features/group-management/components/add-seats/cost-summary.tsx b/services/web/frontend/js/features/group-management/components/add-seats/cost-summary.tsx index 4f8247caa0..7914798c4a 100644 --- a/services/web/frontend/js/features/group-management/components/add-seats/cost-summary.tsx +++ b/services/web/frontend/js/features/group-management/components/add-seats/cost-summary.tsx @@ -1,6 +1,6 @@ import { Trans, useTranslation } from 'react-i18next' import { Card, ListGroup } from 'react-bootstrap-5' -import { formatCurrencyLocalized } from '@/shared/utils/currency' +import { formatCurrency } from '@/shared/utils/currency' import { formatTime } from '@/features/utils/format-date' import { AddOnUpdate, @@ -67,7 +67,7 @@ function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) { {t('seats')} - {formatCurrencyLocalized( + {formatCurrency( subscriptionChange.immediateCharge.subtotal, subscriptionChange.currency )} @@ -82,7 +82,7 @@ function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) { {subscriptionChange.nextInvoice.tax.rate * 100}% - {formatCurrencyLocalized( + {formatCurrency( subscriptionChange.immediateCharge.tax, subscriptionChange.currency )} @@ -94,7 +94,7 @@ function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) { > {t('total_due_today')} - {formatCurrencyLocalized( + {formatCurrency( subscriptionChange.immediateCharge.total, subscriptionChange.currency )} @@ -112,7 +112,7 @@ function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) { {t( 'after_that_well_bill_you_x_annually_on_date_unless_you_cancel', { - subtotal: formatCurrencyLocalized( + subtotal: formatCurrency( subscriptionChange.nextInvoice.total, subscriptionChange.currency ), 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 6c87023e97..c61e46ac49 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 @@ -3,7 +3,7 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Card, Row, Col } from 'react-bootstrap-5' import MaterialIcon from '@/shared/components/material-icon' -import { formatCurrencyLocalized } from '@/shared/utils/currency' +import { formatCurrency } from '@/shared/utils/currency' const LICENSE_ADD_ON = 'additional-license' @@ -30,7 +30,7 @@ function UpgradeSubscriptionPlanDetails() { - {formatCurrencyLocalized( + {formatCurrency( licenseUnitPrice, preview.currency, getMeta('ol-i18n')?.currentLangCode ?? 'en', 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 f20022b0df..4ce6557a0d 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,6 +1,6 @@ import { useTranslation } from 'react-i18next' import { Card, ListGroup } from 'react-bootstrap-5' -import { formatCurrencyLocalized } from '@/shared/utils/currency' +import { formatCurrency } from '@/shared/utils/currency' import { formatTime } from '@/features/utils/format-date' import { GroupPlanUpgrade, @@ -39,7 +39,7 @@ function UpgradeSummary({ subscriptionChange }: UpgradeSummaryProps) { {t('users')} - {formatCurrencyLocalized( + {formatCurrency( subscriptionChange.immediateCharge.subtotal, subscriptionChange.currency )} @@ -48,7 +48,7 @@ function UpgradeSummary({ subscriptionChange }: UpgradeSummaryProps) { {t('sales_tax')} - {formatCurrencyLocalized( + {formatCurrency( subscriptionChange.immediateCharge.tax, subscriptionChange.currency )} @@ -57,7 +57,7 @@ function UpgradeSummary({ subscriptionChange }: UpgradeSummaryProps) { {t('total_due_today')} - {formatCurrencyLocalized( + {formatCurrency( subscriptionChange.immediateCharge.total, subscriptionChange.currency )} @@ -73,7 +73,7 @@ function UpgradeSummary({ subscriptionChange }: UpgradeSummaryProps) {
{t('after_that_well_bill_you_x_annually_on_date_unless_you_cancel', { - subtotal: formatCurrencyLocalized( + subtotal: formatCurrency( subscriptionChange.nextInvoice.subtotal, subscriptionChange.currency ), diff --git a/services/web/frontend/js/features/plans/group-plan-modal/index.js b/services/web/frontend/js/features/plans/group-plan-modal/index.js index abf318601a..32858d295b 100644 --- a/services/web/frontend/js/features/plans/group-plan-modal/index.js +++ b/services/web/frontend/js/features/plans/group-plan-modal/index.js @@ -1,12 +1,7 @@ import getMeta from '../../../utils/meta' import { swapModal } from '../../utils/swapModal' import * as eventTracking from '../../../infrastructure/event-tracking' -import { - createLocalizedGroupPlanPrice, - formatCurrencyDefault, -} from '../utils/group-plan-pricing' -import { getSplitTestVariant } from '@/utils/splitTestUtils' -import { formatCurrencyLocalized } from '@/shared/utils/currency' +import { createLocalizedGroupPlanPrice } from '../utils/group-plan-pricing' export const GROUP_PLAN_MODAL_HASH = '#groups' @@ -27,18 +22,12 @@ export function updateGroupModalPlanPricing() { const modalEl = document.querySelector('[data-ol-group-plan-modal]') const { planCode, size, currency, usage } = getFormValues() - const localCcyVariant = getSplitTestVariant('local-ccy-format-v2') - const { localizedPrice, localizedPerUserPrice } = createLocalizedGroupPlanPrice({ plan: planCode, licenseSize: size, currency, usage, - formatCurrency: - localCcyVariant === 'enabled' - ? formatCurrencyLocalized - : formatCurrencyDefault, }) modalEl.querySelectorAll('[data-ol-group-plan-plan-code]').forEach(el => { diff --git a/services/web/frontend/js/features/plans/utils/group-plan-pricing.js b/services/web/frontend/js/features/plans/utils/group-plan-pricing.js index 53959d7ecf..060a8ea2a7 100644 --- a/services/web/frontend/js/features/plans/utils/group-plan-pricing.js +++ b/services/web/frontend/js/features/plans/utils/group-plan-pricing.js @@ -1,7 +1,8 @@ +import { formatCurrency } from '@/shared/utils/currency' import getMeta from '../../../utils/meta' /** - * @import { CurrencyCode } from '../../../../../types/currency-code' + * @import { CurrencyCode } from '../../../../../types/subscription/currency' */ // plan: 'collaborator' or 'professional' @@ -13,7 +14,6 @@ import getMeta from '../../../utils/meta' * @param {CurrencyCode} opts.currency * @param {'enterprise' | 'educational'} opts.usage * @param {string} [opts.locale] - * @param {(amount: number, currency: CurrencyCode, locale: string, includeSymbol: boolean) => string} opts.formatCurrency * @returns {{localizedPrice: string, localizedPerUserPrice: string}} */ export function createLocalizedGroupPlanPrice({ @@ -22,7 +22,6 @@ export function createLocalizedGroupPlanPrice({ currency, usage, locale = getMeta('ol-i18n').currentLangCode || 'en', - formatCurrency, }) { const groupPlans = getMeta('ol-groupPlans') const priceInCents = @@ -42,44 +41,3 @@ export function createLocalizedGroupPlanPrice({ localizedPerUserPrice: formatPrice(perUserPrice), } } - -const LOCALES = { - BRL: 'pt-BR', - MXN: 'es-MX', - COP: 'es-CO', - CLP: 'es-CL', - PEN: 'es-PE', -} - -/** - * @param {number} amount - * @param {string} currency - */ -export function formatCurrencyDefault(amount, currency) { - const currencySymbols = getMeta('ol-currencySymbols') - - const currencySymbol = currencySymbols[currency] - - switch (currency) { - case 'BRL': - case 'MXN': - case 'COP': - case 'CLP': - case 'PEN': - // Test using toLocaleString to format currencies for new LATAM regions - return amount.toLocaleString(LOCALES[currency], { - style: 'currency', - currency, - minimumFractionDigits: Number.isInteger(amount) ? 0 : null, - }) - case 'CHF': - return `${currencySymbol} ${amount}` - case 'DKK': - case 'SEK': - case 'NOK': - return `${amount} ${currencySymbol}` - default: { - return `${currencySymbol}${amount}` - } - } -} 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 04d8de4d74..404a733adf 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 @@ -8,7 +8,7 @@ import { PremiumSubscriptionChange, } from '../../../../../../types/subscription/subscription-change-preview' import getMeta from '@/utils/meta' -import { formatCurrencyLocalized } from '@/shared/utils/currency' +import { formatCurrency } from '@/shared/utils/currency' import useAsync from '@/shared/hooks/use-async' import { useLocation } from '@/shared/hooks/use-location' import { debugConsole } from '@/utils/debugging' @@ -101,7 +101,7 @@ function PreviewSubscriptionChange() { {changeName} - {formatCurrencyLocalized( + {formatCurrency( preview.immediateCharge.subtotal, preview.currency )} @@ -115,7 +115,7 @@ function PreviewSubscriptionChange() { {t('vat')} {preview.nextInvoice.tax.rate * 100}% - {formatCurrencyLocalized( + {formatCurrency( preview.immediateCharge.tax, preview.currency )} @@ -127,7 +127,7 @@ function PreviewSubscriptionChange() { {t('total_today')} - {formatCurrencyLocalized( + {formatCurrency( preview.immediateCharge.total, preview.currency )} @@ -169,7 +169,7 @@ function PreviewSubscriptionChange() { {preview.nextInvoice.plan.name} - {formatCurrencyLocalized( + {formatCurrency( preview.nextInvoice.plan.amount, preview.currency )} @@ -183,7 +183,7 @@ function PreviewSubscriptionChange() { {addOn.quantity > 1 ? ` ×${addOn.quantity}` : ''} - {formatCurrencyLocalized(addOn.amount, preview.currency)} + {formatCurrency(addOn.amount, preview.currency)} ))} @@ -194,7 +194,7 @@ function PreviewSubscriptionChange() { {t('vat')} {preview.nextInvoice.tax.rate * 100}% - {formatCurrencyLocalized( + {formatCurrency( preview.nextInvoice.tax.amount, preview.currency )} @@ -209,10 +209,7 @@ function PreviewSubscriptionChange() { : t('total_per_month')} - {formatCurrencyLocalized( - preview.nextInvoice.total, - preview.currency - )} + {formatCurrency(preview.nextInvoice.total, preview.currency)}
diff --git a/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx b/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx index 766054f2c5..6c17f9ae8c 100644 --- a/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx +++ b/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx @@ -21,15 +21,13 @@ import { import { Institution } from '../../../../../types/institution' import getMeta from '../../../utils/meta' import { - formatCurrencyDefault, loadDisplayPriceWithTaxPromise, loadGroupDisplayPriceWithTaxPromise, } from '../util/recurly-pricing' import { isRecurlyLoaded } from '../util/is-recurly-loaded' import { SubscriptionDashModalIds } from '../../../../../types/subscription/dashboard/modal-ids' import { debugConsole } from '@/utils/debugging' -import { useFeatureFlag } from '@/shared/context/split-test-context' -import { formatCurrencyLocalized } from '@/shared/utils/currency' +import { formatCurrency } from '@/shared/utils/currency' import { ManagedInstitution } from '../../../../../types/subscription/dashboard/managed-institution' import { Publisher } from '../../../../../types/subscription/dashboard/publisher' @@ -141,10 +139,6 @@ export function SubscriptionDashboardProvider({ memberGroupSubscriptions?.length > 0 ) - const formatCurrency = useFeatureFlag('local-ccy-format-v2') - ? formatCurrencyLocalized - : formatCurrencyDefault - useEffect(() => { if (!isRecurlyLoaded()) { setRecurlyLoadError(true) @@ -167,8 +161,7 @@ export function SubscriptionDashboardProvider({ plan.planCode, currency, taxRate, - i18n.language, - formatCurrency + i18n.language ) if (priceData?.totalAsNumber !== undefined) { plan.displayPrice = formatCurrency( @@ -186,12 +179,7 @@ export function SubscriptionDashboardProvider({ } fetchPlansDisplayPrices().catch(debugConsole.error) } - }, [ - personalSubscription, - plansWithoutDisplayPrice, - i18n.language, - formatCurrency, - ]) + }, [personalSubscription, plansWithoutDisplayPrice, i18n.language]) useEffect(() => { if ( @@ -214,8 +202,7 @@ export function SubscriptionDashboardProvider({ taxRate, groupPlanToChangeToSize, groupPlanToChangeToUsage, - i18n.language, - formatCurrency + i18n.language ) } catch (e) { debugConsole.error(e) @@ -231,7 +218,6 @@ export function SubscriptionDashboardProvider({ groupPlanToChangeToSize, personalSubscription, groupPlanToChangeToCode, - formatCurrency, i18n.language, ]) diff --git a/services/web/frontend/js/features/subscription/util/recurly-pricing.ts b/services/web/frontend/js/features/subscription/util/recurly-pricing.ts index e3e8c7b616..584174da1a 100644 --- a/services/web/frontend/js/features/subscription/util/recurly-pricing.ts +++ b/services/web/frontend/js/features/subscription/util/recurly-pricing.ts @@ -1,11 +1,9 @@ import { SubscriptionPricingState } from '@recurly/recurly-js' import { PriceForDisplayData } from '../../../../../types/subscription/plan' -import { - currencies, - CurrencyCode, -} from '../../../../../types/subscription/currency' +import { CurrencyCode } from '../../../../../types/subscription/currency' import { getRecurlyGroupPlanCode } from './recurly-group-plan-code' import { debugConsole } from '@/utils/debugging' +import { formatCurrency } from '@/shared/utils/currency' function queryRecurlyPlanPrice(planCode: string, currency: CurrencyCode) { return new Promise(resolve => { @@ -23,31 +21,11 @@ function queryRecurlyPlanPrice(planCode: string, currency: CurrencyCode) { }) } -type FormatCurrency = ( - price: number, - currency: CurrencyCode, - locale: string, - stripIfInteger?: boolean -) => string - -export const formatCurrencyDefault: FormatCurrency = ( - price: number, - currency: CurrencyCode, - _locale: string, - stripIfInteger = false -) => { - const currencySymbol = currencies[currency] - const number = - stripIfInteger && price % 1 === 0 ? Number(price) : price.toFixed(2) - return `${currencySymbol}${number}` -} - export function formatPriceForDisplayData( price: string, taxRate: number, currencyCode: CurrencyCode, - locale: string, - formatCurrency: FormatCurrency + locale: string ): PriceForDisplayData { const totalPriceExTax = parseFloat(price) let taxAmount = totalPriceExTax * taxRate @@ -69,8 +47,7 @@ function getPerUserDisplayPrice( totalPrice: number, currency: CurrencyCode, size: string, - locale: string, - formatCurrency: FormatCurrency + locale: string ): string { return formatCurrency(totalPrice / parseInt(size), currency, locale, true) } @@ -79,8 +56,7 @@ export async function loadDisplayPriceWithTaxPromise( planCode: string, currencyCode: CurrencyCode, taxRate: number, - locale: string, - formatCurrency: FormatCurrency + locale: string ) { if (!recurly) return @@ -93,8 +69,7 @@ export async function loadDisplayPriceWithTaxPromise( price.next.total, taxRate, currencyCode, - locale, - formatCurrency + locale ) } @@ -104,8 +79,7 @@ export async function loadGroupDisplayPriceWithTaxPromise( taxRate: number, size: string, usage: string, - locale: string, - formatCurrency: FormatCurrency + locale: string ) { if (!recurly) return @@ -114,8 +88,7 @@ export async function loadGroupDisplayPriceWithTaxPromise( planCode, currencyCode, taxRate, - locale, - formatCurrency + locale ) if (price) { @@ -123,8 +96,7 @@ export async function loadGroupDisplayPriceWithTaxPromise( price.totalAsNumber, currencyCode, size, - locale, - formatCurrency + locale ) } diff --git a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-group-plan.js b/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-group-plan.js index 8204f66eb1..5616d40fa8 100644 --- a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-group-plan.js +++ b/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-group-plan.js @@ -1,12 +1,8 @@ -import { updateGroupModalPlanPricing } from '../../../../features/plans/group-plan-modal' import '../../../../features/plans/plans-v2-group-plan-modal' -import { - createLocalizedGroupPlanPrice, - formatCurrencyDefault, -} from '../../../../features/plans/utils/group-plan-pricing' + import getMeta from '../../../../utils/meta' -import { getSplitTestVariant } from '@/utils/splitTestUtils' -import { formatCurrencyLocalized } from '@/shared/utils/currency' +import { updateGroupModalPlanPricing } from '../../../../features/plans/group-plan-modal' +import { createLocalizedGroupPlanPrice } from '../../../../features/plans/utils/group-plan-pricing' const MINIMUM_LICENSE_SIZE_EDUCATIONAL_DISCOUNT = 10 @@ -26,11 +22,6 @@ export function updateMainGroupPlanPricing() { ? 'educational' : 'enterprise' - const localCcyVariant = getSplitTestVariant('local-ccy-format-v2') - const formatCurrency = - localCcyVariant === 'enabled' - ? formatCurrencyLocalized - : formatCurrencyDefault const { localizedPrice: localizedPriceProfessional, localizedPerUserPrice: localizedPerUserPriceProfessional, @@ -39,7 +30,6 @@ export function updateMainGroupPlanPricing() { licenseSize, currency, usage, - formatCurrency, }) const { @@ -50,7 +40,6 @@ export function updateMainGroupPlanPricing() { licenseSize, currency, usage, - formatCurrency, }) document.querySelector( diff --git a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-tracking.ts b/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-tracking.ts index 0377be631d..3f10f033ca 100644 --- a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-tracking.ts +++ b/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-tracking.ts @@ -13,10 +13,6 @@ export function sendPlansViewEvent() { 'group-tab-improvements' ) - const websiteRedesignPlansTestVariant = getMeta( - 'ol-websiteRedesignPlansVariant' - ) - const periodToggleTestVariant = getSplitTestVariant( 'period-toggle-improvements' ) @@ -32,7 +28,6 @@ export function sendPlansViewEvent() { currency, countryCode, device, - 'website-redesign-plans': websiteRedesignPlansTestVariant, 'group-tab-improvements': groupTabImprovementsVariant, plan: planTabParam, 'period-toggle-improvements': periodToggleTestVariant, diff --git a/services/web/frontend/js/shared/utils/currency.ts b/services/web/frontend/js/shared/utils/currency.ts index 4fe3fef8b6..5a51150d56 100644 --- a/services/web/frontend/js/shared/utils/currency.ts +++ b/services/web/frontend/js/shared/utils/currency.ts @@ -2,7 +2,7 @@ import getMeta from '@/utils/meta' const DEFAULT_LOCALE = getMeta('ol-i18n')?.currentLangCode ?? 'en' -export function formatCurrencyLocalized( +export function formatCurrency( amount: number, currency: string, locale: string = DEFAULT_LOCALE, diff --git a/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx index 1a9eccd210..4d7afd5742 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx @@ -344,7 +344,7 @@ describe('', function () { within(modal).getByText('Customize your group subscription') within(modal).getByText('Save 30% or more') - within(modal).getByText('$1290 per year') + within(modal).getByText('$1,290 per year') expect(within(modal).getAllByText('$129 per user').length).to.equal(2) within(modal).getByText('Each user will have access to:') @@ -432,9 +432,9 @@ describe('', function () { within(modal).getByText('Total:', { exact: false }) expect( - within(modal).getAllByText('€1438.40', { exact: false }).length + within(modal).getAllByText('€1,438.40', { exact: false }).length ).to.equal(3) - within(modal).getByText('(€1160.00 + €278.40 tax) per year', { + within(modal).getByText('(€1,160.00 + €278.40 tax) per year', { exact: false, }) }) @@ -444,7 +444,7 @@ describe('', function () { await openModal() - within(modal).getByText('$1290 per year') + within(modal).getByText('$1,290 per year') within(modal).getAllByText('$129 per user') // plan type (pro collab) @@ -468,7 +468,7 @@ describe('', function () { ) as HTMLInputElement expect(professionalPlanRadioInput.checked).to.be.true - await within(modal).findByText('$2590 per year') + await within(modal).findByText('$2,590 per year') await within(modal).findAllByText('$259 per user') // user count @@ -478,7 +478,7 @@ describe('', function () { sizeSelect = within(modal).getByRole('combobox') as HTMLInputElement expect(sizeSelect.value).to.equal('5') - await within(modal).findByText('$1395 per year') + await within(modal).findByText('$1,395 per year') await within(modal).findAllByText('$279 per user') // usage (enterprise or educational) @@ -493,12 +493,12 @@ describe('', function () { expect(educationInput.checked).to.be.true // make sure doesn't change price until back at min user to qualify - await within(modal).findByText('$1395 per year') + await within(modal).findByText('$1,395 per year') await within(modal).findAllByText('$279 per user') await userEvent.selectOptions(sizeSelect, [screen.getByText('10')]) - await within(modal).findByText('$1550 per year') + await within(modal).findByText('$1,550 per year') await within(modal).findAllByText('$155 per user') }) diff --git a/services/web/test/frontend/features/subscription/util/recurly-pricing.test.ts b/services/web/test/frontend/features/subscription/util/recurly-pricing.test.ts index d9590087e2..6804df63db 100644 --- a/services/web/test/frontend/features/subscription/util/recurly-pricing.test.ts +++ b/services/web/test/frontend/features/subscription/util/recurly-pricing.test.ts @@ -1,16 +1,9 @@ import { expect } from 'chai' import { formatPriceForDisplayData } from '../../../../../frontend/js/features/subscription/util/recurly-pricing' -import { formatCurrencyLocalized } from '@/shared/utils/currency' describe('formatPriceForDisplayData', function () { it('should handle no tax rate', function () { - const data = formatPriceForDisplayData( - '1000', - 0, - 'USD', - 'en', - formatCurrencyLocalized - ) + const data = formatPriceForDisplayData('1000', 0, 'USD', 'en') expect(data).to.deep.equal({ totalForDisplay: '$1,000', totalAsNumber: 1000, @@ -21,13 +14,7 @@ describe('formatPriceForDisplayData', function () { }) it('should handle a tax rate', function () { - const data = formatPriceForDisplayData( - '380', - 0.2, - 'EUR', - 'en', - formatCurrencyLocalized - ) + const data = formatPriceForDisplayData('380', 0.2, 'EUR', 'en') expect(data).to.deep.equal({ totalForDisplay: '€456', totalAsNumber: 456, @@ -38,13 +25,7 @@ describe('formatPriceForDisplayData', function () { }) it('should handle total with cents', function () { - const data = formatPriceForDisplayData( - '8', - 0.2, - 'EUR', - 'en', - formatCurrencyLocalized - ) + const data = formatPriceForDisplayData('8', 0.2, 'EUR', 'en') expect(data).to.deep.equal({ totalForDisplay: '€9.60', totalAsNumber: 9.6, diff --git a/services/web/test/frontend/shared/utils/group-plan-pricing.test.js b/services/web/test/frontend/shared/utils/group-plan-pricing.test.js index 949c3a7da6..d5d5f19de5 100644 --- a/services/web/test/frontend/shared/utils/group-plan-pricing.test.js +++ b/services/web/test/frontend/shared/utils/group-plan-pricing.test.js @@ -1,6 +1,5 @@ import { expect } from 'chai' import { createLocalizedGroupPlanPrice } from '../../../../frontend/js/features/plans/utils/group-plan-pricing' -import { formatCurrencyLocalized } from '@/shared/utils/currency' describe('group-plan-pricing', function () { beforeEach(function () { @@ -25,11 +24,6 @@ describe('group-plan-pricing', function () { }, }, }) - window.metaAttributesCache.set('ol-currencySymbols', { - CHF: 'Fr', - DKK: 'kr', - USD: '$', - }) window.metaAttributesCache.set('ol-i18n', { currentLangCode: 'en' }) }) @@ -41,7 +35,6 @@ describe('group-plan-pricing', function () { currency: 'CHF', licenseSize: '2', usage: 'enterprise', - formatCurrency: formatCurrencyLocalized, }) expect(localizedGroupPlanPrice).to.deep.equal({ @@ -57,7 +50,6 @@ describe('group-plan-pricing', function () { currency: 'DKK', licenseSize: '2', usage: 'enterprise', - formatCurrency: formatCurrencyLocalized, }) expect(localizedGroupPlanPrice).to.deep.equal({ @@ -73,7 +65,6 @@ describe('group-plan-pricing', function () { currency: 'USD', licenseSize: '2', usage: 'enterprise', - formatCurrency: formatCurrencyLocalized, }) expect(localizedGroupPlanPrice).to.deep.equal({ diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index 79dcc3234b..806113920d 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -176,7 +176,7 @@ describe('SubscriptionController', function () { }, '../../infrastructure/Features': this.Features, '../../util/currency': (this.currency = { - formatCurrencyLocalized: sinon.stub(), + formatCurrency: sinon.stub(), }), }, }) @@ -380,26 +380,10 @@ describe('SubscriptionController', function () { }) }) - describe('localCcyAssignment', function () { - it('uses formatCurrencyLocalized when variant is enabled', function (done) { - this.SplitTestV2Hander.promises.getAssignment - .withArgs(this.req, this.res, 'local-ccy-format-v2') - .resolves({ - variant: 'enabled', - }) + describe('formatCurrency data', function () { + it('return correct formatCurrency function', function (done) { this.res.render = (page, opts) => { - expect(opts.formatCurrency).to.equal( - this.currency.formatCurrencyLocalized - ) - done() - } - this.SubscriptionController.plansPage(this.req, this.res) - }) - it('uses formatCurrencyDefault when variant is default', function (done) { - this.res.render = (page, opts) => { - expect(opts.formatCurrency).to.equal( - this.SubscriptionHelper.formatCurrencyDefault - ) + expect(opts.formatCurrency).to.equal(this.currency.formatCurrency) done() } this.SubscriptionController.plansPage(this.req, this.res) @@ -698,26 +682,10 @@ describe('SubscriptionController', function () { }) }) - describe('localCcyAssignment', function () { - it('uses formatCurrencyLocalized when variant is enabled', function (done) { - this.SplitTestV2Hander.promises.getAssignment - .withArgs(this.req, this.res, 'local-ccy-format-v2') - .resolves({ - variant: 'enabled', - }) + describe('formatCurrency data', function () { + it('return correct formatCurrency function', function (done) { this.res.render = (page, opts) => { - expect(opts.formatCurrency).to.equal( - this.currency.formatCurrencyLocalized - ) - done() - } - this.SubscriptionController.plansPageLightDesign(this.req, this.res) - }) - it('uses formatCurrencyDefault when variant is default', function (done) { - this.res.render = (page, opts) => { - expect(opts.formatCurrency).to.equal( - this.SubscriptionHelper.formatCurrencyDefault - ) + expect(opts.formatCurrency).to.equal(this.currency.formatCurrency) done() } this.SubscriptionController.plansPageLightDesign(this.req, this.res) diff --git a/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js b/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js index 7ec420543c..69dcb20ea1 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js @@ -1,6 +1,5 @@ const SandboxedModule = require('sandboxed-module') const { expect } = require('chai') -const { formatCurrencyLocalized } = require('../../../../app/src/util/currency') const modulePath = '../../../../app/src/Features/Subscription/SubscriptionHelper' @@ -34,15 +33,7 @@ describe('SubscriptionHelper', function () { beforeEach(function () { this.INITIAL_LICENSE_SIZE = 2 this.settings = { - groupPlanModalOptions: { - currencySymbols: { - USD: '$', - CHF: 'Fr', - DKK: 'kr', - NOK: 'kr', - SEK: 'kr', - }, - }, + groupPlanModalOptions: {}, } this.GroupPlansData = { enterprise: { @@ -154,8 +145,7 @@ describe('SubscriptionHelper', function () { const localizedPrice = this.SubscriptionHelper.generateInitialLocalizedGroupPrice( 'CHF', - 'fr', - formatCurrencyLocalized + 'fr' ) expect(localizedPrice).to.deep.equal({ @@ -176,8 +166,7 @@ describe('SubscriptionHelper', function () { const localizedPrice = this.SubscriptionHelper.generateInitialLocalizedGroupPrice( 'DKK', - 'da', - formatCurrencyLocalized + 'da' ) expect(localizedPrice).to.deep.equal({ @@ -198,8 +187,7 @@ describe('SubscriptionHelper', function () { const localizedPrice = this.SubscriptionHelper.generateInitialLocalizedGroupPrice( 'SEK', - 'sv', - formatCurrencyLocalized + 'sv' ) expect(localizedPrice).to.deep.equal({ @@ -222,8 +210,7 @@ describe('SubscriptionHelper', function () { 'NOK', // there seem to be possible inconsistencies with the CI // maybe it depends on what languages are installed on the server? - 'en', - formatCurrencyLocalized + 'en' ) expect(localizedPrice).to.deep.equal({ @@ -244,8 +231,7 @@ describe('SubscriptionHelper', function () { const localizedPrice = this.SubscriptionHelper.generateInitialLocalizedGroupPrice( 'USD', - 'en', - formatCurrencyLocalized + 'en' ) expect(localizedPrice).to.deep.equal({ diff --git a/services/web/types/currency-code.ts b/services/web/types/currency-code.ts deleted file mode 100644 index a9963f5fe5..0000000000 --- a/services/web/types/currency-code.ts +++ /dev/null @@ -1 +0,0 @@ -export type { CurrencyCode } from './subscription/currency' diff --git a/services/web/types/subscription/currency.ts b/services/web/types/subscription/currency.ts index 4a844e3869..8d6b88dc0b 100644 --- a/services/web/types/subscription/currency.ts +++ b/services/web/types/subscription/currency.ts @@ -20,4 +20,3 @@ export const currencies = { type Currency = typeof currencies export type CurrencyCode = keyof Currency -export type CurrencySymbol = Currency[CurrencyCode] diff --git a/services/web/types/subscription/payment-context-value.tsx b/services/web/types/subscription/payment-context-value.tsx index a305095dbb..07472efb28 100644 --- a/services/web/types/subscription/payment-context-value.tsx +++ b/services/web/types/subscription/payment-context-value.tsx @@ -2,7 +2,7 @@ import countries from '@/features/subscription/data/countries' import { Plan } from './plan' import { SubscriptionPricingStateTax } from 'recurly__recurly-js' import { SubscriptionPricingInstanceCustom } from '../recurly/pricing/subscription' -import { currencies, CurrencyCode, CurrencySymbol } from './currency' +import { currencies, CurrencyCode } from './currency' export type PricingFormState = { first_name: string @@ -23,7 +23,6 @@ export type PaymentContextValue = { setCurrencyCode: React.Dispatch< React.SetStateAction > - currencySymbol: CurrencySymbol limitedCurrencies: Partial pricingFormState: PricingFormState setPricingFormState: React.Dispatch< From a69036e0050501e8239da5a6896572a469844b13 Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Tue, 14 Jan 2025 07:48:57 -0500 Subject: [PATCH 0022/1724] Merge pull request #22825 from overleaf/em-move-project-snapshot Move full project on client code to the main web tree GitOrigin-RevId: a2afd0d7fceaef213841e662df0b20587e9fef69 --- .../src/Features/History/HistoryRouter.mjs | 166 ++++++++++++++++ services/web/app/src/router.mjs | 122 +----------- .../js/infrastructure/project-snapshot.ts | 124 ++++++++++++ .../infrastructure/project-snapshot.test.ts | 187 ++++++++++++++++++ 4 files changed, 482 insertions(+), 117 deletions(-) create mode 100644 services/web/app/src/Features/History/HistoryRouter.mjs create mode 100644 services/web/frontend/js/infrastructure/project-snapshot.ts create mode 100644 services/web/test/frontend/infrastructure/project-snapshot.test.ts diff --git a/services/web/app/src/Features/History/HistoryRouter.mjs b/services/web/app/src/Features/History/HistoryRouter.mjs new file mode 100644 index 0000000000..3e532a2cad --- /dev/null +++ b/services/web/app/src/Features/History/HistoryRouter.mjs @@ -0,0 +1,166 @@ +// @ts-check + +import Settings from '@overleaf/settings' +import { Joi, validate } from '../../infrastructure/Validation.js' +import { RateLimiter } from '../../infrastructure/RateLimiter.js' +import AuthenticationController from '../Authentication/AuthenticationController.js' +import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.js' +import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js' +import HistoryController from './HistoryController.js' + +const rateLimiters = { + downloadProjectRevision: new RateLimiter('download-project-revision', { + points: 30, + duration: 60 * 60, + }), + getProjectBlob: new RateLimiter('get-project-blob', { + // Download project in full once per hour + points: Settings.maxEntitiesPerProject, + duration: 60 * 60, + }), + flushHistory: new RateLimiter('flush-project-history', { + points: 30, + duration: 60, + }), +} + +function apply(webRouter, privateApiRouter) { + // Blobs + + webRouter.head( + '/project/:project_id/blob/:hash', + validate({ + params: Joi.object({ + project_id: Joi.objectId().required(), + hash: Joi.string().required().hex().length(40), + }), + query: Joi.object({ + fallback: Joi.objectId().optional(), + }), + }), + RateLimiterMiddleware.rateLimit(rateLimiters.getProjectBlob), + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.headBlob + ) + webRouter.get( + '/project/:project_id/blob/:hash', + validate({ + params: Joi.object({ + project_id: Joi.objectId().required(), + hash: Joi.string().required().hex().length(40), + }), + query: Joi.object({ + fallback: Joi.objectId().optional(), + }), + }), + RateLimiterMiddleware.rateLimit(rateLimiters.getProjectBlob), + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.getBlob + ) + + // History diffs + + webRouter.get( + '/project/:Project_id/updates', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.proxyToHistoryApiAndInjectUserDetails + ) + webRouter.get( + '/project/:Project_id/doc/:doc_id/diff', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.proxyToHistoryApi + ) + webRouter.get( + '/project/:Project_id/diff', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.proxyToHistoryApiAndInjectUserDetails + ) + webRouter.get( + '/project/:Project_id/filetree/diff', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.proxyToHistoryApi + ) + + // File and project restore + + webRouter.post( + '/project/:project_id/restore_file', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + HistoryController.restoreFileFromV2 + ) + webRouter.post( + '/project/:project_id/revert_file', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + HistoryController.revertFile + ) + webRouter.post( + '/project/:project_id/revert-project', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + HistoryController.revertProject + ) + + // History download + + webRouter.get( + '/project/:project_id/version/:version/zip', + RateLimiterMiddleware.rateLimit(rateLimiters.downloadProjectRevision), + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.downloadZipOfVersion + ) + + // History flush and resync + + webRouter.post( + '/project/:Project_id/flush', + RateLimiterMiddleware.rateLimit(rateLimiters.flushHistory), + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.proxyToHistoryApi + ) + privateApiRouter.post( + '/project/:Project_id/history/resync', + AuthenticationController.requirePrivateApiAuth(), + HistoryController.resyncProjectHistory + ) + + // History labels + + webRouter.get( + '/project/:Project_id/labels', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.getLabels + ) + webRouter.post( + '/project/:Project_id/labels', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + HistoryController.createLabel + ) + webRouter.delete( + '/project/:Project_id/labels/:label_id', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + HistoryController.deleteLabel + ) + + // History snapshot + + webRouter.get( + '/project/:project_id/latest/history', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.proxyToHistoryApi + ) + webRouter.get( + '/project/:project_id/changes', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.proxyToHistoryApi + ) +} + +export default { apply } diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 2ec36f55ee..da3ac2958b 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -32,6 +32,7 @@ import ProjectDownloadsController from './Features/Downloads/ProjectDownloadsCon import FileStoreController from './Features/FileStore/FileStoreController.mjs' import DocumentUpdaterController from './Features/DocumentUpdater/DocumentUpdaterController.mjs' import HistoryController from './Features/History/HistoryController.js' +import HistoryRouter from './Features/History/HistoryRouter.mjs' import ExportsController from './Features/Exports/ExportsController.mjs' import PasswordResetRouter from './Features/PasswordReset/PasswordResetRouter.mjs' import StaticPagesRouter from './Features/StaticPages/StaticPagesRouter.mjs' @@ -120,24 +121,6 @@ const rateLimiters = { points: 10, duration: 60, }), - downloadProjectRevision: new RateLimiter('download-project-revision', { - points: 30, - duration: 60 * 60, - }), - flushHistory: new RateLimiter('flush-project-history', { - // Allow flushing once every 30s-1s (allow for network jitter). - points: 1, - duration: 30 - 1, - }), - getProjectBlob: new RateLimiter('get-project-blob', { - // Download project in full once per hour - points: Settings.maxEntitiesPerProject, - duration: 60 * 60, - }), - getHistorySnapshot: new RateLimiter( - 'get-history-snapshot', - openProjectRateLimiter.getOptions() - ), endorseEmail: new RateLimiter('endorse-email', { points: 30, duration: 60, @@ -552,36 +535,10 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { HistoryController.fileToBlobRedirectMiddleware, FileStoreController.getFile ) - webRouter.head( - '/project/:project_id/blob/:hash', - validate({ - params: Joi.object({ - project_id: Joi.objectId().required(), - hash: Joi.string().required().hex().length(40), - }), - query: Joi.object({ - fallback: Joi.objectId().optional(), - }), - }), - RateLimiterMiddleware.rateLimit(rateLimiters.getProjectBlob), - AuthorizationMiddleware.ensureUserCanReadProject, - HistoryController.headBlob - ) - webRouter.get( - '/project/:project_id/blob/:hash', - validate({ - params: Joi.object({ - project_id: Joi.objectId().required(), - hash: Joi.string().required().hex().length(40), - }), - query: Joi.object({ - fallback: Joi.objectId().optional(), - }), - }), - RateLimiterMiddleware.rateLimit(rateLimiters.getProjectBlob), - AuthorizationMiddleware.ensureUserCanReadProject, - HistoryController.getBlob - ) + + // Has to be applied after any route using fileToBlobRedirectMiddleware + HistoryRouter.apply(webRouter, privateApiRouter) + webRouter.get( '/Project/:Project_id/doc/:Doc_id/download', // "download" suffix to avoid conflict with private API route at doc/:doc_id AuthorizationMiddleware.ensureUserCanReadProject, @@ -801,75 +758,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { AuthorizationMiddleware.ensureUserCanAdminProject, ProjectController.renameProject ) - webRouter.get( - '/project/:Project_id/updates', - AuthorizationMiddleware.blockRestrictedUserFromProject, - AuthorizationMiddleware.ensureUserCanReadProject, - HistoryController.proxyToHistoryApiAndInjectUserDetails - ) - webRouter.get( - '/project/:Project_id/doc/:doc_id/diff', - AuthorizationMiddleware.blockRestrictedUserFromProject, - AuthorizationMiddleware.ensureUserCanReadProject, - HistoryController.proxyToHistoryApi - ) - webRouter.get( - '/project/:Project_id/diff', - AuthorizationMiddleware.blockRestrictedUserFromProject, - AuthorizationMiddleware.ensureUserCanReadProject, - HistoryController.proxyToHistoryApiAndInjectUserDetails - ) - webRouter.get( - '/project/:Project_id/filetree/diff', - AuthorizationMiddleware.blockRestrictedUserFromProject, - AuthorizationMiddleware.ensureUserCanReadProject, - HistoryController.proxyToHistoryApi - ) - webRouter.post( - '/project/:project_id/restore_file', - AuthorizationMiddleware.ensureUserCanWriteProjectContent, - HistoryController.restoreFileFromV2 - ) - webRouter.post( - '/project/:project_id/revert_file', - AuthorizationMiddleware.ensureUserCanWriteProjectContent, - HistoryController.revertFile - ) - webRouter.post( - '/project/:project_id/revert-project', - AuthorizationMiddleware.ensureUserCanWriteProjectContent, - HistoryController.revertProject - ) - webRouter.get( - '/project/:project_id/version/:version/zip', - RateLimiterMiddleware.rateLimit(rateLimiters.downloadProjectRevision), - AuthorizationMiddleware.blockRestrictedUserFromProject, - AuthorizationMiddleware.ensureUserCanReadProject, - HistoryController.downloadZipOfVersion - ) - privateApiRouter.post( - '/project/:Project_id/history/resync', - AuthenticationController.requirePrivateApiAuth(), - HistoryController.resyncProjectHistory - ) - - webRouter.get( - '/project/:Project_id/labels', - AuthorizationMiddleware.blockRestrictedUserFromProject, - AuthorizationMiddleware.ensureUserCanReadProject, - HistoryController.getLabels - ) - webRouter.post( - '/project/:Project_id/labels', - AuthorizationMiddleware.ensureUserCanWriteProjectContent, - HistoryController.createLabel - ) - webRouter.delete( - '/project/:Project_id/labels/:label_id', - AuthorizationMiddleware.ensureUserCanWriteProjectContent, - HistoryController.deleteLabel - ) - webRouter.post( '/project/:project_id/export/:brand_variation_id', AuthorizationMiddleware.ensureUserCanWriteProjectContent, diff --git a/services/web/frontend/js/infrastructure/project-snapshot.ts b/services/web/frontend/js/infrastructure/project-snapshot.ts new file mode 100644 index 0000000000..226d44923c --- /dev/null +++ b/services/web/frontend/js/infrastructure/project-snapshot.ts @@ -0,0 +1,124 @@ +import pLimit from 'p-limit' +import { Change, Chunk, Snapshot } from 'overleaf-editor-core' +import { RawChange, RawChunk } from 'overleaf-editor-core/lib/types' +import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json' + +const DOWNLOAD_BLOBS_CONCURRENCY = 10 + +/** + * Project snapshot container with on-demand refresh + */ +export class ProjectSnapshot { + private projectId: string + private snapshot: Snapshot + private version: number + private state: 'init' | 'refreshing' | 'ready' + private blobStore: SimpleBlobStore + + constructor(projectId: string) { + this.projectId = projectId + this.snapshot = new Snapshot() + this.version = 0 + this.state = 'init' + this.blobStore = new SimpleBlobStore(this.projectId) + } + + async refresh() { + if (this.state === 'refreshing') { + // Prevent concurrent refreshes + return + } + + await flushHistory(this.projectId) + + if (this.state === 'init') { + const chunk = await fetchLatestChunk(this.projectId) + this.snapshot = chunk.getSnapshot() + this.snapshot.applyAll(chunk.getChanges()) + this.version = chunk.getEndVersion() + } else { + const changes = await fetchLatestChanges(this.projectId, this.version) + this.snapshot.applyAll(changes) + this.version += changes.length + } + + this.state = 'ready' + await this.loadDocs() + } + + getDocPaths(): string[] { + const allPaths = this.snapshot.getFilePathnames() + return allPaths.filter(path => this.snapshot.getFile(path)?.isEditable()) + } + + getDocContents(path: string): string | null { + const file = this.snapshot.getFile(path) + if (file == null) { + return null + } + return file.getContent() ?? null + } + + private async loadDocs() { + const paths = this.getDocPaths() + const limit = pLimit(DOWNLOAD_BLOBS_CONCURRENCY) + await Promise.all( + paths.map(path => + limit(async () => { + const file = this.snapshot.getFile(path) + await file?.load('eager', this.blobStore) + }) + ) + ) + } +} + +/** + * Blob store that fetches blobs from the history service + */ +class SimpleBlobStore { + private projectId: string + + constructor(projectId: string) { + this.projectId = projectId + } + + async getString(hash: string): Promise { + return await fetchBlob(this.projectId, hash) + } + + async getObject(hash: string) { + const blob = await this.getString(hash) + return JSON.parse(blob) + } +} + +async function flushHistory(projectId: string) { + await postJSON(`/project/${projectId}/flush`) +} + +async function fetchLatestChunk(projectId: string): Promise { + const response = await getJSON<{ chunk: RawChunk }>( + `/project/${projectId}/latest/history` + ) + return Chunk.fromRaw(response.chunk) +} + +async function fetchLatestChanges( + projectId: string, + version: number +): Promise { + const response = await getJSON( + `/project/${projectId}/changes?since=${version}` + ) + return response.map(Change.fromRaw).filter(change => change != null) +} + +async function fetchBlob(projectId: string, hash: string): Promise { + const url = `/project/${projectId}/blob/${hash}` + const res = await fetch(url) + if (!res.ok) { + throw new FetchError('Failed to fetch blob', url, undefined, res) + } + return await res.text() +} diff --git a/services/web/test/frontend/infrastructure/project-snapshot.test.ts b/services/web/test/frontend/infrastructure/project-snapshot.test.ts new file mode 100644 index 0000000000..5038738ed2 --- /dev/null +++ b/services/web/test/frontend/infrastructure/project-snapshot.test.ts @@ -0,0 +1,187 @@ +import { expect } from 'chai' +import fetchMock from 'fetch-mock' +import { ProjectSnapshot } from '@/infrastructure/project-snapshot' + +describe('ProjectSnapshot', function () { + let snapshot: ProjectSnapshot + const projectId = 'project-id' + + beforeEach(function () { + snapshot = new ProjectSnapshot(projectId) + }) + + describe('before initialization', function () { + describe('getDocPaths()', function () { + it('returns an empty string', function () { + expect(snapshot.getDocPaths()).to.deep.equal([]) + }) + }) + + describe('getDocContents()', function () { + it('returns null', function () { + expect(snapshot.getDocContents('main.tex')).to.be.null + }) + }) + }) + + const files = { + 'main.tex': { + contents: '\\documentclass{article}\netc.', + hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }, + 'hello.txt': { + contents: 'Hello history!', + hash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + 'goodbye.txt': { + contents: "We're done here", + hash: 'dddddddddddddddddddddddddddddddddddddddd', + }, + } + + const chunk = { + history: { + snapshot: { + files: {}, + }, + changes: [ + { + operations: [ + { + pathname: 'hello.txt', + file: { + hash: files['hello.txt'].hash, + stringLength: files['hello.txt'].contents.length, + }, + }, + { + pathname: 'main.tex', + file: { + hash: files['main.tex'].hash, + stringLength: files['main.tex'].contents.length, + }, + }, + { + pathname: 'frog.jpg', + file: { + hash: 'cccccccccccccccccccccccccccccccccccccccc', + byteLength: 97080, + }, + }, + ], + timestamp: '2025-01-01T12:00:00.000Z', + }, + ], + }, + startVersion: 0, + } + + async function initializeSnapshot() { + fetchMock.postOnce(`/project/${projectId}/flush`, 200) + fetchMock.getOnce(`/project/${projectId}/latest/history`, { chunk }) + fetchMock.getOnce( + `/project/${projectId}/blob/${files['main.tex'].hash}`, + files['main.tex'].contents + ) + fetchMock.getOnce( + `/project/${projectId}/blob/${files['hello.txt'].hash}`, + files['hello.txt'].contents + ) + await snapshot.refresh() + expect(fetchMock.done()).to.be.true + fetchMock.reset() + } + + describe('after initialization', function () { + beforeEach(initializeSnapshot) + + describe('getDocPaths()', function () { + it('returns the editable docs', function () { + expect(snapshot.getDocPaths()).to.have.members([ + 'main.tex', + 'hello.txt', + ]) + }) + }) + + describe('getDocContents()', function () { + it('returns the doc contents', function () { + expect(snapshot.getDocContents('main.tex')).to.equal( + files['main.tex'].contents + ) + }) + + it('returns null for binary files', function () { + expect(snapshot.getDocContents('frog.jpg')).to.be.null + }) + + it('returns null for inexistent files', function () { + expect(snapshot.getDocContents('does-not-exist.txt')).to.be.null + }) + }) + }) + + const changes = [ + { + operations: [ + { + pathname: 'hello.txt', + textOperation: ['Quote: ', files['hello.txt'].contents.length], + }, + { + pathname: 'goodbye.txt', + file: { + hash: files['goodbye.txt'].hash, + stringLength: files['goodbye.txt'].contents.length, + }, + }, + ], + timestamp: '2025-01-01T13:00:00.000Z', + }, + ] + + async function refreshSnapshot() { + fetchMock.postOnce(`/project/${projectId}/flush`, 200, { repeat: 2 }) + fetchMock.getOnce(`/project/${projectId}/changes?since=1`, changes) + fetchMock.getOnce( + `/project/${projectId}/blob/${files['goodbye.txt'].hash}`, + files['goodbye.txt'].contents + ) + await snapshot.refresh() + expect(fetchMock.done()).to.be.true + fetchMock.reset() + } + + describe('after refresh', function () { + beforeEach(initializeSnapshot) + beforeEach(refreshSnapshot) + + afterEach(function () { + fetchMock.reset() + }) + + describe('getDocPaths()', function () { + it('returns the editable docs', function () { + expect(snapshot.getDocPaths()).to.have.members([ + 'main.tex', + 'hello.txt', + 'goodbye.txt', + ]) + }) + }) + + describe('getDocCotents()', function () { + it('returns the up to date content', function () { + expect(snapshot.getDocContents('hello.txt')).to.equal( + `Quote: ${files['hello.txt'].contents}` + ) + }) + + it('returns contents of new files', function () { + expect(snapshot.getDocContents('goodbye.txt')).to.equal( + files['goodbye.txt'].contents + ) + }) + }) + }) +}) From c9591e250a14c4f2faccf3d4af58ec3c32abad3b Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Tue, 14 Jan 2025 07:49:41 -0500 Subject: [PATCH 0023/1724] Merge pull request #22822 from overleaf/em-ai-manually-collected Hide Error Assist from manually collected group admins GitOrigin-RevId: fd4d851d1d427b1978649129674d74cb375bc3f5 --- services/web/app/src/Features/Project/ProjectController.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 31bc4997c1..ad8bd618eb 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -672,10 +672,13 @@ const _ProjectController = { const hasNonRecurlySubscription = subscription && !subscription.recurlySubscription_id + const hasManuallyCollectedSubscription = + subscription?.collectionMethod === 'manual' const canUseErrorAssistant = user.features?.aiErrorAssistant || (splitTestAssignments['ai-add-on']?.variant === 'enabled' && - !hasNonRecurlySubscription) + !hasNonRecurlySubscription && + !hasManuallyCollectedSubscription) let featureUsage = {} From ef5a52b29d22201bce7665d3563850c096e0f80c Mon Sep 17 00:00:00 2001 From: M Fahru Date: Tue, 14 Jan 2025 08:43:35 -0700 Subject: [PATCH 0024/1724] Merge pull request #22493 from overleaf/mf-remove-website-redesign-plans-test-config [web] Remove `website-redesign-plans` test config from acceptance test and redirection tests from unit tests GitOrigin-RevId: f1b30231be06748726ec2921fe23deadf2a701b6 --- .../SubscriptionControllerTests.js | 89 ------------------- 1 file changed, 89 deletions(-) diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index 806113920d..67a7233ae9 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -390,95 +390,6 @@ describe('SubscriptionController', function () { }) }) - describe('website-redesign-plans test', function () { - beforeEach(function () { - this.req.query = {} - }) - describe('"default" variant', function () { - // note: if test is not active, default variant is assigned - beforeEach(function () { - this.SplitTestV2Hander.promises.getAssignment - .withArgs(this.req, this.res, 'website-redesign-plans') - .resolves({ - variant: 'default', - }) - }) - - it('renders "default" variant', function (done) { - this.res.render = page => { - page.should.equal('subscriptions/plans') - expect(this.res.redirect).to.not.have.been.called - done() - } - this.SubscriptionController.plansPage(this.req, this.res) - }) - }) - - describe('"new-design" variant', function () { - beforeEach(function () { - this.SplitTestV2Hander.promises.getAssignment - .withArgs(this.req, this.res, 'website-redesign-plans') - .resolves({ - variant: 'new-design', - }) - }) - - it('redirects to "new-design" variant', function (done) { - this.res.callback = () => { - expect(this.res.redirect).to.have.been.calledWith( - 302, - '/user/subscription/plans-2' - ) - done() - } - this.SubscriptionController.plansPage(this.req, this.res) - }) - - it('passes query params when redirecting to new design variant', function (done) { - this.req.query = { currency: 'USD' } - this.res.callback = () => { - expect(this.res.redirect).to.have.been.calledWith( - 302, - '/user/subscription/plans-2?currency=USD' - ) - done() - } - this.SubscriptionController.plansPage(this.req, this.res) - }) - }) - describe('"light-design" variant', function () { - beforeEach(function () { - this.SplitTestV2Hander.promises.getAssignment - .withArgs(this.req, this.res, 'website-redesign-plans') - .resolves({ - variant: 'light-design', - }) - }) - - it('renders "light-design" variant', function (done) { - this.res.callback = () => { - expect(this.res.redirect).to.have.been.calledWith( - 302, - '/user/subscription/plans-3' - ) - done() - } - this.SubscriptionController.plansPage(this.req, this.res) - }) - - it('passes query params when redirecting to new design variant', function (done) { - this.req.query = { currency: 'USD' } - this.res.callback = () => { - expect(this.res.redirect).to.have.been.calledWith( - 302, - '/user/subscription/plans-3?currency=USD' - ) - done() - } - this.SubscriptionController.plansPage(this.req, this.res) - }) - }) - }) it('should return correct countryCode', function (done) { this.GeoIpLookup.promises.getCurrencyCode.resolves({ countryCode: 'MX', From 4150baefcbe258b3274ac24ea60b29098b8bb280 Mon Sep 17 00:00:00 2001 From: M Fahru Date: Tue, 14 Jan 2025 08:43:47 -0700 Subject: [PATCH 0025/1724] Merge pull request #22539 from overleaf/mf-move-plansbanners-helper-to-module [web] Move "plansBanners" function (along with its tests) to subscription module GitOrigin-RevId: a231ecd862e7f052ca2597eb07d6612b23f2c7d3 --- .../SubscriptionControllerTests.js | 324 ------------------ 1 file changed, 324 deletions(-) diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index 67a7233ae9..d8cfce75eb 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -272,114 +272,6 @@ describe('SubscriptionController', function () { }) }) - describe('showInrGeoBanner data', function () { - it('should return true for Indian Users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans') - opts.showInrGeoBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'IN', - }) - this.SubscriptionController.plansPage(this.req, this.res) - }) - it('should return false for US Users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans') - opts.showInrGeoBanner.should.equal(false) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'US', - }) - this.SubscriptionController.plansPage(this.req, this.res) - }) - }) - - describe('showBrlGeoBanner data', function () { - it('should return true for Brazilian users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans') - opts.showBrlGeoBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'BR', - }) - this.SubscriptionController.plansPage(this.req, this.res) - }) - it('should return false for US users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans') - opts.showBrlGeoBanner.should.equal(false) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'US', - }) - this.SubscriptionController.plansPage(this.req, this.res) - }) - }) - - describe('showLATAMBanner', function () { - it('should return true for Mexican users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans') - opts.showLATAMBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'MX', - }) - this.SubscriptionController.plansPage(this.req, this.res) - }) - it('should return true for Colombian users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans') - opts.showLATAMBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'CO', - }) - this.SubscriptionController.plansPage(this.req, this.res) - }) - it('should return true for Chilean users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans') - opts.showLATAMBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'CL', - }) - this.SubscriptionController.plansPage(this.req, this.res) - }) - it('should return true for Peruvian users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans') - opts.showLATAMBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'PE', - }) - this.SubscriptionController.plansPage(this.req, this.res) - }) - it('should return true for US users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans') - opts.showLATAMBanner.should.equal(false) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'US', - }) - this.SubscriptionController.plansPage(this.req, this.res) - }) - }) - describe('formatCurrency data', function () { it('return correct formatCurrency function', function (done) { this.res.render = (page, opts) => { @@ -485,114 +377,6 @@ describe('SubscriptionController', function () { }) }) - describe('showInrGeoBanner data', function () { - it('should return true for Indian Users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans-light-design') - opts.showInrGeoBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'IN', - }) - this.SubscriptionController.plansPageLightDesign(this.req, this.res) - }) - it('should return false for US Users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans-light-design') - opts.showInrGeoBanner.should.equal(false) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'US', - }) - this.SubscriptionController.plansPageLightDesign(this.req, this.res) - }) - }) - - describe('showBrlGeoBanner data', function () { - it('should return true for Brazilian users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans-light-design') - opts.showBrlGeoBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'BR', - }) - this.SubscriptionController.plansPageLightDesign(this.req, this.res) - }) - it('should return false for US users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans-light-design') - opts.showBrlGeoBanner.should.equal(false) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'US', - }) - this.SubscriptionController.plansPageLightDesign(this.req, this.res) - }) - }) - - describe('showLATAMBanner', function () { - it('should return true for Mexican users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans-light-design') - opts.showLATAMBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'MX', - }) - this.SubscriptionController.plansPageLightDesign(this.req, this.res) - }) - it('should return true for Colombian users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans-light-design') - opts.showLATAMBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'CO', - }) - this.SubscriptionController.plansPageLightDesign(this.req, this.res) - }) - it('should return true for Chilean users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans-light-design') - opts.showLATAMBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'CL', - }) - this.SubscriptionController.plansPageLightDesign(this.req, this.res) - }) - it('should return true for Peruvian users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans-light-design') - opts.showLATAMBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'PE', - }) - this.SubscriptionController.plansPageLightDesign(this.req, this.res) - }) - it('should return true for US users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans-light-design') - opts.showLATAMBanner.should.equal(false) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'US', - }) - this.SubscriptionController.plansPageLightDesign(this.req, this.res) - }) - }) - describe('formatCurrency data', function () { it('return correct formatCurrency function', function (done) { this.res.render = (page, opts) => { @@ -646,114 +430,6 @@ describe('SubscriptionController', function () { }) }) - describe('showInrGeoBanner data', function () { - it('should return true for Indian users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/interstitial-payment') - opts.showInrGeoBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'IN', - }) - this.SubscriptionController.interstitialPaymentPage(this.req, this.res) - }) - it('should be false for US users', function (done) { - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'US', - }) - this.res.render = (page, opts) => { - page.should.equal('subscriptions/interstitial-payment') - opts.showInrGeoBanner.should.equal(false) - done() - } - this.SubscriptionController.interstitialPaymentPage(this.req, this.res) - }) - }) - - describe('showBrlGeoBanner data', function () { - it('should return true for Brazilian users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/interstitial-payment') - opts.showBrlGeoBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'BR', - }) - this.SubscriptionController.interstitialPaymentPage(this.req, this.res) - }) - it('should return false for US users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/interstitial-payment') - opts.showBrlGeoBanner.should.equal(false) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'US', - }) - this.SubscriptionController.interstitialPaymentPage(this.req, this.res) - }) - }) - - describe('showLATAMBanner', function () { - it('should return true for Mexican users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/interstitial-payment') - opts.showLATAMBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'MX', - }) - this.SubscriptionController.interstitialPaymentPage(this.req, this.res) - }) - it('should return true for Colombian users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/interstitial-payment') - opts.showLATAMBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'CO', - }) - this.SubscriptionController.interstitialPaymentPage(this.req, this.res) - }) - it('should return true for Chilean users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/interstitial-payment') - opts.showLATAMBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'CL', - }) - this.SubscriptionController.interstitialPaymentPage(this.req, this.res) - }) - it('should return true for Peruvian users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/interstitial-payment') - opts.showLATAMBanner.should.equal(true) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'PE', - }) - this.SubscriptionController.interstitialPaymentPage(this.req, this.res) - }) - it('should return true for US users', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/interstitial-payment') - opts.showLATAMBanner.should.equal(false) - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'US', - }) - this.SubscriptionController.interstitialPaymentPage(this.req, this.res) - }) - }) - it('should return correct countryCode', function (done) { this.GeoIpLookup.promises.getCurrencyCode.resolves({ countryCode: 'MX', From 292308d546d20473958d061dddcf3d0369fd278b Mon Sep 17 00:00:00 2001 From: M Fahru Date: Tue, 14 Jan 2025 08:43:59 -0700 Subject: [PATCH 0026/1724] Merge pull request #22540 from overleaf/mf-remove-plans-page-default-variant-code [web] Remove dead backend (include pug template) code after `website-redesign-plans` test has been concluded and `new-design` variant is fully activated GitOrigin-RevId: 143129da71d43b88535f971b13e9e72c98bad798 --- .../Subscription/SubscriptionController.js | 228 ------ .../Subscription/interstitialPaymentConfig.js | 229 ------ .../src/Features/Subscription/plansConfig.js | 64 -- .../Features/Subscription/plansFeatures.js | 651 ------------------ .../interstitial-payment-light-design.pug | 75 -- .../subscriptions/interstitial-payment.pug | 72 -- .../subscriptions/plans-light-design.pug | 59 -- .../web/app/views/subscriptions/plans.pug | 57 -- .../plans/_cards_controls_tables.pug | 74 -- .../app/views/subscriptions/plans/_faq.pug | 43 -- .../subscriptions/plans/_group_plan_modal.pug | 111 --- .../app/views/subscriptions/plans/_mixins.pug | 646 ----------------- .../app/views/subscriptions/plans/_quotes.pug | 25 - .../subscriptions/plans/_university_info.pug | 16 - .../plans/_university_info_light_design.pug | 17 - .../light-redesign/_cards_controls_tables.pug | 76 -- .../light-redesign/_group_plan_modal.pug | 118 ---- .../plans/light-redesign/_mixins.pug | 403 ----------- .../_table_column_headers_row.pug | 34 - .../plans/light-redesign/_table_ctas.pug | 146 ---- .../_table_short_feature_lists.pug | 49 -- services/web/locales/en.json | 110 --- .../SubscriptionControllerTests.js | 256 ------- 23 files changed, 3559 deletions(-) delete mode 100644 services/web/app/src/Features/Subscription/interstitialPaymentConfig.js delete mode 100644 services/web/app/src/Features/Subscription/plansConfig.js delete mode 100644 services/web/app/src/Features/Subscription/plansFeatures.js delete mode 100644 services/web/app/views/subscriptions/interstitial-payment-light-design.pug delete mode 100644 services/web/app/views/subscriptions/interstitial-payment.pug delete mode 100644 services/web/app/views/subscriptions/plans-light-design.pug delete mode 100644 services/web/app/views/subscriptions/plans.pug delete mode 100644 services/web/app/views/subscriptions/plans/_cards_controls_tables.pug delete mode 100644 services/web/app/views/subscriptions/plans/_faq.pug delete mode 100644 services/web/app/views/subscriptions/plans/_group_plan_modal.pug delete mode 100644 services/web/app/views/subscriptions/plans/_mixins.pug delete mode 100644 services/web/app/views/subscriptions/plans/_quotes.pug delete mode 100644 services/web/app/views/subscriptions/plans/_university_info.pug delete mode 100644 services/web/app/views/subscriptions/plans/_university_info_light_design.pug delete mode 100644 services/web/app/views/subscriptions/plans/light-redesign/_cards_controls_tables.pug delete mode 100644 services/web/app/views/subscriptions/plans/light-redesign/_group_plan_modal.pug delete mode 100644 services/web/app/views/subscriptions/plans/light-redesign/_mixins.pug delete mode 100644 services/web/app/views/subscriptions/plans/light-redesign/_table_column_headers_row.pug delete mode 100644 services/web/app/views/subscriptions/plans/light-redesign/_table_ctas.pug delete mode 100644 services/web/app/views/subscriptions/plans/light-redesign/_table_short_feature_lists.pug diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index dbf0ed1f14..a9b505ade1 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -9,8 +9,6 @@ const Settings = require('@overleaf/settings') const logger = require('@overleaf/logger') const GeoIpLookup = require('../../infrastructure/GeoIpLookup') const FeaturesUpdater = require('./FeaturesUpdater') -const plansConfig = require('./plansConfig') -const interstitialPaymentConfig = require('./interstitialPaymentConfig') const GroupPlansData = require('./GroupPlansData') const V1SubscriptionManager = require('./V1SubscriptionManager') const AnalyticsManager = require('../Analytics/AnalyticsManager') @@ -19,13 +17,10 @@ const { expressify } = require('@overleaf/promise-utils') const OError = require('@overleaf/o-error') const { DuplicateAddOnError, AddOnNotPresentError } = require('./Errors') const SplitTestHandler = require('../SplitTests/SplitTestHandler') -const SubscriptionHelper = require('./SubscriptionHelper') const AuthorizationManager = require('../Authorization/AuthorizationManager') const Modules = require('../../infrastructure/Modules') const async = require('async') -const { formatCurrency } = require('../../util/currency') const HttpErrorHandler = require('../Errors/HttpErrorHandler') -const { URLSearchParams } = require('url') const RecurlyClient = require('./RecurlyClient') const { AI_ADD_ON_CODE } = require('./RecurlyEntities') const PlansLocator = require('./PlansLocator') @@ -38,154 +33,6 @@ const PlansLocator = require('./PlansLocator') */ const groupPlanModalOptions = Settings.groupPlanModalOptions -const validGroupPlanModalOptions = { - plan_code: groupPlanModalOptions.plan_codes.map(item => item.code), - currency: groupPlanModalOptions.currencies.map(item => item.code), - size: groupPlanModalOptions.sizes, - usage: groupPlanModalOptions.usages.map(item => item.code), -} - -function _getGroupPlanModalDefaults(req, currency) { - function getDefault(param, category, defaultValue) { - const v = req.query && req.query[param] - if (v && validGroupPlanModalOptions[category].includes(v)) { - return v - } - return defaultValue - } - - let defaultGroupPlanModalCurrency = 'USD' - if (validGroupPlanModalOptions.currency.includes(currency)) { - defaultGroupPlanModalCurrency = currency - } - - return { - plan_code: getDefault('plan', 'plan_code', 'collaborator'), - size: getDefault('number', 'size', '2'), - currency: getDefault('currency', 'currency', defaultGroupPlanModalCurrency), - usage: getDefault('usage', 'usage', 'enterprise'), - } -} - -function _plansBanners(countryCode) { - const showLATAMBanner = ['MX', 'CO', 'CL', 'PE'].includes(countryCode) - const showInrGeoBanner = countryCode === 'IN' - const showBrlGeoBanner = countryCode === 'BR' - return { showLATAMBanner, showInrGeoBanner, showBrlGeoBanner } -} - -async function plansPage(req, res) { - const websiteRedesignPlansAssignment = - await SplitTestHandler.promises.getAssignment( - req, - res, - 'website-redesign-plans' - ) - if (websiteRedesignPlansAssignment.variant !== 'default') { - const queryParamString = new URLSearchParams(req.query)?.toString() - const queryParamForRedirect = queryParamString ? '?' + queryParamString : '' - - if (websiteRedesignPlansAssignment.variant === 'new-design') { - return res.redirect( - 302, - '/user/subscription/plans-2' + queryParamForRedirect - ) - } else if (websiteRedesignPlansAssignment.variant === 'light-design') { - return res.redirect( - 302, - '/user/subscription/plans-3' + queryParamForRedirect - ) - } - } - - const language = req.i18n.language || 'en' - - const plans = SubscriptionViewModelBuilder.buildPlansList() - - const { currency, countryCode } = await _getRecommendedCurrency(req, res) - - const latamCountryBannerDetails = await getLatamCountryBannerDetails(req, res) - const groupPlanModalDefaults = _getGroupPlanModalDefaults(req, currency) - - const currentView = 'annual' - - const { showLATAMBanner, showInrGeoBanner, showBrlGeoBanner } = - _plansBanners(countryCode) - - const shouldLoadHotjar = await getShouldLoadHotjar(req, res) - - res.render('subscriptions/plans', { - title: 'plans_and_pricing', - currentView, - plans, - itm_content: req.query?.itm_content, - itm_referrer: req.query?.itm_referrer, - itm_campaign: 'plans', - language, - formatCurrency, - recommendedCurrency: currency, - plansConfig, - groupPlans: GroupPlansData, - groupPlanModalOptions, - groupPlanModalDefaults, - initialLocalizedGroupPrice: - SubscriptionHelper.generateInitialLocalizedGroupPrice( - currency ?? 'USD', - language - ), - showInrGeoBanner, - showBrlGeoBanner, - showLATAMBanner, - latamCountryBannerDetails, - countryCode, - websiteRedesignPlansVariant: 'default', - shouldLoadHotjar, - }) -} - -async function plansPageLightDesign(req, res) { - const { currency, countryCode } = await _getRecommendedCurrency(req, res) - - const language = req.i18n.language || 'en' - const currentView = 'annual' - const plans = SubscriptionViewModelBuilder.buildPlansList() - const groupPlanModalDefaults = _getGroupPlanModalDefaults(req, currency) - - const { showLATAMBanner, showInrGeoBanner, showBrlGeoBanner } = - _plansBanners(countryCode) - - const latamCountryBannerDetails = await getLatamCountryBannerDetails(req, res) - - const shouldLoadHotjar = await getShouldLoadHotjar(req, res) - - res.render('subscriptions/plans-light-design', { - title: 'plans_and_pricing', - currentView, - plans, - itm_content: req.query?.itm_content, - itm_referrer: req.query?.itm_referrer, - itm_campaign: 'plans', - language, - formatCurrency, - recommendedCurrency: currency, - plansConfig, - groupPlans: GroupPlansData, - groupPlanModalOptions, - groupPlanModalDefaults, - initialLocalizedGroupPrice: - SubscriptionHelper.generateInitialLocalizedGroupPrice( - currency ?? 'USD', - language - ), - showLATAMBanner, - showInrGeoBanner, - showBrlGeoBanner, - latamCountryBannerDetails, - countryCode, - websiteRedesignPlansVariant: 'light-design', - shouldLoadHotjar, - }) -} function formatGroupPlansDataForDash() { return { @@ -295,68 +142,6 @@ async function userSubscriptionPage(req, res) { res.render('subscriptions/dashboard-react', data) } -async function interstitialPaymentPage(req, res) { - const websiteRedesignPlansAssignment = - await SplitTestHandler.promises.getAssignment( - req, - res, - 'website-redesign-plans' - ) - - let template = 'subscriptions/interstitial-payment' - - if (websiteRedesignPlansAssignment.variant === 'new-design') { - return await Modules.promises.hooks.fire( - 'interstitialPaymentPageNewDesign', - req, - res - ) - } else if (websiteRedesignPlansAssignment.variant === 'light-design') { - template = 'subscriptions/interstitial-payment-light-design' - } - - const user = SessionManager.getSessionUser(req.session) - const { recommendedCurrency, countryCode } = await _getRecommendedCurrency( - req, - res - ) - - const latamCountryBannerDetails = await getLatamCountryBannerDetails(req, res) - - const { hasSubscription } = - await LimitationsManager.promises.userHasSubscription(user) - const showSkipLink = req.query?.skipLink === 'true' - - if (hasSubscription) { - res.redirect('/user/subscription?hasSubscription=true') - } else { - const { showLATAMBanner, showInrGeoBanner, showBrlGeoBanner } = - _plansBanners(countryCode) - - const shouldLoadHotjar = await getShouldLoadHotjar(req, res) - - res.render(template, { - title: 'subscribe', - itm_content: req.query?.itm_content, - itm_campaign: req.query?.itm_campaign, - itm_referrer: req.query?.itm_referrer, - recommendedCurrency, - interstitialPaymentConfig, - showSkipLink, - formatCurrency, - showCurrencyAndPaymentMethods: true, // TODO: remove hardcode - showInrGeoBanner, - showBrlGeoBanner, - showLATAMBanner, - latamCountryBannerDetails, - skipLinkTarget: req.session?.postCheckoutRedirect || '/project', - websiteRedesignPlansVariant: websiteRedesignPlansAssignment.variant, - countryCode, - shouldLoadHotjar, - }) - } -} - async function successfulSubscription(req, res) { const user = SessionManager.getSessionUser(req.session) @@ -828,15 +613,6 @@ async function getLatamCountryBannerDetails(req, res) { return latamCountryBannerDetails } -async function getShouldLoadHotjar(req, res) { - const assignment = await SplitTestHandler.promises.getAssignment( - req, - res, - 'hotjar-plans' - ) - return assignment?.variant === 'enabled' -} - /** * Build a subscription change preview for display purposes * @@ -886,10 +662,7 @@ function makeChangePreview( } module.exports = { - plansPage: expressify(plansPage), - plansPageLightDesign: expressify(plansPageLightDesign), userSubscriptionPage: expressify(userSubscriptionPage), - interstitialPaymentPage: expressify(interstitialPaymentPage), successfulSubscription: expressify(successfulSubscription), cancelSubscription, canceledSubscription: expressify(canceledSubscription), @@ -904,7 +677,6 @@ module.exports = { recurlyNotificationParser, refreshUserFeatures: expressify(refreshUserFeatures), redirectToHostedPage: expressify(redirectToHostedPage), - plansBanners: _plansBanners, previewAddonPurchase: expressify(previewAddonPurchase), purchaseAddon, removeAddon, diff --git a/services/web/app/src/Features/Subscription/interstitialPaymentConfig.js b/services/web/app/src/Features/Subscription/interstitialPaymentConfig.js deleted file mode 100644 index cbe138c566..0000000000 --- a/services/web/app/src/Features/Subscription/interstitialPaymentConfig.js +++ /dev/null @@ -1,229 +0,0 @@ -const config = { - tableHead: { - individual_free: {}, - individual_collaborator: {}, - individual_professional: {}, - student_student: { - showExtraContent: true, - }, - }, - highlightedColumn: { - index: 1, - text: { - monthly: 'most_popular', - annual: 'most_popular', - }, - }, - eventTrackingKey: 'paywall-plans-page-click', - showStudentsOnlyLabel: true, - features: [ - { - divider: false, - items: [ - { - feature: 'number_of_users', - info: 'number_of_users_info', - value: 'str', - plans: { - free: 'one_user', - collaborator: 'one_user', - professional: 'one_user', - student: 'one_user', - }, - }, - { - feature: 'max_collab_per_project', - info: 'max_collab_per_project_info', - value: 'str', - plans: { - free: 'you_plus_1', - collaborator: 'you_plus_10', - professional: 'unlimited_bold', - student: 'you_plus_6', - }, - }, - ], - }, - { - divider: true, - dividerLabel: 'you_and_collaborators_get_access_to', - dividerInfo: 'you_and_collaborators_get_access_to_info', - items: [ - { - feature: 'compile_timeout_short', - info: 'compile_timeout_short_info_basic', - value: 'str', - plans: { - free: 'basic', - collaborator: '12x_basic', - professional: '12x_basic', - student: '12x_basic', - }, - }, - { - feature: 'compile_servers', - info: 'compile_servers_info', - value: 'str', - plans: { - free: 'fast', - collaborator: 'fastest', - professional: 'fastest', - student: 'fastest', - }, - }, - { - feature: 'realtime_track_changes', - info: 'realtime_track_changes_info_v2', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - student: true, - }, - }, - { - feature: 'full_doc_history', - info: 'full_doc_history_info_v2', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - student: true, - }, - }, - { - feature: 'reference_search', - info: 'reference_search_info_v2', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - student: true, - }, - }, - { - feature: 'git_integration_lowercase', - info: 'git_integration_lowercase_info', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - student: true, - }, - }, - ], - }, - { - divider: true, - dividerLabel: 'you_get_access_to', - dividerInfo: 'you_get_access_to_info', - items: [ - { - feature: 'powerful_latex_editor_and_realtime_collaboration', - info: 'powerful_latex_editor_and_realtime_collaboration_info', - value: 'bool', - plans: { - free: true, - collaborator: true, - professional: true, - student: true, - }, - }, - { - feature: 'unlimited_projects', - info: 'unlimited_projects_info', - value: 'bool', - plans: { - free: true, - collaborator: true, - professional: true, - student: true, - }, - }, - { - feature: 'thousands_templates', - info: 'hundreds_templates_info', - value: 'bool', - plans: { - free: true, - collaborator: true, - professional: true, - student: true, - }, - }, - { - feature: 'symbol_palette', - info: 'symbol_palette_info', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - student: true, - }, - }, - { - feature: 'github_only_integration_lowercase', - info: 'github_only_integration_lowercase_info', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - student: true, - }, - }, - { - feature: 'dropbox_integration_lowercase', - info: 'dropbox_integration_info', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - student: true, - }, - }, - { - feature: 'mendeley_integration_lowercase', - info: 'mendeley_integration_lowercase_info', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - student: true, - }, - }, - { - feature: 'zotero_integration_lowercase', - info: 'zotero_integration_lowercase_info', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - student: true, - }, - }, - { - feature: 'priority_support', - info: 'priority_support_info', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - student: true, - }, - }, - ], - }, - ], -} - -module.exports = config diff --git a/services/web/app/src/Features/Subscription/plansConfig.js b/services/web/app/src/Features/Subscription/plansConfig.js deleted file mode 100644 index f7fbe38477..0000000000 --- a/services/web/app/src/Features/Subscription/plansConfig.js +++ /dev/null @@ -1,64 +0,0 @@ -const plansFeatures = require('./plansFeatures') - -const config = { - individual: { - maxColumn: 3, - tableHead: { - individual_free: {}, - individual_collaborator: {}, - individual_professional: {}, - }, - features: plansFeatures.individual, - highlightedColumn: { - index: 1, - text: { - monthly: 'most_popular', - annual: 'most_popular', - }, - }, - eventTrackingKey: 'plans-page-click', - additionalEventSegmentation: {}, - }, - group: { - maxColumn: 3, - tableHead: { - group_collaborator: {}, - group_professional: {}, - group_organization: {}, - }, - features: plansFeatures.group, - highlightedColumn: { - index: 0, - text: { - annual: 'most_popular', - }, - }, - eventTrackingKey: 'plans-page-click', - additionalEventSegmentation: {}, - }, - student: { - baseColspan: 1, - maxColumn: 3, - tableHead: { - student_free: { - colspan: 1, - }, - student_student: { - showExtraContent: false, - colspan: 1, - }, - }, - features: plansFeatures.student, - highlightedColumn: { - index: 1, - text: { - monthly: 'save_20_percent_by_paying_annually', - annual: 'saving_20_percent', - }, - }, - eventTrackingKey: 'plans-page-click', - additionalEventSegmentation: {}, - }, -} - -module.exports = config diff --git a/services/web/app/src/Features/Subscription/plansFeatures.js b/services/web/app/src/Features/Subscription/plansFeatures.js deleted file mode 100644 index d89e621a9a..0000000000 --- a/services/web/app/src/Features/Subscription/plansFeatures.js +++ /dev/null @@ -1,651 +0,0 @@ -const individualPlans = [ - { - divider: false, - items: [ - { - feature: 'number_of_users', - info: 'number_of_users_info', - value: 'str', - plans: { - free: 'one_user', - collaborator: 'one_user', - professional: 'one_user', - }, - }, - { - feature: 'max_collab_per_project', - info: 'max_collab_per_project_info', - value: 'str', - plans: { - free: 'you_plus_1', - collaborator: 'you_plus_10', - professional: 'unlimited_bold', - }, - }, - ], - }, - { - divider: true, - dividerLabel: 'you_and_collaborators_get_access_to', - dividerInfo: 'you_and_collaborators_get_access_to_info', - items: [ - { - feature: 'compile_timeout_short', - info: 'compile_timeout_short_info_basic', - value: 'str', - plans: { - free: 'basic', - collaborator: '12x_basic', - professional: '12x_basic', - }, - }, - { - feature: 'compile_servers', - info: 'compile_servers_info', - value: 'str', - plans: { - free: 'fast', - collaborator: 'fastest', - professional: 'fastest', - }, - }, - { - feature: 'realtime_track_changes', - info: 'realtime_track_changes_info_v2', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - }, - }, - { - feature: 'full_doc_history', - info: 'full_doc_history_info_v2', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - }, - }, - { - feature: 'reference_search', - info: 'reference_search_info_v2', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - }, - }, - { - feature: 'git_integration_lowercase', - info: 'git_integration_lowercase_info', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - }, - }, - ], - }, - { - divider: true, - dividerLabel: 'you_get_access_to', - dividerInfo: 'you_get_access_to_info', - items: [ - { - feature: 'powerful_latex_editor_and_realtime_collaboration', - info: 'powerful_latex_editor_and_realtime_collaboration_info', - value: 'bool', - plans: { - free: true, - collaborator: true, - professional: true, - }, - }, - { - feature: 'unlimited_projects', - info: 'unlimited_projects_info', - value: 'bool', - plans: { - free: true, - collaborator: true, - professional: true, - }, - }, - { - feature: 'thousands_templates', - info: 'hundreds_templates_info', - value: 'bool', - plans: { - free: true, - collaborator: true, - professional: true, - }, - }, - { - feature: 'symbol_palette', - info: 'symbol_palette_info', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - }, - }, - { - feature: 'github_only_integration_lowercase', - info: 'github_only_integration_lowercase_info', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - }, - }, - { - feature: 'dropbox_integration_lowercase', - info: 'dropbox_integration_info', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - }, - }, - { - feature: 'mendeley_integration_lowercase', - info: 'mendeley_integration_lowercase_info', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - }, - }, - { - feature: 'zotero_integration_lowercase', - info: 'zotero_integration_lowercase_info', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - }, - }, - { - feature: 'priority_support', - info: 'priority_support_info', - value: 'bool', - plans: { - free: false, - collaborator: true, - professional: true, - }, - }, - ], - }, -] - -const groupPlans = [ - { - divider: false, - items: [ - { - feature: 'number_of_users', - info: 'number_of_users_info', - value: 'str', - plans: { - group_standard: 'two_users', - group_professional: 'two_users', - organization: 'contact_sales', - }, - }, - { - feature: 'max_collab_per_project', - info: 'max_collab_per_project_info', - value: 'str', - plans: { - group_standard: 'project_owner_plus_10', - group_professional: 'unlimited_bold', - organization: 'unlimited_bold', - }, - }, - ], - }, - { - divider: true, - dividerLabel: 'group_admins_get_access_to', - dividerInfo: 'group_admins_get_access_to_info', - items: [ - { - feature: 'user_management', - info: 'user_management_info', - value: 'str', - plans: { - group_standard: 'subscription_admin_panel', - group_professional: 'subscription_admin_panel', - organization: 'automatic_user_registration', - }, - }, - { - feature: 'usage_metrics', - info: 'usage_metrics_info', - value: 'bool', - plans: { - group_standard: true, - group_professional: true, - organization: true, - }, - }, - { - feature: 'managed_users_accounts', - info: 'managed_users_accounts_plan_info', - value: 'bool', - plans: { - group_standard: false, - group_professional: true, - organization: false, - }, - }, - { - feature: 'sso_integration', - info: 'sso_integration_info', - value: 'bool', - plans: { - group_standard: false, - group_professional: true, - organization: true, - }, - }, - { - feature: 'sitewide_option_available', - info: 'sitewide_option_available_info', - value: 'bool', - plans: { - group_standard: false, - group_professional: false, - organization: true, - }, - }, - { - feature: 'custom_resource_portal', - info: 'custom_resource_portal_info', - value: 'bool', - plans: { - group_standard: false, - group_professional: false, - organization: true, - }, - }, - { - feature: 'personalized_onboarding', - info: 'personalized_onboarding_info', - value: 'bool', - plans: { - group_standard: false, - group_professional: false, - organization: true, - }, - }, - { - feature: 'dedicated_account_manager', - info: 'dedicated_account_manager_info', - value: 'bool', - plans: { - group_standard: false, - group_professional: false, - organization: true, - }, - }, - ], - }, - { - divider: true, - dividerLabel: 'group_members_and_collaborators_get_access_to', - dividerInfo: 'group_members_and_their_collaborators_get_access_to_info', - items: [ - { - feature: 'compile_timeout_short', - info: 'compile_timeout_short_info_basic', - value: 'str', - plans: { - group_standard: 'four_minutes', - group_professional: 'four_minutes', - organization: 'four_minutes', - }, - }, - { - feature: 'compile_servers', - info: 'compile_servers_info', - value: 'str', - plans: { - group_standard: 'fastest', - group_professional: 'fastest', - organization: 'fastest', - }, - }, - { - feature: 'realtime_track_changes', - info: 'realtime_track_changes_info_v2', - value: 'bool', - plans: { - group_standard: true, - group_professional: true, - organization: true, - }, - }, - { - feature: 'full_doc_history', - info: 'full_doc_history_info_v2', - value: 'bool', - plans: { - group_standard: true, - group_professional: true, - organization: true, - }, - }, - { - feature: 'reference_search', - info: 'reference_search_info_v2', - value: 'bool', - plans: { - group_standard: true, - group_professional: true, - organization: true, - }, - }, - { - feature: 'git_integration_lowercase', - info: 'git_integration_lowercase_info', - value: 'bool', - plans: { - group_standard: true, - group_professional: true, - organization: true, - }, - }, - ], - }, - { - divider: true, - dividerLabel: 'group_members_get_access_to', - dividerInfo: 'group_members_get_access_to_info', - items: [ - { - feature: 'powerful_latex_editor_and_realtime_collaboration', - info: 'powerful_latex_editor_and_realtime_collaboration_info', - value: 'bool', - plans: { - group_standard: true, - group_professional: true, - organization: true, - }, - }, - { - feature: 'unlimited_projects', - info: 'unlimited_projects_info', - value: 'bool', - plans: { - group_standard: true, - group_professional: true, - organization: true, - }, - }, - { - feature: 'thousands_templates', - info: 'hundreds_templates_info', - value: 'bool', - plans: { - group_standard: true, - group_professional: true, - organization: true, - }, - }, - { - feature: 'symbol_palette', - info: 'symbol_palette_info', - value: 'bool', - plans: { - group_standard: true, - group_professional: true, - organization: true, - }, - }, - { - feature: 'github_only_integration_lowercase', - info: 'github_only_integration_lowercase_info', - value: 'bool', - plans: { - group_standard: true, - group_professional: true, - organization: true, - }, - }, - { - feature: 'dropbox_integration_lowercase', - info: 'dropbox_integration_info', - value: 'bool', - plans: { - group_standard: true, - group_professional: true, - organization: true, - }, - }, - { - feature: 'mendeley_integration_lowercase', - info: 'mendeley_integration_lowercase_info', - value: 'bool', - plans: { - group_standard: true, - group_professional: true, - organization: true, - }, - }, - { - feature: 'zotero_integration_lowercase', - info: 'zotero_integration_lowercase_info', - value: 'bool', - plans: { - group_standard: true, - group_professional: true, - organization: true, - }, - }, - { - feature: 'priority_support', - info: 'priority_support_info', - value: 'bool', - plans: { - group_standard: true, - group_professional: true, - organization: true, - }, - }, - ], - }, -] - -const studentPlans = [ - { - divider: false, - items: [ - { - feature: 'number_of_users', - info: 'number_of_users_info', - value: 'str', - plans: { - free: 'one_user', - student: 'one_user', - }, - }, - { - feature: 'max_collab_per_project', - info: 'max_collab_per_project_info', - value: 'str', - plans: { - free: 'you_plus_1', - student: 'you_plus_6', - }, - }, - ], - }, - { - divider: true, - dividerLabel: 'you_and_collaborators_get_access_to', - dividerInfo: 'you_and_collaborators_get_access_to_info', - items: [ - { - feature: 'compile_timeout_short', - info: 'compile_timeout_short_info_basic', - value: 'str', - plans: { - free: 'basic', - student: '12x_basic', - }, - }, - { - feature: 'compile_servers', - info: 'compile_servers_info', - value: 'str', - plans: { - free: 'fast', - student: 'fastest', - }, - }, - { - feature: 'realtime_track_changes', - info: 'realtime_track_changes_info_v2', - value: 'bool', - plans: { - free: false, - student: true, - }, - }, - { - feature: 'full_doc_history', - info: 'full_doc_history_info_v2', - value: 'bool', - plans: { - free: false, - student: true, - }, - }, - { - feature: 'reference_search', - info: 'reference_search_info_v2', - value: 'bool', - plans: { - free: false, - student: true, - }, - }, - { - feature: 'git_integration_lowercase', - info: 'git_integration_lowercase_info', - value: 'bool', - plans: { - free: false, - student: true, - }, - }, - ], - }, - { - divider: true, - dividerLabel: 'you_get_access_to', - dividerInfo: 'you_get_access_to_info', - items: [ - { - feature: 'powerful_latex_editor_and_realtime_collaboration', - info: 'powerful_latex_editor_and_realtime_collaboration_info', - value: 'bool', - plans: { - free: true, - student: true, - }, - }, - { - feature: 'unlimited_projects', - info: 'unlimited_projects_info', - value: 'bool', - plans: { - free: true, - student: true, - }, - }, - { - feature: 'thousands_templates', - info: 'thousands_templates_info', - value: 'bool', - plans: { - free: true, - student: true, - }, - }, - { - feature: 'symbol_palette', - info: 'symbol_palette_info', - value: 'bool', - plans: { - free: false, - student: true, - }, - }, - { - feature: 'github_only_integration_lowercase', - info: 'github_only_integration_lowercase_info', - value: 'bool', - plans: { - free: false, - student: true, - }, - }, - { - feature: 'dropbox_integration_lowercase', - info: 'dropbox_integration_info', - value: 'bool', - plans: { - free: false, - student: true, - }, - }, - { - feature: 'mendeley_integration_lowercase', - info: 'mendeley_integration_lowercase_info', - value: 'bool', - plans: { - free: false, - student: true, - }, - }, - { - feature: 'zotero_integration_lowercase', - info: 'zotero_integration_lowercase_info', - value: 'bool', - plans: { - free: false, - student: true, - }, - }, - { - feature: 'priority_support', - info: 'priority_support_info', - value: 'bool', - plans: { - free: false, - student: true, - }, - }, - ], - }, -] - -module.exports = { - individual: individualPlans, - group: groupPlans, - student: studentPlans, -} diff --git a/services/web/app/views/subscriptions/interstitial-payment-light-design.pug b/services/web/app/views/subscriptions/interstitial-payment-light-design.pug deleted file mode 100644 index 460e2c1c51..0000000000 --- a/services/web/app/views/subscriptions/interstitial-payment-light-design.pug +++ /dev/null @@ -1,75 +0,0 @@ -extends ../layout-website-redesign - -include ./plans/light-redesign/_mixins -include ../_mixins/eyebrow -include ../_mixins/links - -block entrypointVar - - entrypoint = 'pages/user/subscription/plans-v2/plans-v2-main' - -block vars - - var suppressFooter = true - - var suppressNavbarRight = true - - var suppressCookieBanner = true - -block append meta - meta(name="ol-recommendedCurrency" content=recommendedCurrency) - meta(name="ol-itm_content" content=itm_content) - meta(name="ol-countryCode" content=countryCode) - meta(name="ol-websiteRedesignPlansVariant" content=websiteRedesignPlansVariant) - meta(name="ol-shouldLoadHotjar" data-type="boolean" content=shouldLoadHotjar) - -block content - main.website-redesign#main-content - .plans-page.plans-page-interstitial - .container - if showInrGeoBanner - .mb-5.notification.notification-type-success.text-center - div.notification-content !{translate("inr_discount_offer_plans_page_banner", {flag: '🇮🇳'})} - if showBrlGeoBanner - .mb-5.notification.notification-type-success.text-center - div.notification-content !{translate("brl_discount_offer_plans_page_banner", {flag: '🇧🇷'})} - if showLATAMBanner - .mb-5.notification.notification-type-success.text-center - div.notification-content !{translate("latam_discount_offer_plans_page_banner", {flag: latamCountryBannerDetails.latamCountryFlag, country: latamCountryBannerDetails.country, currency: latamCountryBannerDetails.currency, discount: latamCountryBannerDetails.discount })} - - .row - .col-md-12.text-center - h1 - +eyebrow(translate('plans_and_pricing_lowercase')) - | #{translate('choose_your_plan')} - - +monthly_annual_switch("monthly", "paywall-plans-page-toggle", '{}') - - .plans-table-sticky-header-container - +plans_table_sticky_header(true, interstitialPaymentConfig) - - .row.plans-table-container(data-ol-plans-v2-period='monthly') - .col-sm-12 - .row - table.card.plans-table.plans-table-individual - +plans_table('monthly', interstitialPaymentConfig) - - .row.plans-table-container(hidden data-ol-plans-v2-period='annual') - .col-sm-12 - .row - table.card.plans-table.plans-table-individual - +plans_table('annual', interstitialPaymentConfig) - - if (showCurrencyAndPaymentMethods) - +currency_and_payment_methods() - - //- sticky header on mobile will be "hidden" (by removing its sticky position) if it reaches this div - .invisible(aria-hidden="true" data-ol-plans-v2-table-sticky-header-stop) - - if (showSkipLink) - .row.row-spaced-small.text-center - +linkWithArrow({ - text: translate("continue_with_free_plan"), - href: skipLinkTarget, - eventTracking: 'skip-button-click', - eventSegmentation: {location: 'interstitial-page'}, - eventTrackingTrigger: 'click' - }) - - != moduleIncludes("contactModalGeneral-marketing", locals) diff --git a/services/web/app/views/subscriptions/interstitial-payment.pug b/services/web/app/views/subscriptions/interstitial-payment.pug deleted file mode 100644 index f112805520..0000000000 --- a/services/web/app/views/subscriptions/interstitial-payment.pug +++ /dev/null @@ -1,72 +0,0 @@ -extends ../layout-marketing - -include ./plans/_mixins - -block entrypointVar - - entrypoint = 'pages/user/subscription/plans-v2/plans-v2-main' - -block vars - - var suppressFooter = true - - var suppressNavbarRight = true - - var suppressCookieBanner = true - -block append meta - meta(name="ol-recommendedCurrency" content=recommendedCurrency) - meta(name="ol-itm_content" content=itm_content) - meta(name="ol-countryCode" content=countryCode) - meta(name="ol-websiteRedesignPlansVariant" content=websiteRedesignPlansVariant) - meta(name="ol-shouldLoadHotjar" data-type="boolean" content=shouldLoadHotjar) - -block content - main.content.content-alt#main-content - .content-page - .plans - .container - if showInrGeoBanner - div.notification.notification-type-success.text-centered - div.notification-content !{translate("inr_discount_offer_plans_page_banner", {flag: '🇮🇳'})} - if showBrlGeoBanner - div.notification.notification-type-success.text-centered - div.notification-content !{translate("brl_discount_offer_plans_page_banner", {flag: '🇧🇷'})} - if showLATAMBanner - div.notification.notification-type-success.text-centered - div.notification-content !{translate("latam_discount_offer_plans_page_banner", {flag: latamCountryBannerDetails.latamCountryFlag, country: latamCountryBannerDetails.country, currency: latamCountryBannerDetails.currency, discount: latamCountryBannerDetails.discount })} - - .row - .col-md-12 - .page-header.centered.plans-header.text-centered.top-page-header - h1.text-capitalize #{translate('choose_your_plan')} - - +monthly_annual_switch("monthly", "paywall-plans-page-toggle", '{}') - - +plans_v2_table_sticky_header(true, interstitialPaymentConfig) - - .row.plans-v2-table-container(data-ol-plans-v2-period='monthly') - .col-sm-12 - .row - table.card.plans-v2-table.plans-v2-table-individual - +plans_v2_table('monthly', interstitialPaymentConfig) - - .row.plans-v2-table-container(hidden data-ol-plans-v2-period='annual') - .col-sm-12 - .row - table.card.plans-v2-table.plans-v2-table-individual - +plans_v2_table('annual', interstitialPaymentConfig) - - if (showCurrencyAndPaymentMethods) - +currency_and_payment_methods() - - //- sticky header on mobile will be "hidden" (by removing its sticky position) if it reaches this div - .invisible(aria-hidden="true" data-ol-plans-v2-table-sticky-header-stop) - - if (showSkipLink) - .row.row-spaced-small.text-center - a(href=skipLinkTarget - event-tracking="skip-button-click" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation='{"location": "interstitial-page"}' - ) - | #{translate("continue_with_free_plan")} - - != moduleIncludes("contactModalGeneral-marketing", locals) diff --git a/services/web/app/views/subscriptions/plans-light-design.pug b/services/web/app/views/subscriptions/plans-light-design.pug deleted file mode 100644 index 5a8cb2cf54..0000000000 --- a/services/web/app/views/subscriptions/plans-light-design.pug +++ /dev/null @@ -1,59 +0,0 @@ -extends ../layout-website-redesign -include ../_mixins/quote -include ../_mixins/eyebrow - -block vars - - entrypoint = 'pages/user/subscription/plans-v2/plans-v2-main' - - var suppressRelAlternateLinks = true - - metadata.canonicalURL = (settings.siteUrl ? settings.siteUrl : '') + '/user/subscription/plans' - -block append meta - meta(name="ol-recommendedCurrency" content=recommendedCurrency) - meta(name="ol-groupPlans" data-type="json" content=groupPlans) - meta(name="ol-itm_content" content=itm_content) - meta(name="ol-currentView" content=currentView) - meta(name="ol-countryCode" content=countryCode) - meta(name="ol-websiteRedesignPlansVariant" content=websiteRedesignPlansVariant) - meta(name="ol-shouldLoadHotjar" data-type="boolean" content=shouldLoadHotjar) - -block content - main.website-redesign#main-content - .plans-page - .container - if showInrGeoBanner - .mb-5.notification.notification-type-success.text-center - div.notification-content !{translate("inr_discount_offer_plans_page_banner", {flag: '🇮🇳'})} - if showBrlGeoBanner - .mb-5.notification.notification-type-success.text-center - div.notification-content !{translate("brl_discount_offer_plans_page_banner", {flag: '🇧🇷'})} - if showLATAMBanner - .mb-5.notification.notification-type-success.text-center - div.notification-content !{translate("latam_discount_offer_plans_page_banner", {flag: latamCountryBannerDetails.latamCountryFlag, country: latamCountryBannerDetails.country, currency: latamCountryBannerDetails.currency, discount: latamCountryBannerDetails.discount })} - - .row - .col-md-12 - h1.text-center - +eyebrow(translate('plans_and_pricing_lowercase')) - | #{translate('choose_your_plan')} - - include ./plans/light-redesign/_cards_controls_tables - - +currency_and_payment_methods() - - //- TODO: changing .plans-page-quote-row-hidden causes flickering on page load - .plans-page-quote-row(data-ol-show-for-plan-type="individual") - +collinsQuote1() - - .plans-page-quote-row.plans-page-quote-row-hidden(data-ol-show-for-plan-type="group") - +bennettQuote1() - - .plans-page-quote-row.plans-page-quote-row-hidden(data-ol-show-for-plan-type="student") - +collinsQuote2() - - include ./plans/_university_info_light_design - - include ./plans/_faq_new - - include ./plans/light-redesign/_group_plan_modal - - != moduleIncludes("contactModalGeneral-marketing", locals) diff --git a/services/web/app/views/subscriptions/plans.pug b/services/web/app/views/subscriptions/plans.pug deleted file mode 100644 index 66cdc1ad5f..0000000000 --- a/services/web/app/views/subscriptions/plans.pug +++ /dev/null @@ -1,57 +0,0 @@ -extends ../layout-marketing - -block entrypointVar - - entrypoint = 'pages/user/subscription/plans-v2/plans-v2-main' - -block append meta - meta(name="ol-recommendedCurrency" content=recommendedCurrency) - meta(name="ol-groupPlans" data-type="json" content=groupPlans) - meta(name="ol-itm_content" content=itm_content) - meta(name="ol-currentView" content=currentView) - meta(name="ol-countryCode" content=countryCode) - meta(name="ol-websiteRedesignPlansVariant" content=websiteRedesignPlansVariant) - meta(name="ol-shouldLoadHotjar" data-type="boolean" content=shouldLoadHotjar) - -block content - main.content.content-alt#main-content - .content-page - .plans - .container - if showInrGeoBanner - div.notification.notification-type-success.text-centered - div.notification-content !{translate("inr_discount_offer_plans_page_banner", {flag: '🇮🇳'})} - if showBrlGeoBanner - div.notification.notification-type-success.text-centered - div.notification-content !{translate("brl_discount_offer_plans_page_banner", {flag: '🇧🇷'})} - if showLATAMBanner - div.notification.notification-type-success.text-centered - div.notification-content !{translate("latam_discount_offer_plans_page_banner", {flag: latamCountryBannerDetails.latamCountryFlag, country: latamCountryBannerDetails.country, currency: latamCountryBannerDetails.currency, discount: latamCountryBannerDetails.discount })} - - .row - .col-md-12 - .page-header.centered.plans-header.text-centered.top-page-header - h1.text-capitalize #{translate('choose_your_plan')} - - include ./plans/_cards_controls_tables - - +currency_and_payment_methods() - - include ./plans/_university_info - - include ./plans/_quotes - - include ./plans/_faq - - .row.row-spaced-large - .col-md-12 - .plans-header.text-centered - hr - h2 #{translate('still_have_questions')} - button.btn.plans-v2-btn-header.text-capitalize( - data-ol-open-contact-form-modal="general" - ) #{translate('contact_us')} - - .row.row-spaced-large - - include ./plans/_group_plan_modal - != moduleIncludes("contactModalGeneral-marketing", locals) diff --git a/services/web/app/views/subscriptions/plans/_cards_controls_tables.pug b/services/web/app/views/subscriptions/plans/_cards_controls_tables.pug deleted file mode 100644 index 042dd7aba4..0000000000 --- a/services/web/app/views/subscriptions/plans/_cards_controls_tables.pug +++ /dev/null @@ -1,74 +0,0 @@ -include ./_mixins - -.row.plans-v2-top-switch - .col-xs-12 - ul.nav.plans-v2-nav(role="tablist") - li.active.plans-v2-top-switch-individual( - data-ol-plans-v2-view-tab='individual' - event-tracking="plans-page-toggle-plan" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation='{"button": "individual"}' - role="presentation" - ) - button.btn.btn-default-outline(role="tab" aria-controls="panel-individual" aria-selected="true") #{translate("indvidual_plans")} - li.plans-v2-top-switch-group( - data-ol-plans-v2-view-tab='group' - event-tracking="plans-page-toggle-plan" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation='{"button": "group"}' - role="presentation" - ) - button.btn.btn-default-outline( - aria-controls="panel-group" - href="#" - role="tab" - aria-selected="false" - ) - span #{translate("group_plans")} - span (#{translate("save_30_percent_or_more")}) - li.plans-v2-top-switch-student( - data-ol-plans-v2-view-tab='student' - event-tracking="plans-page-toggle-plan" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation='{"button": "student"}' - role="presentation" - ) - button.btn.btn-default-outline( - aria-controls="panel-student" - href="#" - role="tab" - aria-selected="false" - ) #{translate("student_plans")} - -+monthly_annual_switch("annual", "plans-page-toggle-period") - -.row(hidden data-ol-plans-v2-license-picker-container) - .col-sm-12 - +group_plans_license_picker() - -+table_sticky_header_all(plansConfig) - -.row.plans-v2-table-container(hidden data-ol-plans-v2-period='monthly') - .col-sm-12(data-ol-plans-v2-view='individual' role="tabpanel") - .row - +table_individual('monthly') - .col-sm-12(hidden data-ol-plans-v2-view='student' role="tabpanel") - .row - +table_student('monthly') - -.row.plans-v2-table-container(data-ol-plans-v2-period='annual') - .col-sm-12(data-ol-plans-v2-view='individual' id="panel-individual" role="tabpanel") - .row - +table_individual('annual') - .col-sm-12(hidden data-ol-plans-v2-view='group' id="panel-group" role="tabpanel") - .row - +table_group('annual') - .col-sm-12(hidden data-ol-plans-v2-view='student' id="panel-student" role="tabpanel") - .row - +table_student('annual') - -//- sticky header on mobile will be "hidden" (by removing its sticky position) if it reaches this div -.invisible(aria-hidden="true" data-ol-plans-v2-table-sticky-header-stop) diff --git a/services/web/app/views/subscriptions/plans/_faq.pug b/services/web/app/views/subscriptions/plans/_faq.pug deleted file mode 100644 index 2c8f2c653e..0000000000 --- a/services/web/app/views/subscriptions/plans/_faq.pug +++ /dev/null @@ -1,43 +0,0 @@ -.plans-v2-faq - .row.row-spaced-large - .col-md-12 - .page-header.plans-header.text-centered - h2 FAQ - .row - .col-md-12 - h3 #{translate('faq_what_is_the_difference_between_users_and_collaborators_question')} - p !{translate('faq_what_is_the_difference_between_users_and_collaborators_answer_first_paragraph', {}, [{name: 'strong'}])} - br - p #{translate('faq_what_is_the_difference_between_users_and_collaborators_answer_second_paragraph')} - .row - .col-md-12 - h3 #{translate('faq_do_collab_need_on_paid_plan_question')} - p !{translate('faq_do_collab_need_on_paid_plan_answer', {}, [{ name: 'a', attrs: { href: "/learn/how-to/Overleaf_Accounts_and_Subscriptions", target: '_blank'}}, { name: 'a', attrs: { href: "/learn/how-to/Overleaf_premium_features", target: '_blank'}}])} - .row - .col-md-12 - h3 #{translate('faq_i_have_free_account_want_subscription_how_question')} - p !{translate('faq_i_have_free_account_want_subscription_how_answer_first_paragraph', {}, [{ name: 'a', attrs: { href: "/for/universities", target: '_blank'}}])} - br - p !{translate('faq_i_have_free_account_want_subscription_how_answer_second_paragraph', {}, [{ name: 'a', attrs: { href: "/learn/how-to/Overleaf_Accounts_and_Subscriptions", target: '_blank'}}])} - .row - .col-md-12 - h3 #{translate('faq_the_individual_standard_plan_10_collab_question')} - p #{translate('faq_the_individual_standard_plan_10_collab_first_paragraph')} - br - p !{translate('faq_the_individual_standard_plan_10_collab_second_paragraph', {}, [{ name: 'a', attrs: { href: "/learn/how-to/Overleaf_premium_features#Account_and_project_level_features", target: '_blank'}}])} - .row - .col-md-12 - h3 #{translate('faq_how_does_a_group_plan_work_question')} - p !{translate('faq_how_does_a_group_plan_work_answer', {}, [{ name: 'a', attrs: { href: "/learn/how-to/Joining_an_Overleaf_Group_Subscription", target: '_blank'}}, { name: 'a', attrs: { href: "/learn/how-to/Managing_a_group_subscription", target: '_blank'}}, { name: 'a', attrs: { href: "/contact", target: '_blank'}}])} - .row - .col-md-12 - h3 #{translate('faq_how_free_trial_works_question')} - p #{translate('faq_how_free_trial_works_answer_v2', { len:'7' })} - .row - .col-md-12 - h3 #{translate('faq_change_plans_or_cancel_question')} - p #{translate('faq_change_plans_or_cancel_answer')} - .row - .col-md-12 - h3 #{translate('faq_pay_by_invoice_question')} - p #{translate('faq_pay_by_invoice_answer_v2')} diff --git a/services/web/app/views/subscriptions/plans/_group_plan_modal.pug b/services/web/app/views/subscriptions/plans/_group_plan_modal.pug deleted file mode 100644 index 078427705b..0000000000 --- a/services/web/app/views/subscriptions/plans/_group_plan_modal.pug +++ /dev/null @@ -1,111 +0,0 @@ -div.modal.fade(tabindex="-1" role="dialog" data-ol-group-plan-modal) - .modal-dialog(role="document") - .modal-content - .modal-header - button.close( - type="button" - data-dismiss="modal" - aria-label=translate("close") - ) - span(aria-hidden="true") × - h2 #{translate("customize_your_group_subscription")} - h3 #{translate("save_30_percent_or_more_uppercase")} - .modal-body.plans.group-subscription-modal - .container-fluid - .row - .col-md-6.text-center - .circle.circle-lg - span(data-ol-group-plan-display-price) ... - span.small / #{translate('year')} - br - span.circle-subtext(data-ol-group-plan-price-per-user=translate('per_user')) ... - ul.list-unstyled - li #{translate('each_user_will_have_access_to')}: - li   - li( - hidden=(groupPlanModalDefaults.plan_code !== 'collaborator') - data-ol-group-plan-plan-code='collaborator' - ) - strong #{translate("collabs_per_proj", {collabcount:10})} - li( - hidden=(groupPlanModalDefaults.plan_code !== 'professional') - data-ol-group-plan-plan-code='professional' - ) - strong #{translate("unlimited_collabs")} - +features_premium - .col-md-6 - form.form(data-ol-group-plan-form) - .form-group - label(for='plan_code') - | #{translate('plan')} - for plan_code in groupPlanModalOptions.plan_codes - label.group-plan-option - input( - type="radio" - name="plan_code" - checked=(plan_code.code === "collaborator") - value=plan_code.code - data-ol-group-plan-code=plan_code.code - ) - span #{translate(plan_code.i18n)} - .form-group - label(for='size') - | #{translate('number_of_users')} - select.form-control( - id="size" - event-tracking="groups-modal-group-size" - event-tracking-mb="true" - event-tracking-trigger="click" - event-tracking-element="select" - ) - for size in groupPlanModalOptions.sizes - option( - value=size - selected=(size === groupPlanModalDefaults.size) - ) #{size} - .form-group(data-ol-group-plan-form-currency) - label(for='currency') - | #{translate('currency')} - select.form-control(id="currency") - for currency in groupPlanModalOptions.currencies - option( - value=currency.code - selected=(currency.code === groupPlanModalDefaults.currency) - ) #{currency.display} - .form-group - label(for='usage') - | #{translate('educational_discount_for_groups_of_ten_or_more')} - label.group-plan-option - input( - id="usage" - type="checkbox" - autocomplete="off" - event-tracking="groups-modal-edu-discount" - event-tracking-mb="true" - event-tracking-trigger="click" - event-tracking-element="checkbox" - ) - span #{translate('educational_discount_disclaimer')} - - .row - .col-md-12.text-center - .educational-discount-badge - div(hidden=(groupPlanModalDefaults.usage !== 'educational') data-ol-group-plan-educational-discount) - p.applied(hidden=true data-ol-group-plan-educational-discount-applied) - | #{translate('educational_discount_applied')} - p.ineligible(hidden=true data-ol-group-plan-educational-discount-ineligible) - | #{translate('educational_discount_available_for_groups_of_ten_or_more')} - .modal-footer - .text-center - button.btn.btn-primary.btn-lg( - data-ol-purchase-group-plan - event-tracking="form-submitted-groups-modal-purchase-click" - event-tracking-mb="true" - event-tracking-trigger="click" - ) #{translate('purchase_now')} - hr.thin - a( - href - data-ol-open-contact-form-for-more-than-50-licenses - ) #{translate('need_more_than_to_licenses_get_in_touch')} - diff --git a/services/web/app/views/subscriptions/plans/_mixins.pug b/services/web/app/views/subscriptions/plans/_mixins.pug deleted file mode 100644 index 2f5c67db4a..0000000000 --- a/services/web/app/views/subscriptions/plans/_mixins.pug +++ /dev/null @@ -1,646 +0,0 @@ -mixin features_premium - li   - li - strong #{translate('all_premium_features')} - li #{translate('sync_dropbox_github')} - li #{translate('full_doc_history')} - li #{translate('track_changes')} - li + #{translate('more').toLowerCase()} - -mixin gen_localized_price_for_plan_view(plan, view) - span #{formatCurrency(settings.localizedPlanPricing[recommendedCurrency][plan][view], recommendedCurrency, language, true)} - -mixin currency_and_payment_methods() - .row.row-spaced-large.text-centered - .col-xs-12 - p.text-centered - strong #{translate("all_prices_displayed_are_in_currency", { recommendedCurrency })} - |   - span #{translate("subject_to_additional_vat")} - i.fa.fa-cc-mastercard.fa-2x(aria-hidden="true")   - span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Mastercard' })} - i.fa.fa-cc-visa.fa-2x(aria-hidden="true")   - span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Visa' })} - i.fa.fa-cc-amex.fa-2x(aria-hidden="true")   - span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Amex' })} - i.fa.fa-cc-paypal.fa-2x(aria-hidden="true")   - span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Paypal' })} - - -mixin plans_v2_table(period, config) - - var baseColspan = config.baseColspan || 1 - - var maxColumn = config.maxColumn || 4 - - var tableHeadKeys = Object.keys(config.tableHead) - tr(class=`plans-v2-table-cols-${tableHeadKeys.length}`) - th(colspan=baseColspan) - - for (var i = 0; i < maxColumn; i++) - - var tableHeadKey = tableHeadKeys[i] - - var tableHeadOptions = Object.values(config.tableHead)[i] || {} - - var colspan = tableHeadOptions.colspan || baseColspan - - var highlighted = i === config.highlightedColumn.index - - var eventTrackingKey = config.eventTrackingKey - - var additionalEventSegmentation = config.additionalEventSegmentation || {} - - - if (highlighted) { - var thClass = 'plans-v2-table-green-highlighted' - } else if (i === config.highlightedColumn.index - 1) { - var thClass = 'plans-v2-table-cell-before-green-highlighted-column' - } else { - var thClass = '' - } - thClass += ' plans-v2-table-column-header' - if (colspan > 1) { - var scopeValue = 'colgroup' - } - else { - var scopeValue = 'col' - } - case tableHeadKey - when 'individual_free' - - var ariaLabel = translate("free") - when 'individual_collaborator' - - var ariaLabel = translate("standard") - when 'individual_professional' - - var ariaLabel = translate("professional") - when 'group_collaborator' - - var ariaLabel = translate("group_standard") - when 'group_professional' - - var ariaLabel = translate("group_professional") - when 'group_organization' - - var ariaLabel = translate("organization") - when 'student_free' - - var ariaLabel = translate("free") - when 'student_student' - - var ariaLabel = translate("student") - when 'student_university' - - var ariaLabel = translate("university") - default - - var ariaLabel = undefined - th( - aria-label=ariaLabel - class=thClass - colspan=colspan - scope=scopeValue - ) - .plans-v2-table-th - if (highlighted) - p.plans-v2-table-green-highlighted-text #{translate(config.highlightedColumn.text[period]).toUpperCase()} - case tableHeadKey - when 'individual_free' - +table_head_individual_free(highlighted, period) - when 'individual_collaborator' - +table_head_individual_collaborator(highlighted, eventTrackingKey, additionalEventSegmentation, period) - when 'individual_professional' - +table_head_individual_professional(highlighted, eventTrackingKey, additionalEventSegmentation, period) - when 'group_collaborator' - +table_head_group_collaborator(highlighted, eventTrackingKey, additionalEventSegmentation) - when 'group_professional' - +table_head_group_professional(highlighted, eventTrackingKey, additionalEventSegmentation) - when 'group_organization' - +table_head_group_organization(highlighted, eventTrackingKey, additionalEventSegmentation) - when 'student_free' - +table_head_student_free(highlighted, period) - when 'student_student' - +table_head_student_student(highlighted, eventTrackingKey, additionalEventSegmentation, period, tableHeadOptions.showExtraContent) - when 'student_university' - +table_head_student_university(highlighted, eventTrackingKey, additionalEventSegmentation, period) - - for featuresPerSection in config.features - - var dividerColspan = Object.values(config.tableHead).reduce((prev, curr) => (prev) + (curr.colspan || 1), baseColspan) - if featuresPerSection.divider - tr.plans-v2-table-divider - td( - colspan=dividerColspan - class=((config.highlightedColumn.index === Object.keys(config.tableHead).length - 1) ? 'plans-v2-table-divider-highlighted' : '') - ) - div - b.plans-v2-table-divider-label #{translate(featuresPerSection.dividerLabel)} - //- will only appear on screen width >= 768px (using CSS) - i.fa.fa-question-circle.plans-v2-table-divider-question-icon( - data-toggle="tooltip" - title=translate(featuresPerSection.dividerInfo), - data-placement="top" - ) - //- will only appear on screen width < 768px (using CSS) - span.plans-v2-table-divider-learn-more-container - span ( - span.plans-v2-table-divider-learn-more-text( - data-toggle="tooltip" - title=translate(featuresPerSection.dividerInfo), - data-placement="top" - ) #{translate("learn_more_lowercase")} - span ) - span.sr-only #{translate(featuresPerSection.dividerInfo)} - for feature, featureIndex in featuresPerSection.items - tr( - class=(featureIndex === (featuresPerSection.items.length - 1) ? `plans-v2-table-row-last-row-per-section plans-v2-table-cols-${tableHeadKeys.length}` : `plans-v2-table-cols-${tableHeadKeys.length}`) - ) - th( - class="plans-v2-table-row-header" - event-tracking="plans-page-table" - event-tracking-trigger="hover" - event-tracking-ga="subscription-funnel" - event-tracking-label=`${feature.feature}` - colspan=baseColspan - scope="row" - ) - .plans-v2-table-feature-name - if feature.info - span #{translate(feature.feature)} - //- will only appear on screen width >= 768px (using CSS) - i.fa.fa-question-circle.plans-v2-table-feature-name-question-icon( - data-toggle="tooltip" - title=translate(feature.info), - data-placement="right" - ) - //- will only appear on screen width < 768px (using CSS) - span.plans-v2-table-feature-name-learn-more-container - span ( - span.plans-v2-table-feature-name-learn-more-text( - data-toggle="tooltip" - title=translate(feature.info), - data-placement="top" - ) #{translate("learn_more_lowercase")} - span ) - span.sr-only #{translate(feature.info)} - else - | #{translate(feature.feature)} - for plan, planIndex in Object.keys(feature.plans) - - var tableHeadOptions = Object.values(config.tableHead)[planIndex] || {} - - var colspan = tableHeadOptions.colspan || baseColspan - - - if (planIndex === config.highlightedColumn.index) { - var tdClass = 'plans-v2-table-green-highlighted' - } else if (planIndex === config.highlightedColumn.index - 1) { - var tdClass = 'plans-v2-table-cell-before-green-highlighted-column' - } else { - var tdClass = '' - } - td( - class=tdClass - colspan=colspan - ) - +table_cell(feature, plan) - -mixin table_individual(period) - table.card.plans-v2-table.plans-v2-table-individual - +plans_v2_table(period, plansConfig.individual) - -mixin table_group - table.card.plans-v2-table.plans-v2-table-group - +plans_v2_table('annual', plansConfig.group) - -mixin table_student(period) - table.card.plans-v2-table.plans-v2-table-student - +plans_v2_table(period, plansConfig.student) - -mixin table_head_individual_free(highlighted, period) - .plans-v2-table-th-content - p.plans-v2-table-th-content-title #{translate("free")} - +table_head_price('free', period) - .plans-v2-table-btn-buy-container-mobile - +btn_buy_individual_free(highlighted) - ul.plans-v2-table-th-content-benefit - li #{translate("one_collaborator")} - .plans-v2-table-btn-buy-container-desktop - +btn_buy_individual_free(highlighted) - -mixin table_head_individual_collaborator(highlighted, eventTrackingKey, additionalEventSegmentation, period) - .plans-v2-table-th-content - p.plans-v2-table-th-content-title #{translate("standard")} - +table_head_price('collaborator', period) - .plans-v2-table-btn-buy-container-mobile - +btn_buy_individual_collaborator(highlighted, eventTrackingKey, additionalEventSegmentation, period) - ul.plans-v2-table-th-content-benefit - li !{translate("x_collaborators_per_project", {collaboratorsCount: '10'})} - li #{translate("all_premium_features")} - .plans-v2-table-btn-buy-container-desktop - +btn_buy_individual_collaborator(highlighted, eventTrackingKey, additionalEventSegmentation, period) - -mixin table_head_individual_professional(highlighted, eventTrackingKey, additionalEventSegmentation, period) - .plans-v2-table-th-content - p.plans-v2-table-th-content-title #{translate("professional")} - +table_head_price('professional', period) - .plans-v2-table-btn-buy-container-mobile - +btn_buy_individual_professional(highlighted, eventTrackingKey, additionalEventSegmentation, period) - ul.plans-v2-table-th-content-benefit - li !{translate("unlimited_collabs_rt",{},["b"])} - li #{translate("all_premium_features")} - .plans-v2-table-btn-buy-container-desktop - +btn_buy_individual_professional(highlighted, eventTrackingKey, additionalEventSegmentation, period) - -mixin table_head_group_collaborator(highlighted, eventTrackingKey, additionalEventSegmentation) - .plans-v2-table-th-content - p.plans-v2-table-th-content-title #{translate("group_standard")} - .plans-v2-table-price-container - s.plans-v2-table-price-before-discount - +gen_localized_price_for_plan_view('collaborator', 'annual') - p.plans-v2-table-price - span(data-ol-plans-v2-group-price-per-user='collaborator') #{initialLocalizedGroupPrice.pricePerUser.collaborator} - p.plans-v2-table-price-period-label - | #{translate('per_user_year')} - .plans-v2-table-btn-buy-container-mobile - +btn_buy_group_collaborator(highlighted, eventTrackingKey) - +additional_link_group(eventTrackingKey, additionalEventSegmentation, 'group_collaborator') - ul.plans-v2-table-th-content-benefit - li #{translate("up_to")} !{translate("x_collaborators_per_project", {collaboratorsCount: '10'})} - li - +table_head_group_total_per_year('collaborator') - .plans-v2-table-btn-buy-container-desktop - +btn_buy_group_collaborator(highlighted, eventTrackingKey) - +additional_link_group(eventTrackingKey, additionalEventSegmentation, 'group_collaborator') - -mixin table_head_group_professional(highlighted, eventTrackingKey, additionalEventSegmentation) - .plans-v2-table-th-content - p.plans-v2-table-th-content-title #{translate("group_professional")} - .plans-v2-table-price-container - s.plans-v2-table-price-before-discount - +gen_localized_price_for_plan_view('professional', 'annual') - p.plans-v2-table-price - span(data-ol-plans-v2-group-price-per-user='professional') #{initialLocalizedGroupPrice.pricePerUser.professional} - p.plans-v2-table-price-period-label - | #{translate('per_user_year')} - .plans-v2-table-btn-buy-container-mobile - +btn_buy_group_professional(highlighted, eventTrackingKey) - +additional_link_group(eventTrackingKey, additionalEventSegmentation, 'group_professional') - ul.plans-v2-table-th-content-benefit - li #{translate("unlimited_collaborators_in_each_project")} - li - +table_head_group_total_per_year('professional') - .plans-v2-table-btn-buy-container-desktop - +btn_buy_group_professional(highlighted, eventTrackingKey) - +additional_link_group(eventTrackingKey, additionalEventSegmentation, 'group_professional') - -mixin table_head_group_total_per_year(groupPlan) - - var initialLicenseSize = '2' - span.plans-v2-group-total-price(data-ol-plans-v2-group-total-price=groupPlan) #{initialLocalizedGroupPrice.price[groupPlan]} - |   - for licenseSize in groupPlanModalOptions.sizes - span( - hidden=(licenseSize !== initialLicenseSize) - data-ol-plans-v2-table-th-group-license-size=licenseSize - ) !{translate("total_per_year_for_x_users", {licenseSize})} - -mixin table_head_group_organization(highlighted, eventTrackingKey, additionalEventSegmentation) - - additionalEventSegmentation = additionalEventSegmentation || {} - - var segmentation = { ...additionalEventSegmentation, button: 'group_organization-link', location: 'table-header-list', period: 'annual', currency: recommendedCurrency } - - .plans-v2-table-th-content - p.plans-v2-table-th-content-title #{translate("organization")} - .plans-v2-table-comments-icon - i.fa.fa-comments-o - .plans-v2-table-btn-buy-container-mobile - +btn_buy_group_organization(highlighted, eventTrackingKey) - small.plans-v2-table-th-content-additional-link.invisible(aria-hidden="true") - ul.plans-v2-table-th-content-benefit - li #{translate("best_choices_companies_universities_non_profits")} - li #{translate("for_groups_or_site_wide")} - li - a( - target="_blank" - href="/for/contact-sales" - event-tracking="plans-page-click" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation=segmentation - ) #{translate("also_available_as_on_premises")} - .plans-v2-table-btn-buy-container-desktop - +btn_buy_group_organization(highlighted, eventTrackingKey) - small.plans-v2-table-th-content-additional-link.invisible(aria-hidden="true") - -mixin table_head_student_free(highlighted, period) - div.plans-v2-table-th-content - p.plans-v2-table-th-content-title #{translate("free")} - +table_head_price('free', period) - .plans-v2-table-btn-buy-container-mobile - +btn_buy_student_free(highlighted) - ul.plans-v2-table-th-content-benefit - li #{translate("one_collaborator")} - .plans-v2-table-btn-buy-container-desktop - +btn_buy_student_free(highlighted) - -mixin table_head_student_student(highlighted, eventTrackingKey, additionalEventSegmentation, period, showExtraContent) - div.plans-v2-table-th-content - p.plans-v2-table-th-content-title #{translate("student")} - +table_head_price('student', period) - .plans-v2-table-btn-buy-container-mobile - +btn_buy_student_student(highlighted, eventTrackingKey, additionalEventSegmentation, period) - ul.plans-v2-table-th-content-benefit - li !{translate("x_collaborators_per_project", {collaboratorsCount: '6'})} - li #{translate("all_premium_features")} - if showExtraContent - li - b !{translate("for_students_only")} - - .plans-v2-table-btn-buy-container-desktop - +btn_buy_student_student(highlighted, eventTrackingKey, additionalEventSegmentation, period) - -mixin table_head_student_university(highlighted, eventTrackingKey, additionalEventSegmentation, period) - div.plans-v2-table-th-content - p.plans-v2-table-th-content-title #{translate("university")} - div.plans-v2-table-comments-icon - i.fa.fa-comments-o - .plans-v2-table-btn-buy-container-mobile - +btn_buy_student_university(highlighted, eventTrackingKey, additionalEventSegmentation, period) - p.plans-v2-table-th-content-benefit !{translate("all_our_group_plans_offer_educational_discount", {}, [{name: 'b'}, {name: 'b'}])} - .plans-v2-table-btn-buy-container-desktop - +btn_buy_student_university(highlighted, eventTrackingKey, additionalEventSegmentation, period) - -mixin table_head_price(plan, period) - div.plans-v2-table-price-container - if plan !== 'free' && period === 'annual' - s.plans-v2-table-price-before-discount - +gen_localized_price_for_plan_view(plan, 'monthlyTimesTwelve') - p.plans-v2-table-price - +gen_localized_price_for_plan_view(plan, period) - p.plans-v2-table-price-period-label - if period == 'annual' - | #{translate("per_year")} - else - | #{translate("per_month")} - -mixin table_cell(feature, plan) - - var planValue = feature.plans[plan] - - var featureName = feature.feature - - .plans-v2-table-cell - .plans-v2-table-cell-content( - data-ol-plans-v2-table-cell-plan=plan - data-ol-plans-v2-table-cell-feature=featureName - ) - if (feature.value === 'str') - | !{translate(planValue, {}, ['strong'])} - else if (feature.value === 'bool') - if (planValue) - i.fa.fa-check(aria-hidden="true") - span.sr-only #{translate("feature_included")} - else - span(aria-hidden="true") - - span.sr-only #{translate("feature_not_included")} - -mixin group_plans_license_picker() - form.plans-v2-license-picker-form(data-ol-plans-v2-license-picker-form) - .plans-v2-license-picker-select-container - span #{translate("number_of_users_with_colon")} - select.plans-v2-license-picker-select( - name="plans-v2-license-picker-select" - id="plans-v2-license-picker-select" - autocomplete="off" - data-ol-plans-v2-license-picker-select - event-tracking="plans-page-group-size" - event-tracking-mb="true" - event-tracking-trigger="click" - event-tracking-element="select" - ) - option(value="2") 2 - option(value="3") 3 - option(value="4") 4 - option(value="5") 5 - option(value="10") 10 - option(value="20") 20 - option(value="50") 50 - .plans-v2-license-picker-educational-discount - label.plans-v2-license-picker-educational-discount-label(data-ol-plans-v2-license-picker-educational-discount-label) - input.plans-v2-license-picker-educational-discount-checkbox( - type="checkbox" - id="license-picker-educational-discount" - autocomplete="off" - data-ol-plans-v2-license-picker-educational-discount-input - event-tracking="plans-page-edu-discount" - event-tracking-mb="true" - event-tracking-trigger="click" - event-tracking-element="checkbox" - ) - span #{translate("apply_educational_discount")} - //- will only appear on screen width >= 768px (using CSS) - i.fa.fa-question-circle.plans-v2-license-picker-educational-discount-question-icon( - data-toggle="tooltip" - title=translate("apply_educational_discount_info"), - data-placement="bottom" - ) - //- will only appear on screen width < 768px (using CSS) - span.plans-v2-license-picker-educational-discount-learn-more-container - span ( - span.plans-v2-license-picker-educational-discount-learn-more-text( - data-toggle="tooltip" - title=translate("apply_educational_discount_info"), - data-placement="bottom" - ) #{translate("learn_more_lowercase")} - span ) - span.sr-only #{translate("apply_educational_discount_info")} - -mixin btn_buy_individual(highlighted, eventTrackingKey, subscriptionPlan, period) - a.btn.plans-v2-table-btn-buy( - data-ol-start-new-subscription=subscriptionPlan - data-ol-event-tracking-key=eventTrackingKey - data-ol-item-view=period - class=(highlighted ? 'btn-primary' : 'btn-default') - ) - if (period === 'monthly') - span #{translate("try_for_free")} - else - span #{translate("buy_now_no_exclamation_mark")} - -mixin btn_buy_individual_free() - if (!getSessionUser()) - a.btn.plans-v2-table-btn-buy( - href="/register" - class=(highlighted ? 'btn-primary' : 'btn-default') - ) - span #{translate("try_for_free")} - else - a.btn.plans-v2-table-btn-buy.invisible( - aria-hidden="true" - class=(highlighted ? 'btn-primary' : 'btn-default') - ) - -mixin btn_buy_individual_collaborator(highlighted, eventTrackingKey, additionalEventSegmentation, period) - +btn_buy_individual(highlighted, eventTrackingKey, 'collaborator', period) - if (period === 'monthly') - +additional_link_buy(eventTrackingKey, additionalEventSegmentation, 'collaborator', period) - -mixin btn_buy_individual_professional(highlighted, eventTrackingKey, additionalEventSegmentation, period) - +btn_buy_individual(highlighted, eventTrackingKey, 'professional', period) - if (period === 'monthly') - +additional_link_buy(eventTrackingKey, additionalEventSegmentation, 'professional', period) - -mixin btn_buy_group_collaborator(highlighted, eventTrackingKey) - a.btn.plans-v2-table-btn-buy( - data-ol-start-new-subscription='group_collaborator' - data-ol-event-tracking-key=eventTrackingKey - data-ol-item-view='annual' - data-ol-has-custom-href - data-ol-location='table-header' - class=(highlighted ? 'btn-primary' : 'btn-default') - ) - span.hidden-desktop #{translate("customize")} - span.hidden-mobile #{translate("customize_your_plan")} - -mixin btn_buy_group_professional(highlighted, eventTrackingKey) - a.btn.plans-v2-table-btn-buy( - data-ol-start-new-subscription='group_professional' - data-ol-event-tracking-key=eventTrackingKey - data-ol-item-view='annual' - data-ol-has-custom-href - data-ol-location='table-header' - class=(highlighted ? 'btn-primary' : 'btn-default') - ) - span.hidden-desktop #{translate("customize")} - span.hidden-mobile #{translate("customize_your_plan")} - -mixin btn_buy_group_organization(highlighted, eventTrackingKey) - a.btn.plans-v2-table-btn-buy( - data-ol-start-new-subscription='group_organization' - data-ol-event-tracking-key=eventTrackingKey - data-ol-item-view='annual' - data-ol-has-custom-href - data-ol-location='table-header' - href='/for/contact-sales' - target='_blank' - class=(highlighted ? 'btn-primary' : 'btn-default') - ) - span #{translate("contact_us_lowercase")} - -mixin btn_buy_student_free(highlighted) - if (!getSessionUser()) - a.btn.plans-v2-table-btn-buy( - href="/register" - class=(highlighted ? 'btn-primary' : 'btn-default') - ) - span #{translate("try_for_free")} - -mixin btn_buy_student_student(highlighted, eventTrackingKey, additionalEventSegmentation, period) - a.btn.plans-v2-table-btn-buy( - data-ol-start-new-subscription='student' - data-ol-event-tracking-key=eventTrackingKey - data-ol-item-view=period - data-ol-location='card' - class=(highlighted ? 'btn-primary' : 'btn-default') - ) - if (period === 'monthly') - span #{translate("try_for_free")} - else - span #{translate("buy_now_no_exclamation_mark")} - if (period === 'monthly') - +additional_link_buy(eventTrackingKey, additionalEventSegmentation, 'student', period) - -mixin btn_buy_student_university(highlighted, eventTrackingKey, additionalEventSegmentation, period) - - var segmentation = JSON.stringify(Object.assign({}, {button: 'student-university', location: 'table-header-list', period}, additionalEventSegmentation)) - a.btn.plans-v2-table-btn-buy( - href="/for/contact-sales" - target="_blank" - event-tracking=eventTrackingKey - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation=segmentation - class=(highlighted ? 'btn-primary' : 'btn-default') - ) - span #{translate("contact_us_lowercase")} - -mixin additional_link_group(eventTrackingKey, additionalEventSegmentation, plan) - - var buttonSegmentation = plan + '-link' - - additionalEventSegmentation = additionalEventSegmentation || {} - - var segmentation = { ...additionalEventSegmentation, button: buttonSegmentation, location: 'table-header', currency: recommendedCurrency } - small.plans-v2-table-th-content-additional-link - | #{translate("or")} - a( - href="/for/contact-sales" - target="_blank" - event-tracking=eventTrackingKey - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation=segmentation - ) #{translate("contact_us_lowercase")} - -mixin additional_link_buy(eventTrackingKey, additionalEventSegmentation, plan, period) - - var buttonSegmentation = plan + '-link' - - additionalEventSegmentation = additionalEventSegmentation || {} - - var segmentation = { ...additionalEventSegmentation, button: buttonSegmentation, location: 'table-header', currency: recommendedCurrency } - - var itmCampaign = itm_campaign ? { itm_campaign } : {itm_campaign: 'plans'} - - var itmReferrer = itm_referrer ? { itm_referrer } : {} - - var qs = new URLSearchParams({planCode: plan, currency: recommendedCurrency, itm_content: 'card', ...itmCampaign, ...itmReferrer}) - small.plans-v2-table-th-content-additional-link - | #{translate("or")} - a( - href=`/user/subscription/new?${qs.toString()}` - event-tracking=eventTrackingKey - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation=segmentation - ) #{translate("buy_now_no_exclamation_mark")} - -mixin plans_v2_table_sticky_header(withSwitch, config) - - var tableHeadKeys = Object.keys(config.tableHead) - .row.plans-v2-table-sticky-header.sticky( - data-ol-plans-v2-table-sticky-header - class=(withSwitch ? 'plans-v2-table-sticky-header-with-switch' : 'plans-v2-table-sticky-header-without-switch') - ) - - for (var i = 0; i < tableHeadKeys.length; i++) - - var tableHeadKey = tableHeadKeys[i] - - var translateKey = tableHeadKey.split('_')[1] - - - if (config.highlightedColumn.index === i) { - var elClass = 'plans-v2-table-sticky-header-item-green-highlighted' - } else { - var elClass = '' - } - .plans-v2-table-sticky-header-item( - class=elClass - ) - case tableHeadKey - when 'individual_collaborator' - span #{translate('standard')} - when 'group_professional' - span #{translate(tableHeadKey)} - when 'group_collaborator' - span #{translate('group_standard')} - default - span #{translate(translateKey)} - -mixin table_sticky_header_all(plansConfig) - .row.plans-v2-table-sticky-header-container( - data-ol-plans-v2-view='individual' - ) - +plans_v2_table_sticky_header(true, plansConfig.individual) - .row.plans-v2-table-sticky-header-container( - hidden - data-ol-plans-v2-view='group' - ) - +plans_v2_table_sticky_header(false, plansConfig.group) - .row.plans-v2-table-sticky-header-container( - hidden - data-ol-plans-v2-view='student' - ) - +plans_v2_table_sticky_header(true, plansConfig.student) - -mixin monthly_annual_switch(initialState, eventTracking, eventSegmentation) - - var monthlyAnnualToggleChecked = initialState === 'monthly' - .row - .col-md-4.col-md-offset-4.text-centered.plans-v2-m-a-switch-container(data-ol-plans-v2-m-a-switch-container) - .plans-v2-m-a-switch-annual-text-container - span.underline(data-ol-plans-v2-m-a-switch-text='annual') #{translate("annual")} - .tooltip.in.left.plans-v2-m-a-tooltip( - role="tooltip" - data-ol-plans-v2-m-a-tooltip - class=monthlyAnnualToggleChecked ? 'plans-v2-m-a-tooltip-monthly-selected' : '' - ) - .tooltip-arrow - .tooltip-inner - span(hidden=!monthlyAnnualToggleChecked data-ol-tooltip-period='monthly') #{translate("save_20_percent_by_paying_annually")} - span(hidden=monthlyAnnualToggleChecked data-ol-tooltip-period='annual') #{translate("saving_20_percent")} - - label.plans-v2-m-a-switch(data-ol-plans-v2-m-a-switch) - input( - type="checkbox" - checked=monthlyAnnualToggleChecked - role="switch" - aria-label=translate("select_monthly_plans") - autocomplete="off" - event-tracking=eventTracking - event-tracking-mb="true" - event-tracking-trigger="click" - event-tracking-element="checkbox" - event-segmentation=eventSegmentation - ) - span - span(data-ol-plans-v2-m-a-switch-text='monthly') #{translate("monthly")} diff --git a/services/web/app/views/subscriptions/plans/_quotes.pug b/services/web/app/views/subscriptions/plans/_quotes.pug deleted file mode 100644 index 950bff8909..0000000000 --- a/services/web/app/views/subscriptions/plans/_quotes.pug +++ /dev/null @@ -1,25 +0,0 @@ -.row.row-spaced-large - .col-md-12 - .page-header.plans-header.text-centered - h2 #{translate('in_good_company')} -.row - .col-md-6 - div - .row - .col-md-3 - .circle-img - img(src=buildImgPath('advocates/schultz.jpg') alt="Kevin Schultz") - .col-md-9 - blockquote - p It is the ability to collaborate very easily that drew me to Overleaf. - footer Kevin Schultz, Assistant Professor of Physics, Hartwick College - .col-md-6 - div - .row - .col-md-3 - .circle-img - img(src=buildImgPath('advocates/dagoret-campagne.jpg') alt="Dr Sylvie Dagoret-Campagne") - .col-md-9 - blockquote - p Overleaf is a great educational tool for publishing scientific documents. - footer Dr Sylvie Dagoret-Campagne, Director of Research at CNRS, University of Paris-Saclay diff --git a/services/web/app/views/subscriptions/plans/_university_info.pug b/services/web/app/views/subscriptions/plans/_university_info.pug deleted file mode 100644 index 3d78b626ff..0000000000 --- a/services/web/app/views/subscriptions/plans/_university_info.pug +++ /dev/null @@ -1,16 +0,0 @@ -.row.row-spaced-large.text-centered( - data-ol-plans-university-info-container - hidden -) - .col-sm-8.col-sm-offset-2.col-xs-12 - .card.plans-v2-university-info - h3.plans-v2-university-info-header #{translate('would_you_like_to_see_a_university_subscription')} - p.plans-v2-university-info-text #{translate('student_and_faculty_support_make_difference')} - a.btn.plans-v2-btn-header.text-capitalize.plans-v2-btn-university-info( - target="_blank" - href="/for/support-an-overleaf-university-subscription" - event-tracking="plans-page-click" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={button: "university-support", currency: recommendedCurrency} - ) #{translate('show_your_support')} diff --git a/services/web/app/views/subscriptions/plans/_university_info_light_design.pug b/services/web/app/views/subscriptions/plans/_university_info_light_design.pug deleted file mode 100644 index 476594f667..0000000000 --- a/services/web/app/views/subscriptions/plans/_university_info_light_design.pug +++ /dev/null @@ -1,17 +0,0 @@ -.row.row-spaced-large( - data-ol-plans-university-info-container - hidden -) - .col-xs-12 - .card.plans-v2-university-info-light - div - h3.plans-v2-university-info-header-light #{translate('would_you_like_to_see_a_university_subscription')} - p.plans-v2-university-info-text-light #{translate('student_and_faculty_support_make_difference')} - a.btn.btn-secondary.plans-v2-btn-header-light( - target="_blank" - href="/for/support-an-overleaf-university-subscription" - event-tracking="plans-page-click" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation='{"button": "university-support"}' - ) #{translate('show_your_support')} diff --git a/services/web/app/views/subscriptions/plans/light-redesign/_cards_controls_tables.pug b/services/web/app/views/subscriptions/plans/light-redesign/_cards_controls_tables.pug deleted file mode 100644 index d4b4b04d74..0000000000 --- a/services/web/app/views/subscriptions/plans/light-redesign/_cards_controls_tables.pug +++ /dev/null @@ -1,76 +0,0 @@ -include ./_mixins - -.row.plans-top-switch.text-center - .col-xs-12 - ul.nav(role="tablist") - li.active.plans-switch-individual( - data-ol-plans-v2-view-tab='individual' - event-tracking="plans-page-toggle-plan" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation='{"button": "individual"}' - role="presentation" - ) - button.btn( - role="tab" - aria-controls="panel-individual" - aria-selected="true" - ) #{translate("indvidual_plans")} - li.plans-switch-group( - data-ol-plans-v2-view-tab='group' - event-tracking="plans-page-toggle-plan" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation='{"button": "group"}' - role="presentation" - ) - button.btn( - aria-controls="panel-group" - role="tab" - aria-selected="false" - ) - span #{translate("group_plans")} - span (#{translate("save_30_percent_or_more")}) - li.plans-switch-student( - data-ol-plans-v2-view-tab='student' - event-tracking="plans-page-toggle-plan" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation='{"button": "student"}' - role="presentation" - ) - button.btn( - aria-controls="panel-student" - role="tab" - aria-selected="false" - ) #{translate("student_plans")} - -+monthly_annual_switch("annual", "plans-page-toggle-period") - -.row(hidden data-ol-plans-v2-license-picker-container) - .col-sm-12 - +group_plans_license_picker() - -+table_sticky_header_all(plansConfig) - -.row.plans-table-container(hidden data-ol-plans-v2-period='monthly') - .col-sm-12(data-ol-plans-v2-view='individual' role="tabpanel") - .row - +table_individual('monthly') - .col-sm-12(hidden data-ol-plans-v2-view='student' role="tabpanel") - .row - +table_student('monthly') - -.row.plans-table-container(data-ol-plans-v2-period='annual') - .col-sm-12(data-ol-plans-v2-view='individual' id="panel-individual" role="tabpanel") - .row - +table_individual('annual') - .col-sm-12(hidden data-ol-plans-v2-view='group' id="panel-group" role="tabpanel") - .row - +table_group('annual') - .col-sm-12(hidden data-ol-plans-v2-view='student' id="panel-student" role="tabpanel") - .row - +table_student('annual') - -//- sticky header on mobile will be "hidden" (by removing its sticky position) if it reaches this div -.invisible(aria-hidden="true" data-ol-plans-v2-table-sticky-header-stop) diff --git a/services/web/app/views/subscriptions/plans/light-redesign/_group_plan_modal.pug b/services/web/app/views/subscriptions/plans/light-redesign/_group_plan_modal.pug deleted file mode 100644 index 470c06b049..0000000000 --- a/services/web/app/views/subscriptions/plans/light-redesign/_group_plan_modal.pug +++ /dev/null @@ -1,118 +0,0 @@ -include ../../../_mixins/notification - -div.modal.fade.group-customize-subscription-modal.website-redesign-modal(tabindex="-1" role="dialog" data-ol-group-plan-modal) - .modal-dialog(role="document") - .modal-content - .modal-header - button.close( - type="button" - data-dismiss="modal" - aria-label=translate("close") - ) - i.material-symbols(aria-hidden="true") close - h1.modal-title #{translate("customize_your_group_subscription")} - h2.modal-subtitle #{translate("save_30_percent_or_more_uppercase")} - .modal-body - .container-fluid - .row - .col-md-6.text-center - .circle.circle-lg - .group-price - span(data-ol-group-plan-display-price) ... - span  /#{translate('year')} - .group-price-per-user(data-ol-group-plan-price-per-user=translate('per_user')) ... - .group-modal-features - | #{translate('each_user_will_have_access_to')}: - ul.list-unstyled - li( - hidden=(groupPlanModalDefaults.plan_code !== 'collaborator') - data-ol-group-plan-plan-code='collaborator' - ) - strong #{translate("collabs_per_proj", {collabcount:10})} - li( - hidden=(groupPlanModalDefaults.plan_code !== 'professional') - data-ol-group-plan-plan-code='professional' - ) - strong #{translate("unlimited_collabs")} - li.list-item-pro-features-header #{translate('all_premium_features')} - li #{translate('sync_dropbox_github')} - li #{translate('full_doc_history')} - li #{translate('track_changes')} - li + #{translate('more_lowercase')} - .col-md-6 - form.form(data-ol-group-plan-form) - .form-group - label(for='plan_code') - | #{translate('plan')} - for plan_code in groupPlanModalOptions.plan_codes - label.group-plan-option - input( - type="radio" - name="plan_code" - checked=(plan_code.code === groupPlanModalDefaults.plan_code) - value=plan_code.code - data-ol-group-plan-code=plan_code.code - ) - span #{translate(plan_code.i18n)} - .form-group - label(for='size') - | #{translate('number_of_users')} - select.form-control( - id="size" - event-tracking="groups-modal-group-size" - event-tracking-mb="true" - event-tracking-trigger="click" - event-tracking-element="select" - ) - for size in groupPlanModalOptions.sizes - option( - value=size - selected=(size === groupPlanModalDefaults.size) - ) #{size} - .form-group(data-ol-group-plan-form-currency) - label(for='currency') - | #{translate('currency')} - select.form-control(id="currency") - for currency in groupPlanModalOptions.currencies - option( - value=currency.code - selected=(currency.code === groupPlanModalDefaults.currency) - ) #{currency.display} - .form-group - label(for='usage') - | #{translate('educational_discount_for_groups_of_ten_or_more')} - label.group-plan-educational-discount - input( - id="usage" - type="checkbox" - autocomplete="off" - event-tracking="groups-modal-edu-discount" - event-tracking-mb="true" - event-tracking-trigger="click" - event-tracking-element="checkbox" - ) - span #{translate('educational_discount_disclaimer')} - - .row - .col-md-12 - .educational-discount-section - div(hidden=(groupPlanModalDefaults.usage !== 'educational') data-ol-group-plan-educational-discount) - .applied(hidden=true data-ol-group-plan-educational-discount-applied) - +notification({ariaLive: 'polite', content: translate('educational_discount_applied'), type: 'success', ariaLive: 'polite'}) - .ineligible(hidden=true data-ol-group-plan-educational-discount-ineligible) - +notification({ariaLive: 'polite', content: translate('educational_discount_available_for_groups_of_ten_or_more'), type: 'info', ariaLive: 'polite'}) - .modal-footer - .text-center - button.btn.btn-primary.btn-lg( - data-ol-purchase-group-plan - event-tracking="form-submitted-groups-modal-purchase-click" - event-tracking-mb="true" - event-tracking-trigger="click" - ) #{translate('purchase_now_lowercase')} - br - p #{translate('need_more_than_x_licenses', {x: '50'})} - |   - button.btn.btn-inline-link( - data-ol-open-contact-form-for-more-than-50-licenses - ) #{translate('please_get_in_touch')} - diff --git a/services/web/app/views/subscriptions/plans/light-redesign/_mixins.pug b/services/web/app/views/subscriptions/plans/light-redesign/_mixins.pug deleted file mode 100644 index b706d0d87e..0000000000 --- a/services/web/app/views/subscriptions/plans/light-redesign/_mixins.pug +++ /dev/null @@ -1,403 +0,0 @@ -include ./_table_ctas -include ./_table_short_feature_lists -include ./_table_column_headers_row - -mixin features_premium - li   - li - strong #{translate('all_premium_features')} - li #{translate('sync_dropbox_github')} - li #{translate('full_doc_history')} - li #{translate('track_changes')} - li + #{translate('more').toLowerCase()} - -mixin gen_localized_price_for_plan_view(plan, view) - span #{formatCurrency(settings.localizedPlanPricing[recommendedCurrency][plan][view], recommendedCurrency, language, true)} - -mixin currency_and_payment_methods() - .row.plans-payment-methods.text-centered - .col-xs-12 - p - b #{translate("all_prices_displayed_are_in_currency", { recommendedCurrency })} - |  #{translate("subject_to_additional_vat")} - - .plans-payment-methods-icons - img(src=buildImgPath('/other-brands/logo_mastercard.svg') aria-hidden="true") - span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Mastercard' })} - img(src=buildImgPath('/other-brands/logo_visa.svg') aria-hidden="true") - span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Visa' })} - img(src=buildImgPath('/other-brands/logo_amex.svg') aria-hidden="true") - span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Amex' })} - img(src=buildImgPath('/other-brands/logo_paypal.svg') aria-hidden="true") - span.sr-only #{translate('payment_method_accepted', { paymentMethod: 'Paypal' })} - - -mixin plans_table(period, config) - - var maxColumn = config.maxColumn || 4 - - var tableHeadKeys = Object.keys(config.tableHead) - - var highlightedColKey = tableHeadKeys[config.highlightedColumn.index] - -var highlightedColTranslationKey = config.highlightedColumn.text[period] === 'most_popular' ? 'most_popular_uppercase' : config.highlightedColumn.text[period] === 'saving_20_percent' ? 'saving_20_percent_no_exclamation' : config.highlightedColumn.text[period] - - +table_column_headers_row({maxColumn, tableHeadKeys, highlightedColKey, highlightedColTranslationKey}) - - tr(class=`plans-table-price-row plans-table-cols-${tableHeadKeys.length}`) - td - - for (const [tableHeadKey] of Object.entries(config.tableHead)) - - var highlighted = highlightedColKey === tableHeadKey - - var eventTrackingKey = config.eventTrackingKey - - var additionalEventSegmentation = config.additionalEventSegmentation || {} - - var tdClass = highlighted ? 'plans-table-green-highlighted' : '' - - td(class=tdClass) - .plans-table-cell.plans-table-cell-price - case tableHeadKey - when 'individual_free' - +table_head_price('free', period) - when 'individual_collaborator' - +table_head_price('collaborator', period) - when 'individual_professional' - +table_head_price('professional', period) - when 'group_collaborator' - +table_price_group_collaborator() - when 'group_professional' - +table_price_group_professional() - when 'group_organization' - .plans-table-comments-icon - .match-non-discounted-price-alignment   - i.material-symbols.material-symbols-outlined(aria-hidden="true") forum - when 'student_free' - +table_head_price('free', period) - when 'student_student' - +table_head_price('student', period) - - tr(class=`plans-table-cta-mobile plans-table-cols-${tableHeadKeys.length}`) - td - - for (const [tableHeadKey] of Object.entries(config.tableHead)) - - var highlighted = highlightedColKey === tableHeadKey - - var eventTrackingKey = config.eventTrackingKey - - var additionalEventSegmentation = config.additionalEventSegmentation || {} - - var tdClass = highlighted ? 'plans-table-green-highlighted' : '' - - td(class=tdClass) - .plans-table-cell.plans-table-cell-cta-mobile - .plans-table-btn-buy-container-mobile - +plans_cta(tableHeadKey, highlighted, eventTrackingKey, additionalEventSegmentation, period) - - tr(class=`plans-table-short-feature-list plans-table-cols-${tableHeadKeys.length}`) - td - - for (const [tableHeadKey, tableHeadOptions] of Object.entries(config.tableHead)) - - var highlighted = highlightedColKey === tableHeadKey - - var eventTrackingKey = config.eventTrackingKey - - var additionalEventSegmentation = config.additionalEventSegmentation || {} - - var tdClass = highlighted ? 'plans-table-green-highlighted' : '' - - td(class=tdClass) - .plans-table-cell.plans-table-cell-short-feature-list - div - case tableHeadKey - when 'individual_free' - +table_short_feature_list_free() - when 'individual_collaborator' - +table_short_feature_list_collaborator() - when 'individual_professional' - +table_short_feature_list_professional() - when 'group_collaborator' - +table_short_feature_list_group_collaborator() - when 'group_professional' - +table_short_feature_list_group_professional() - when 'group_organization' - +table_short_feature_list_group_organization(additionalEventSegmentation) - when 'student_free' - +table_short_feature_list_free() - when 'student_student' - +table_short_feature_list_student_student(tableHeadOptions.showExtraContent) - - tr(class=`plans-table-cta-desktop plans-table-cols-${tableHeadKeys.length}`) - td - - for (const [tableHeadKey] of Object.entries(config.tableHead)) - - var highlighted = highlightedColKey === tableHeadKey - - var eventTrackingKey = config.eventTrackingKey - - var additionalEventSegmentation = config.additionalEventSegmentation || {} - - var tdClass = highlighted ? 'plans-table-green-highlighted' : '' - - td(class=tdClass) - .plans-table-cell.plans-table-cell-cta-desktop - .plans-table-btn-buy-container-desktop - +plans_cta(tableHeadKey, highlighted, eventTrackingKey, additionalEventSegmentation, period) - - for featuresPerSection, featuresSectionIndex in config.features - - var dividerColspan = Object.values(config.tableHead).length + 1 - if featuresPerSection.divider - tr.plans-table-divider - td( - colspan=dividerColspan - class=((config.highlightedColumn.index === Object.keys(config.tableHead).length - 1) ? 'plans-table-last-col-highlighted' : '') - ) - .plans-table-cell-divider - //- all cells need 2 layers of divs for handling border and padding. See .plans-table in CSS for more details - .plans-table-cell-divider-content - b.plans-table-divider-label #{translate(featuresPerSection.dividerLabel)} - //- will only appear on screen width >= 768px (using CSS) - i.material-symbols.material-symbols-outlined.icon-sm( - data-toggle="tooltip" - title=translate(featuresPerSection.dividerInfo), - data-placement="top" - ) help - //- will only appear on screen width < 768px (using CSS) - span.plans-table-divider-learn-more-container - span ( - span.plans-table-divider-learn-more-text( - data-toggle="tooltip" - title=translate(featuresPerSection.dividerInfo), - data-placement="top" - ) #{translate("learn_more_lowercase")} - span ) - span.sr-only #{translate(featuresPerSection.dividerInfo)} - for feature, featureIndex in featuresPerSection.items - tr( - class=`plans-table-feature-row plans-table-cols-${tableHeadKeys.length}` - ) - th( - event-tracking="plans-page-table" - event-tracking-trigger="hover" - event-tracking-ga="subscription-funnel" - event-tracking-label=`${feature.feature}` - scope="row" - class=`${featuresSectionIndex === 0 && featureIndex === 0 ? 'plans-table-first-feature-header' : ''}` - ) - .plans-table-feature-name - .plans-table-feature-name-content - if feature.info - span #{translate(feature.feature)} - //- will only appear on screen width >= 768px (using CSS) - i.material-symbols.material-symbols-outlined.icon-sm.plans-table-feature-question-icon( - data-toggle="tooltip" - title=translate(feature.info), - data-placement="right" - ) help - //- will only appear on screen width < 768px (using CSS) - span.plans-table-feature-learn-more-container - span ( - span.plans-table-feature-learn-more-text( - data-toggle="tooltip" - title=translate(feature.info), - data-placement="top" - ) #{translate("learn_more_lowercase")} - span ) - span.sr-only #{translate(feature.info)} - else - | #{translate(feature.feature)} - for plan, planIndex in Object.keys(feature.plans) - - var tableHeadOptions = Object.values(config.tableHead)[planIndex] || {} - - var tdClass = planIndex === config.highlightedColumn.index ? 'plans-table-green-highlighted' : '' - - td(class=tdClass) - +table_cell(feature, plan) - -mixin table_individual(period) - table.plans-table.plans-table-individual - +plans_table(period, plansConfig.individual) - -mixin table_group - table.plans-table.plans-table-group - +plans_table('annual', plansConfig.group) - -mixin table_student(period) - table.plans-table.plans-table-student - +plans_table(period, plansConfig.student) - -//- group mixins - -mixin table_price_group_collaborator() - .plans-table-price-container - s - +gen_localized_price_for_plan_view('collaborator', 'annual') - p.plans-table-price - span(data-ol-plans-v2-group-price-per-user='collaborator') #{initialLocalizedGroupPrice.pricePerUser.collaborator} - p.plans-table-price-period-label - | #{translate('per_user_year')} - -mixin table_price_group_professional() - .plans-table-price-container - s - +gen_localized_price_for_plan_view('professional', 'annual') - p.plans-table-price - span(data-ol-plans-v2-group-price-per-user='professional') #{initialLocalizedGroupPrice.pricePerUser.professional} - p.plans-table-price-period-label - | #{translate('per_user_year')} - -mixin table_head_group_total_per_year(groupPlan) - - var initialLicenseSize = '2' - span.plans-group-total-price(data-ol-plans-v2-group-total-price=groupPlan) #{initialLocalizedGroupPrice.price[groupPlan]} - |   - for licenseSize in groupPlanModalOptions.sizes - span( - hidden=(licenseSize !== initialLicenseSize) - data-ol-plans-v2-table-th-group-license-size=licenseSize - ) !{translate("total_per_year_for_x_users", {licenseSize})} - -mixin group_plans_license_picker() - form.plans-license-picker-form(data-ol-plans-v2-license-picker-form) - .plans-license-picker-select-container - span #{translate("number_of_users_with_colon")} - select( - name="plans-v2-license-picker-select" - id="plans-v2-license-picker-select" - autocomplete="off" - data-ol-plans-v2-license-picker-select - event-tracking="plans-page-group-size" - event-tracking-mb="true" - event-tracking-trigger="click" - event-tracking-element="select" - ) - option(value="2") 2 - option(value="3") 3 - option(value="4") 4 - option(value="5") 5 - option(value="10") 10 - option(value="20") 20 - option(value="50") 50 - .plans-v2-license-picker-educational-discount - label(data-ol-plans-v2-license-picker-educational-discount-label) - input.plans-v2-license-picker-educational-discount-checkbox( - type="checkbox" - id="license-picker-educational-discount" - autocomplete="off" - data-ol-plans-v2-license-picker-educational-discount-input - event-tracking="plans-page-edu-discount" - event-tracking-mb="true" - event-tracking-trigger="click" - event-tracking-element="checkbox" - ) - span #{translate("apply_educational_discount")} - //- will only appear on screen width >= 768px (using CSS) - i.material-symbols.material-symbols-outlined.icon-sm( - data-toggle="tooltip" - title=translate("apply_educational_discount_info"), - data-placement="bottom" - ) help - - //- will only appear on screen width < 768px (using CSS) - span.plans-v2-license-picker-educational-discount-learn-more-container - span ( - span.plans-v2-license-picker-educational-discount-learn-more-text( - data-toggle="tooltip" - title=translate("apply_educational_discount_info"), - data-placement="bottom" - ) #{translate("learn_more_lowercase")} - span ) - span.sr-only #{translate("apply_educational_discount_info")} - -//- all plans mixins - -mixin table_head_price(plan, period) - div.plans-table-price-container - if plan !== 'free' && period === 'annual' - s - +gen_localized_price_for_plan_view(plan, 'monthlyTimesTwelve') - else - .match-non-discounted-price-alignment   - p.plans-table-price - +gen_localized_price_for_plan_view(plan, period) - p.plans-table-price-period-label - if period == 'annual' - | #{translate("per_year")} - else - | #{translate("per_month")} - -mixin table_cell(feature, plan) - - var planValue = feature.plans[plan] - - var featureName = feature.feature - - .plans-table-cell - .plans-table-cell-content( - data-ol-plans-v2-table-cell-plan=plan - data-ol-plans-v2-table-cell-feature=featureName - ) - if (feature.value === 'str') - | !{translate(planValue, {}, ['strong'])} - else if (feature.value === 'bool') - if (planValue) - i.material-symbols.material-symbols-outlined.icon-green-round-background.icon-sm(aria-hidden="true") check - span.sr-only #{translate("feature_included")} - else - span(aria-hidden="true") - - span.sr-only #{translate("feature_not_included")} - -//- table header and control mixins - -mixin plans_table_sticky_header(withSwitch, config) - - var tableHeadKeys = Object.keys(config.tableHead) - .row.plans-table-sticky-header.sticky( - data-ol-plans-v2-table-sticky-header - class=(withSwitch ? 'plans-table-sticky-header-with-switch' : 'plans-table-sticky-header-without-switch') - ) - - for (var i = 0; i < tableHeadKeys.length; i++) - - var tableHeadKey = tableHeadKeys[i] - - var translateKey = tableHeadKey.split('_')[1] - - - if (config.highlightedColumn.index === i) { - var elClass = 'plans-table-sticky-header-item-green-highlighted' - } else { - var elClass = '' - } - .plans-table-sticky-header-item( - class=elClass - ) - case tableHeadKey - when 'individual_collaborator' - span #{translate('standard')} - when 'group_professional' - span #{translate(tableHeadKey)} - when 'group_collaborator' - span #{translate('group_standard')} - default - span #{translate(translateKey)} - -mixin table_sticky_header_all(plansConfig) - .row.plans-table-sticky-header-container( - data-ol-plans-v2-view='individual' - ) - +plans_table_sticky_header(true, plansConfig.individual) - .row.plans-table-sticky-header-container( - hidden - data-ol-plans-v2-view='group' - ) - +plans_table_sticky_header(false, plansConfig.group) - .row.plans-table-sticky-header-container( - hidden - data-ol-plans-v2-view='student' - ) - +plans_table_sticky_header(true, plansConfig.student) - -mixin monthly_annual_switch(initialState, eventTracking, eventSegmentation) - - var monthlyAnnualToggleChecked = initialState === 'monthly' - .row - .col-md-4.col-md-offset-4.text-centered.monthly-annual-switch(data-ol-plans-v2-m-a-switch-container) - .monthly-annual-switch-text - span.underline(data-ol-plans-v2-m-a-switch-text='annual') #{translate("annual")} - .tooltip.in.left( - role="tooltip" - data-ol-plans-v2-m-a-tooltip - class=monthlyAnnualToggleChecked ? 'plans-v2-m-a-tooltip-monthly-selected' : '' - ) - .tooltip-arrow - .tooltip-inner - span(hidden=!monthlyAnnualToggleChecked data-ol-tooltip-period='monthly') #{translate("save_20_percent_by_paying_annually")} - span(hidden=monthlyAnnualToggleChecked data-ol-tooltip-period='annual') #{translate("saving_20_percent")} - - label(data-ol-plans-v2-m-a-switch) - input( - type="checkbox" - checked=monthlyAnnualToggleChecked - role="switch" - aria-label=translate("select_monthly_plans") - autocomplete="off" - event-tracking=eventTracking - event-tracking-mb="true" - event-tracking-trigger="click" - event-tracking-element="checkbox" - event-segmentation=eventSegmentation - ) - span - span(data-ol-plans-v2-m-a-switch-text='monthly') #{translate("monthly")} diff --git a/services/web/app/views/subscriptions/plans/light-redesign/_table_column_headers_row.pug b/services/web/app/views/subscriptions/plans/light-redesign/_table_column_headers_row.pug deleted file mode 100644 index 56baa1405a..0000000000 --- a/services/web/app/views/subscriptions/plans/light-redesign/_table_column_headers_row.pug +++ /dev/null @@ -1,34 +0,0 @@ -mixin table_column_headers_row({maxColumn, tableHeadKeys, highlightedColKey, highlightedColTranslationKey}) - tr(class=`plans-table-cols-${tableHeadKeys.length}`) - th - - for (var i = 0; i < maxColumn; i++) - - var tableHeadKey = tableHeadKeys[i] - - var highlighted = highlightedColKey === tableHeadKey - - var thClass = highlighted ? 'plans-table-green-highlighted' : '' - - th( - class=thClass - scope="col" - ) - .plans-table-th - if (highlighted) - p.plans-table-green-highlighted-text #{translate(highlightedColTranslationKey)} - .plans-table-th-content - if tableHeadKey - case tableHeadKey - when 'individual_free' - | #{translate("free")} - when 'individual_collaborator' - | #{translate("standard")} - when 'individual_professional' - | #{translate("professional")} - when 'group_collaborator' - | #{translate("group_standard")} - when 'group_professional' - | #{translate("group_professional")} - when 'group_organization' - | #{translate("organization")} - when 'student_free' - | #{translate("free")} - when 'student_student' - | #{translate("student")} diff --git a/services/web/app/views/subscriptions/plans/light-redesign/_table_ctas.pug b/services/web/app/views/subscriptions/plans/light-redesign/_table_ctas.pug deleted file mode 100644 index 6331d33daf..0000000000 --- a/services/web/app/views/subscriptions/plans/light-redesign/_table_ctas.pug +++ /dev/null @@ -1,146 +0,0 @@ -mixin btn_buy_individual(highlighted, eventTrackingKey, subscriptionPlan, period) - a.btn( - data-ol-start-new-subscription=subscriptionPlan - data-ol-event-tracking-key=eventTrackingKey - data-ol-item-view=period - class=(highlighted ? 'btn-primary' : 'btn-secondary') - ) - if (period === 'monthly') - span #{translate("try_for_free")} - else - span #{translate("buy_now_no_exclamation_mark")} - -mixin btn_buy_individual_free() - if (!getSessionUser()) - a.btn( - href="/register" - class=(highlighted ? 'btn-primary' : 'btn-secondary') - ) - span #{translate("try_for_free")} - else - a.btn.invisible( - aria-hidden="true" - class=(highlighted ? 'btn-primary' : 'btn-secondary') - ) - -mixin btn_buy_individual_collaborator(highlighted, eventTrackingKey, additionalEventSegmentation, period) - +btn_buy_individual(highlighted, eventTrackingKey, 'collaborator', period) - if (period === 'monthly') - +additional_link_buy(eventTrackingKey, additionalEventSegmentation, 'collaborator', period) - -mixin btn_buy_individual_professional(highlighted, eventTrackingKey, additionalEventSegmentation, period) - +btn_buy_individual(highlighted, eventTrackingKey, 'professional', period) - if (period === 'monthly') - +additional_link_buy(eventTrackingKey, additionalEventSegmentation, 'professional', period) - -mixin btn_buy_group_collaborator(highlighted, eventTrackingKey) - a.btn( - data-ol-start-new-subscription='group_collaborator' - data-ol-event-tracking-key=eventTrackingKey - data-ol-item-view='annual' - data-ol-has-custom-href - data-ol-location='table-header' - class=(highlighted ? 'btn-primary' : 'btn-secondary') - ) - span.visible-mobile-and-tablet #{translate("customize")} - span.visible-desktop #{translate("customize_your_plan")} - -mixin btn_buy_group_professional(highlighted, eventTrackingKey) - a.btn( - data-ol-start-new-subscription='group_professional' - data-ol-event-tracking-key=eventTrackingKey - data-ol-item-view='annual' - data-ol-has-custom-href - data-ol-location='table-header' - class=(highlighted ? 'btn-primary' : 'btn-secondary') - ) - span.visible-mobile-and-tablet #{translate("customize")} - span.visible-desktop #{translate("customize_your_plan")} - -mixin btn_buy_group_organization(highlighted, eventTrackingKey) - a.btn( - data-ol-start-new-subscription='group_organization' - data-ol-event-tracking-key=eventTrackingKey - data-ol-item-view='annual' - data-ol-has-custom-href - data-ol-location='table-header' - href='/for/contact-sales' - target='_blank' - class=(highlighted ? 'btn-primary' : 'btn-secondary') - ) - span #{translate("contact_us_lowercase")} - -mixin btn_buy_student_free(highlighted) - if (!getSessionUser()) - a.btn( - href="/register" - class=(highlighted ? 'btn-primary' : 'btn-secondary') - ) - span #{translate("try_for_free")} - -mixin btn_buy_student_student(highlighted, eventTrackingKey, additionalEventSegmentation, period) - a.btn( - data-ol-start-new-subscription='student' - data-ol-event-tracking-key=eventTrackingKey - data-ol-item-view=period - data-ol-location='card' - class=(highlighted ? 'btn-primary' : 'btn-secondary') - ) - if (period === 'monthly') - span #{translate("try_for_free")} - else - span #{translate("buy_now_no_exclamation_mark")} - if (period === 'monthly') - +additional_link_buy(eventTrackingKey, additionalEventSegmentation, 'student', period) - -mixin additional_link_group(eventTrackingKey, additionalEventSegmentation, plan) - - var buttonSegmentation = plan + '-link' - - additionalEventSegmentation = additionalEventSegmentation || {} - - var segmentation = { ...additionalEventSegmentation, button: buttonSegmentation, location: 'table-header' } - - a.btn.btn-bg-ghost( - href="/for/contact-sales" - target="_blank" - event-tracking=eventTrackingKey - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation=segmentation - ) #{translate("contact_us_lowercase")} - -mixin additional_link_buy(eventTrackingKey, additionalEventSegmentation, plan, period) - - var buttonSegmentation = plan + '-link' - - additionalEventSegmentation = additionalEventSegmentation || {} - - var segmentation = { ...additionalEventSegmentation, button: buttonSegmentation, location: 'table-header', period } - - var itmCampaign = itm_campaign ? { itm_campaign } : {itm_campaign: 'plans'} - - var itmReferrer = itm_referrer ? { itm_referrer } : {} - - var qs = new URLSearchParams({planCode: plan, currency: recommendedCurrency, itm_content: 'card', ...itmCampaign, ...itmReferrer}) - - a.btn.btn-bg-ghost( - href=`/user/subscription/new?${qs.toString()}` - event-tracking=eventTrackingKey - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation=segmentation - ) #{translate("buy_now_no_exclamation_mark")} - -mixin plans_cta(tableHeadKey, highlighted, eventTrackingKey, additionalEventSegmentation, period) - case tableHeadKey - when 'individual_free' - +btn_buy_individual_free() - when 'individual_collaborator' - +btn_buy_individual_collaborator(highlighted, eventTrackingKey, additionalEventSegmentation, period) - when 'individual_professional' - +btn_buy_individual_professional(highlighted, eventTrackingKey, additionalEventSegmentation, period) - when 'group_collaborator' - +btn_buy_group_collaborator(highlighted, eventTrackingKey) - +additional_link_group(eventTrackingKey, additionalEventSegmentation, 'group_collaborator') - when 'group_professional' - +btn_buy_group_professional(highlighted, eventTrackingKey) - +additional_link_group(eventTrackingKey, additionalEventSegmentation, 'group_professional') - when 'group_organization' - +btn_buy_group_organization(highlighted, eventTrackingKey) - small.plans-table-th-content-additional-link.invisible(aria-hidden="true") - when 'student_free' - +btn_buy_student_free(highlighted) - when 'student_student' - +btn_buy_student_student(highlighted, eventTrackingKey, additionalEventSegmentation, period) diff --git a/services/web/app/views/subscriptions/plans/light-redesign/_table_short_feature_lists.pug b/services/web/app/views/subscriptions/plans/light-redesign/_table_short_feature_lists.pug deleted file mode 100644 index bdcce6f0e7..0000000000 --- a/services/web/app/views/subscriptions/plans/light-redesign/_table_short_feature_lists.pug +++ /dev/null @@ -1,49 +0,0 @@ -mixin table_short_feature_list_free() - ul - li #{translate("one_collaborator")} - -mixin table_short_feature_list_collaborator() - ul - li !{translate("x_collaborators_per_project", {collaboratorsCount: '10'})} - li #{translate("all_premium_features")} - -mixin table_short_feature_list_professional() - ul - li !{translate("unlimited_collabs_rt",{},["b"])} - li #{translate("all_premium_features")} - -mixin table_short_feature_list_group_collaborator() - ul - li #{translate("up_to")} !{translate("x_collaborators_per_project", {collaboratorsCount: '10'})} - li - +table_head_group_total_per_year('collaborator') - -mixin table_short_feature_list_group_professional() - ul - li #{translate("unlimited_collaborators_in_each_project")} - li - +table_head_group_total_per_year('professional') - -mixin table_short_feature_list_group_organization(additionalEventSegmentation) - - additionalEventSegmentation = additionalEventSegmentation || {} - - var segmentation = { ...additionalEventSegmentation, button: 'group_organization-link', location: 'table-header-list', period: 'annual', currency: recommendedCurrency} - ul - li #{translate("best_choices_companies_universities_non_profits")} - li #{translate("for_groups_or_site_wide")} - li - a.inline-green-link( - target="_blank" - href="/for/contact-sales" - event-tracking="plans-page-click" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation=segmentation - ) #{translate("also_available_as_on_premises")} - -mixin table_short_feature_list_student_student(showExtraContent) - ul - li !{translate("x_collaborators_per_project", {collaboratorsCount: '6'})} - li #{translate("all_premium_features")} - if showExtraContent - li - b !{translate("for_students_only")} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 82aebaf28f..9146adc92c 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -131,7 +131,6 @@ "all": "All", "all_borders": "All borders", "all_features_in_group_standard_plus": "All features in Group Standard, plus:", - "all_our_group_plans_offer_educational_discount": "All of our <0>group plans offer an <1>educational discount for students and faculty", "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__.", @@ -145,7 +144,6 @@ "already_have_sl_account": "Already have an __appName__ account?", "already_subscribed_try_refreshing_the_page": "Already subscribed? Try refreshing the page.", "also": "Also", - "also_available_as_on_premises": "Also available as On-Premises", "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.", "an_error_occured_while_restoring_project": "An error occured while restoring the project", @@ -158,7 +156,6 @@ "anyone_with_link_can_view": "Anyone with this link can view this project", "app_on_x": "__appName__ on __social__", "apply_educational_discount": "Apply educational discount", - "apply_educational_discount_info": "Overleaf offers a 40% educational discount for groups of 10 or more. Applies to students or faculty using Overleaf for teaching.", "apply_educational_discount_info_2025_pricing": "40% discount for groups using __appName__ for teaching", "apply_educational_discount_info_new": "40% discount for groups of 10 or more using __appName__ for teaching", "apply_suggestion": "Apply suggestion", @@ -193,7 +190,6 @@ "autocompile_disabled_reason": "Due to high server load, background recompilation has been temporarily disabled. Please recompile by clicking the button above.", "autocomplete": "Autocomplete", "autocomplete_references": "Reference Autocomplete (inside a \\cite{} block)", - "automatic_user_registration": "automatic user registration", "automatic_user_registration_uppercase": "Automatic user registration", "back": "Back", "back_to_account_settings": "Back to account settings", @@ -207,7 +203,6 @@ "basic_compile_timeout_on_fast_servers": "Basic compile timeout on fast servers", "become_an_advisor": "Become an __appName__ advisor", "before_you_use_error_assistant": "Before you use Error Assist", - "best_choices_companies_universities_non_profits": "Best choice for companies, universities and non-profits", "beta": "Beta", "beta_feature_badge": "Beta feature badge", "beta_program_already_participating": "You are enrolled in the Beta Program", @@ -352,11 +347,9 @@ "compile_larger_projects": "Compile larger projects", "compile_mode": "Compile Mode", "compile_servers": "Compile servers", - "compile_servers_info": "Compiles for users on premium plans always run on a dedicated pool of the fastest available 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_timeout_short": "Compile timeout", - "compile_timeout_short_info_basic": "This is how much time you get to compile your project on the Overleaf servers. You may need additional time for longer or more complex projects.", "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", @@ -440,12 +433,9 @@ "currently_subscribed_to_plan": "You are currently subscribed to the <0>__planName__ plan.", "custom": "Custom", "custom_borders": "Custom borders", - "custom_resource_portal": "Custom resource portal", - "custom_resource_portal_info": "You can have your own custom portal page on Overleaf. This is a great place for your users to find out more about Overleaf, access templates, FAQs and Help resources, and sign up to Overleaf.", "customer_resource_portal": "Customer resource portal", "customize": "Customize", "customize_your_group_subscription": "Customize your group subscription", - "customize_your_plan": "Customize your plan", "customizing_figures": "Customizing figures", "customizing_tables": "Customizing tables", "da": "Danish", @@ -455,7 +445,6 @@ "dealing_with_errors": "Dealing with errors", "december": "December", "dedicated_account_manager": "Dedicated account manager", - "dedicated_account_manager_info": "Our Account Management Team will be able to assist with requests, questions and to help you spread the word about Overleaf with promotional materials, training resources and webinars.", "default": "Default", "delete": "Delete", "delete_account": "Delete Account", @@ -548,7 +537,6 @@ "dropbox_duplicate_project_names_suggestion": "Please make your project names unique across all your <0>active, archived and trashed projects and then re-link your Dropbox account.", "dropbox_email_not_verified": "We have been unable to retrieve updates from your Dropbox account. Dropbox reported that your email address is unverified. Please verify your email address in your Dropbox account to resolve this.", "dropbox_for_link_share_projs": "This project was accessed via link-sharing and won’t be synchronised to your Dropbox unless you are invited via e-mail by the project owner.", - "dropbox_integration_info": "Work online and offline seamlessly with two-way Dropbox sync. Changes you make locally will be sent automatically to the version on Overleaf and vice versa.", "dropbox_integration_lowercase": "Dropbox integration", "dropbox_successfully_linked_description": "Thanks, we’ve successfully linked your Dropbox account to __appName__.", "dropbox_sync": "Dropbox Sync", @@ -592,10 +580,6 @@ "editor_theme": "Editor theme", "educational_disclaimer": "I confirm that users will be students or faculty using Overleaf primarily for study and teaching, and can provide evidence of this if requested.", "educational_disclaimer_heading": "Educational discount confirmation", - "educational_discount_applied": "40% educational discount applied!", - "educational_discount_available_for_groups_of_ten_or_more": "The educational discount is available for groups of 10 or more", - "educational_discount_disclaimer": "This license is for educational purposes (applies to students or faculty using Overleaf for teaching)", - "educational_discount_for_groups_of_ten_or_more": "Overleaf offers a 40% educational discount for groups of 10 or more.", "educational_discount_for_groups_of_x_or_more": "The educational discount is available for groups of __size__ or more", "educational_percent_discount_applied": "__percent__% educational discount applied!", "email": "Email", @@ -671,26 +655,7 @@ "failed_to_send_group_invite_to_email": "Failed to send Group invite to <0>__email__. Please try again later.", "failed_to_send_managed_user_invite_to_email": "Failed to send Managed User invite to <0>__email__. Please try again later.", "failed_to_send_sso_link_invite_to_email": "Failed to send SSO invite reminder to <0>__email__. Please try again later.", - "faq_change_plans_or_cancel_answer": "Yes, you can do this at any time via your subscription settings. You can change plans, switch between monthly and annual billing options, or cancel to downgrade to the free plan. When cancelling, your subscription will continue until the end of the billing period. If your account temporarily does not have a subscription, the only change will be to the features available to you. Your projects will always be available on your account.", - "faq_change_plans_or_cancel_question": "Can I change plans or cancel later?", - "faq_do_collab_need_on_paid_plan_answer": "No, they can be on any plan, including the free plan. If you are on a premium plan, some premium features will be available to your collaborators in projects that you have created, even if those collaborators are on the free plan. For more information, read about <0>account and subscriptions and <1>how premium features work.", - "faq_do_collab_need_on_paid_plan_question": "Do my collaborators also need to be on a paid plan?", - "faq_how_does_a_group_plan_work_answer": "Group subscriptions are a way to upgrade more than one Overleaf account. They are easy to manage, help to save on paperwork, and reduce the cost of purchasing multiple subscriptions separately. To learn more, read about <0>joining a group subscription and <1>managing a group subscription. You can purchase group subscriptions above or by <2>contacting us.", - "faq_how_does_a_group_plan_work_question": "How does a group plan work? How can I add people to the plan?", "faq_how_does_free_trial_works_answer": "You get full access to your chosen __appName__ plan during your __len__-day free trial. There is no obligation to continue beyond the trial. Your card will be charged at the end of your __len__ day trial unless you cancel before then. You can cancel via your subscription settings.", - "faq_how_free_trial_works_answer_v2": "You get full access to your chosen premium plan during your __len__ day free trial, and there is no obligation to continue beyond the trial. Your card will be charged at the end of your trial unless you cancel before then. To cancel, go to your subscription settings in your account (the trial will continue for the full __len__ days).", - "faq_how_free_trial_works_question": "How does the free trial work?", - "faq_i_have_free_account_want_subscription_how_answer_first_paragraph": "In Overleaf, every user creates and manages their own Overleaf account. Most users start on the free plan but can upgrade and enjoy the premium features by subscribing to a plan, joining a group subscription or joining a <0>Commons subscription. When you purchase, join or leave a subscription, you can still keep the same Overleaf account.", - "faq_i_have_free_account_want_subscription_how_answer_second_paragraph": "To find out more, read more about <0>how accounts and subscriptions work together in Overleaf.", - "faq_i_have_free_account_want_subscription_how_question": "I have a free account and want to join a subscription, how do I do that?", - "faq_pay_by_invoice_answer_v2": "Yes, if you’d like to purchase a group subscription for five or more people, or a site license. For individual subscriptions we can only accept payment online via credit card, debit card or PayPal.", - "faq_pay_by_invoice_question": "Can I pay by invoice / purchase order?", - "faq_the_individual_standard_plan_10_collab_first_paragraph": "No. Only the subscriber’s account will be upgraded. An individual Standard subscription allows you to invite 10 collaborators to each project owned by you.", - "faq_the_individual_standard_plan_10_collab_question": "The individual Standard plan has 10 project collaborators, does it mean that 10 people will be upgraded?", - "faq_the_individual_standard_plan_10_collab_second_paragraph": "While working on a project that you, as a subscriber, share with them, your collaborators will be able to access some premium features such as the full document history and extended compile time for that particular project. Inviting them to a particular project does not upgrade their accounts overall, however. Read more about <0>which features are per project, and which are per account.", - "faq_what_is_the_difference_between_users_and_collaborators_answer_first_paragraph": "In Overleaf, every user creates their own account. You can create projects that only you work on, and you can also invite others to view or work with you on projects that you own. Users that you share your project with are called <0>collaborators. We sometimes refer to them as project collaborators.", - "faq_what_is_the_difference_between_users_and_collaborators_answer_second_paragraph": "In other words, collaborators are just other Overleaf users that you are working with on one of your projects.", - "faq_what_is_the_difference_between_users_and_collaborators_question": "What’s the difference between users and collaborators?", "fast": "Fast", "fastest": "Fastest", "feature_included": "Feature included", @@ -746,14 +711,12 @@ "for_business": "For business", "for_enterprise": "For enterprise", "for_government": "For government", - "for_groups_or_site_wide": "For groups or site-wide", "for_individuals_and_groups": "For individuals & groups", "for_large_institutions_and_organizations_need_sitewide_on_premise": "For large institutions and organizations that need site-wide access or an on-premises solution.", "for_more_information_see_managed_accounts_section": "For more information, see the \"Managed Accounts\" section in <0>our terms of use, which you agree to by clicking Accept invitation.", "for_publishers": "For publishers", "for_small_teams_and_departments_who_want_to_write_collaborate": "For small teams and departments who want to write and collaborate easily in LaTeX.", "for_students": "For students", - "for_students_only": "For students only", "for_teaching": "For teaching", "for_teams_and_organizations_who_want_a_streamlined_sso_and_security": "For teams and organizations who want a streamlined sign-on process and our strongest cloud security.", "for_universities": "For universities", @@ -761,7 +724,6 @@ "forgot_your_password": "Forgot your password", "format": "Format", "found_matching_deleted_users": "Found __deletedUserCount__ matching deleted users", - "four_minutes": "4 minutes", "fr": "French", "free": "Free", "free_7_day_trial_billed_annually": "Free 7-day trial, then billed annually", @@ -779,7 +741,6 @@ "from_provider": "From __provider__", "from_url": "From URL", "full_doc_history": "Full document history", - "full_doc_history_info_v2": "You can see all the edits in your project and who made every change. Add labels to quickly access specific versions.", "full_document_history": "Full document <0>history", "full_project_search": "Full Project Search", "full_width": "Full width", @@ -831,8 +792,6 @@ "git_gitHub_dropbox_mendeley_and_zotero_integrations": "Git, GitHub, Dropbox, Mendeley, 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_integration_lowercase": "Git integration", - "git_integration_lowercase_info": "You can clone your Overleaf project to a local repository, treating your Overleaf project as a remote repository that changes can be pushed to and pulled from.", "github": "GitHub", "github_commit_message_placeholder": "Commit message for changes made in __appName__...", "github_credentials_expired": "Your GitHub authorization credentials have expired", @@ -847,8 +806,6 @@ "github_large_files_error": "Merge failed: your GitHub repository contains files over the 50mb file size limit ", "github_merge_failed": "Your changes in __appName__ and GitHub could not be automatically merged. Please manually merge the <0>__sharelatex_branch__ branch into the default branch in git. Click below to continue, after you have manually merged.", "github_no_master_branch_error": "This repository cannot be imported as it is missing a default branch. Please make sure the project has a default branch", - "github_only_integration_lowercase": "GitHub integration", - "github_only_integration_lowercase_info": "Link your Overleaf projects directly to a GitHub repository that acts as a remote repository for your overleaf project. This allows you to share with collaborators outside of Overleaf, and integrate Overleaf into more complex workflows.", "github_private_description": "You choose who can see and commit to this repository.", "github_public_description": "Anyone can see this repository. You choose who can commit.", "github_repository_diverged": "The default branch of the linked repository has been force-pushed. Pulling GitHub changes after a force push can cause Overleaf and GitHub to get out of sync. You might need to push changes after pulling to get back in sync.", @@ -886,22 +843,15 @@ "great_for_small_teams_and_departments": "Great for small teams and departments", "group": "Group", "group_admin": "Group admin", - "group_admins_get_access_to": "Group admins get access to", - "group_admins_get_access_to_info": "Special features available only on group plans.", "group_full": "This group is already full", "group_invitations": "Group invitations", "group_invite_has_been_sent_to_email": "Group invite has been sent to <0>__email__", "group_libraries": "Group Libraries", "group_managed_by_group_administrator": "User accounts in this group are managed by the group administrator.", - "group_members_and_collaborators_get_access_to": "Group members and their project collaborators get access to", - "group_members_and_their_collaborators_get_access_to_info": "These features are available to group members and their collaborators (other Overleaf users invited to projects owned by a group member).", - "group_members_get_access_to": "Group members get access to", - "group_members_get_access_to_info": "These features are available only to group members (subscribers).", "group_plan_admins_can_easily_add_and_remove_users_from_a_group": "Group plan admins can easily add and remove users from a group. For site-wide plans, users are automatically upgraded when they register or add their email address to Overleaf (domain-based enrollment or SSO).", "group_plan_tooltip": "You are on the __plan__ plan as a member of a group subscription. Click to find out how to make the most of your Overleaf premium features.", "group_plan_upgrade_description": "You’re on the <0>__currentPlan__ plan and you’re upgrading to the <0>__nextPlan__ plan. If you’re interested in a site-wide Overleaf Commons plan please <1>get in touch.", "group_plan_with_name_tooltip": "You are on the __plan__ plan as a member of a group subscription, __groupName__. Click to find out how to make the most of your Overleaf premium features.", - "group_plans": "Group Plans", "group_professional": "Group Professional", "group_sso_configuration_idp_metadata": "The information you provide here comes from your Identity Provider (IdP). This is often referred to as its <0>SAML metadata. You can add this manually or click <1>Import IdP metadata to import an XML file.", "group_sso_configure_service_provider_in_idp": "For some IdPs, you must configure Overleaf as a Service Provider to get the data you need to fill out this form. To do this, you will need to download the Overleaf metadata.", @@ -1005,7 +955,6 @@ "imported_from_zotero_at_date": "Imported from Zotero at __formattedDate__ __relativeDate__", "importing": "Importing", "importing_and_merging_changes_in_github": "Importing and merging changes in GitHub", - "in_good_company": "You’re In Good Company", "in_order_to_have_a_secure_account_make_sure_your_password": "To help keep your account secure, make sure your new password:", "in_order_to_match_institutional_metadata_2": "In order to match your institutional metadata, we’ve linked your account using <0>__email__.", "in_order_to_match_institutional_metadata_associated": "In order to match your institutional metadata, your account is associated with the email __email__.", @@ -1016,7 +965,6 @@ "include_the_error_message_and_ai_response": "Include the error message and AI response", "increased_compile_timeout": "Increased compile timeout", "individuals": "Individuals", - "indvidual_plans": "Individual Plans", "info": "Info", "inr_discount_modal_info": "Get document history, track changes, additional collaborators, and more at Purchasing Power Parity prices.", "inr_discount_modal_title": "70% off all Overleaf premium plans for users in India", @@ -1158,7 +1106,6 @@ "learn_more_about_link_sharing": "Learn more about Link Sharing", "learn_more_about_managed_users": "Learn more about Managed Users.", "learn_more_about_other_causes_of_compile_timeouts": "<0>Learn more about other causes of compile timeouts and how to fix them.", - "learn_more_lowercase": "learn more", "leave": "Leave", "leave_any_group_subscriptions": "Leave any group subscriptions other than the one that will be managing your account. <0>Leave them from the Subscription page.", "leave_group": "Leave group", @@ -1279,8 +1226,6 @@ "managed_user_accounts": "Managed user accounts", "managed_user_invite_has_been_sent_to_email": "Managed User invite has been sent to <0>__email__", "managed_users": "Managed Users", - "managed_users_accounts": "Managed user accounts", - "managed_users_accounts_plan_info": "Managed Users gives you more control over your group’s use of Overleaf. It ensures tighter management of user access and deletion and allows you to keep control of projects when someone leaves the group.", "managed_users_explanation": "Managed Users ensures you stay in control of your organization’s projects and who owns them. <0>Read more about Managed Users.", "managed_users_gives_gives_you_more_control_over_your_group": "Managed Users gives you more control over your group’s use of __appName__. It ensures tighter management of user access and deletion and allows you to keep control of your projects when someone leaves the group.", "managed_users_is_enabled": "Managed Users is enabled", @@ -1294,8 +1239,6 @@ "marked_as_resolved": "Marked as resolved", "math_display": "Math Display", "math_inline": "Math Inline", - "max_collab_per_project": "Max. collaborators per project", - "max_collab_per_project_info": "The number of people you can invite to work on each project. They just need to have an Overleaf account. They can be different people in each project.", "maximum_files_uploaded_together": "Maximum __max__ files uploaded together", "may": "May", "maybe_later": "Maybe later", @@ -1307,8 +1250,6 @@ "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_lowercase": "Mendeley integration", - "mendeley_integration_lowercase_info": "Manage your reference library in Mendeley, and link it directly to .bib files in Overleaf, so you can easily cite anything from your libraries.", "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", @@ -1328,12 +1269,10 @@ "more_actions": "More actions", "more_comments": "More comments", "more_info": "More Info", - "more_lowercase": "more", "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": "most popular", "most_popular_uppercase": "Most popular", "must_be_email_address": "Must be an email address", "must_be_purchased_online": "Must be purchased online", @@ -1358,7 +1297,6 @@ "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", "need_more_than_50_users": "Need more than 50 users?", - "need_more_than_to_licenses_get_in_touch": "Need more than 50 licenses? Please get in touch", "need_more_than_x_licenses": "Need more than __x__ licenses?", "need_to_add_new_primary_before_remove": "You’ll need to add a new primary email address before you can remove this one.", "need_to_leave": "Need to leave?", @@ -1438,8 +1376,6 @@ "number_collab_info": "The number of people you can invite to work on a project with you. The limit is per project, so you can invite different people to each project.", "number_of_projects": "Number of projects", "number_of_users": "Number of users", - "number_of_users_info": "The number of users that can upgrade their Overleaf account if you purchase this plan.", - "number_of_users_with_colon": "Number of users:", "oauth_orcid_description": " Securely establish your identity by linking your ORCID iD to your __appName__ account. Submissions to participating publishers will automatically include your ORCID iD for improved workflow and visibility. ", "october": "October", "off": "Off", @@ -1449,12 +1385,10 @@ "ok_join_project": "OK, join project", "on": "On", "on_free_plan_upgrade_to_access_features": "You are on the __appName__ Free plan. Upgrade to access these <0>Premium features", - "one_collaborator": "Only one collaborator", "one_collaborator_per_project": "1 collaborator per project", "one_free_collab": "One free collaborator", "one_per_project": "1 per project", "one_step_away_from_professional_features": "You are one step away from accessing <0>Overleaf Professional features!", - "one_user": "1 user", "ongoing_experiments": "Ongoing experiments", "online_latex_editor": "Online LaTeX Editor", "only_group_admin_or_managers_can_delete_your_account_1": "By becoming a managed user, your organization will have admin rights over your account and control over your stuff, including the right to close your account and access, delete and share your stuff. As a result:", @@ -1569,7 +1503,6 @@ "personal": "Personal", "personal_library": "Personal library", "personalized_onboarding": "Personalized onboarding", - "personalized_onboarding_info": "We’ll help you get everything set up and then we’re here to answer questions from your users about the platform, templates or LaTeX!", "pl": "Polish", "plan": "Plan", "plan_tooltip": "You’re on the __plan__ plan. Click to find out how to make the most of your Overleaf premium features.", @@ -1612,8 +1545,6 @@ "position": "Position", "postal_code": "Postal Code", "postal_code_sentence_case": "Postal code", - "powerful_latex_editor_and_realtime_collaboration": "Powerful LaTeX editor & real-time collaboration", - "powerful_latex_editor_and_realtime_collaboration_info": "Spell check, intelligent autocomplete, syntax highlighting, dozens of color themes, vim and emacs bindings, help with LaTeX warnings and error messages, and more. Everyone always has the latest version, and you can see your collaborators’ cursors and changes in real time.", "premium_feature": "Premium feature", "premium_features": "Premium features", "premium_plan_label": "You’re using Overleaf Premium", @@ -1631,7 +1562,6 @@ "primary_certificate": "Primary certificate", "primary_email_check_question": "Is <0>__email__ still your email address?", "priority_support": "Priority support", - "priority_support_info": "Our helpful Support team will prioritise and escalate your support requests where necessary.", "privacy": "Privacy", "privacy_and_terms": "Privacy and Terms", "privacy_policy": "Privacy Policy", @@ -1656,7 +1586,6 @@ "project_layout_sharing_submission": "Project Layout, Sharing, and Submission", "project_name": "Project Name", "project_not_linked_to_github": "This project is not linked to a GitHub repository. You can create a repository for it in GitHub:", - "project_owner_plus_10": "Project author + 10", "project_ownership_transfer_confirmation_1": "Are you sure you want to make <0>__user__ the owner of <1>__project__?", "project_ownership_transfer_confirmation_2": "This action cannot be undone. The new owner will be notified and will be able to change project access settings (including removing your own access).", "project_renamed_or_deleted": "Project Renamed or Deleted", @@ -1683,8 +1612,6 @@ "publisher_account": "Publisher Account", "publishing": "Publishing", "pull_github_changes_into_sharelatex": "Pull GitHub changes into __appName__", - "purchase_now": "Purchase Now", - "purchase_now_lowercase": "Purchase now", "push_sharelatex_changes_to_github": "Push __appName__ changes to GitHub", "push_to_github_pull_to_overleaf": "Push to GitHub, pull to __appName__", "quoted_text": "Quoted text", @@ -1706,7 +1633,6 @@ "ready_to_use_templates": "Ready-to-use templates", "real_time_track_changes": "Real-time track-changes", "realtime_track_changes": "Real-time track changes", - "realtime_track_changes_info_v2": "Switch on track changes to see who made every change, accept or reject others’ changes, and write comments.", "reasons_for_compile_timeouts": "Reasons for compile timeouts", "reauthorize_github_account": "Reauthorize your GitHub Account", "recaptcha_conditions": "The site is protected by reCAPTCHA and the Google <1>Privacy Policy and <2>Terms of Service apply.", @@ -1731,7 +1657,6 @@ "reference_managers": "Reference managers", "reference_search": "Advanced reference search", "reference_search_info_new": "Find your references easily—search by author, title, year, or journal.", - "reference_search_info_v2": "It’s easy to find your references - you can search by author, title, year or journal. You can still search by citation key too.", "reference_search_setting": "Reference search", "reference_search_settings": "Reference search settings", "reference_search_style": "Reference search style", @@ -1861,17 +1786,13 @@ "saml_response": "SAML Response", "save": "Save", "save_20_percent": "save 20%", - "save_20_percent_by_paying_annually": "Save 20% by paying annually", "save_30_percent_or_more": "save 30% or more", - "save_30_percent_or_more_uppercase": "Save 30% or more", "save_n_percent": "Save __percentage__%", "save_or_cancel-cancel": "Cancel", "save_or_cancel-or": "or", "save_or_cancel-save": "Save", "save_x_percent_or_more": "Save __percent__% or more", "saving": "Saving", - "saving_20_percent": "Saving 20%!", - "saving_20_percent_no_exclamation": "Saving 20%", "saving_notification_with_seconds": "Saving __docname__... (__seconds__ seconds of unsaved changes)", "search": "Search", "search_all_project_files": "Search all project files", @@ -1982,7 +1903,6 @@ "show_more": "show more", "show_outline": "Show File outline", "show_x_more_projects": "Show __x__ more projects", - "show_your_support": "Show your support", "showing_1_result": "Showing 1 result", "showing_1_result_of_total": "Showing 1 result of __total__", "showing_x_out_of_n_projects": "Showing __x__ out of __n__ projects.", @@ -1995,8 +1915,6 @@ "single_sign_on_sso": "Single Sign-On (SSO)", "site_description": "An online LaTeX editor that’s easy to use. No installation, real-time collaboration, version control, hundreds of LaTeX templates, and more.", "site_wide_option_available": "Site-wide option available", - "sitewide_option_available": "Site-wide option available", - "sitewide_option_available_info": "Users are automatically upgraded when they register or add their email address to Overleaf (domain-based enrollment or SSO).", "six_collaborators_per_project": "6 collaborators per project", "six_per_project": "6 per project", "skip": "Skip", @@ -2049,7 +1967,6 @@ "sso_explanation": "Set up single sign-on for your group. This sign in method will be optional for group members unless Managed Users is enabled. <0>Learn more about Overleaf Group SSO.", "sso_here_is_the_data_we_received": "Here is the data we received in the SAML response:", "sso_integration": "SSO integration", - "sso_integration_info": "Overleaf offers a standard SAML-based Single Sign On integration.", "sso_is_disabled": "SSO is disabled", "sso_is_disabled_explanation_1": "Group members won’t be able to log in via SSO", "sso_is_disabled_explanation_2": "All members of the group will need a username and password to log in to __appName__", @@ -2095,9 +2012,7 @@ "store_your_work": "Store your work on your own infrastructure", "stretch_width_to_text": "Stretch width to text", "student": "Student", - "student_and_faculty_support_make_difference": "Student and faculty support make a difference! We can share this information with our contacts at your university when discussing an Overleaf institutional account.", "student_disclaimer": "The educational discount applies to all students at secondary and postsecondary institutions (schools and universities). We may contact you to confirm that you’re eligible for the discount.", - "student_plans": "Student Plans", "students": "Students", "subject": "Subject", "subject_area": "Subject area", @@ -2108,7 +2023,6 @@ "subscribe_to_find_the_symbols_you_need_faster": "Subscribe to find the symbols you need faster", "subscribe_to_plan": "Subscribe to __planName__", "subscription": "Subscription", - "subscription_admin_panel": "admin panel", "subscription_admins_cannot_be_deleted": "You cannot delete your account while on a subscription. Please cancel your subscription and try again. If you keep seeing this message please contact us.", "subscription_canceled": "Subscription canceled", "subscription_canceled_and_terminate_on_x": " Your subscription has been canceled and will terminate on <0>__terminateDate__. No further payments will be taken.", @@ -2133,7 +2047,6 @@ "switch_to_pdf": "Switch to PDF", "symbol_palette": "Symbol palette", "symbol_palette_highlighted": "<0>Symbol palette", - "symbol_palette_info": "A quick and convenient way to insert math symbols into your document.", "symbol_palette_info_new": "Insert math symbols into your document with the click of a button.", "sync": "Sync", "sync_dropbox_github": "Sync with Dropbox and GitHub", @@ -2238,8 +2151,6 @@ "this_total_reflects_the_amount_due_until": "This total reflects the amount due from today until __date__, the end of the billing period of your existing plan.", "this_was_helpful": "This was helpful", "this_wasnt_helpful": "This wasn’t helpful", - "thousands_templates": "Thousands of templates", - "thousands_templates_info": "Produce beautiful documents starting from our gallery of LaTeX templates for journals, conferences, theses, reports, CVs and much more.", "three_free_collab": "Three free collaborators", "timedout": "Timed out", "tip": "Tip", @@ -2317,7 +2228,6 @@ "total_due_today": "Total due today", "total_per_month": "Total per month", "total_per_year": "Total per year", - "total_per_year_for_x_users": "total per year for __licenseSize__ users", "total_per_year_lowercase": "total per year", "total_today": "Total today", "total_with_subtotal_and_tax": "Total: <0>__total__ (__subtotal__ + __tax__ tax) per year", @@ -2360,7 +2270,6 @@ "turn_on": "Turn on", "turn_on_link_sharing": "Turn on link sharing", "tutorials": "Tutorials", - "two_users": "2 users", "uk": "Ukrainian", "unable_to_extract_the_supplied_zip_file": "Opening this content on Overleaf failed because the zip file could not be extracted. Please ensure that it is a valid zip file. If this keeps happening for links on a particular site, please report this to them.", "unarchive": "Restore", @@ -2376,13 +2285,9 @@ "university_school": "University or school name", "unknown": "Unknown", "unlimited": "Unlimited", - "unlimited_bold": "<0>Unlimited", - "unlimited_collaborators_in_each_project": "Unlimited collaborators in each project", "unlimited_collaborators_per_project": "Unlimited collaborators per project", "unlimited_collabs": "Unlimited collaborators", - "unlimited_collabs_rt": "<0>Unlimited collaborators", "unlimited_projects": "Unlimited projects", - "unlimited_projects_info": "Your projects are private by default. This means that only you can view them, and only you can allow other people to access them.", "unlink": "Unlink", "unlink_all_users": "Unlink all users", "unlink_all_users_explanation": "You’re about to remove the SSO login option for all users in your group. If SSO is enabled, this will force users to reauthenticate their Overleaf accounts with your IdP. They’ll receive an email asking them to do this.", @@ -2408,7 +2313,6 @@ "unsubscribed": "Unsubscribed", "unsubscribing": "Unsubscribing", "untrash": "Restore", - "up_to": "Up to", "update": "Update", "update_account_info": "Update Account Info", "update_billing_details": "Update billing details", @@ -2436,8 +2340,6 @@ "url_to_fetch_the_file_from": "URL to fetch the file from", "us_gov_banner_government_purchasing": "<0>Get __appName__ for US federal government. Move faster through procurement with our tailored purchasing options. Talk to our government team.", "us_gov_banner_small_business_reseller": "<0>Easy procurement for US federal government. We partner with small business resellers to help you buy Overleaf organizational plans. Talk to our government team.", - "usage_metrics": "Usage metrics", - "usage_metrics_info": "Metrics that show how many users are accessing the licence, how many projects are being created and worked on, and how much collaboration is happening in Overleaf.", "use_a_different_password": "Please use a different password", "use_saml_metadata_to_configure_sso_with_idp": "Use the Overleaf SAML metadata to configure SSO with your Identity Provider.", "use_your_own_machine": "Use your own machine, with your own setup", @@ -2454,7 +2356,6 @@ "user_is_not_part_of_group": "User is not part of group", "user_last_name_attribute": "User last name attribute", "user_management": "User management", - "user_management_info": "Group plan admins have access to an admin panel where users can be added and removed easily. For site-wide plans, users are automatically upgraded when they register or add their email address to Overleaf (domain-based enrollment or SSO).", "user_metrics": "User metrics", "user_not_found": "User not found", "user_sessions": "User Sessions", @@ -2541,7 +2442,6 @@ "work_or_university_sso": "Work/university single sign-on", "work_with_non_overleaf_users": "Work with non Overleaf users", "work_with_other_github_users": "Work with other GitHub users", - "would_you_like_to_see_a_university_subscription": "Would you like to see a university-wide __appName__ subscription at your university?", "write_and_collaborate_faster_with_features_like": "Write and collaborate faster with features like:", "writefull": "Writefull", "writefull_learn_more": "Learn more about Writefull for Overleaf", @@ -2550,7 +2450,6 @@ "writefull_settings_description": "Get free AI-based language feedback specifically tailored for research writing with Writefull for Overleaf.", "x_changes_in": "__count__ change in", "x_changes_in_plural": "__count__ changes in", - "x_collaborators_per_project": "__collaboratorsCount__ collaborators per project", "x_libraries_accessed_in_this_project": "__provider__ libraries accessed in this project", "x_price_for_first_month": "<0>__price__ for your first month", "x_price_for_first_year": "<0>__price__ for your first year", @@ -2564,8 +2463,6 @@ "yes_that_is_correct": "Yes, that’s correct", "you": "You", "you_already_have_a_subscription": "You already have a subscription", - "you_and_collaborators_get_access_to": "You and your project collaborators get access to", - "you_and_collaborators_get_access_to_info": "These features are available to you and your collaborators (other Overleaf users that you invite to your projects).", "you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "You are a <1>manager and <1>member of the <0>__planName__ group subscription <1>__groupName__ administered by <1>__adminEmail__.", "you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z_you": "You are a <1>manager and <1>member of the <0>__planName__ group subscription <1>__groupName__ administered by <1>you (__adminEmail__).", "you_are_a_manager_of_commons_at_institution_x": "You are a <0>manager of the Overleaf Commons subscription at <0>__institutionName__", @@ -2591,8 +2488,6 @@ "you_cant_join_this_group_subscription": "You can’t join this group subscription", "you_cant_reset_password_due_to_sso": "You can’t reset your password because your group or organization uses SSO. <0>Log in with SSO.", "you_dont_have_any_repositories": "You don’t have any repositories", - "you_get_access_to": "You get access to", - "you_get_access_to_info": "These features are available only to you (the subscriber).", "you_have_0_free_suggestions_left": "You have 0 free suggestions left", "you_have_1_free_suggestion_left": "You have 1 free suggestion left", "you_have_1_user_and_your_plan_supports_up_to_y": "You have 1 user and your plan supports up to __groupSize__.", @@ -2603,9 +2498,6 @@ "you_have_x_users_and_your_plan_supports_up_to_y": "You have __addedUsersSize__ users and your plan supports up to __groupSize__.", "you_have_x_users_on_your_subscription": "You have __groupSize__ users on your subscription.", "you_need_to_configure_your_sso_settings": "You need to configure and test your SSO settings before enabling SSO", - "you_plus_1": "You + 1", - "you_plus_10": "You + 10", - "you_plus_6": "You + 6", "you_will_be_able_to_contact_us_any_time_to_share_your_feedback": "<0>You will be able to contact us any time to share your feedback", "you_will_be_able_to_reassign_subscription": "You will be able to reassign their subscription membership to another person in your organization", "youll_get_best_results_in_visual_but_can_be_used_in_source": "You’ll get the best results from using this tool in the <0>Visual Editor, although you can still use it to insert tables in the <1>Code Editor. Once you’ve selected the number of rows and columns you need, the table will appear in your document and you can double click in a cell to add contents to it.", @@ -2672,8 +2564,6 @@ "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_lowercase": "Zotero integration", - "zotero_integration_lowercase_info": "Manage your reference library in Zotero, and link it directly to .bib files in Overleaf, so you can easily cite anything from your libraries.", "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/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index d8cfce75eb..50211bc145 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -132,9 +132,6 @@ describe('SubscriptionController', function () { getAssignment: sinon.stub().resolves({ variant: 'default' }), }, } - this.SubscriptionHelper = { - generateInitialLocalizedGroupPrice: sinon.stub(), - } this.Features = { hasFeature: sinon.stub().returns(false), } @@ -189,259 +186,6 @@ describe('SubscriptionController', function () { this.stubbedCurrencyCode = 'GBP' }) - describe('plansPage', function () { - beforeEach(function () { - this.req.ip = '1234.3123.3131.333 313.133.445.666 653.5345.5345.534' - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - currencyCode: this.stubbedCurrencyCode, - }) - }) - - describe('ip override', function () { - beforeEach(function () { - this.req.ip = '1.2.3.4' - this.req.query = { ip: '5.6.7.8' } - this.GeoIpLookup.promises.getCurrencyCode.withArgs('1.2.3.4').resolves({ - currencyCode: 'GBP', - }) - this.GeoIpLookup.promises.getCurrencyCode.withArgs('5.6.7.8').resolves({ - currencyCode: 'USD', - }) - }) - it('should ignore override for non admin', function (done) { - this.res.render = (page, opts) => { - opts.recommendedCurrency.should.equal('GBP') - done() - } - this.AuthorizationManager.promises.isUserSiteAdmin.resolves(false) - this.SubscriptionController.plansPage(this.req, this.res) - }) - - it('should accept override for admin', function (done) { - this.res.render = (page, opts) => { - opts.recommendedCurrency.should.equal('USD') - done() - } - this.AuthorizationManager.promises.isUserSiteAdmin.resolves(true) - this.SubscriptionController.plansPage(this.req, this.res) - }) - }) - - describe('groupPlanModal data', function () { - it('should pass local currency if valid', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans') - opts.groupPlanModalDefaults.currency.should.equal('GBP') - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - currencyCode: 'GBP', - }) - this.SubscriptionController.plansPage(this.req, this.res) - }) - - it('should fallback to USD when valid', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans') - opts.groupPlanModalDefaults.currency.should.equal('USD') - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - currencyCode: 'FOO', - }) - this.SubscriptionController.plansPage(this.req, this.res) - }) - - it('should pass valid options for group plan modal and discard invalid', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans') - opts.groupPlanModalDefaults.size.should.equal('42') - opts.groupPlanModalDefaults.plan_code.should.equal('collaborator') - opts.groupPlanModalDefaults.currency.should.equal('GBP') - opts.groupPlanModalDefaults.usage.should.equal('foo') - done() - } - this.GeoIpLookup.isValidCurrencyParam.returns(false) - this.req.query = { - number: '42', - currency: 'ABC', - plan: 'does-not-exist', - usage: 'foo', - } - this.SubscriptionController.plansPage(this.req, this.res) - }) - }) - - describe('formatCurrency data', function () { - it('return correct formatCurrency function', function (done) { - this.res.render = (page, opts) => { - expect(opts.formatCurrency).to.equal(this.currency.formatCurrency) - done() - } - this.SubscriptionController.plansPage(this.req, this.res) - }) - }) - - it('should return correct countryCode', function (done) { - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'MX', - }) - this.res.render = (page, opts) => { - expect(opts.countryCode).to.equal('MX') - done() - } - this.SubscriptionController.plansPage(this.req, this.res) - }) - }) - - describe('plansPage light touch redesign', function () { - beforeEach(function () { - this.req.ip = '1234.3123.3131.333 313.133.445.666 653.5345.5345.534' - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - currencyCode: this.stubbedCurrencyCode, - }) - }) - - describe('ip override', function () { - beforeEach(function () { - this.req.ip = '1.2.3.4' - this.req.query = { ip: '5.6.7.8' } - this.GeoIpLookup.promises.getCurrencyCode.withArgs('1.2.3.4').resolves({ - currencyCode: 'GBP', - }) - this.GeoIpLookup.promises.getCurrencyCode.withArgs('5.6.7.8').resolves({ - currencyCode: 'USD', - }) - }) - it('should ignore override for non admin', function (done) { - this.res.render = (page, opts) => { - opts.recommendedCurrency.should.equal('GBP') - done() - } - this.AuthorizationManager.promises.isUserSiteAdmin.resolves(false) - this.SubscriptionController.plansPageLightDesign(this.req, this.res) - }) - - it('should accept override for admin', function (done) { - this.res.render = (page, opts) => { - opts.recommendedCurrency.should.equal('USD') - done() - } - this.AuthorizationManager.promises.isUserSiteAdmin.resolves(true) - this.SubscriptionController.plansPageLightDesign(this.req, this.res) - }) - }) - - describe('groupPlanModal data', function () { - it('should pass local currency if valid', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans-light-design') - opts.groupPlanModalDefaults.currency.should.equal('GBP') - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - currencyCode: 'GBP', - }) - this.SubscriptionController.plansPageLightDesign(this.req, this.res) - }) - - it('should fallback to USD when valid', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans-light-design') - opts.groupPlanModalDefaults.currency.should.equal('USD') - done() - } - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - currencyCode: 'FOO', - }) - this.SubscriptionController.plansPageLightDesign(this.req, this.res) - }) - - it('should pass valid options for group plan modal and discard invalid', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/plans-light-design') - opts.groupPlanModalDefaults.size.should.equal('42') - opts.groupPlanModalDefaults.plan_code.should.equal('collaborator') - opts.groupPlanModalDefaults.currency.should.equal('GBP') - opts.groupPlanModalDefaults.usage.should.equal('foo') - done() - } - this.GeoIpLookup.isValidCurrencyParam.returns(false) - this.req.query = { - number: '42', - currency: 'ABC', - plan: 'does-not-exist', - usage: 'foo', - } - this.SubscriptionController.plansPageLightDesign(this.req, this.res) - }) - }) - - describe('formatCurrency data', function () { - it('return correct formatCurrency function', function (done) { - this.res.render = (page, opts) => { - expect(opts.formatCurrency).to.equal(this.currency.formatCurrency) - done() - } - this.SubscriptionController.plansPageLightDesign(this.req, this.res) - }) - }) - - it('should return correct countryCode', function (done) { - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'MX', - }) - this.res.render = (page, opts) => { - expect(opts.countryCode).to.equal('MX') - done() - } - this.SubscriptionController.plansPageLightDesign(this.req, this.res) - }) - }) - - describe('interstitialPaymentPage', function () { - beforeEach(function () { - this.req.ip = '1234.3123.3131.333 313.133.445.666 653.5345.5345.534' - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - currencyCode: this.stubbedCurrencyCode, - }) - }) - - describe('with a user without subscription', function () { - it('should render the interstitial payment page', function (done) { - this.res.render = (page, opts) => { - page.should.equal('subscriptions/interstitial-payment') - done() - } - this.SubscriptionController.interstitialPaymentPage(this.req, this.res) - }) - }) - - describe('with a user with subscription', function () { - it('should redirect to the subscription dashboard', function (done) { - this.LimitationsManager.promises.userHasSubscription.resolves({ - hasSubscription: true, - }) - this.res.redirect = url => { - url.should.equal('/user/subscription?hasSubscription=true') - done() - } - this.SubscriptionController.interstitialPaymentPage(this.req, this.res) - }) - }) - - it('should return correct countryCode', function (done) { - this.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'MX', - }) - this.res.render = (page, opts) => { - expect(opts.countryCode).to.equal('MX') - done() - } - this.SubscriptionController.interstitialPaymentPage(this.req, this.res) - }) - }) - describe('successfulSubscription', function () { it('without a personal subscription', function (done) { this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves( From 22d007e8f4969c0985aa3f57df248c29481e9bff Mon Sep 17 00:00:00 2001 From: CloudBuild Date: Wed, 15 Jan 2025 02:03:31 +0000 Subject: [PATCH 0027/1724] auto update translation GitOrigin-RevId: 087736340479ca8ef4b974e9b652e9e49011c764 --- services/web/locales/es.json | 9 --------- services/web/locales/no.json | 2 -- services/web/locales/pt.json | 8 -------- 3 files changed, 19 deletions(-) diff --git a/services/web/locales/es.json b/services/web/locales/es.json index dc8e542e74..1453a345a0 100644 --- a/services/web/locales/es.json +++ b/services/web/locales/es.json @@ -107,7 +107,6 @@ "alignment": "Alineado", "all": "Todos", "all_borders": "Todos los bordes", - "all_our_group_plans_offer_educational_discount": "Todos nuestros <0>planes para grupos ofrecen un <1>descuento educativo para estudiantes y profesores.", "all_premium_features": "Todas las características premium", "all_premium_features_including": "Todas las características premium, incluyendo:", "all_prices_displayed_are_in_currency": "Todos los precios mostrados son en __recommendedCurrency__.", @@ -120,7 +119,6 @@ "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", - "also_available_as_on_premises": "También disponible en las instalaciones de la empresa", "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.", "an_error_occured_while_restoring_project": "Se ha producido un error al restaurar el proyecto", @@ -132,7 +130,6 @@ "anyone_with_link_can_view": "Cualquiera con este enlace puede ver este proyecto", "app_on_x": "__appName__ en __social__", "apply_educational_discount": "Aplicar descuento educacional", - "apply_educational_discount_info": "Overleaf ofrece un descuento educacional del 40% para grupos de 10 o más personas. Se aplica a estudiantes o profesores que utilicen Overleaf para impartir clases", "apply_educational_discount_info_new": "40% de descuento para grupos de 10 o más personas que utilicen __appName__ para la enseñanza", "apply_suggestion": "Aplicar sugerencia", "april": "Abril", @@ -159,7 +156,6 @@ "autocompile_disabled": "Compilación automática desactivada", "autocompile_disabled_reason": "Debido a la elevada carga del servidor, se ha desactivado temporalmente la recompilación en segundo plano. Por favor, recompile haciendo clic en el botón de arriba.", "autocomplete": "Autocompletado", - "automatic_user_registration": "registro automático de usuarios", "automatic_user_registration_uppercase": "Registro automático de usuarios", "back": "Volver", "back_to_account_settings": "Volver a la configuración de la cuenta", @@ -353,7 +349,6 @@ "go_to_code_location_in_pdf": "Ir a la ubicación del código en el PDF", "go_to_pdf_location_in_code": "Ir a la ubicación del PDF en el código", "group_admin": "Administrador de grupo", - "group_plans": "Planes grupales", "groups": "Grupos", "have_more_days_to_try": "¡Aquí tienes __days__ días más de prueba!", "headers": "Encabezados", @@ -365,7 +360,6 @@ "import_to_sharelatex": "Importa a __appName__", "importing": "Importando", "importing_and_merging_changes_in_github": "Importando y uniendo cambios en GitHub", - "indvidual_plans": "Planes individuales", "info": "Información", "institution": "Institución", "it": "Italiano", @@ -459,7 +453,6 @@ "october": "Octubre", "off": "Apagado", "ok": "Aceptar", - "one_collaborator": "Solo un colaborador", "one_free_collab": "Un colaborador gratis", "online_latex_editor": "Editor de LaTeX online", "optional": "Opcional", @@ -590,7 +583,6 @@ "sso_configuration": "Configuración de SSO", "sso_explanation": "Configure el inicio de sesión único (SSO) para su grupo. Este método de inicio de sesión será opcional para los miembros del grupo a menos que la opción de Usuarios Administrados esté habilitada. <0>Más información sobre Overleaf Group SSO.", "sso_integration": "Integración de SSO", - "sso_integration_info": "Overleaf ofrece una integración estándar de inicio de sesión único (SSO) basada en SAML", "sso_is_disabled": "El SSO está deshabilitado", "sso_is_disabled_explanation_1": "Los miembros del grupo no podrán iniciar sesión a través de SSO", "sso_is_disabled_explanation_2": "Todos los miembros del grupo necesitarán un nombre de usuario y una contraseña para iniciar sesión en __appName__", @@ -715,7 +707,6 @@ "zotero_cta": "Obtener integración con Zotero", "zotero_groups_loading_error": "Hubo un error cargando los grupos desde Zotero", "zotero_integration": "Integración de Zotero.", - "zotero_integration_lowercase": "Integración con Zotero", "zotero_is_premium": "La integración de Zotero es una característica premium", "zotero_reference_loading_error": "Error, no se han podido cargar las referencias de Zotero", "zotero_reference_loading_error_expired": "Tu token de Zotero ha caducado, vuelve a vincular tu cuenta", diff --git a/services/web/locales/no.json b/services/web/locales/no.json index dd5de1be1b..4375bc1ca8 100644 --- a/services/web/locales/no.json +++ b/services/web/locales/no.json @@ -151,7 +151,6 @@ "import_to_sharelatex": "Importer til __appName__", "importing": "Importerer", "importing_and_merging_changes_in_github": "Importerer og merger endringer i GitHub", - "indvidual_plans": "Individuelle planer", "info": "Info", "institution": "Institusjon", "it": "Italiensk", @@ -225,7 +224,6 @@ "october": "Oktober", "off": "Av", "ok": "OK", - "one_collaborator": "Kun én samarbeidspartner", "one_free_collab": "Én gratis samarbeidspartner", "online_latex_editor": "Online LaTeX-redigeringsprogram", "optional": "Valgfri", diff --git a/services/web/locales/pt.json b/services/web/locales/pt.json index 0264ef4f80..b75e30e76d 100644 --- a/services/web/locales/pt.json +++ b/services/web/locales/pt.json @@ -177,7 +177,6 @@ "drag_here": "arraste aqui", "drop_files_here_to_upload": "Largar arquivos aqui para enviar", "dropbox_for_link_share_projs": "Este projeto foi acessado via compartilhamento de links e não será sincronizado com o seu Dropbox, a menos que você seja convidado por e-mail pelo proprietário do projeto", - "dropbox_integration_info": "Trabalhe online ou offline perfeitamente com a sincronia do Dropbox. As suas alterações locais serão enviadas automaticamente para a sua versão do Overleaf e vice-e-versa.", "dropbox_integration_lowercase": "Integração com Dropbox", "dropbox_sync": "Sincronização Dropbox", "dropbox_sync_description": "Mantenha seus projetos __appName__ sincronizados com o Dropbox. Mudanças no __appName__ serão enviadas automaticamente para o Dropbox, e o inverso também.", @@ -205,8 +204,6 @@ "export_csv": "Exportar CSV", "export_project_to_github": "Exportar Projeto para o GitHub", "faq_how_does_free_trial_works_answer": "Você obtém acesso total ao plano __appName__ escolhido durante a avaliação gratuita de __len__ dias. Não há obrigação de continuar além da versão de avaliação. Seu cartão será cobrado no final da avaliação de __len__ dias, a menos que você cancele antes disso. Você pode cancelar via suas configurações de assinatura.", - "faq_how_free_trial_works_question": "Como foi o uso da versão de experimentação?", - "faq_pay_by_invoice_question": "Eu posso pagar com boleto ou ordem de pedido?", "fast": "Rápido", "featured_latex_templates": "Templates LaTeX Destacados", "features": "Recursos", @@ -246,7 +243,6 @@ "go_to_code_location_in_pdf": "Vá para a localização do código no PDF", "go_to_pdf_location_in_code": "Ir para a localização do PDF no código", "group_admin": "Administrador do Grupo", - "group_plans": "Planos de Grupos", "groups": "Grupos", "have_more_days_to_try": "Ganhe mais __days__ dias na sua Experimentação!", "headers": "Cabeçalhos", @@ -275,8 +271,6 @@ "import_to_sharelatex": "Importar para o __appName__", "importing": "Importando", "importing_and_merging_changes_in_github": "Importar e mesclar mudanças no GitHub", - "in_good_company": "Você esta em Boa Companhia", - "indvidual_plans": "Planos individuais", "info": "Info", "institution": "Instituição", "institution_account": "Conta Institucional", @@ -418,7 +412,6 @@ "off": "Desligar", "ok": "OK", "on": "Ligado", - "one_collaborator": "Um colaborador apenas", "one_free_collab": "Um colaborador grátis", "online_latex_editor": "Editor LaTeX Online", "open_project": "Abrir Projeto", @@ -632,7 +625,6 @@ "this_project_is_public": "Esse projeto é publico e pode ser editado por qualquer pessoa com a URL.", "this_project_is_public_read_only": "Esse projeto é público e pode ser visualizado, mas não editado, por qualquer pessoa com a URL", "this_project_will_appear_in_your_dropbox_folder_at": "Esse projeto irá aparecer em sua pasta Dropbox em ", - "thousands_templates": "Milhares de templates", "three_free_collab": "Três colaboradores grátis", "timedout": "Tempo Expirado", "title": "Título", From 3d543b20b50589ed4c7e78774bc7b3f0f2aaf778 Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Wed, 15 Jan 2025 10:26:29 +0100 Subject: [PATCH 0028/1724] [web] Show most recently resolved comments at the top (#22835) GitOrigin-RevId: cb42a0fe3d6bf5d23d329b6ef9732f7cb9612907 --- .../components/review-panel-resolved-threads-menu.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads-menu.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads-menu.tsx index cf2c35850e..30a09ec2cd 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads-menu.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads-menu.tsx @@ -56,6 +56,10 @@ export const ReviewPanelResolvedThreadsMenu: FC = () => { resolvedThreads.push({ thread, id }) } } + resolvedThreads.sort((a, b) => { + return Date.parse(b.thread.resolved_at) - Date.parse(a.thread.resolved_at) + }) + return resolvedThreads }, [threads]) From 36f2e521672dbba09d16f95748d70077f86a18dd Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Wed, 15 Jan 2025 10:26:52 +0100 Subject: [PATCH 0029/1724] Add comment option in editor toolbar (#22849) * Add comment option in editor toolbar * move addComment to commands GitOrigin-RevId: 690b70f67abe5653f28ec9ec61deb3f201a78131 --- .../source-editor/components/toolbar/toolbar-items.tsx | 10 ++++++++++ .../source-editor/extensions/toolbar/commands.ts | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx index 52ec88e2e2..0fea34bc6a 100644 --- a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx +++ b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx @@ -12,6 +12,7 @@ import { MathDropdown } from './math-dropdown' import { TableInserterDropdown } from './table-inserter-dropdown' import { withinFormattingCommand } from '@/features/source-editor/utils/tree-operations/formatting' import { bsVersion } from '@/features/utils/bootstrap-5' +import { isSplitTestEnabled } from '@/utils/splitTestUtils' const isMac = /Mac/.test(window.navigator?.platform) @@ -124,6 +125,15 @@ export const ToolbarItems: FC<{ command={commands.wrapInHref} icon={bsVersion({ bs5: 'add_link', bs3: 'link' })} /> + {isSplitTestEnabled('review-panel-redesign') && ( + + )} { } return true } + +export const addComment = () => { + window.dispatchEvent(new Event('add-new-review-comment')) +} From 23c455d7b9121b54725053d83be66d7f5144df49 Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Wed, 15 Jan 2025 11:09:57 +0100 Subject: [PATCH 0030/1724] [web] tear down group-pricing-2025 split test on plans page (#22786) * [web] rm split test from new plans page * [web] rm showDiscountPercentage from group_member_picker (always false) * [web] rm now unused classes on edu discount checkbox * [web] rm split test from FAQ * [web] rm unused translation GitOrigin-RevId: 262d97f317d8aaef6e5a07a8ecd4edc67557408b --- .../subscriptions/plans/_plans_faq_tabs.pug | 30 ++++++++----------- .../app/plans/plans-new-design.less | 16 ---------- services/web/locales/en.json | 6 +--- 3 files changed, 13 insertions(+), 39 deletions(-) diff --git a/services/web/app/views/subscriptions/plans/_plans_faq_tabs.pug b/services/web/app/views/subscriptions/plans/_plans_faq_tabs.pug index fd11e11efe..b6ea1fb69a 100644 --- a/services/web/app/views/subscriptions/plans/_plans_faq_tabs.pug +++ b/services/web/app/views/subscriptions/plans/_plans_faq_tabs.pug @@ -115,28 +115,22 @@ mixin overleafGroupPlans() div.mt-2 Collaborators are people that your group users may invite to work with them on their projects. So, for example, if you have the Group Standard plan, the users in your group can invite up to 10 people to work with them on a project. And if you have the Group Professional plan, your users can invite as many people to work with them as they want. .custom-accordion-item button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafGroupPlansQ2" aria-expanded="false" aria-controls="overleafGroupPlansQ2") - if showGroupPricing2025 - | What is the benefit of purchasing an Overleaf Group plan? - else - | Is an Overleaf Group plan more cost effective? + | What is the benefit of purchasing an Overleaf Group plan? span.custom-accordion-icon i.material-symbols.material-symbols-outlined(aria-hidden="true") keyboard_arrow_down .collapse(id="overleafGroupPlansQ2") .custom-accordion-body - if showGroupPricing2025 - | Our Group subscriptions allow you to purchase access to our premium features for multiple people. They’re easy to manage, help save on paperwork, and allow groups of 5 or more to purchase via purchase order (PO). We also offer discounts on purchases of Group subscriptions for more than 50 users; just get in touch with our - a.inline-green-link( - target="_blank" - href="/for/contact-sales" - event-tracking="plans-page-click" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={ button: 'contact', location: 'faq' } - ) - span Sales team - | . - else - | Our Group subscriptions allow you to purchase access to our premium features for multiple people. They’re easy to manage, help save on paperwork, and reduce the cost of purchasing multiple subscriptions separately. + | Our Group subscriptions allow you to purchase access to our premium features for multiple people. They’re easy to manage, help save on paperwork, and allow groups of 5 or more to purchase via purchase order (PO). We also offer discounts on purchases of Group subscriptions for more than 50 users; just get in touch with our + a.inline-green-link( + target="_blank" + href="/for/contact-sales" + event-tracking="plans-page-click" + event-tracking-mb="true" + event-tracking-trigger="click" + event-segmentation={ button: 'contact', location: 'faq' } + ) + span Sales team + | . .custom-accordion-item button.custom-accordion-header.collapsed(type="button" data-toggle="collapse" data-target="#overleafGroupPlansQ3" aria-expanded="false" aria-controls="overleafGroupPlansQ3") | Who is eligible for the educational discount? diff --git a/services/web/frontend/stylesheets/app/plans/plans-new-design.less b/services/web/frontend/stylesheets/app/plans/plans-new-design.less index 3456a86191..d5c3f16bb8 100644 --- a/services/web/frontend/stylesheets/app/plans/plans-new-design.less +++ b/services/web/frontend/stylesheets/app/plans/plans-new-design.less @@ -606,22 +606,6 @@ margin-bottom: var(--spacing-06); font-weight: 400; - @media (max-width: @screen-xs-max) { - &.invisible-desktop { - display: none; - } - } - - @media (min-width: @screen-sm-min) { - &.invisible-desktop { - visibility: hidden; - } - } - - &.disabled { - opacity: 0.5; - } - input[type='checkbox'] { margin: var(--spacing-02); accent-color: var(--green-50); diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 9146adc92c..137442ea55 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -156,8 +156,7 @@ "anyone_with_link_can_view": "Anyone with this link can view this project", "app_on_x": "__appName__ on __social__", "apply_educational_discount": "Apply educational discount", - "apply_educational_discount_info_2025_pricing": "40% discount for groups using __appName__ for teaching", - "apply_educational_discount_info_new": "40% discount for groups of 10 or more using __appName__ for teaching", + "apply_educational_discount_description": "40% discount for groups using __appName__ for teaching", "apply_suggestion": "Apply suggestion", "april": "April", "archive": "Archive", @@ -1296,7 +1295,6 @@ "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", - "need_more_than_50_users": "Need more than 50 users?", "need_more_than_x_licenses": "Need more than __x__ licenses?", "need_to_add_new_primary_before_remove": "You’ll need to add a new primary email address before you can remove this one.", "need_to_leave": "Need to leave?", @@ -1786,8 +1784,6 @@ "saml_response": "SAML Response", "save": "Save", "save_20_percent": "save 20%", - "save_30_percent_or_more": "save 30% or more", - "save_n_percent": "Save __percentage__%", "save_or_cancel-cancel": "Cancel", "save_or_cancel-or": "or", "save_or_cancel-save": "Save", From 79943909678cc56fbe2f3a958a2f8d02e3eacc16 Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Wed, 15 Jan 2025 11:10:30 +0100 Subject: [PATCH 0031/1724] [web] tear down group-pricing-2025 split test on non-plans pages (#22785) * [web] rm split test from the change to group modal * [web] rm split test from cancel flow * [web] rm split test from checkout flow * [web] rm unused translations GitOrigin-RevId: 0188f2631ed18e79fdf55dabedac7cdea4f213d3 --- .../Subscription/SubscriptionController.js | 1 - .../web/frontend/extracted-translations.json | 6 +- .../cancel-plan/cancel-subscription.tsx | 4 - .../modals/change-to-group-modal.tsx | 87 ++++--------------- services/web/locales/en.json | 6 +- .../active/change-plan/change-plan.test.tsx | 17 ++-- 6 files changed, 24 insertions(+), 97 deletions(-) diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index a9b505ade1..187e049f21 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -55,7 +55,6 @@ async function userSubscriptionPage(req, res) { res, 'bootstrap-5-subscription' ) - await SplitTestHandler.promises.getAssignment(req, res, 'group-pricing-2025') const results = await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 130a748b0c..0f7a458ed5 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -444,7 +444,6 @@ "editor_theme": "", "educational_disclaimer": "", "educational_disclaimer_heading": "", - "educational_discount_for_groups_of_x_or_more": "", "educational_percent_discount_applied": "", "email": "", "email_address": "", @@ -832,8 +831,7 @@ "let_us_know_how_we_can_help": "", "let_us_know_what_you_think": "", "library": "", - "license_for_educational_purposes": "", - "license_for_educational_purposes_2025": "", + "license_for_educational_purposes_confirmation": "", "limited_offer": "", "limited_to_n_editors": "", "limited_to_n_editors_per_project": "", @@ -1099,7 +1097,6 @@ "pending_addon_cancellation": "", "pending_invite": "", "per_user": "", - "percent_discount_for_groups": "", "percent_is_the_percentage_of_the_line_width": "", "permanently_disables_the_preview": "", "personal_library": "", @@ -1329,7 +1326,6 @@ "save_or_cancel-cancel": "", "save_or_cancel-or": "", "save_or_cancel-save": "", - "save_x_percent_or_more": "", "saving": "", "saving_notification_with_seconds": "", "search": "", diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx index 1b1a3dcf3f..c50666b3fc 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx @@ -16,7 +16,6 @@ import { useLocation } from '../../../../../../../shared/hooks/use-location' import { debugConsole } from '@/utils/debugging' import OLButton from '@/features/ui/components/ol/ol-button' import moment from 'moment' -import { getSplitTestVariant } from '@/utils/splitTestUtils' import OLNotification from '@/features/ui/components/ol/ol-notification' const planCodeToDowngradeTo = 'paid-personal' @@ -160,8 +159,6 @@ export function CancelSubscription() { isSuccessSecondaryAction || isSuccessCancel - const groupPricingVariant = getSplitTestVariant('group-pricing-2025') - if (!personalSubscription || !('recurly' in personalSubscription)) return null const showDowngrade = showDowngradeOption( @@ -179,7 +176,6 @@ export function CancelSubscription() { const startDate = moment.utc(personalSubscription.recurly.account.created_at) const pricingChangeEffectiveDate = moment.utc('2025-01-08T12:00:00Z') const displayPricingWarning = - groupPricingVariant === 'enabled' && personalSubscription.plan.groupPlan && startDate.isBefore(pricingChangeEffectiveDate) diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx index c5deba1765..96fe6a3dfe 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx @@ -25,10 +25,8 @@ import OLButton from '@/features/ui/components/ol/ol-button' import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' import OLNotification from '@/features/ui/components/ol/ol-notification' import { bsVersion } from '@/features/utils/bootstrap-5' -import { useFeatureFlag } from '@/shared/context/split-test-context' const educationalPercentDiscount = 40 -const groupSizeForEducationalDiscount = 10 function GroupPlanCollaboratorCount({ planCode }: { planCode: string }) { const { t } = useTranslation() @@ -47,34 +45,6 @@ function GroupPlanCollaboratorCount({ planCode }: { planCode: string }) { return null } -function EducationDiscountAppliedOrNot({ - groupSize, - showGroupPricing2025, -}: { - groupSize: string - showGroupPricing2025: boolean -}) { - const { t } = useTranslation() - const size = parseInt(groupSize) - if (size >= groupSizeForEducationalDiscount || showGroupPricing2025) { - return ( -

- {t('educational_percent_discount_applied', { - percent: educationalPercentDiscount, - })} -

- ) - } - - return ( -

- {t('educational_discount_for_groups_of_x_or_more', { - size: groupSizeForEducationalDiscount, - })} -

- ) -} - function GroupPrice({ groupPlanToChangeToPrice, queryingGroupPlanToChangeToPrice, @@ -152,7 +122,6 @@ export function ChangeToGroupModal() { const [error, setError] = useState(false) const [inflight, setInflight] = useState(false) const location = useLocation() - const showGroupPricing2025 = useFeatureFlag('group-pricing-2025') async function upgrade() { setError(false) @@ -203,14 +172,6 @@ export function ChangeToGroupModal() { {t('customize_your_group_subscription')} -
- {!showGroupPricing2025 && ( - - {t('save_x_percent_or_more', { - percent: '30', - })} - - )}
@@ -286,17 +247,6 @@ export function ChangeToGroupModal() { - {!showGroupPricing2025 && ( - - - {t('percent_discount_for_groups', { - percent: educationalPercentDiscount, - size: groupSizeForEducationalDiscount, - })} - - - )} - , - /* eslint-disable-next-line react/jsx-key */ -
, - ]} - /> - ) : ( - t('license_for_educational_purposes') - ) + , + /* eslint-disable-next-line react/jsx-key */ +
, + ]} + /> } /> @@ -333,10 +279,11 @@ export function ChangeToGroupModal() {
{groupPlanToChangeToUsage === 'educational' && ( - +

+ {t('educational_percent_discount_applied', { + percent: educationalPercentDiscount, + })} +

)}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 137442ea55..7b55d30854 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -579,7 +579,6 @@ "editor_theme": "Editor theme", "educational_disclaimer": "I confirm that users will be students or faculty using Overleaf primarily for study and teaching, and can provide evidence of this if requested.", "educational_disclaimer_heading": "Educational discount confirmation", - "educational_discount_for_groups_of_x_or_more": "The educational discount is available for groups of __size__ or more", "educational_percent_discount_applied": "__percent__% educational discount applied!", "email": "Email", "email_address": "Email address", @@ -1120,8 +1119,7 @@ "libraries": "Libraries", "library": "Library", "license": "License", - "license_for_educational_purposes": "This license is for educational purposes (applies to students or faculty using __appName__ for teaching)", - "license_for_educational_purposes_2025": "<0>__percent__% educational discount<1/>I confirm this subscription is for educational purposes (applies to students or faculty using __appName__ for teaching)", + "license_for_educational_purposes_confirmation": "<0>__percent__% educational discount<1/>I confirm this subscription is for educational purposes (applies to students or faculty using __appName__ for teaching)", "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", @@ -1495,7 +1493,6 @@ "per_user_per_year": "per user / per year", "per_user_year": "per user / year", "per_year": "per year", - "percent_discount_for_groups": "__appName__ offers a __percent__% educational discount for groups of __size__ or more.", "percent_is_the_percentage_of_the_line_width": "% is the percentage of the line width", "permanently_disables_the_preview": "Permanently disables the preview", "personal": "Personal", @@ -1787,7 +1784,6 @@ "save_or_cancel-cancel": "Cancel", "save_or_cancel-or": "or", "save_or_cancel-save": "Save", - "save_x_percent_or_more": "Save __percent__% or more", "saving": "Saving", "saving_notification_with_seconds": "Saving __docname__... (__seconds__ seconds of unsaved changes)", "search": "Search", diff --git a/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx index 4d7afd5742..5bca8c03d9 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx @@ -322,7 +322,7 @@ describe('', function () { const standardPlanCollaboratorText = '10 collaborators per project' const professionalPlanCollaboratorText = 'Unlimited collaborators' const educationInputLabel = - 'This license is for educational purposes (applies to students or faculty using Overleaf for teaching)' + '40% educational discountI confirm this subscription is for educational purposes (applies to students or faculty using Overleaf for teaching)' let modal: HTMLElement async function openModal() { @@ -342,7 +342,6 @@ describe('', function () { await openModal() within(modal).getByText('Customize your group subscription') - within(modal).getByText('Save 30% or more') within(modal).getByText('$1,290 per year') expect(within(modal).getAllByText('$129 per user').length).to.equal(2) @@ -369,16 +368,14 @@ describe('', function () { expect(sizeSelect.value).to.equal('10') const sizeOption = within(sizeSelect).getAllByRole('option') expect(sizeOption.length).to.equal(groupPlans.sizes.length) - within(modal).getByText( - 'Overleaf offers a 40% educational discount for groups of 10 or more.' - ) + within(modal).getByText('40% educational discount') const educationalCheckbox = within(modal).getByRole( 'checkbox' ) as HTMLInputElement expect(educationalCheckbox.checked).to.be.false within(modal).getByText( - 'This license is for educational purposes (applies to students or faculty using Overleaf for teaching)' + 'I confirm this subscription is for educational purposes (applies to students or faculty using Overleaf for teaching)' ) within(modal).getByText( @@ -406,10 +403,8 @@ describe('', function () { expect(within(modal).queryByText(standardPlanCollaboratorText)).to.be.null }) - it('shows educational discount applied when input checked and also notes if not enough users to get discount', async function () { + it('shows educational discount applied when input checked', async function () { const discountAppliedText = '40% educational discount applied!' - const discountNotAppliedText = - 'The educational discount is available for groups of 10 or more' renderActiveSubscription(annualActiveSubscription) await openModal() @@ -417,12 +412,10 @@ describe('', function () { const educationInput = within(modal).getByLabelText(educationInputLabel) fireEvent.click(educationInput) await within(modal).findByText(discountAppliedText) - expect(within(modal).queryByText(discountNotAppliedText)).to.be.null const sizeSelect = within(modal).getByRole('combobox') as HTMLInputElement await userEvent.selectOptions(sizeSelect, [screen.getByText('5')]) - await within(modal).findByText(discountNotAppliedText) - expect(within(modal).queryByText(discountAppliedText)).to.be.null + await within(modal).findByText(discountAppliedText) }) it('shows total with tax when tax applied', async function () { From f2f1178bcd1773fba01fbc0ea4db7c566360094c Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Wed, 15 Jan 2025 11:17:14 +0000 Subject: [PATCH 0032/1724] Refactor isMac to a utility module (#22829) GitOrigin-RevId: c6ab1dbeb1c56c35af121e7f657325a89fc847ea --- .../features/editor-left-menu/components/help-show-hotkeys.tsx | 2 +- .../js/features/file-tree/contexts/file-tree-selectable.tsx | 3 +-- .../js/features/pdf-preview/components/pdf-zoom-buttons.tsx | 3 +-- .../js/features/pdf-preview/components/pdf-zoom-dropdown.tsx | 3 +-- .../components/paste-html/pasted-content-menu.tsx | 3 +-- .../source-editor/components/table-generator/table.tsx | 2 +- .../source-editor/components/toolbar/toolbar-items.tsx | 3 +-- services/web/frontend/js/shared/utils/os.ts | 1 + .../source-editor/components/codemirror-editor-cursor.spec.tsx | 2 +- .../components/codemirror-editor-visual-list.spec.tsx | 3 +-- .../components/codemirror-editor-visual-toolbar.spec.tsx | 3 +-- .../test/frontend/features/source-editor/helpers/meta-key.ts | 2 +- 12 files changed, 12 insertions(+), 18 deletions(-) create mode 100644 services/web/frontend/js/shared/utils/os.ts diff --git a/services/web/frontend/js/features/editor-left-menu/components/help-show-hotkeys.tsx b/services/web/frontend/js/features/editor-left-menu/components/help-show-hotkeys.tsx index 977e201d46..98e027977d 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/help-show-hotkeys.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/help-show-hotkeys.tsx @@ -5,12 +5,12 @@ import { useProjectContext } from '../../../shared/context/project-context' import HotkeysModal from '../../hotkeys-modal/components/hotkeys-modal' import LeftMenuButton from './left-menu-button' import { bsVersionIcon } from '@/features/utils/bootstrap-5' +import { isMac } from '@/shared/utils/os' export default function HelpShowHotkeys() { const [showModal, setShowModal] = useState(false) const { t } = useTranslation() const { features } = useProjectContext() - const isMac = /Mac/.test(window.navigator?.platform) const showModalWithAnalytics = useCallback(() => { eventTracking.sendMB('left-menu-hotkeys') diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx index e8658ed918..2d3b8cd0f9 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.tsx @@ -21,6 +21,7 @@ import { FindResult } from '@/features/file-tree/util/path' import { fileCollator } from '@/features/file-tree/util/file-collator' import { Folder } from '../../../../../types/folder' import { FileTreeEntity } from '../../../../../types/file-tree-entity' +import { isMac } from '@/shared/utils/os' const FileTreeSelectableContext = createContext< | { @@ -234,8 +235,6 @@ export const FileTreeSelectableProvider: FC<{ ) } -const isMac = /Mac/.test(window.navigator?.platform) - export function useSelectableEntity(id: string, type: string) { const { view, setView } = useLayoutContext() const { setContextMenuCoords } = useFileTreeMainContext() diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-zoom-buttons.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-zoom-buttons.tsx index 8f1f408cc7..56a2901b87 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-zoom-buttons.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-zoom-buttons.tsx @@ -1,8 +1,7 @@ import PDFToolbarButton from './pdf-toolbar-button' import { useTranslation } from 'react-i18next' import OLButtonGroup from '@/features/ui/components/ol/ol-button-group' - -const isMac = /Mac/.test(window.navigator?.platform) +import { isMac } from '@/shared/utils/os' type PdfZoomButtonsProps = { setZoom: (zoom: string) => void diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-zoom-dropdown.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-zoom-dropdown.tsx index 9ad9487fbe..6dc05cfc8f 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-zoom-dropdown.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-zoom-dropdown.tsx @@ -14,8 +14,7 @@ import { DropdownToggle, } from '@/features/ui/components/bootstrap-5/dropdown-menu' import FormControl from '@/features/ui/components/bootstrap-5/form/form-control' - -const isMac = /Mac/.test(window.navigator?.platform) +import { isMac } from '@/shared/utils/os' const shortcuts = isMac ? { diff --git a/services/web/frontend/js/features/source-editor/components/paste-html/pasted-content-menu.tsx b/services/web/frontend/js/features/source-editor/components/paste-html/pasted-content-menu.tsx index 807b20c9f4..a58b22c0b9 100644 --- a/services/web/frontend/js/features/source-editor/components/paste-html/pasted-content-menu.tsx +++ b/services/web/frontend/js/features/source-editor/components/paste-html/pasted-content-menu.tsx @@ -18,8 +18,7 @@ import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/boots import MaterialIcon from '@/shared/components/material-icon' import OLOverlay from '@/features/ui/components/ol/ol-overlay' import OLPopover from '@/features/ui/components/ol/ol-popover' - -const isMac = /Mac/.test(window.navigator?.platform) +import { isMac } from '@/shared/utils/os' export const PastedContentMenu: FC<{ insertPastedContent: ( diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/table.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/table.tsx index 3227173c96..c213b38293 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/table.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/table.tsx @@ -21,6 +21,7 @@ import { ChangeSpec } from '@codemirror/state' import { startCompileKeypress } from '@/features/pdf-preview/hooks/use-compile-triggers' import { useTabularContext } from './contexts/tabular-context' import { ColumnSizeIndicator } from './column-size-indicator' +import { isMac } from '@/shared/utils/os' type NavigationKey = | 'ArrowRight' @@ -38,7 +39,6 @@ type NavigationMap = { } } -const isMac = /Mac/.test(window.navigator?.platform) const MINIMUM_CELL_WIDTH_CHARACTERS = 15 const MINIMUM_EDITING_CELL_WIDTH_CHARACTERS = 20 const CELL_WIDTH_BUFFER = 3 // characters diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx index 0fea34bc6a..16190a34b3 100644 --- a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx +++ b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx @@ -13,8 +13,7 @@ import { TableInserterDropdown } from './table-inserter-dropdown' import { withinFormattingCommand } from '@/features/source-editor/utils/tree-operations/formatting' import { bsVersion } from '@/features/utils/bootstrap-5' import { isSplitTestEnabled } from '@/utils/splitTestUtils' - -const isMac = /Mac/.test(window.navigator?.platform) +import { isMac } from '@/shared/utils/os' export const ToolbarItems: FC<{ state: EditorState diff --git a/services/web/frontend/js/shared/utils/os.ts b/services/web/frontend/js/shared/utils/os.ts new file mode 100644 index 0000000000..e34c8fcd14 --- /dev/null +++ b/services/web/frontend/js/shared/utils/os.ts @@ -0,0 +1 @@ +export const isMac = /Mac/.test(window.navigator?.platform) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-cursor.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-cursor.spec.tsx index 839a96de86..9d33f2a2e5 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-cursor.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-cursor.spec.tsx @@ -3,7 +3,7 @@ import { EditorProviders } from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { mockScope } from '../helpers/mock-scope' import { TestContainer } from '../helpers/test-container' -const isMac = /Mac/.test(window.navigator?.platform) +import { isMac } from '@/shared/utils/os' describe('Cursor and active line highlight', function () { const content = `line 1 diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx index 2f75fb35af..4d97ab9098 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx @@ -3,8 +3,7 @@ import { EditorProviders } from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { mockScope } from '../helpers/mock-scope' import { TestContainer } from '../helpers/test-container' - -const isMac = /Mac/.test(window.navigator?.platform) +import { isMac } from '@/shared/utils/os' const mountEditor = (content: string) => { const scope = mockScope(content) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx index 631b4a6c50..c70bec97bd 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx @@ -3,8 +3,7 @@ import { EditorProviders } from '../../../helpers/editor-providers' import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' import { mockScope } from '../helpers/mock-scope' import { TestContainer } from '../helpers/test-container' - -const isMac = /Mac/.test(window.navigator?.platform) +import { isMac } from '@/shared/utils/os' const selectAll = () => { cy.get('.cm-content').trigger( diff --git a/services/web/test/frontend/features/source-editor/helpers/meta-key.ts b/services/web/test/frontend/features/source-editor/helpers/meta-key.ts index 19920f0880..4e1231b654 100644 --- a/services/web/test/frontend/features/source-editor/helpers/meta-key.ts +++ b/services/web/test/frontend/features/source-editor/helpers/meta-key.ts @@ -1,3 +1,3 @@ -const isMac = /Mac/.test(window.navigator?.platform) +import { isMac } from '@/shared/utils/os' export const metaKey = isMac ? 'meta' : 'ctrl' From 386d6f8ffd481d869a02c5fe909f213145085f5a Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Wed, 15 Jan 2025 11:17:56 +0000 Subject: [PATCH 0033/1724] Wrap range building in setTimeout (#22796) GitOrigin-RevId: a0841e6eb9f2f637653dd0b8a37a61136097cc4f --- .../js/features/review-panel-new/context/ranges-context.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 2da4b34b76..019ed5ff00 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 @@ -98,7 +98,9 @@ export const RangesProvider: FC = ({ children }) => { useEffect(() => { if (currentDoc) { const listener = () => { - setRanges(buildRanges(currentDoc)) + window.setTimeout(() => { + setRanges(buildRanges(currentDoc)) + }) } // currentDoc.on('ranges:clear.cm6', listener) From 2fbb4615f9038576cb54913c52a08f205ee97e5f Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Wed, 15 Jan 2025 11:20:18 +0000 Subject: [PATCH 0034/1724] Convert utility functions to TypeScript (#22658) * Convert event-tracking to TypeScript * Convert local-storage to TypeScript * Convert mapSeries to TypeScript * Convert SessionStorage to TypeScript * Convert account-upgrade to TypeScript * Convert isValidTeXFile to TypeScript * Convert date functions to TypeScript * Convert EventEmitter to TypeScript * Convert isNetworkError to TypeScript * Convert webpack-public-path to TypeScript * Convert displayNameForUser to TypeScript GitOrigin-RevId: 79c5a2d1101fcd520f3116f0f4af29d974189d94 --- .../components/settings/settings-document.tsx | 2 +- .../change-list/owner-paywall-prompt.tsx | 3 +- .../history/utils/display-name-for-user.ts | 22 +++++++++ .../utils/highlights-from-diff-response.ts | 2 +- .../ide-react/context/outline-context.tsx | 2 +- .../pdf-preview/util/pdf-caching-transport.js | 2 +- .../components/add-collaborators-upgrade.tsx | 2 +- .../access-levels-changed.tsx | 2 +- .../add-collaborators-upgrade.tsx | 2 +- .../components/editor-switch.tsx | 2 +- .../upgrade-track-changes-modal.tsx | 2 +- .../hooks/use-codemirror-scope.ts | 4 +- .../js/ide/history/util/displayNameForUser.js | 24 --------- .../{event-tracking.js => event-tracking.ts} | 44 ++++++++++++----- .../{local-storage.js => local-storage.ts} | 21 +++++--- .../web/frontend/js/infrastructure/promise.js | 16 ------ .../web/frontend/js/infrastructure/promise.ts | 13 +++++ ...{session-storage.js => session-storage.ts} | 21 +++++--- .../web/frontend/js/main/account-upgrade.js | 49 ------------------- .../web/frontend/js/main/account-upgrade.ts | 29 +++++++++++ ...valid-tex-file.js => is-valid-tex-file.ts} | 4 +- .../web/frontend/js/pages/project-list.tsx | 2 +- .../web/frontend/js/pages/sharing-updates.tsx | 2 +- .../web/frontend/js/pages/token-access.tsx | 2 +- .../web/frontend/js/pages/user/settings.jsx | 2 +- .../js/pages/user/subscription/base.js | 2 +- .../components/start-free-trial-button.tsx | 4 +- .../{EventEmitter.js => EventEmitter.ts} | 24 ++++++--- .../frontend/js/utils/{dates.js => dates.ts} | 4 +- ...{isNetworkError.js => is-network-error.ts} | 4 +- ...-public-path.js => webpack-public-path.ts} | 0 .../settings/leavers-survey-alert.stories.tsx | 2 +- .../pdf-preview/pdf-preview.spec.tsx | 2 +- .../settings/settings-document.test.tsx | 15 +++--- .../layout-dropdown-button.test.jsx | 2 +- .../utils/display-name-for-user.test.ts} | 17 ++----- .../components/current-plan-widget.test.tsx | 2 +- .../components/notifications.test.tsx | 2 +- .../components/project-list-root.test.tsx | 2 +- .../components/project-search.test.tsx | 2 +- ...e-and-download-project-pdf-button.test.tsx | 2 +- .../table/projects-action-modal.test.tsx | 2 +- .../components/leavers-survey-alert.test.tsx | 4 +- .../linking/integration-widget.test.tsx | 2 +- .../settings/components/root.test.tsx | 2 +- .../context/user-email-context.test.tsx | 2 +- .../dashboard/states/active/active.test.tsx | 2 +- .../infrastructure/local-storage.test.js | 2 +- .../shared/components/notification.test.tsx | 2 +- .../shared/hooks/use-persisted-state.test.tsx | 2 +- .../test/frontend/utils/EventEmitterTests.js | 29 ++++------- services/web/types/window.ts | 2 + 52 files changed, 203 insertions(+), 210 deletions(-) create mode 100644 services/web/frontend/js/features/history/utils/display-name-for-user.ts delete mode 100644 services/web/frontend/js/ide/history/util/displayNameForUser.js rename services/web/frontend/js/infrastructure/{event-tracking.js => event-tracking.ts} (68%) rename services/web/frontend/js/infrastructure/{local-storage.js => local-storage.ts} (62%) delete mode 100644 services/web/frontend/js/infrastructure/promise.js create mode 100644 services/web/frontend/js/infrastructure/promise.ts rename services/web/frontend/js/infrastructure/{session-storage.js => session-storage.ts} (63%) delete mode 100644 services/web/frontend/js/main/account-upgrade.js create mode 100644 services/web/frontend/js/main/account-upgrade.ts rename services/web/frontend/js/main/{is-valid-tex-file.js => is-valid-tex-file.ts} (76%) rename services/web/frontend/js/utils/{EventEmitter.js => EventEmitter.ts} (77%) rename services/web/frontend/js/utils/{dates.js => dates.ts} (58%) rename services/web/frontend/js/utils/{isNetworkError.js => is-network-error.ts} (96%) rename services/web/frontend/js/utils/{webpack-public-path.js => webpack-public-path.ts} (100%) rename services/web/test/frontend/{ide/history/util/displayNameForUserTests.js => features/history/utils/display-name-for-user.test.ts} (76%) diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx index 5bff9be999..7a9e7c0c30 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import isValidTeXFile from '../../../../main/is-valid-tex-file' +import { isValidTeXFile } from '../../../../main/is-valid-tex-file' import { useEditorContext } from '../../../../shared/context/editor-context' import { useProjectSettingsContext } from '../../context/project-settings-context' import SettingsMenuSelect from './settings-menu-select' diff --git a/services/web/frontend/js/features/history/components/change-list/owner-paywall-prompt.tsx b/services/web/frontend/js/features/history/components/change-list/owner-paywall-prompt.tsx index 736940c16b..e81b58a277 100644 --- a/services/web/frontend/js/features/history/components/change-list/owner-paywall-prompt.tsx +++ b/services/web/frontend/js/features/history/components/change-list/owner-paywall-prompt.tsx @@ -3,7 +3,6 @@ import Icon from '../../../../shared/components/icon' import { useCallback, useEffect, useState } from 'react' import * as eventTracking from '../../../../infrastructure/event-tracking' import StartFreeTrialButton from '../../../../shared/components/start-free-trial-button' -import { paywallPrompt } from '../../../../main/account-upgrade' import { useFeatureFlag } from '@/shared/context/split-test-context' function FeatureItem({ text }: { text: string }) { @@ -22,7 +21,7 @@ export function OwnerPaywallPrompt() { useEffect(() => { eventTracking.send('subscription-funnel', 'editor-click-feature', 'history') - paywallPrompt('history') + eventTracking.sendMB('paywall-prompt', { 'paywall-type': 'history' }) }, []) const handleFreeTrialClick = useCallback(() => { diff --git a/services/web/frontend/js/features/history/utils/display-name-for-user.ts b/services/web/frontend/js/features/history/utils/display-name-for-user.ts new file mode 100644 index 0000000000..626e8994bd --- /dev/null +++ b/services/web/frontend/js/features/history/utils/display-name-for-user.ts @@ -0,0 +1,22 @@ +import { User } from '@/features/history/services/types/shared' +import getMeta from '@/utils/meta' +import { formatUserName } from '@/features/history/utils/history-details' + +export default function displayNameForUser( + user: + | (User & { + name?: string + }) + | null +) { + if (user == null) { + return 'Anonymous' + } + if (user.id === getMeta('ol-user').id) { + return 'you' + } + if (user.name != null) { + return user.name + } + return formatUserName(user) +} diff --git a/services/web/frontend/js/features/history/utils/highlights-from-diff-response.ts b/services/web/frontend/js/features/history/utils/highlights-from-diff-response.ts index eddc55a8cc..bdcc087ab1 100644 --- a/services/web/frontend/js/features/history/utils/highlights-from-diff-response.ts +++ b/services/web/frontend/js/features/history/utils/highlights-from-diff-response.ts @@ -1,8 +1,8 @@ -import displayNameForUser from '../../../ide/history/util/displayNameForUser' import moment from 'moment/moment' import ColorManager from '../../../ide/colors/ColorManager' import { DocDiffChunk, Highlight } from '../services/types/doc' import { TFunction } from 'i18next' +import displayNameForUser from './display-name-for-user' export function highlightsFromDiffResponse( chunks: DocDiffChunk[], diff --git a/services/web/frontend/js/features/ide-react/context/outline-context.tsx b/services/web/frontend/js/features/ide-react/context/outline-context.tsx index 4711103859..ac64033897 100644 --- a/services/web/frontend/js/features/ide-react/context/outline-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/outline-context.tsx @@ -12,7 +12,7 @@ import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter' import useEventListener from '@/shared/hooks/use-event-listener' import * as eventTracking from '@/infrastructure/event-tracking' import useScopeValue from '@/shared/hooks/use-scope-value' -import isValidTeXFile from '@/main/is-valid-tex-file' +import { isValidTeXFile } from '@/main/is-valid-tex-file' import localStorage from '@/infrastructure/local-storage' import { useProjectContext } from '@/shared/context/project-context' 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 5fb7e66f9a..cb7433ee52 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 @@ -9,7 +9,7 @@ import { prefetchLargeEnabled, trackPdfDownloadEnabled, } from './pdf-caching-flags' -import { isNetworkError } from '@/utils/isNetworkError' +import { isNetworkError } from '@/utils/is-network-error' import { debugConsole } from '@/utils/debugging' import { PDFJS } from './pdf-js' diff --git a/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.tsx b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.tsx index d34f70e4da..8e485d6d35 100644 --- a/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useUserContext } from '../../../shared/context/user-context' -import { upgradePlan } from '../../../main/account-upgrade' +import { upgradePlan } from '@/main/account-upgrade' import StartFreeTrialButton from '../../../shared/components/start-free-trial-button' import Icon from '../../../shared/components/icon' import { useFeatureFlag } from '../../../shared/context/split-test-context' diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/access-levels-changed.tsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/access-levels-changed.tsx index 6fbf4d6bed..e0ac152a58 100644 --- a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/access-levels-changed.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/access-levels-changed.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next' import Notification from '@/shared/components/notification' -import { upgradePlan } from '../../../../main/account-upgrade' +import { upgradePlan } from '@/main/account-upgrade' import { useProjectContext } from '@/shared/context/project-context' import { useUserContext } from '@/shared/context/user-context' import { sendMB } from '@/infrastructure/event-tracking' diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators-upgrade.tsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators-upgrade.tsx index 5645192a09..6619b41a5d 100644 --- a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators-upgrade.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators-upgrade.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next' import Notification from '@/shared/components/notification' -import { upgradePlan } from '../../../../main/account-upgrade' +import { upgradePlan } from '@/main/account-upgrade' import { linkSharingEnforcementDate } from '../../utils/link-sharing' import { useProjectContext } from '@/shared/context/project-context' import { useUserContext } from '@/shared/context/user-context' diff --git a/services/web/frontend/js/features/source-editor/components/editor-switch.tsx b/services/web/frontend/js/features/source-editor/components/editor-switch.tsx index c8f53190d2..8df024ee99 100644 --- a/services/web/frontend/js/features/source-editor/components/editor-switch.tsx +++ b/services/web/frontend/js/features/source-editor/components/editor-switch.tsx @@ -3,7 +3,7 @@ import useScopeValue from '@/shared/hooks/use-scope-value' import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import useTutorial from '@/shared/hooks/promotions/use-tutorial' import { sendMB } from '../../../infrastructure/event-tracking' -import isValidTeXFile from '../../../main/is-valid-tex-file' +import { isValidTeXFile } from '../../../main/is-valid-tex-file' import { useTranslation } from 'react-i18next' import { EditorSwitchBeginnerTooltip, diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/upgrade-track-changes-modal.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/upgrade-track-changes-modal.tsx index 4045ada811..a4b54b9e26 100644 --- a/services/web/frontend/js/features/source-editor/components/review-panel/upgrade-track-changes-modal.tsx +++ b/services/web/frontend/js/features/source-editor/components/review-panel/upgrade-track-changes-modal.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next' import Icon from '../../../../shared/components/icon' import { useProjectContext } from '../../../../shared/context/project-context' import { useUserContext } from '../../../../shared/context/user-context' -import { startFreeTrial, upgradePlan } from '../../../../main/account-upgrade' +import { startFreeTrial, upgradePlan } from '@/main/account-upgrade' import { memo } from 'react' import { useFeatureFlag } from '@/shared/context/split-test-context' import OLModal, { diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index 1965724654..7d6a8e6589 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts @@ -47,7 +47,7 @@ import { setVisual } from '../extensions/visual/visual' import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' import { useUserSettingsContext } from '@/shared/context/user-settings-context' import { setDocName } from '@/features/source-editor/extensions/doc-name' -import isValidTexFile from '@/main/is-valid-tex-file' +import { isValidTeXFile } from '@/main/is-valid-tex-file' import { captureException } from '@/infrastructure/error-reporter' import grammarlyExtensionPresent from '@/shared/utils/grammarly' import { DocumentContainer } from '@/features/ide-react/editor/document-container' @@ -288,7 +288,7 @@ function useCodeMirrorScope(view: EditorView) { const { previewByPath } = useFileTreePathContext() - const showVisual = visual && isValidTexFile(docName) + const showVisual = visual && isValidTeXFile(docName) const visualRef = useRef({ previewByPath, diff --git a/services/web/frontend/js/ide/history/util/displayNameForUser.js b/services/web/frontend/js/ide/history/util/displayNameForUser.js deleted file mode 100644 index dbdfec8f0b..0000000000 --- a/services/web/frontend/js/ide/history/util/displayNameForUser.js +++ /dev/null @@ -1,24 +0,0 @@ -import getMeta from '@/utils/meta' - -export default function displayNameForUser(user) { - if (user == null) { - return 'Anonymous' - } - if (user.id === getMeta('ol-user').id) { - return 'you' - } - if (user.name != null) { - return user.name - } - let name = [user.first_name, user.last_name] - .filter(n => n != null) - .join(' ') - .trim() - if (name === '') { - name = user.email.split('@')[0] - } - if (name == null || name === '') { - return '?' - } - return name -} diff --git a/services/web/frontend/js/infrastructure/event-tracking.js b/services/web/frontend/js/infrastructure/event-tracking.ts similarity index 68% rename from services/web/frontend/js/infrastructure/event-tracking.js rename to services/web/frontend/js/infrastructure/event-tracking.ts index 1c67b10aac..698f5df425 100644 --- a/services/web/frontend/js/infrastructure/event-tracking.js +++ b/services/web/frontend/js/infrastructure/event-tracking.ts @@ -1,25 +1,40 @@ -import sessionStorage from '../infrastructure/session-storage' +import sessionStorage from './session-storage' import getMeta from '@/utils/meta' +type Segmentation = Record< + string, + string | number | boolean | undefined | unknown | any // TODO: RecurlyError +> + const CACHE_KEY = 'mbEvents' -function alreadySent(key) { +function alreadySent(key: string) { const eventCache = sessionStorage.getItem(CACHE_KEY) || {} return !!eventCache[key] } -function markAsSent(key) { +function markAsSent(key: string) { const eventCache = sessionStorage.getItem(CACHE_KEY) || {} eventCache[key] = true sessionStorage.setItem(CACHE_KEY, eventCache) } -export function send(category, action, label, value) { +export function send( + category: string, + action: string, + label?: string, + value?: string +) { if (typeof window.ga === 'function') { window.ga('send', 'event', category, action, label, value) } } -export function sendOnce(category, action, label, value) { +export function sendOnce( + category: string, + action: string, + label: string, + value: string +) { if (alreadySent(category)) return if (typeof window.ga !== 'function') return @@ -27,7 +42,7 @@ export function sendOnce(category, action, label, value) { markAsSent(category) } -export function sendMB(key, segmentation = {}) { +export function sendMB(key: string, segmentation: Segmentation = {}) { if (!segmentation.page) { segmentation.page = window.location.pathname } @@ -40,21 +55,28 @@ export function sendMB(key, segmentation = {}) { } } -export function sendMBOnce(key, segmentation = {}) { +export function sendMBOnce(key: string, segmentation: Segmentation = {}) { if (alreadySent(key)) return sendMB(key, segmentation) markAsSent(key) } -export function sendMBSampled(key, body = {}, rate = 0.01) { +export function sendMBSampled( + key: string, + segmentation: Segmentation = {}, + rate = 0.01 +) { if (Math.random() < rate) { - sendMB(key, body) + sendMB(key, segmentation) } } const sentOncePerPageLoad = new Set() -export function sendMBOncePerPageLoad(key, segmentation = {}) { +export function sendMBOncePerPageLoad( + key: string, + segmentation: Segmentation = {} +) { if (sentOncePerPageLoad.has(key)) return sendMB(key, segmentation) sentOncePerPageLoad.add(key) @@ -66,7 +88,7 @@ export function sendMBOncePerPageLoad(key, segmentation = {}) { // @screen-sm: 768px; export const isSmallDevice = window.screen.width < 768 -function sendBeacon(key, data) { +function sendBeacon(key: string, data: Segmentation) { if (!navigator || !navigator.sendBeacon) return if (!getMeta('ol-ExposedSettings').isOverleaf) return diff --git a/services/web/frontend/js/infrastructure/local-storage.js b/services/web/frontend/js/infrastructure/local-storage.ts similarity index 62% rename from services/web/frontend/js/infrastructure/local-storage.js rename to services/web/frontend/js/infrastructure/local-storage.ts index 4ae017f82f..69738dded4 100644 --- a/services/web/frontend/js/infrastructure/local-storage.js +++ b/services/web/frontend/js/infrastructure/local-storage.ts @@ -12,7 +12,11 @@ import { debugConsole } from '@/utils/debugging' * @param {string?} key Key passed to the localStorage function (if any) * @param {any?} value Value passed to the localStorage function (if any) */ -const callSafe = function (fn, key, value) { +const callSafe = function ( + fn: (...args: any) => any, + key?: string, + value?: any +) { try { return fn(key, value) } catch (e) { @@ -21,11 +25,12 @@ const callSafe = function (fn, key, value) { } } -const getItem = function (key) { - return JSON.parse(localStorage.getItem(key)) +const getItem = function (key: string) { + const value = localStorage.getItem(key) + return value === null ? null : JSON.parse(value) } -const setItem = function (key, value) { +const setItem = function (key: string, value: any) { localStorage.setItem(key, JSON.stringify(value)) } @@ -33,15 +38,15 @@ const clear = function () { localStorage.clear() } -const removeItem = function (key) { +const removeItem = function (key: string) { return localStorage.removeItem(key) } const customLocalStorage = { - getItem: key => callSafe(getItem, key), - setItem: (key, value) => callSafe(setItem, key, value), + getItem: (key: string) => callSafe(getItem, key), + setItem: (key: string, value: any) => callSafe(setItem, key, value), clear: () => callSafe(clear), - removeItem: key => callSafe(removeItem, key), + removeItem: (key: string) => callSafe(removeItem, key), } export default customLocalStorage diff --git a/services/web/frontend/js/infrastructure/promise.js b/services/web/frontend/js/infrastructure/promise.js deleted file mode 100644 index 58a0bd67d4..0000000000 --- a/services/web/frontend/js/infrastructure/promise.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * run `fn` in serie for all values, and resolve with an array of the results - * inspired by https://stackoverflow.com/a/50506360/1314820 - * @template T the input array's item type - * @template V the `fn` function's return type - * @param {T[]} values - * @param {(item: T) => Promise} fn - * @returns {V[]} - */ -export function mapSeries(values, fn) { - return values.reduce(async (promiseChain, value) => { - const chainResults = await promiseChain - const currentResult = await fn(value) - return [...chainResults, currentResult] - }, Promise.resolve([])) -} diff --git a/services/web/frontend/js/infrastructure/promise.ts b/services/web/frontend/js/infrastructure/promise.ts new file mode 100644 index 0000000000..edbfc0114c --- /dev/null +++ b/services/web/frontend/js/infrastructure/promise.ts @@ -0,0 +1,13 @@ +/** + * run `fn` in series for all values, and resolve with an array of the results + */ +export const mapSeries = async ( + values: T[], + fn: (item: T) => Promise +) => { + const output: V[] = [] + for (const value of values) { + output.push(await fn(value)) + } + return output +} diff --git a/services/web/frontend/js/infrastructure/session-storage.js b/services/web/frontend/js/infrastructure/session-storage.ts similarity index 63% rename from services/web/frontend/js/infrastructure/session-storage.js rename to services/web/frontend/js/infrastructure/session-storage.ts index 1e2757874f..09c5dcbcee 100644 --- a/services/web/frontend/js/infrastructure/session-storage.js +++ b/services/web/frontend/js/infrastructure/session-storage.ts @@ -12,7 +12,11 @@ import { debugConsole } from '@/utils/debugging' * @param {string?} key Key passed to the sessionStorage function (if any) * @param {any?} value Value passed to the sessionStorage function (if any) */ -const callSafe = function (fn, key, value) { +const callSafe = function ( + fn: (...args: any) => any, + key?: string, + value?: any +) { try { return fn(key, value) } catch (e) { @@ -21,11 +25,12 @@ const callSafe = function (fn, key, value) { } } -const getItem = function (key) { - return JSON.parse(sessionStorage.getItem(key)) +const getItem = function (key: string) { + const value = sessionStorage.getItem(key) + return value === null ? null : JSON.parse(value) } -const setItem = function (key, value) { +const setItem = function (key: string, value: any) { sessionStorage.setItem(key, JSON.stringify(value)) } @@ -33,15 +38,15 @@ const clear = function () { sessionStorage.clear() } -const removeItem = function (key) { +const removeItem = function (key: string) { return sessionStorage.removeItem(key) } const customSessionStorage = { - getItem: key => callSafe(getItem, key), - setItem: (key, value) => callSafe(setItem, key, value), + getItem: (key: string) => callSafe(getItem, key), + setItem: (key: string, value: any) => callSafe(setItem, key, value), clear: () => callSafe(clear), - removeItem: key => callSafe(removeItem, key), + removeItem: (key: string) => callSafe(removeItem, key), } export default customSessionStorage diff --git a/services/web/frontend/js/main/account-upgrade.js b/services/web/frontend/js/main/account-upgrade.js deleted file mode 100644 index e1997a3a81..0000000000 --- a/services/web/frontend/js/main/account-upgrade.js +++ /dev/null @@ -1,49 +0,0 @@ -import * as eventTracking from '../infrastructure/event-tracking' - -function startFreeTrial(source, version, $scope, variant) { - const eventSegmentation = { 'paywall-type': source } - if (variant) { - eventSegmentation.variant = variant - } - - eventTracking.send('subscription-funnel', 'upgraded-free-trial', source) - eventTracking.sendMB('paywall-click', eventSegmentation) - - const searchParams = new URLSearchParams({ - itm_campaign: source, - }) - - if (version) { - searchParams.set('itm_content', version) - } - - if ($scope) { - $scope.startedFreeTrial = true - } - - window.open(`/user/subscription/choose-your-plan?${searchParams.toString()}`) -} - -function upgradePlan(source, $scope) { - const w = window.open() - const go = function () { - if (typeof ga === 'function') { - ga('send', 'event', 'subscription-funnel', 'upgraded-plan', source) - } - const url = '/user/subscription' - - if ($scope) { - $scope.startedFreeTrial = true - } - - w.location = url - } - - go() -} - -function paywallPrompt(source) { - eventTracking.sendMB('paywall-prompt', { 'paywall-type': source }) -} - -export { startFreeTrial, upgradePlan, paywallPrompt } diff --git a/services/web/frontend/js/main/account-upgrade.ts b/services/web/frontend/js/main/account-upgrade.ts new file mode 100644 index 0000000000..9aa2490768 --- /dev/null +++ b/services/web/frontend/js/main/account-upgrade.ts @@ -0,0 +1,29 @@ +import * as eventTracking from '../infrastructure/event-tracking' + +export function startFreeTrial(source: string, variant?: string) { + const eventSegmentation: Record = { 'paywall-type': source } + if (variant) { + eventSegmentation.variant = variant + } + + eventTracking.send('subscription-funnel', 'upgraded-free-trial', source) + eventTracking.sendMB('paywall-click', eventSegmentation) + + const searchParams = new URLSearchParams({ + itm_campaign: source, + }) + + window.open(`/user/subscription/choose-your-plan?${searchParams.toString()}`) +} + +export function upgradePlan(source: string) { + const openedWindow = window.open() + + if (typeof window.ga === 'function') { + window.ga('send', 'event', 'subscription-funnel', 'upgraded-plan', source) + } + + if (openedWindow) { + openedWindow.location = '/user/subscription' + } +} diff --git a/services/web/frontend/js/main/is-valid-tex-file.js b/services/web/frontend/js/main/is-valid-tex-file.ts similarity index 76% rename from services/web/frontend/js/main/is-valid-tex-file.js rename to services/web/frontend/js/main/is-valid-tex-file.ts index e0159d2c79..005d6f92bb 100644 --- a/services/web/frontend/js/main/is-valid-tex-file.js +++ b/services/web/frontend/js/main/is-valid-tex-file.ts @@ -1,6 +1,6 @@ import getMeta from '@/utils/meta' -function isValidTeXFile(filename) { +export const isValidTeXFile = (filename: string) => { const validTeXFileRegExp = new RegExp( `\\.(${getMeta('ol-ExposedSettings').validRootDocExtensions.join('|')})$`, 'i' @@ -8,5 +8,3 @@ function isValidTeXFile(filename) { return validTeXFileRegExp.test(filename) } - -export default isValidTeXFile diff --git a/services/web/frontend/js/pages/project-list.tsx b/services/web/frontend/js/pages/project-list.tsx index 60fcda413c..dd24bb00f9 100644 --- a/services/web/frontend/js/pages/project-list.tsx +++ b/services/web/frontend/js/pages/project-list.tsx @@ -1,5 +1,5 @@ import './../utils/meta' -import './../utils/webpack-public-path' +import '../utils/webpack-public-path' import './../infrastructure/error-reporter' import '@/i18n' import '../features/event-tracking' diff --git a/services/web/frontend/js/pages/sharing-updates.tsx b/services/web/frontend/js/pages/sharing-updates.tsx index 429bc8f57c..63219542ef 100644 --- a/services/web/frontend/js/pages/sharing-updates.tsx +++ b/services/web/frontend/js/pages/sharing-updates.tsx @@ -1,5 +1,5 @@ import './../utils/meta' -import './../utils/webpack-public-path' +import '../utils/webpack-public-path' import './../infrastructure/error-reporter' import '@/i18n' import ReactDOM from 'react-dom' diff --git a/services/web/frontend/js/pages/token-access.tsx b/services/web/frontend/js/pages/token-access.tsx index f18cd1e541..97026d93f4 100644 --- a/services/web/frontend/js/pages/token-access.tsx +++ b/services/web/frontend/js/pages/token-access.tsx @@ -1,5 +1,5 @@ import './../utils/meta' -import './../utils/webpack-public-path' +import '../utils/webpack-public-path' import './../infrastructure/error-reporter' import '@/i18n' import ReactDOM from 'react-dom' diff --git a/services/web/frontend/js/pages/user/settings.jsx b/services/web/frontend/js/pages/user/settings.jsx index b31d1c4d5c..bd55a175d9 100644 --- a/services/web/frontend/js/pages/user/settings.jsx +++ b/services/web/frontend/js/pages/user/settings.jsx @@ -1,6 +1,6 @@ import '../../marketing' import './../../utils/meta' -import './../../utils/webpack-public-path' +import '../../utils/webpack-public-path' import './../../infrastructure/error-reporter' import '@/i18n' import '../../features/settings/components/root' diff --git a/services/web/frontend/js/pages/user/subscription/base.js b/services/web/frontend/js/pages/user/subscription/base.js index 7773ac9bb2..4aa63cf159 100644 --- a/services/web/frontend/js/pages/user/subscription/base.js +++ b/services/web/frontend/js/pages/user/subscription/base.js @@ -1,5 +1,5 @@ import './../../../utils/meta' -import './../../../utils/webpack-public-path' +import '../../../utils/webpack-public-path' import './../../../infrastructure/error-reporter' import '@/i18n' import '../../../features/event-tracking' diff --git a/services/web/frontend/js/shared/components/start-free-trial-button.tsx b/services/web/frontend/js/shared/components/start-free-trial-button.tsx index 0f3c159cbf..3389ebd2af 100644 --- a/services/web/frontend/js/shared/components/start-free-trial-button.tsx +++ b/services/web/frontend/js/shared/components/start-free-trial-button.tsx @@ -1,6 +1,6 @@ import { MouseEventHandler, useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { startFreeTrial } from '../../main/account-upgrade' +import { startFreeTrial } from '@/main/account-upgrade' import * as eventTracking from '../../infrastructure/event-tracking' import OLButton from '@/features/ui/components/ol/ol-button' @@ -41,7 +41,7 @@ export default function StartFreeTrialButton({ handleClick(event) } - startFreeTrial(source, null, null, variant) + startFreeTrial(source, variant) }, [handleClick, source, variant] ) diff --git a/services/web/frontend/js/utils/EventEmitter.js b/services/web/frontend/js/utils/EventEmitter.ts similarity index 77% rename from services/web/frontend/js/utils/EventEmitter.js rename to services/web/frontend/js/utils/EventEmitter.ts index 3ed66cdc41..753cd2423b 100644 --- a/services/web/frontend/js/utils/EventEmitter.js +++ b/services/web/frontend/js/utils/EventEmitter.ts @@ -7,11 +7,19 @@ // Remove a listener for the foo event with the bar namespace: .off 'foo.bar' export default class EventEmitter { + events: Record< + string, + { + callback: (...args: any[]) => void + namespace: string + }[] + > + constructor() { this.events = {} } - on(event, callback) { + on(event: string, callback: (...args: any[]) => void) { if (!this.events) { this.events = {} } @@ -26,7 +34,7 @@ export default class EventEmitter { }) } - off(event, cb) { + off(event?: string, callback?: (...args: any[]) => void) { if (!this.events) { this.events = {} } @@ -36,8 +44,10 @@ export default class EventEmitter { if (!this.events[event]) { this.events[event] = [] } - if (cb) { - this.events[event] = this.events[event].filter(e => e.callback !== cb) + if (callback) { + this.events[event] = this.events[event].filter( + e => e.callback !== callback + ) } else if (!namespace) { // Clear all listeners for event delete this.events[event] @@ -53,7 +63,7 @@ export default class EventEmitter { } } - trigger(event, ...args) { + trigger(event: string, ...args: any[]) { if (!this.events) { this.events = {} } @@ -62,7 +72,7 @@ export default class EventEmitter { } } - emit(...args) { - this.trigger(...args) + emit(event: string, ...args: any[]) { + this.trigger(event, ...args) } } diff --git a/services/web/frontend/js/utils/dates.js b/services/web/frontend/js/utils/dates.ts similarity index 58% rename from services/web/frontend/js/utils/dates.js rename to services/web/frontend/js/utils/dates.ts index b1478da5d2..b4dd84f822 100644 --- a/services/web/frontend/js/utils/dates.js +++ b/services/web/frontend/js/utils/dates.ts @@ -1,6 +1,6 @@ import moment from 'moment' -export function formatDate(date, format) { +export function formatDate(date: moment.MomentInput, format?: string) { if (!date) return 'N/A' if (format == null) { format = 'Do MMM YYYY, h:mm a' @@ -8,6 +8,6 @@ export function formatDate(date, format) { return moment(date).format(format) } -export function fromNowDate(date) { +export function fromNowDate(date: moment.MomentInput | string) { return moment(date).fromNow() } diff --git a/services/web/frontend/js/utils/isNetworkError.js b/services/web/frontend/js/utils/is-network-error.ts similarity index 96% rename from services/web/frontend/js/utils/isNetworkError.js rename to services/web/frontend/js/utils/is-network-error.ts index cfb592436d..95d4fb2750 100644 --- a/services/web/frontend/js/utils/isNetworkError.js +++ b/services/web/frontend/js/utils/is-network-error.ts @@ -68,6 +68,6 @@ const NETWORK_ERRORS = [ '요청한 시간이 초과되었습니다.', ] -export function isNetworkError(err) { - return NETWORK_ERRORS.includes(err?.message) +export function isNetworkError(err?: Error) { + return err && NETWORK_ERRORS.includes(err.message) } diff --git a/services/web/frontend/js/utils/webpack-public-path.js b/services/web/frontend/js/utils/webpack-public-path.ts similarity index 100% rename from services/web/frontend/js/utils/webpack-public-path.js rename to services/web/frontend/js/utils/webpack-public-path.ts diff --git a/services/web/frontend/stories/settings/leavers-survey-alert.stories.tsx b/services/web/frontend/stories/settings/leavers-survey-alert.stories.tsx index 6a1662e35c..075061ab12 100644 --- a/services/web/frontend/stories/settings/leavers-survey-alert.stories.tsx +++ b/services/web/frontend/stories/settings/leavers-survey-alert.stories.tsx @@ -1,7 +1,7 @@ import EmailsSection from '../../js/features/settings/components/emails-section' import { UserEmailsProvider } from '../../js/features/settings/context/user-email-context' import { LeaversSurveyAlert } from '../../js/features/settings/components/leavers-survey-alert' -import localStorage from '../../js/infrastructure/local-storage' +import localStorage from '@/infrastructure/local-storage' import { bsVersionDecorator } from '../../../.storybook/utils/with-bootstrap-switcher' export const SurveyAlert = () => { 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 1f46407f6e..8f61cfc9bf 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 @@ -1,5 +1,5 @@ import '../../helpers/bootstrap-3' -import localStorage from '../../../../frontend/js/infrastructure/local-storage' +import localStorage from '@/infrastructure/local-storage' import PdfPreview from '../../../../frontend/js/features/pdf-preview/components/pdf-preview' import { EditorProviders } from '../../helpers/editor-providers' import { mockScope } from './scope' diff --git a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-document.test.tsx b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-document.test.tsx index 6d3c147cc1..c46e3a6497 100644 --- a/services/web/test/frontend/features/editor-left-menu/components/settings/settings-document.test.tsx +++ b/services/web/test/frontend/features/editor-left-menu/components/settings/settings-document.test.tsx @@ -1,17 +1,13 @@ import { screen, within } from '@testing-library/dom' import { expect } from 'chai' -import sinon from 'sinon' import fetchMock from 'fetch-mock' import SettingsDocument from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-document' -import * as isValidTeXFileModule from '../../../../../../frontend/js/main/is-valid-tex-file' import { Folder } from '../../../../../../types/folder' import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context' import { render } from '@testing-library/react' import { EditorProviders } from '../../../../helpers/editor-providers' describe('', function () { - let isValidTeXFileStub: sinon.SinonStub - const rootFolder: Folder = { _id: 'root-folder-id', name: 'rootFolder', @@ -25,15 +21,18 @@ describe('', function () { folders: [], } + let originalSettings: typeof window.metaAttributesCache + beforeEach(function () { - isValidTeXFileStub = sinon - .stub(isValidTeXFileModule, 'default') - .returns(true) + originalSettings = window.metaAttributesCache.get('ol-ExposedSettings') + window.metaAttributesCache.set('ol-ExposedSettings', { + validRootDocExtensions: ['tex'], + }) }) afterEach(function () { fetchMock.reset() - isValidTeXFileStub.restore() + window.metaAttributesCache.set('ol-ExposedSettings', originalSettings) }) it('shows correct menu', async function () { diff --git a/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.jsx b/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.jsx index c0331ba80c..648c62feb5 100644 --- a/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.jsx +++ b/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.jsx @@ -4,7 +4,7 @@ import { expect } from 'chai' import { fireEvent, screen } from '@testing-library/react' import LayoutDropdownButton from '../../../../../frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button' import { renderWithEditorContext } from '../../../helpers/render-with-context' -import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking' +import * as eventTracking from '@/infrastructure/event-tracking' describe('', function () { let openStub diff --git a/services/web/test/frontend/ide/history/util/displayNameForUserTests.js b/services/web/test/frontend/features/history/utils/display-name-for-user.test.ts similarity index 76% rename from services/web/test/frontend/ide/history/util/displayNameForUserTests.js rename to services/web/test/frontend/features/history/utils/display-name-for-user.test.ts index 7edd9e72e6..07d454a5ed 100644 --- a/services/web/test/frontend/ide/history/util/displayNameForUserTests.js +++ b/services/web/test/frontend/features/history/utils/display-name-for-user.test.ts @@ -1,19 +1,8 @@ -/* eslint-disable - no-return-assign, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ import { expect } from 'chai' +import displayNameForUser from '@/features/history/utils/display-name-for-user' -import displayNameForUser from '../../../../../frontend/js/ide/history/util/displayNameForUser' - -export default describe('displayNameForUser', function () { - const currentUsersId = 42 +describe('displayNameForUser', function () { + const currentUsersId = 'user-a' beforeEach(function () { window.metaAttributesCache.set('ol-user', { id: currentUsersId }) }) 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 ed6877ee07..faca302f05 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 @@ -7,7 +7,7 @@ import { IndividualPlanSubscription, } from '../../../../../types/project/dashboard/subscription' import { DeepReadonly } from '../../../../../types/utils' -import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking' +import * as eventTracking from '@/infrastructure/event-tracking' import CurrentPlanWidget from '../../../../../frontend/js/features/project-list/components/current-plan-widget/current-plan-widget' describe('', function () { 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 3ecb7b6750..679e00815e 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 @@ -36,7 +36,7 @@ import { DeepPartial } from '../../../../../types/utils' import { Project } from '../../../../../types/project/dashboard/api' import GroupsAndEnterpriseBanner from '../../../../../frontend/js/features/project-list/components/notifications/groups-and-enterprise-banner' import GroupSsoSetupSuccess from '../../../../../frontend/js/features/project-list/components/notifications/groups/group-sso-setup-success' -import localStorage from '../../../../../frontend/js/infrastructure/local-storage' +import localStorage from '@/infrastructure/local-storage' import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location' import { commonsSubscription, 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 2c5e3f6dc7..a2567319bd 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 @@ -4,7 +4,7 @@ import fetchMock from 'fetch-mock' import sinon from 'sinon' import ProjectListRoot from '../../../../../frontend/js/features/project-list/components/project-list-root' import { renderWithProjectListContext } from '../helpers/render-with-context' -import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking' +import * as eventTracking from '@/infrastructure/event-tracking' import { projectsData, owner, diff --git a/services/web/test/frontend/features/project-list/components/project-search.test.tsx b/services/web/test/frontend/features/project-list/components/project-search.test.tsx index a8b5879535..f8c482740b 100644 --- a/services/web/test/frontend/features/project-list/components/project-search.test.tsx +++ b/services/web/test/frontend/features/project-list/components/project-search.test.tsx @@ -2,7 +2,7 @@ import sinon from 'sinon' import { render, screen, fireEvent } from '@testing-library/react' import { expect } from 'chai' import SearchForm from '../../../../../frontend/js/features/project-list/components/search-form' -import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking' +import * as eventTracking from '@/infrastructure/event-tracking' import fetchMock from 'fetch-mock' import { Filter } from '../../../../../frontend/js/features/project-list/context/project-list-context' import { Tag } from '../../../../../app/src/Features/Tags/types' diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx index 978faf510f..2479d9d3ab 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx @@ -5,7 +5,7 @@ import { projectsData } from '../../../../fixtures/projects-data' import * as useLocationModule from '../../../../../../../../frontend/js/shared/hooks/use-location' import { CompileAndDownloadProjectPDFButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button' import fetchMock from 'fetch-mock' -import * as eventTracking from '../../../../../../../../frontend/js/infrastructure/event-tracking' +import * as eventTracking from '@/infrastructure/event-tracking' describe('', function () { let assignStub: sinon.SinonStub diff --git a/services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx b/services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx index 8476f64580..66b350eaab 100644 --- a/services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx @@ -7,7 +7,7 @@ import { resetProjectListContextFetch, renderWithProjectListContext, } from '../../helpers/render-with-context' -import * as eventTracking from '../../../../../../frontend/js/infrastructure/event-tracking' +import * as eventTracking from '@/infrastructure/event-tracking' describe('', function () { const actionHandler = sinon.stub().resolves({}) diff --git a/services/web/test/frontend/features/settings/components/leavers-survey-alert.test.tsx b/services/web/test/frontend/features/settings/components/leavers-survey-alert.test.tsx index 570c3019ab..2ffa2e5051 100644 --- a/services/web/test/frontend/features/settings/components/leavers-survey-alert.test.tsx +++ b/services/web/test/frontend/features/settings/components/leavers-survey-alert.test.tsx @@ -3,8 +3,8 @@ import sinon from 'sinon' import { fireEvent, screen, render } from '@testing-library/react' import { UserEmailsProvider } from '../../../../../frontend/js/features/settings/context/user-email-context' import { LeaversSurveyAlert } from '../../../../../frontend/js/features/settings/components/leavers-survey-alert' -import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking' -import localStorage from '../../../../../frontend/js/infrastructure/local-storage' +import * as eventTracking from '@/infrastructure/event-tracking' +import localStorage from '@/infrastructure/local-storage' import fetchMock from 'fetch-mock' function renderWithProvider() { 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 5fd7180036..168421e6c7 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 @@ -2,7 +2,7 @@ import { expect } from 'chai' import sinon from 'sinon' import { screen, fireEvent, render, waitFor } from '@testing-library/react' import { IntegrationLinkingWidget } from '../../../../../../frontend/js/features/settings/components/linking/integration-widget' -import * as eventTracking from '../../../../../../frontend/js/infrastructure/event-tracking' +import * as eventTracking from '@/infrastructure/event-tracking' describe('', function () { const defaultProps = { 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 d15c3c22bd..ea4de26aa1 100644 --- a/services/web/test/frontend/features/settings/components/root.test.tsx +++ b/services/web/test/frontend/features/settings/components/root.test.tsx @@ -1,7 +1,7 @@ import { expect } from 'chai' import sinon from 'sinon' import { screen, render, waitFor } from '@testing-library/react' -import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking' +import * as eventTracking from '@/infrastructure/event-tracking' import SettingsPageRoot from '../../../../../frontend/js/features/settings/components/root' import getMeta from '@/utils/meta' diff --git a/services/web/test/frontend/features/settings/context/user-email-context.test.tsx b/services/web/test/frontend/features/settings/context/user-email-context.test.tsx index bf0b69c208..ba814d6ebf 100644 --- a/services/web/test/frontend/features/settings/context/user-email-context.test.tsx +++ b/services/web/test/frontend/features/settings/context/user-email-context.test.tsx @@ -14,7 +14,7 @@ import { fakeUsersData, unconfirmedCommonsUserData, } from '../fixtures/test-user-email-data' -import localStorage from '../../../../../frontend/js/infrastructure/local-storage' +import localStorage from '@/infrastructure/local-storage' const renderUserEmailsContext = () => renderHook(() => useUserEmailsContext(), { 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 aad32212ca..df8192030f 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 @@ -1,6 +1,6 @@ import { expect } from 'chai' import { fireEvent, screen, waitFor } from '@testing-library/react' -import * as eventTracking from '../../../../../../../../frontend/js/infrastructure/event-tracking' +import * as eventTracking from '@/infrastructure/event-tracking' import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription' import { annualActiveSubscription, diff --git a/services/web/test/frontend/infrastructure/local-storage.test.js b/services/web/test/frontend/infrastructure/local-storage.test.js index c8a35337b3..e6b07cccce 100644 --- a/services/web/test/frontend/infrastructure/local-storage.test.js +++ b/services/web/test/frontend/infrastructure/local-storage.test.js @@ -1,7 +1,7 @@ import { expect } from 'chai' import sinon from 'sinon' -import customLocalStorage from '../../../frontend/js/infrastructure/local-storage' +import customLocalStorage from '@/infrastructure/local-storage' import { debugConsole } from '@/utils/debugging' describe('localStorage', function () { diff --git a/services/web/test/frontend/shared/components/notification.test.tsx b/services/web/test/frontend/shared/components/notification.test.tsx index a4b421efc6..fbe5c04fc2 100644 --- a/services/web/test/frontend/shared/components/notification.test.tsx +++ b/services/web/test/frontend/shared/components/notification.test.tsx @@ -1,7 +1,7 @@ import { expect } from 'chai' import { screen, render } from '@testing-library/react' import Notification from '../../../../frontend/js/shared/components/notification' -import * as eventTracking from '../../../../frontend/js/infrastructure/event-tracking' +import * as eventTracking from '@/infrastructure/event-tracking' import sinon from 'sinon' describe('', function () { diff --git a/services/web/test/frontend/shared/hooks/use-persisted-state.test.tsx b/services/web/test/frontend/shared/hooks/use-persisted-state.test.tsx index 90dc4cde07..5b670698e3 100644 --- a/services/web/test/frontend/shared/hooks/use-persisted-state.test.tsx +++ b/services/web/test/frontend/shared/hooks/use-persisted-state.test.tsx @@ -3,7 +3,7 @@ import { expect } from 'chai' import { useEffect } from 'react' import { render, screen } from '@testing-library/react' import usePersistedState from '../../../../frontend/js/shared/hooks/use-persisted-state' -import localStorage from '../../../../frontend/js/infrastructure/local-storage' +import localStorage from '@/infrastructure/local-storage' describe('usePersistedState', function () { beforeEach(function () { diff --git a/services/web/test/frontend/utils/EventEmitterTests.js b/services/web/test/frontend/utils/EventEmitterTests.js index 68ad71eed2..6f4f4858dc 100644 --- a/services/web/test/frontend/utils/EventEmitterTests.js +++ b/services/web/test/frontend/utils/EventEmitterTests.js @@ -1,22 +1,11 @@ -/* eslint-disable - max-len, - no-return-assign, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ import { expect } from 'chai' import sinon from 'sinon' -import EventEmitter from '../../../frontend/js/utils/EventEmitter' +import EventEmitter from '@/utils/EventEmitter' -export default describe('EventEmitter', function () { +describe('EventEmitter', function () { beforeEach(function () { - return (this.eventEmitter = new EventEmitter()) + this.eventEmitter = new EventEmitter() }) it('calls listeners', function () { @@ -28,7 +17,7 @@ export default describe('EventEmitter', function () { this.eventEmitter.trigger('foo') expect(cb1).to.have.been.called - return expect(cb2).to.not.have.been.called + expect(cb2).to.not.have.been.called }) it('calls multiple listeners', function () { @@ -40,7 +29,7 @@ export default describe('EventEmitter', function () { this.eventEmitter.trigger('foo') expect(cb1).to.have.been.called - return expect(cb2).to.have.been.called + expect(cb2).to.have.been.called }) it('calls listeners with namespace', function () { @@ -52,7 +41,7 @@ export default describe('EventEmitter', function () { this.eventEmitter.trigger('foo') expect(cb1).to.have.been.called - return expect(cb2).to.have.been.called + expect(cb2).to.have.been.called }) it('removes listeners', function () { @@ -62,7 +51,7 @@ export default describe('EventEmitter', function () { this.eventEmitter.trigger('foo') - return expect(cb).to.not.have.been.called + expect(cb).to.not.have.been.called }) it('removes namespaced listeners', function () { @@ -72,7 +61,7 @@ export default describe('EventEmitter', function () { this.eventEmitter.trigger('foo') - return expect(cb).to.not.have.been.called + expect(cb).to.not.have.been.called }) it('does not remove unnamespaced listeners if off called with namespace', function () { @@ -85,6 +74,6 @@ export default describe('EventEmitter', function () { this.eventEmitter.trigger('foo') expect(cb1).to.have.been.called - return expect(cb2).to.not.have.been.called + expect(cb2).to.not.have.been.called }) }) diff --git a/services/web/types/window.ts b/services/web/types/window.ts index 84213774d6..4096958005 100644 --- a/services/web/types/window.ts +++ b/services/web/types/window.ts @@ -37,5 +37,7 @@ declare global { store: ScopeValueStore } } + ga?: (...args: any) => void + gtag?: (...args: any) => void } } From 0d3be44f6af170dd1873aea23b4b9f9a02040a0e Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Wed, 15 Jan 2025 12:22:37 +0100 Subject: [PATCH 0035/1724] [web] Remove partial from `oauthAccessTokens.user_id` index (#22886) * [web] Remove partial from `oauthAccessTokens.user_id` index GitOrigin-RevId: 22b2bc136bef2c45b4bdf1e6a5d6a20eeefea3f7 --- ...5110745_oauth_user_id_index_no_partial.mjs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 services/web/migrations/20250115110745_oauth_user_id_index_no_partial.mjs diff --git a/services/web/migrations/20250115110745_oauth_user_id_index_no_partial.mjs b/services/web/migrations/20250115110745_oauth_user_id_index_no_partial.mjs new file mode 100644 index 0000000000..cc0bec9b86 --- /dev/null +++ b/services/web/migrations/20250115110745_oauth_user_id_index_no_partial.mjs @@ -0,0 +1,38 @@ +/* eslint-disable no-unused-vars */ + +import Helpers from './lib/helpers.mjs' + +const tags = ['server-ce', 'server-pro', 'saas'] + +const oldIndex = { + key: { user_id: 1 }, + name: 'pat_user_id_1', + partialFilterExpression: { type: 'pat' }, +} + +const newIndex = { + key: { user_id: 1 }, + name: 'user_id_1', +} + +const migrate = async client => { + const { db } = client + await Helpers.addIndexesToCollection(db.oauthAccessTokens, [newIndex]) + await Helpers.dropIndexesFromCollection(db.oauthAccessTokens, [oldIndex]) +} + +const rollback = async client => { + const { db } = client + try { + await Helpers.addIndexesToCollection(db.oauthAccessTokens, [oldIndex]) + await Helpers.dropIndexesFromCollection(db.oauthAccessTokens, [newIndex]) + } catch (err) { + console.error('Something went wrong rolling back the migrations', err) + } +} + +export default { + tags, + migrate, + rollback, +} From f069adaf15c0fb99fe7936637bbde6b726c793c9 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Wed, 15 Jan 2025 11:22:43 +0000 Subject: [PATCH 0036/1724] Add full project search UI (#22671) GitOrigin-RevId: f40c85f40f4c16b4b3c26a197924cd9ac9b3db1f --- .../src/Features/Project/ProjectController.js | 1 + services/web/config/settings.defaults.js | 2 ++ .../components/file-tree-toolbar.tsx | 17 ++++++++--- .../ide-react/components/editor-sidebar.tsx | 11 ++++++++ .../js/shared/context/layout-context.tsx | 28 +++++++++++++++++++ .../js/shared/context/project-context.tsx | 7 ++++- .../shared/context/types/project-context.tsx | 2 ++ .../stylesheets/app/editor/ide-react.less | 6 +++- .../bootstrap-5/pages/editor/ide.scss | 1 + 9 files changed, 69 insertions(+), 6 deletions(-) diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index ad8bd618eb..0e0bf3a800 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -333,6 +333,7 @@ const _ProjectController = { const splitTests = [ !anonymous && 'bib-file-tpr-prompt', 'compile-log-events', + 'full-project-search', 'math-preview', 'null-test-share-modal', 'paywall-cta', diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index ddd0d4cd23..c1dd664245 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -983,6 +983,8 @@ module.exports = { autoCompleteExtensions: [], sectionTitleGenerators: [], toastGenerators: [], + editorSidebarComponents: [], + fileTreeToolbarComponents: [], }, moduleImportSequence: [ diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.tsx index 8c7a739245..0f82a232da 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.tsx @@ -7,6 +7,12 @@ import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' import MaterialIcon from '@/shared/components/material-icon' import OLButtonToolbar from '@/features/ui/components/ol/ol-button-toolbar' +import importOverleafModules from '../../../../macros/import-overleaf-module.macro' +import React, { ElementType } from 'react' + +const fileTreeToolbarComponents = importOverleafModules( + 'fileTreeToolbarComponents' +) as { import: { default: ElementType }; path: string }[] function FileTreeToolbar() { const { fileTreeReadOnly } = useFileTreeData() @@ -105,12 +111,14 @@ function FileTreeToolbarRight() { const { canRename, canDelete, startRenaming, startDeleting } = useFileTreeActionable() - if (!canRename && !canDelete) { - return null - } - return (
+ {fileTreeToolbarComponents.map( + ({ import: { default: Component }, path }) => ( + + ) + )} + {canRename ? ( ) : null} + {canDelete ? ( + {editorSidebarComponents.map( + ({ import: { default: Component }, path }) => ( + + ) + )} pdfLayout: IdeLayout pdfPreviewOpen: boolean + projectSearchIsOpen: boolean + setProjectSearchIsOpen: Dispatch> } +const isMac = /Mac/.test(window.navigator?.platform) + const debugPdfDetach = getMeta('ol-debugPdfDetach') export const LayoutContext = createContext( @@ -107,6 +113,9 @@ export const LayoutProvider: FC = ({ children }) => { const [leftMenuShown, setLeftMenuShown] = useScopeValue('ui.leftMenuShown') + // whether the project search is open + const [projectSearchIsOpen, setProjectSearchIsOpen] = useState(false) + useEventListener( 'ui.toggle-left-menu', useCallback( @@ -124,6 +133,21 @@ export const LayoutProvider: FC = ({ children }) => { }, [setReviewPanelOpen]) ) + useEventListener( + 'keydown', + useCallback((event: KeyboardEvent) => { + if ( + (isMac ? event.metaKey : event.ctrlKey) && + event.shiftKey && + event.key === 'f' + ) { + if (isSplitTestEnabled('full-project-search')) { + setProjectSearchIsOpen(true) + } + } + }, []) + ) + // whether to display the editor and preview side-by-side or full-width ("flat") const [pdfLayout, setPdfLayout] = useScopeValue('ui.pdfLayout') @@ -194,6 +218,8 @@ export const LayoutProvider: FC = ({ children }) => { leftMenuShown, pdfLayout, pdfPreviewOpen, + projectSearchIsOpen, + setProjectSearchIsOpen, reviewPanelOpen, miniReviewPanelVisible, loadingStyleSheet, @@ -216,6 +242,8 @@ export const LayoutProvider: FC = ({ children }) => { leftMenuShown, pdfLayout, pdfPreviewOpen, + projectSearchIsOpen, + setProjectSearchIsOpen, reviewPanelOpen, miniReviewPanelVisible, loadingStyleSheet, diff --git a/services/web/frontend/js/shared/context/project-context.tsx b/services/web/frontend/js/shared/context/project-context.tsx index 7577886328..4e54acaeed 100644 --- a/services/web/frontend/js/shared/context/project-context.tsx +++ b/services/web/frontend/js/shared/context/project-context.tsx @@ -1,7 +1,8 @@ -import { FC, createContext, useContext, useMemo } from 'react' +import { FC, createContext, useContext, useMemo, useState } from 'react' import useScopeValue from '../hooks/use-scope-value' import getMeta from '@/utils/meta' import { ProjectContextValue } from './types/project-context' +import { ProjectSnapshot } from '@/infrastructure/project-snapshot' const ProjectContext = createContext(undefined) @@ -43,6 +44,8 @@ export const ProjectProvider: FC = ({ children }) => { mainBibliographyDoc_id: mainBibliographyDocId, } = project || projectFallback + const [projectSnapshot] = useState(() => new ProjectSnapshot(_id)) + const tags = useMemo( () => (getMeta('ol-projectTags') || []) @@ -65,6 +68,7 @@ export const ProjectProvider: FC = ({ children }) => { tags, trackChangesState, mainBibliographyDocId, + projectSnapshot, } }, [ _id, @@ -79,6 +83,7 @@ export const ProjectProvider: FC = ({ children }) => { tags, trackChangesState, mainBibliographyDocId, + projectSnapshot, ]) return ( 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 7eedd18654..ce609d01d1 100644 --- a/services/web/frontend/js/shared/context/types/project-context.tsx +++ b/services/web/frontend/js/shared/context/types/project-context.tsx @@ -1,5 +1,6 @@ import { UserId } from '../../../../../types/user' import { PublicAccessLevel } from '../../../../../types/public-access-level' +import { ProjectSnapshot } from '@/infrastructure/project-snapshot' export type ProjectContextMember = { _id: UserId @@ -46,6 +47,7 @@ export type ProjectContextValue = { color?: string }[] trackChangesState: boolean | Record + projectSnapshot: ProjectSnapshot } export type ProjectContextUpdateValue = Partial diff --git a/services/web/frontend/stylesheets/app/editor/ide-react.less b/services/web/frontend/stylesheets/app/editor/ide-react.less index f6bcfd626d..412796ba0b 100644 --- a/services/web/frontend/stylesheets/app/editor/ide-react.less +++ b/services/web/frontend/stylesheets/app/editor/ide-react.less @@ -51,12 +51,12 @@ // Enable ::before and ::after pseudo-elements to position themselves correctly position: relative; - background-color: @editor-resizer-bg-color; .custom-toggler { padding: 0; border-width: 0; + // Override react-resizable-panels which sets a global * { cursor: ew-resize } cursor: pointer !important; } @@ -74,9 +74,11 @@ width: 7px; height: 18px; } + &::before { top: 25%; } + &::after { top: 75%; } @@ -89,6 +91,7 @@ .synctex-controls { left: -8px; margin: 0; + // Ensure that SyncTex controls appear in front of PDF viewer controls and logs pane z-index: 12; @@ -128,6 +131,7 @@ height: 100%; background-color: @file-tree-bg; color: var(--neutral-20); + position: relative; } .ide-react-symbol-palette { diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss index 1fe67be188..2fa76cfb77 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss @@ -60,6 +60,7 @@ $editor-toggler-bg-dark-color: color.adjust( background-color: var(--file-tree-bg); height: 100%; color: var(--content-secondary-dark); + position: relative; } .ide-react-body { From 9f1b6d480b4d7c94e9bf33a35d5935c271dead7e Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Wed, 15 Jan 2025 07:53:02 -0500 Subject: [PATCH 0037/1724] Merge pull request #22869 from overleaf/em-remove-sanity-check Remove RangesManager sanity check GitOrigin-RevId: 376c2a197aa68cbde9259ec8c2cea1e9d43c8f69 --- .../document-updater/app/js/RangesManager.js | 97 ------------------- 1 file changed, 97 deletions(-) diff --git a/services/document-updater/app/js/RangesManager.js b/services/document-updater/app/js/RangesManager.js index 37006ac1d0..c146afda60 100644 --- a/services/document-updater/app/js/RangesManager.js +++ b/services/document-updater/app/js/RangesManager.js @@ -80,13 +80,6 @@ const RangesManager = { } } - sanityCheckTrackedChanges( - projectId, - docId, - rangesTracker.changes, - getDocLength(newDocLines) - ) - if ( rangesTracker.changes?.length > RangesManager.MAX_CHANGES || rangesTracker.comments?.length > RangesManager.MAX_COMMENTS @@ -139,12 +132,6 @@ const RangesManager = { logger.debug(`accepting ${changeIds.length} changes in ranges`) const rangesTracker = new RangesTracker(changes, comments) rangesTracker.removeChangeIds(changeIds) - sanityCheckTrackedChanges( - projectId, - docId, - rangesTracker.changes, - getDocLength(lines) - ) const newRanges = RangesManager._getRanges(rangesTracker) return newRanges }, @@ -587,88 +574,4 @@ function getCroppedCommentOps(op, comments) { return historyCommentOps } -/** - * Check some tracked changes assumptions: - * - * - Tracked changes can't be empty - * - Tracked inserts can't overlap with another tracked change - * - There can't be two tracked deletes at the same position - * - Ranges should be ordered by position, deletes before inserts - * - * If any assumption isn't upheld, log a warning. - * - * @param {string} projectId - * @param {string} docId - * @param {TrackedChange[]} changes - * @param {number} docLength - */ -function sanityCheckTrackedChanges(projectId, docId, changes, docLength) { - let lastDeletePos = -1 // allow a tracked delete at position 0 - let lastInsertEnd = 0 - let ok = true - let badChangeIndex - for (let i = 0; i < changes.length; i++) { - const change = changes[i] - - const op = change.op - if ('i' in op) { - if ( - op.i.length === 0 || - op.p < lastDeletePos || - op.p < lastInsertEnd || - op.p < 0 || - op.p + op.i.length > docLength - ) { - ok = false - badChangeIndex = i - break - } - lastInsertEnd = op.p + op.i.length - } else if ('d' in op) { - if ( - op.d.length === 0 || - op.p <= lastDeletePos || - op.p < lastInsertEnd || - op.p < 0 || - op.p > docLength - ) { - ok = false - badChangeIndex = i - break - } - lastDeletePos = op.p - if (lastDeletePos >= docLength) { - badChangeIndex = i - break - } - } - } - - if (ok) { - return - } - - const changeRanges = [] - for (const change of changes) { - if ('i' in change.op) { - changeRanges.push({ - id: change.id, - p: change.op.p, - i: change.op.i.length, - }) - } else if ('d' in change.op) { - changeRanges.push({ - id: change.id, - p: change.op.p, - d: change.op.d.length, - }) - } - } - - logger.warn( - { projectId, docId, changes: changeRanges, badChangeIndex }, - 'Malformed tracked changes detected' - ) -} - module.exports = RangesManager From 68dcacea93a28471302c819460870e7c22d5d9cd Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Wed, 15 Jan 2025 13:56:56 +0100 Subject: [PATCH 0038/1724] Merge pull request #22890 from overleaf/msm-fix-migrations Fix module import in migrations GitOrigin-RevId: 462eb54c68942118f3f76b166c3f27d923227e6c --- ...20230817081910_back_fill_gitBridge_feature_server_pro.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/services/web/migrations/20230817081910_back_fill_gitBridge_feature_server_pro.mjs b/services/web/migrations/20230817081910_back_fill_gitBridge_feature_server_pro.mjs index d479529bf7..2325394c76 100644 --- a/services/web/migrations/20230817081910_back_fill_gitBridge_feature_server_pro.mjs +++ b/services/web/migrations/20230817081910_back_fill_gitBridge_feature_server_pro.mjs @@ -1,9 +1,10 @@ -import runScript from '../modules/server-ce-scripts/scripts/upgrade-user-features.mjs' - const tags = ['server-ce', 'server-pro'] const migrate = async () => { // Run-time import as SaaS does not ship with the server-ce-scripts module + const { default: runScript } = await import( + '../modules/server-ce-scripts/scripts/upgrade-user-features.mjs' + ) await runScript(false, { gitBridge: 1, }) From 98e0ed6a068edc204987238cda41da831c90dfce Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Wed, 15 Jan 2025 14:01:35 +0000 Subject: [PATCH 0039/1724] Merge pull request #22894 from overleaf/jpa-large-chunk [history-v1] add test case for large text file GitOrigin-RevId: 5d7cc37d74c67e1927785ea9c544d897491e6abd --- .../test/acceptance/js/storage/backupBlob.test.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/services/history-v1/test/acceptance/js/storage/backupBlob.test.mjs b/services/history-v1/test/acceptance/js/storage/backupBlob.test.mjs index 161acb7a55..8e05e76819 100644 --- a/services/history-v1/test/acceptance/js/storage/backupBlob.test.mjs +++ b/services/history-v1/test/acceptance/js/storage/backupBlob.test.mjs @@ -157,6 +157,12 @@ describe('backupBlob', function () { content: Buffer.from('x'.repeat(1000)), storedSize: 29, // zlib.gzipSync(content).byteLength }, + { + name: 'large text file', + // 'ä' is a 2-byte utf-8 character -> 4MB. + content: Buffer.from('ü'.repeat(2 * 1024 * 1024)), + storedSize: 4101, // zlib.gzipSync(content).byteLength + }, { name: 'binary file', content: Buffer.from([0, 1, 2, 3]), From 4fae817573550de906d680ea1ab58091bcab0c9b Mon Sep 17 00:00:00 2001 From: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> Date: Wed, 15 Jan 2025 15:09:09 +0100 Subject: [PATCH 0040/1724] Merge pull request #22761 from overleaf/rd-migrate-admin-project-bs5 Migrate admin project URL lookup page to Bootstrap 5 GitOrigin-RevId: d8c58bbeb924da4e94a361ab59a66c2c6048dbfd --- .../stylesheets/app/project-url-lookup.less | 16 -------------- .../pages/admin/project-url-lookup.scss | 22 +++++++++++++++++++ .../stylesheets/bootstrap-5/pages/all.scss | 1 + .../web/frontend/stylesheets/main-style.less | 1 - 4 files changed, 23 insertions(+), 17 deletions(-) delete mode 100644 services/web/frontend/stylesheets/app/project-url-lookup.less create mode 100644 services/web/frontend/stylesheets/bootstrap-5/pages/admin/project-url-lookup.scss diff --git a/services/web/frontend/stylesheets/app/project-url-lookup.less b/services/web/frontend/stylesheets/app/project-url-lookup.less deleted file mode 100644 index 0bd8b53b4f..0000000000 --- a/services/web/frontend/stylesheets/app/project-url-lookup.less +++ /dev/null @@ -1,16 +0,0 @@ -.project-url-lookup-results { - margin-top: @line-height-computed; -} - -.project-url-lookup-link-box { - background-color: @gray-lightest; - border: 1px solid @gray-lighter; - padding: 6px 12px 6px 12px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.project-url-lookup-hint { - padding: @line-height-computed / 4; -} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/admin/project-url-lookup.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/admin/project-url-lookup.scss new file mode 100644 index 0000000000..9c2130b15e --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/admin/project-url-lookup.scss @@ -0,0 +1,22 @@ +.project-url-lookup { + margin-top: var(--spacing-08); +} + +.project-url-lookup-results { + margin-top: var(--spacing-08); +} + +.project-url-lookup-link-box { + background-color: var(--bg-light-secondary); + border: 1px solid var(--border-primary-dark); + padding: var(--spacing-03) var(--spacing-05); + display: flex; + align-items: center; + justify-content: space-between; +} + +.project-url-lookup-hint { + display: flex; + align-items: center; + padding: var(--spacing-03); +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss index 4b4c8eff76..85df16d7a4 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss @@ -38,3 +38,4 @@ @import 'login-register'; @import 'login'; @import 'register'; +@import 'admin/project-url-lookup'; diff --git a/services/web/frontend/stylesheets/main-style.less b/services/web/frontend/stylesheets/main-style.less index ac6f36646a..d76c4df196 100644 --- a/services/web/frontend/stylesheets/main-style.less +++ b/services/web/frontend/stylesheets/main-style.less @@ -126,7 +126,6 @@ @import 'app/metrics.less'; @import 'app/open-in-overleaf.less'; @import 'app/primary-email-check'; -@import 'app/project-url-lookup'; @import 'app/grammarly'; @import 'app/sidebar-v2-dash-pane.less'; @import 'app/front-chat-widget.less'; From 971a5d9de469d7774b0f721845b0e122145dc45a Mon Sep 17 00:00:00 2001 From: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> Date: Wed, 15 Jan 2025 15:09:18 +0100 Subject: [PATCH 0041/1724] Merge pull request #22717 from overleaf/rd-migrate-admin-bootstrap5 [web] Migrate the admin page to Bootstrap 5 GitOrigin-RevId: 8d283f7ce4a7d73f033a69a4c075311ff756f06a --- .../app/views/_mixins/bookmarkable_tabset.pug | 3 +- services/web/app/views/admin/index.pug | 180 +++++++++--------- .../bootstrap-5/components/nav.scss | 48 +++++ .../bootstrap-5/components/tabs.scss | 46 +++++ .../bootstrap-5/pages/editor/left-menu.scss | 48 ----- 5 files changed, 189 insertions(+), 136 deletions(-) diff --git a/services/web/app/views/_mixins/bookmarkable_tabset.pug b/services/web/app/views/_mixins/bookmarkable_tabset.pug index 3f43b0c4ce..b2cef1b171 100644 --- a/services/web/app/views/_mixins/bookmarkable_tabset.pug +++ b/services/web/app/views/_mixins/bookmarkable_tabset.pug @@ -1,7 +1,8 @@ mixin bookmarkable-tabset-header(id, title, active) - li(role="presentation" class=(active ? 'active' : '')) + li(role="presentation") a( href='#' + id + class=(active ? 'active' : '') aria-controls=id role="tab" data-toggle="tab" diff --git a/services/web/app/views/admin/index.pug b/services/web/app/views/admin/index.pug index ad31c80667..19ce9edf86 100644 --- a/services/web/app/views/admin/index.pug +++ b/services/web/app/views/admin/index.pug @@ -1,99 +1,105 @@ extends ../layout-marketing include ../_mixins/bookmarkable_tabset +block vars + - bootstrap5PageStatus = 'enabled' // One of 'disabled', 'enabled', and 'queryStringOnly' + block content .content.content-alt#main-content .container .row - .col-xs-12 + .col-sm-12 .card - .page-header - h1 Admin Panel - div(data-ol-bookmarkable-tabset) - ul.nav.nav-tabs(role="tablist") - +bookmarkable-tabset-header('system-messages', 'System Messages', true) - +bookmarkable-tabset-header('open-sockets', 'Open Sockets') - +bookmarkable-tabset-header('open-close-editor', 'Open/Close Editor') - if hasFeature('saas') - +bookmarkable-tabset-header('tpds', 'TPDS/Dropbox Management') + .card-body + .page-header + h1 Admin Panel + div(data-ol-bookmarkable-tabset) + .nav-tabs-container + ul.nav.nav-tabs.d-flex(role="tablist") + +bookmarkable-tabset-header('system-messages', 'System Messages', true) + +bookmarkable-tabset-header('open-sockets', 'Open Sockets') + +bookmarkable-tabset-header('open-close-editor', 'Open/Close Editor') + if hasFeature('saas') + +bookmarkable-tabset-header('tpds', 'TPDS/Dropbox Management') - .tab-content - .tab-pane.active( - role="tabpanel" - id='system-messages' - ) - each message in systemMessages - .alert.alert-info.row-spaced #{message.content} - hr - form(method='post', action='/admin/messages') - input(name="_csrf", type="hidden", value=csrfToken) - .form-group - label(for="content") - input.form-control(name="content", type="text", placeholder="Message…", required) - button.btn.btn-primary(type="submit") Post Message - hr - form(method='post', action='/admin/messages/clear') - input(name="_csrf", type="hidden", value=csrfToken) - button.btn.btn-danger(type="submit") Clear all messages - - .tab-pane( - role="tabpanel" - id='open-sockets' - ) - .row-spaced - ul - each agents, url in openSockets - li #{url} - total : #{agents.length} - ul - each agent in agents - li #{agent} - - .tab-pane( - role="tabpanel" - id='open-close-editor' - ) - if hasFeature('saas') - | The "Open/Close Editor" feature is not available in SAAS. - else - .row-spaced - form(method='post',action='/admin/closeEditor') - input(name="_csrf", type="hidden", value=csrfToken) - button.btn.btn-danger(type="submit") Close Editor - p.small Will stop anyone opening the editor. Will NOT disconnect already connected users. - - .row-spaced - form(method='post',action='/admin/disconnectAllUsers') - input(name="_csrf", type="hidden", value=csrfToken) - button.btn.btn-danger(type="submit") Disconnect all users - p.small Will force disconnect all users with the editor open. Make sure to close the editor first to avoid them reconnecting. - - .row-spaced - form(method='post',action='/admin/openEditor') - input(name="_csrf", type="hidden", value=csrfToken) - button.btn.btn-danger(type="submit") Reopen Editor - p.small Will reopen the editor after closing. - - if hasFeature('saas') - .tab-pane( - role="tabpanel" - id='tpds' - ) - h3 Flush project to TPDS - .row - form.col-xs-6(method='post',action='/admin/flushProjectToTpds') + .tab-content + .tab-pane.active( + role="tabpanel" + id='system-messages' + ) + each message in systemMessages + ul.system-messages + li.system-message.row-spaced #{message.content} + hr + form(method='post', action='/admin/messages') input(name="_csrf", type="hidden", value=csrfToken) .form-group - label(for='project_id') project_id - input.form-control(type='text', name='project_id', placeholder='project_id', required) - .form-group - button.btn-primary.btn(type='submit') Flush - hr - h3 Poll Dropbox for user - .row - form.col-xs-6(method='post',action='/admin/pollDropboxForUser') + label.form-label(for="content") + input.form-control(name="content", type="text", placeholder="Message…", required) + button.btn.btn-primary(type="submit") Post Message + hr + form(method='post', action='/admin/messages/clear') input(name="_csrf", type="hidden", value=csrfToken) - .form-group - label(for='user_id') user_id - input.form-control(type='text', name='user_id', placeholder='user_id', required) - .form-group - button.btn-primary.btn(type='submit') Poll + button.btn.btn-danger(type="submit") Clear all messages + + .tab-pane( + role="tabpanel" + id='open-sockets' + ) + .row-spaced + ul + each agents, url in openSockets + li #{url} - total : #{agents.length} + ul + each agent in agents + li #{agent} + + .tab-pane( + role="tabpanel" + id='open-close-editor' + ) + if hasFeature('saas') + | The "Open/Close Editor" feature is not available in SAAS. + else + .row-spaced + form(method='post',action='/admin/closeEditor') + input(name="_csrf", type="hidden", value=csrfToken) + button.btn.btn-danger(type="submit") Close Editor + p.small Will stop anyone opening the editor. Will NOT disconnect already connected users. + + .row-spaced + form(method='post',action='/admin/disconnectAllUsers') + input(name="_csrf", type="hidden", value=csrfToken) + button.btn.btn-danger(type="submit") Disconnect all users + p.small Will force disconnect all users with the editor open. Make sure to close the editor first to avoid them reconnecting. + + .row-spaced + form(method='post',action='/admin/openEditor') + input(name="_csrf", type="hidden", value=csrfToken) + button.btn.btn-danger(type="submit") Reopen Editor + p.small Will reopen the editor after closing. + + if hasFeature('saas') + .tab-pane( + role="tabpanel" + id='tpds' + ) + h3 Flush project to TPDS + .row + form.col-xs-6(method='post',action='/admin/flushProjectToTpds') + input(name="_csrf", type="hidden", value=csrfToken) + .form-group + label.form-label(for='project_id') project_id + input.form-control(type='text', name='project_id', placeholder='project_id', required) + .form-group + button.btn-primary.btn(type='submit') Flush + hr + h3 Poll Dropbox for user + .row + form.col-xs-6(method='post',action='/admin/pollDropboxForUser') + input(name="_csrf", type="hidden", value=csrfToken) + .form-group + label.form-label(for='user_id') user_id + input.form-control(type='text', name='user_id', placeholder='user_id', required) + .form-group + button.btn-primary.btn(type='submit') Poll diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/nav.scss b/services/web/frontend/stylesheets/bootstrap-5/components/nav.scss index b36e691fcb..5d28341cf5 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/nav.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/nav.scss @@ -57,3 +57,51 @@ --navbar-hamburger-submenu-item-hover-color: var(--navbar-link-color); --navbar-hamburger-submenu-item-hover-bg: var(--navbar-link-hover-bg); } + +.nav { + margin-bottom: 0; + padding-left: 0; + list-style: none; + display: block; + + > li { + position: relative; + display: block; + + > a { + position: relative; + display: block; + padding: var(--spacing-04) var(--spacing-06); + + &:hover, + &:focus { + text-decoration: none; + background-color: var(--bg-info-01); + color: var(--white); + } + } + + // Disabled state sets text to gray and nukes hover/tab effects + &.disabled > a { + color: var(--content-disabled); + + &:hover, + &:focus { + color: var(--content-disabled); + text-decoration: none; + background-color: transparent; + cursor: not-allowed; + } + } + } + + // Open dropdowns + .open > a { + &, + &:hover, + &:focus { + background-color: var(--bg-info-01); + border-color: var(--link-ui); + } + } +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/tabs.scss b/services/web/frontend/stylesheets/bootstrap-5/components/tabs.scss index d0d6197fd8..c7df677bb2 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/tabs.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/tabs.scss @@ -78,3 +78,49 @@ [data-ol-bookmarkable-tabset] .tab-pane { scroll-margin-top: 120px; } + +[data-ol-bookmarkable-tabset] { + .nav-tabs-container { + .nav-tabs { + gap: 8px; + flex-wrap: nowrap; + + > li { + float: left; + + // Make the list-items overlay the bottom border + margin-bottom: -1px; + + // Actual tabs (as links) + > a { + color: var(--content-secondary); + border: 1px solid transparent; + border-radius: var(--border-radius-base) var(--border-radius-base) 0 0; + padding: var(--spacing-04); + text-decoration: none; + + &:hover, + &:focus { + cursor: pointer; + background-color: var(--bg-light-secondary); + } + } + + // Active state, and its :hover to override normal :hover + > a.active { + color: var(--content-primary); + background-color: var(--bg-light-primary); + border: 1px solid var(--border-divider); + border-bottom-color: transparent; + cursor: default; + } + } + } + + .tab-content { + border: 1px solid #ddd; + border-top: none; + padding: var(--spacing-05); + } + } +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/left-menu.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/left-menu.scss index 09339d5862..e40c6159f6 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/left-menu.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/left-menu.scss @@ -189,54 +189,6 @@ background-color: transparent; } -.nav { - margin-bottom: 0; - padding-left: 0; - list-style: none; - display: block; - - > li { - position: relative; - display: block; - - > a { - position: relative; - display: block; - padding: var(--spacing-04) var(--spacing-06); - - &:hover, - &:focus { - text-decoration: none; - background-color: var(--bg-info-01); - color: white; - } - } - - // Disabled state sets text to gray and nukes hover/tab effects - &.disabled > a { - color: var(--content-disabled); - - &:hover, - &:focus { - color: var(--content-disabled); - text-decoration: none; - background-color: transparent; - cursor: not-allowed; - } - } - } - - // Open dropdowns - .open > a { - &, - &:hover, - &:focus { - background-color: var(--bg-info-01); - border-color: var(--link-ui); - } - } -} - .loading-spinner-container { display: flex; align-items: center; From bdd91358ef39f8e8b95b2e4ed2f2aa98346526b3 Mon Sep 17 00:00:00 2001 From: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> Date: Wed, 15 Jan 2025 15:48:50 +0100 Subject: [PATCH 0042/1724] Merge pull request #22817 from overleaf/rd-migrate-admin-split-test-create-bs5 Migrate the split test create admin page to Bootstrap 5 GitOrigin-RevId: 897f634b00136605ce3faf0e2489902d41f51566 --- .../bootstrap-5/abstracts/mixins.scss | 30 ++++++++ .../bootstrap-5/components/form.scss | 18 +++++ .../stylesheets/bootstrap-5/pages/admin.scss | 74 +++++++++++++++++++ .../stylesheets/bootstrap-5/pages/all.scss | 2 +- 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 services/web/frontend/stylesheets/bootstrap-5/pages/admin.scss diff --git a/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss b/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss index e94d955f49..b35dec6212 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss @@ -174,3 +174,33 @@ border-bottom-color: transparent; } } + +// Form validation states + +@mixin form-control-validation($color) { + color: $color; + + // Color the label and help text + .help-block, + .control-label, + .radio, + .checkbox, + .radio-inline, + .checkbox-inline, + .form-label { + color: $color; + } + + .form-control { + border-color: $color; + + &:focus { + border-color: $border-active; + } + } + + // Optional feedback icon + .form-control-feedback { + color: $color; + } +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/form.scss b/services/web/frontend/stylesheets/bootstrap-5/components/form.scss index ebed74c410..b4897bad1c 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/form.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/form.scss @@ -223,3 +223,21 @@ border-color: $input-focus-border-color; box-shadow: $form-check-input-focus-box-shadow; } + +input[type='range'] { + display: block; + width: 100%; +} + +// Feedback states +.has-success { + @include form-control-validation($bg-accent-01); +} + +.has-warning { + @include form-control-validation($bg-warning-03); +} + +.has-error { + @include form-control-validation($bg-danger-01); +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/admin.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/admin.scss new file mode 100644 index 0000000000..c34ed4b771 --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/admin.scss @@ -0,0 +1,74 @@ +.material-switch { + input[type='checkbox'] { + display: none; + + &:checked + label::before { + background: inherit; + opacity: 0.5; + } + + &:checked + label::after { + background: inherit; + left: 20px; + } + + &:disabled + label { + opacity: 0.5; + cursor: not-allowed; + } + } + + label { + cursor: pointer; + height: 0; + position: relative; + width: 40px; + background-color: var(--bg-accent-01); + + &::before { + background: rgb(0 0 0); + box-shadow: inset 0 0 10px rgb(0 0 0 / 50%); + border-radius: var(--border-radius-medium); + content: ''; + height: 16px; + margin-top: calc(var(--spacing-01) * -1); + position: absolute; + opacity: 0.3; + transition: all 0.2s ease-in-out; + width: 40px; + } + + &::after { + background: rgb(255 255 255); + border-radius: var(--border-radius-large); + box-shadow: 0 0 5px rgb(0 0 0 / 30%); + content: ''; + height: 24px; + left: -4px; + margin-top: calc(var(--spacing-01) * -1); + position: absolute; + top: -4px; + transition: all 0.2s ease-in-out; + width: 24px; + } + } +} + +.hr-sect { + display: flex; + flex-basis: 100%; + align-items: center; + color: rgb(0 0 0 / 35%); + margin: var(--spacing-04) 0; +} + +.hr-sect::before, +.hr-sect::after { + content: ''; + flex-grow: 1; + background: rgb(0 0 0 / 35%); + height: 1px; + font-size: 0; + line-height: 0; + margin: 0 var(--spacing-04); +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss index 85df16d7a4..682f292cc3 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss @@ -38,4 +38,4 @@ @import 'login-register'; @import 'login'; @import 'register'; -@import 'admin/project-url-lookup'; +@import 'admin'; From 5c188939d607e16d9ae7d809813024507a85b374 Mon Sep 17 00:00:00 2001 From: Jessica Lawshe <5312836+lawshe@users.noreply.github.com> Date: Wed, 15 Jan 2025 09:53:50 -0600 Subject: [PATCH 0043/1724] Merge pull request #22740 from overleaf/jel-mono-text [web] Eyebrow text margin GitOrigin-RevId: cdf1eadf8ad1da4d81724e8aeb9994cc931388ce --- services/web/app/views/_mixins/eyebrow.pug | 2 +- .../stylesheets/app/website-redesign.less | 6 ++++- .../bootstrap-5/components/styled-text.scss | 27 ++++++++++++------- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/services/web/app/views/_mixins/eyebrow.pug b/services/web/app/views/_mixins/eyebrow.pug index 7d403b1f51..c5f01a10db 100644 --- a/services/web/app/views/_mixins/eyebrow.pug +++ b/services/web/app/views/_mixins/eyebrow.pug @@ -1,5 +1,5 @@ mixin eyebrow(text) - span.mono-text + span.eyebrow-text span(aria-hidden="true") { span #{text} span(aria-hidden="true") } \ No newline at end of file diff --git a/services/web/frontend/stylesheets/app/website-redesign.less b/services/web/frontend/stylesheets/app/website-redesign.less index 21b4cf5995..f66bd83d13 100644 --- a/services/web/frontend/stylesheets/app/website-redesign.less +++ b/services/web/frontend/stylesheets/app/website-redesign.less @@ -45,12 +45,16 @@ h1, h2, h3 { - > span.mono-text { + > span.eyebrow-text { display: block; margin-bottom: @margin-xs; } } + .eyebrow-text { + .mono-text; + } + // override .btn default style .btn { font-weight: 600; diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/styled-text.scss b/services/web/frontend/stylesheets/bootstrap-5/components/styled-text.scss index 151c03f360..e872c3c252 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/styled-text.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/styled-text.scss @@ -36,13 +36,22 @@ margin: 0; } -h1, -h2, -h3, -h4, -h5 { - > span.mono-text { - display: block; - margin-bottom: var(--spacing-04); - } +.eyebrow-text { + @extend .mono-text; + + display: block; + margin-bottom: var(--spacing-04); +} + +p:has(.eyebrow-text) { + margin-bottom: 0; +} + +p:has(.eyebrow-text) + h1, +p:has(.eyebrow-text) + h2, +p:has(.eyebrow-text) + h3, +p:has(.eyebrow-text) + h4, +p:has(.eyebrow-text) + h5, +p:has(.eyebrow-text) + h6 { + margin-top: 0; } From 76c0e6f84da1b04b958b8f9e6f6cf3b8535861af Mon Sep 17 00:00:00 2001 From: Jessica Lawshe <5312836+lawshe@users.noreply.github.com> Date: Wed, 15 Jan 2025 09:54:04 -0600 Subject: [PATCH 0044/1724] Merge pull request #22824 from overleaf/jel-saml-tests [web] SAML test helper update GitOrigin-RevId: 6a721901b0026286e0ef28ed9168c8b97cd4a200 --- services/web/test/acceptance/src/helpers/SAMLHelper.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/web/test/acceptance/src/helpers/SAMLHelper.mjs b/services/web/test/acceptance/src/helpers/SAMLHelper.mjs index 76265e1774..6732454c1e 100644 --- a/services/web/test/acceptance/src/helpers/SAMLHelper.mjs +++ b/services/web/test/acceptance/src/helpers/SAMLHelper.mjs @@ -57,7 +57,9 @@ function createMockSamlAssertion(samlData = {}, opts = {}) { const { signedAssertion = true } = opts const userIdAttributeName = samlData.userIdAttribute || 'uniqueId' - const userIdAttribute = ` + const userIdAttribute = + uniqueId && + ` ${uniqueId} From da6f332269c7900365ed566573b37d021e3777f4 Mon Sep 17 00:00:00 2001 From: M Fahru Date: Wed, 15 Jan 2025 10:04:44 -0700 Subject: [PATCH 0045/1724] Merge pull request #22821 from overleaf/kh-cop-on-upgrade-modal [web] limit COP group plans to 20 seats in upgrade modal GitOrigin-RevId: b1d2713b978d0269892d8f547eeccc5ab702ea77 --- .../Subscription/SubscriptionController.js | 3 +++ .../change-plan/modals/change-to-group-modal.tsx | 14 ++++++++++---- .../features/subscription/fixtures/plans.ts | 1 + .../Subscription/SubscriptionControllerTests.js | 1 + .../types/subscription/dashboard/group-plans.ts | 1 + 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 187e049f21..bbf3802606 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -38,6 +38,9 @@ function formatGroupPlansDataForDash() { return { plans: [...groupPlanModalOptions.plan_codes], sizes: [...groupPlanModalOptions.sizes], + sizesForHighDenominationCurrencies: [ + ...groupPlanModalOptions.sizesForHighDenominationCurrencies, + ], usages: [...groupPlanModalOptions.usages], priceByUsageTypeAndSize: JSON.parse(JSON.stringify(GroupPlansData)), } diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx index 96fe6a3dfe..cfb6e37b73 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useTranslation, Trans } from 'react-i18next' -import { Subscription } from '../../../../../../../../../../types/subscription/dashboard/subscription' +import { RecurlySubscription } from '../../../../../../../../../../types/subscription/dashboard/subscription' import { PriceForDisplayData } from '../../../../../../../../../../types/subscription/plan' import { postJSON } from '../../../../../../../../infrastructure/fetch-json' import getMeta from '../../../../../../../../utils/meta' @@ -118,7 +118,7 @@ export function ChangeToGroupModal() { const { modal: contactModal, showModal: showContactModal } = useContactUsModal({ autofillProjectUrl: false }) const groupPlans = getMeta('ol-groupPlans') - const personalSubscription = getMeta('ol-subscription') as Subscription + const personalSubscription = getMeta('ol-subscription') as RecurlySubscription const [error, setError] = useState(false) const [inflight, setInflight] = useState(false) const location = useLocation() @@ -155,10 +155,16 @@ export function ChangeToGroupModal() { !groupPlans || !groupPlans.plans || !groupPlans.sizes || + !groupPlans.sizesForHighDenominationCurrencies || !groupPlanToChangeToCode ) return null + const isUsingCOP = personalSubscription.recurly?.currency === 'COP' + const groupPlanSizes = isUsingCOP + ? groupPlans.sizesForHighDenominationCurrencies + : groupPlans.sizes + return ( <> {contactModal} @@ -241,7 +247,7 @@ export function ChangeToGroupModal() { value={groupPlanToChangeToSize} onChange={e => setGroupPlanToChangeToSize(e.target.value)} > - {groupPlans.sizes.map(size => ( + {groupPlanSizes.map(size => ( ))} @@ -352,7 +358,7 @@ export function ChangeToGroupModal() { onClick={showContactModal} > {t('need_more_than_x_licenses', { - x: 50, + x: isUsingCOP ? 20 : 50, })}{' '} {t('please_get_in_touch')} diff --git a/services/web/test/frontend/features/subscription/fixtures/plans.ts b/services/web/test/frontend/features/subscription/fixtures/plans.ts index fb0da284a3..e3b94b57df 100644 --- a/services/web/test/frontend/features/subscription/fixtures/plans.ts +++ b/services/web/test/frontend/features/subscription/fixtures/plans.ts @@ -227,6 +227,7 @@ export const groupPlans: GroupPlans = { }, ], sizes: ['2', '3', '4', '5', '10', '20', '50'], + sizesForHighDenominationCurrencies: ['2', '3', '4', '5', '10', '20'], } export const groupPriceByUsageTypeAndSize = { diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index 50211bc145..17e6c18a19 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -97,6 +97,7 @@ describe('SubscriptionController', function () { }, ], sizes: ['42'], + sizesForHighDenominationCurrencies: ['42'], usages: [{ code: 'foo', display: 'Foo' }], }, apis: { diff --git a/services/web/types/subscription/dashboard/group-plans.ts b/services/web/types/subscription/dashboard/group-plans.ts index de93fe6a6d..9a82399fc2 100644 --- a/services/web/types/subscription/dashboard/group-plans.ts +++ b/services/web/types/subscription/dashboard/group-plans.ts @@ -4,4 +4,5 @@ export type GroupPlans = { code: string }[] sizes: string[] + sizesForHighDenominationCurrencies: string[] } From 685db9935a5a16a09c0e3dba13f2801460ed7b69 Mon Sep 17 00:00:00 2001 From: M Fahru Date: Wed, 15 Jan 2025 10:05:18 -0700 Subject: [PATCH 0046/1724] Merge pull request #22636 from overleaf/mf-remove-list-group-item-variant [web] Remove `list-group-item-variant` mixin since it's no longer used GitOrigin-RevId: f7d01acf1537d40060598d9ed68e602c1fc12609 --- .../stylesheets/components/list-group.less | 10 ------- .../web/frontend/stylesheets/core/mixins.less | 30 ------------------- 2 files changed, 40 deletions(-) diff --git a/services/web/frontend/stylesheets/components/list-group.less b/services/web/frontend/stylesheets/components/list-group.less index 66fcee7459..9420d22965 100755 --- a/services/web/frontend/stylesheets/components/list-group.less +++ b/services/web/frontend/stylesheets/components/list-group.less @@ -83,16 +83,6 @@ button.list-group-item { } } -// Contextual variants -// -// Add modifier classes to change text and background color on individual items. -// Organizationally, this must come after the `:hover` states. - -.list-group-item-variant(success; @state-success-bg; @state-success-text); -.list-group-item-variant(info; @state-info-bg; @state-info-text); -.list-group-item-variant(warning; @state-warning-bg; @state-warning-text); -.list-group-item-variant(danger; @state-danger-bg; @state-danger-text); - // Custom content options // // Extra classes for creating well-formatted content within `.list-group-item`s. diff --git a/services/web/frontend/stylesheets/core/mixins.less b/services/web/frontend/stylesheets/core/mixins.less index 40753a8bbc..cfd04581bc 100755 --- a/services/web/frontend/stylesheets/core/mixins.less +++ b/services/web/frontend/stylesheets/core/mixins.less @@ -709,36 +709,6 @@ } } -// List Groups -// ------------------------- -.list-group-item-variant(@state; @background; @color) { - .list-group-item-@{state} { - color: @color; - background-color: @background; - - a& { - color: @color; - - .list-group-item-heading { - color: inherit; - } - - &:hover, - &:focus { - color: @color; - background-color: darken(@background, 5%); - } - &.active, - &.active:hover, - &.active:focus { - color: #fff; - background-color: @color; - border-color: @color; - } - } - } -} - // Button variants // ------------------------- // Easily pump out default styles, as well as :hover, :focus, :active, From 545f99083799959e5918a74a308a7a6d79d5931d Mon Sep 17 00:00:00 2001 From: M Fahru Date: Wed, 15 Jan 2025 10:05:29 -0700 Subject: [PATCH 0047/1724] Merge pull request #22744 from overleaf/mf-update-blog-tagged-text [web] On tagged blog pages, update "Posts tagged X" text color to neutral-70 GitOrigin-RevId: ad8e14ac275761f441e0db9760ba9c91de9019cd --- .../stylesheets/bootstrap-5/components/blog-posts.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/blog-posts.scss b/services/web/frontend/stylesheets/bootstrap-5/components/blog-posts.scss index f379fd93b9..72353d1d31 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/blog-posts.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/blog-posts.scss @@ -32,6 +32,10 @@ .blog-list-container-title { margin-top: var(--spacing-11); + + small { + color: var(--neutral-70); + } } } From 03bb4c57f92821296c37cefe32ab0d13289dd014 Mon Sep 17 00:00:00 2001 From: M Fahru Date: Wed, 15 Jan 2025 10:05:44 -0700 Subject: [PATCH 0048/1724] Merge pull request #22742 from overleaf/mf-remove-frontend-plans-page-dead-code [web] Remove frontend plans page dead code GitOrigin-RevId: 6db07b909f99a7afd17880698787a2c3527e879f --- .../features/plans/group-plan-modal/index.js | 162 --------- .../plans/plans-v2-group-plan-modal.js | 74 ---- .../plans/utils/group-plan-pricing.js | 43 --- .../plans-v2/plans-v2-group-plan.js | 142 -------- .../subscription/plans-v2/plans-v2-hash.js | 52 --- .../plans-v2/plans-v2-m-a-switch.js | 63 ---- .../subscription/plans-v2/plans-v2-main.js | 336 ------------------ .../plans-v2/plans-v2-sticky-header.js | 17 - .../plans-v2/plans-v2-subscription-button.js | 32 -- .../plans-v2/plans-v2-tracking.ts | 51 --- .../shared/utils/group-plan-pricing.test.js | 77 ---- 11 files changed, 1049 deletions(-) delete mode 100644 services/web/frontend/js/features/plans/group-plan-modal/index.js delete mode 100644 services/web/frontend/js/features/plans/plans-v2-group-plan-modal.js delete mode 100644 services/web/frontend/js/features/plans/utils/group-plan-pricing.js delete mode 100644 services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-group-plan.js delete mode 100644 services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-hash.js delete mode 100644 services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-m-a-switch.js delete mode 100644 services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-main.js delete mode 100644 services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-sticky-header.js delete mode 100644 services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-subscription-button.js delete mode 100644 services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-tracking.ts delete mode 100644 services/web/test/frontend/shared/utils/group-plan-pricing.test.js diff --git a/services/web/frontend/js/features/plans/group-plan-modal/index.js b/services/web/frontend/js/features/plans/group-plan-modal/index.js deleted file mode 100644 index 32858d295b..0000000000 --- a/services/web/frontend/js/features/plans/group-plan-modal/index.js +++ /dev/null @@ -1,162 +0,0 @@ -import getMeta from '../../../utils/meta' -import { swapModal } from '../../utils/swapModal' -import * as eventTracking from '../../../infrastructure/event-tracking' -import { createLocalizedGroupPlanPrice } from '../utils/group-plan-pricing' - -export const GROUP_PLAN_MODAL_HASH = '#groups' - -function getFormValues() { - const modalEl = document.querySelector('[data-ol-group-plan-modal]') - const planCode = modalEl.querySelector( - 'input[name="plan_code"]:checked' - ).value - const size = modalEl.querySelector('#size').value - const currency = modalEl.querySelector('#currency').value - const usage = modalEl.querySelector('#usage').checked - ? 'educational' - : 'enterprise' - return { planCode, size, currency, usage } -} - -export function updateGroupModalPlanPricing() { - const modalEl = document.querySelector('[data-ol-group-plan-modal]') - const { planCode, size, currency, usage } = getFormValues() - - const { localizedPrice, localizedPerUserPrice } = - createLocalizedGroupPlanPrice({ - plan: planCode, - licenseSize: size, - currency, - usage, - }) - - modalEl.querySelectorAll('[data-ol-group-plan-plan-code]').forEach(el => { - el.hidden = el.getAttribute('data-ol-group-plan-plan-code') !== planCode - }) - modalEl.querySelectorAll('[data-ol-group-plan-usage]').forEach(el => { - el.hidden = el.getAttribute('data-ol-group-plan-usage') !== usage - }) - modalEl.querySelector('[data-ol-group-plan-display-price]').innerText = - localizedPrice - modalEl - .querySelectorAll('[data-ol-group-plan-price-per-user]') - .forEach(el => { - el.innerText = `${localizedPerUserPrice} ${el.getAttribute( - 'data-ol-group-plan-price-per-user' - )}` - }) - - modalEl.querySelector('[data-ol-group-plan-educational-discount]').hidden = - usage !== 'educational' - - modalEl.querySelector( - '[data-ol-group-plan-educational-discount-applied]' - ).hidden = size < 10 - - modalEl.querySelector( - '[data-ol-group-plan-educational-discount-ineligible]' - ).hidden = size >= 10 -} - -const modalEl = $('[data-ol-group-plan-modal]') -modalEl - .on('shown.bs.modal', function () { - const path = `${window.location.pathname}${window.location.search}` - history.replaceState(null, document.title, path + GROUP_PLAN_MODAL_HASH) - eventTracking.sendMB('form-submitted-groups-modal-open') - }) - .on('hidden.bs.modal', function () { - const path = `${window.location.pathname}${window.location.search}${window.location.hash}` - history.replaceState(null, document.title, path) - }) - -function showGroupPlanModal() { - modalEl.modal() - eventTracking.send( - 'subscription-funnel', - 'plans-page', - 'group-inquiry-potential' - ) // deprecated by plans-page-click -} - -document - .querySelectorAll('[data-ol-group-plan-form] select') - .forEach(el => el.addEventListener('change', updateGroupModalPlanPricing)) -document - .querySelectorAll('[data-ol-group-plan-form] input') - .forEach(el => el.addEventListener('change', updateGroupModalPlanPricing)) -document.querySelectorAll('[data-ol-purchase-group-plan]').forEach(el => - el.addEventListener('click', e => { - e.preventDefault() - - const { planCode, size, currency, usage } = getFormValues() - const queryParams = new URLSearchParams( - Object.entries({ - planCode: `group_${planCode}_${size}_${usage}`, - currency, - itm_campaign: 'groups', - }) - ) - const itmContent = getMeta('ol-itm_content') - if (itmContent) { - queryParams.set('itm_content', itmContent) - } - eventTracking.sendMB('groups-modal-click', { - plan: planCode, - users: size, - currency, - type: usage, - }) - const url = new URL('/user/subscription/new', window.origin) - url.search = queryParams.toString() - window.location = url.toString() - }) -) - -document.querySelectorAll('[data-ol-open-group-plan-modal]').forEach(el => { - const location = el.getAttribute('data-ol-location') - el.addEventListener('click', function (e) { - e.preventDefault() - eventTracking.sendMB('plans-page-click', { - button: 'group', - location, - 'billing-period': 'annual', - }) - showGroupPlanModal() - }) -}) - -document - .querySelectorAll('[data-ol-open-contact-form-for-more-than-50-licenses]') - .forEach(el => { - el.addEventListener('click', function (e) { - e.preventDefault() - swapModal( - '[data-ol-group-plan-modal]', - '[data-ol-contact-form-modal="general"]' - ) - }) - }) - -function updateGroupModalPlanPricingIfAvailable() { - const isGroupPlanModalAvailable = document.querySelector( - '[data-ol-group-plan-modal]' - ) - - if (isGroupPlanModalAvailable) { - updateGroupModalPlanPricing() - } -} - -updateGroupModalPlanPricingIfAvailable() - -// When using browser back buttons, we need to update the pricing plan -// after the page has fully loaded as we need to wait for the previously -// selected values to load for e.g. size. -window.addEventListener('load', () => { - updateGroupModalPlanPricingIfAvailable() -}) - -if (window.location.hash === GROUP_PLAN_MODAL_HASH) { - showGroupPlanModal() -} diff --git a/services/web/frontend/js/features/plans/plans-v2-group-plan-modal.js b/services/web/frontend/js/features/plans/plans-v2-group-plan-modal.js deleted file mode 100644 index 8655b1a43c..0000000000 --- a/services/web/frontend/js/features/plans/plans-v2-group-plan-modal.js +++ /dev/null @@ -1,74 +0,0 @@ -import './group-plan-modal' -import { updateMainGroupPlanPricing } from '../../pages/user/subscription/plans-v2/plans-v2-group-plan' - -export function changePlansV2MainPageGroupData() { - const mainPlansPageFormEl = document.querySelector( - '[data-ol-plans-v2-license-picker-form]' - ) - const mainPlansPageLicensePickerEl = mainPlansPageFormEl.querySelector( - '[data-ol-plans-v2-license-picker-select]' - ) - - const mainPlansPageEducationalDiscountEl = mainPlansPageFormEl.querySelector( - '[data-ol-plans-v2-license-picker-educational-discount-input]' - ) - - const groupPlanModalNumberOfLicenses = document.querySelector( - '[data-ol-group-plan-modal] #size' - ).value - - const educationalDiscountChecked = document.querySelector( - '[data-ol-group-plan-modal] #usage' - ).checked - - const educationalDiscountEnabled = - educationalDiscountChecked && groupPlanModalNumberOfLicenses >= 10 - - // update license picker on the main plans page - mainPlansPageLicensePickerEl.value = groupPlanModalNumberOfLicenses - - // update educational discount checkbox on the main plans page - // - // extra note - // for number of users < 10, there is a difference on the checkbox behaviour - // between the group plan modal and the main plan page - // - // On the group plan modal, the checkbox button is not visually disabled for number of users < 10 (checkbox can still be clicked) - // but the logic is disabled and there will be an extra text whether or not the discount is applied - // - // However, on the main group plan page, the checkbox button is visually disabled for number of users < 10 (checkbox can not be clicked) - // Hence, there's a possibility that the checkbox on the group plan modal is checked, but the discount is not applied. - // i.e user can still click the checkbox with number of users < 10. The price won't be discounted, but the checkbox is checked. - if (groupPlanModalNumberOfLicenses >= 10) { - mainPlansPageEducationalDiscountEl.checked = educationalDiscountEnabled - } else { - // The code below is for disabling the checkbox button on the main plan page for number of users <10 - // while still checking the educational discount - if (educationalDiscountChecked) { - mainPlansPageEducationalDiscountEl.checked = false - } - } - - updateMainGroupPlanPricing() -} - -function hideCurrencyPicker() { - document.querySelector('[data-ol-group-plan-form-currency]').hidden = true -} - -document.querySelectorAll('[data-ol-group-plan-form] select').forEach(el => - el.addEventListener('change', () => { - changePlansV2MainPageGroupData() - }) -) -document - .querySelectorAll('[data-ol-group-plan-form] input') - .forEach(el => el.addEventListener('change', changePlansV2MainPageGroupData)) - -const isGroupPlanModalAvailable = document.querySelector( - '[data-ol-group-plan-modal]' -) - -if (isGroupPlanModalAvailable) { - hideCurrencyPicker() -} diff --git a/services/web/frontend/js/features/plans/utils/group-plan-pricing.js b/services/web/frontend/js/features/plans/utils/group-plan-pricing.js deleted file mode 100644 index 060a8ea2a7..0000000000 --- a/services/web/frontend/js/features/plans/utils/group-plan-pricing.js +++ /dev/null @@ -1,43 +0,0 @@ -import { formatCurrency } from '@/shared/utils/currency' -import getMeta from '../../../utils/meta' - -/** - * @import { CurrencyCode } from '../../../../../types/subscription/currency' - */ - -// plan: 'collaborator' or 'professional' -// the rest of available arguments can be seen in the groupPlans value -/** - * @param {Object} opts - * @param {'collaborator' | 'professional'} opts.plan - * @param {string} opts.licenseSize - * @param {CurrencyCode} opts.currency - * @param {'enterprise' | 'educational'} opts.usage - * @param {string} [opts.locale] - * @returns {{localizedPrice: string, localizedPerUserPrice: string}} - */ -export function createLocalizedGroupPlanPrice({ - plan, - licenseSize, - currency, - usage, - locale = getMeta('ol-i18n').currentLangCode || 'en', -}) { - const groupPlans = getMeta('ol-groupPlans') - const priceInCents = - groupPlans[usage][plan][currency][licenseSize].price_in_cents - - const price = priceInCents / 100 - const perUserPrice = price / parseInt(licenseSize) - - /** - * @param {number} price - * @returns {string} - */ - const formatPrice = price => formatCurrency(price, currency, locale, true) - - return { - localizedPrice: formatPrice(price), - localizedPerUserPrice: formatPrice(perUserPrice), - } -} diff --git a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-group-plan.js b/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-group-plan.js deleted file mode 100644 index 5616d40fa8..0000000000 --- a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-group-plan.js +++ /dev/null @@ -1,142 +0,0 @@ -import '../../../../features/plans/plans-v2-group-plan-modal' - -import getMeta from '../../../../utils/meta' -import { updateGroupModalPlanPricing } from '../../../../features/plans/group-plan-modal' -import { createLocalizedGroupPlanPrice } from '../../../../features/plans/utils/group-plan-pricing' - -const MINIMUM_LICENSE_SIZE_EDUCATIONAL_DISCOUNT = 10 - -export function updateMainGroupPlanPricing() { - const currency = getMeta('ol-recommendedCurrency') - - const formEl = document.querySelector( - '[data-ol-plans-v2-license-picker-form]' - ) - const licenseSize = formEl.querySelector( - '[data-ol-plans-v2-license-picker-select]' - ).value - - const usage = formEl.querySelector( - '[data-ol-plans-v2-license-picker-educational-discount-input]' - ).checked - ? 'educational' - : 'enterprise' - - const { - localizedPrice: localizedPriceProfessional, - localizedPerUserPrice: localizedPerUserPriceProfessional, - } = createLocalizedGroupPlanPrice({ - plan: 'professional', - licenseSize, - currency, - usage, - }) - - const { - localizedPrice: localizedPriceCollaborator, - localizedPerUserPrice: localizedPerUserPriceCollaborator, - } = createLocalizedGroupPlanPrice({ - plan: 'collaborator', - licenseSize, - currency, - usage, - }) - - document.querySelector( - '[data-ol-plans-v2-group-total-price="professional"]' - ).innerText = localizedPriceProfessional - - document.querySelector( - '[data-ol-plans-v2-group-price-per-user="professional"]' - ).innerText = localizedPerUserPriceProfessional - - document.querySelector( - '[data-ol-plans-v2-group-total-price="collaborator"]' - ).innerText = localizedPriceCollaborator - - document.querySelector( - '[data-ol-plans-v2-group-price-per-user="collaborator"]' - ).innerText = localizedPerUserPriceCollaborator - - const notEligibleForEducationalDiscount = - licenseSize < MINIMUM_LICENSE_SIZE_EDUCATIONAL_DISCOUNT - - formEl - .querySelector( - '[data-ol-plans-v2-license-picker-educational-discount-label]' - ) - .classList.toggle('disabled', notEligibleForEducationalDiscount) - - formEl - .querySelector('.plans-v2-license-picker-educational-discount') - .classList.toggle( - 'total-licenses-not-eligible-for-discount', - notEligibleForEducationalDiscount - ) - - formEl.querySelector( - '[data-ol-plans-v2-license-picker-educational-discount-input]' - ).disabled = notEligibleForEducationalDiscount - - if (notEligibleForEducationalDiscount) { - // force disable educational discount checkbox - formEl.querySelector( - '[data-ol-plans-v2-license-picker-educational-discount-input]' - ).checked = false - } - - changeNumberOfUsersInTableHead() - changeNumberOfUsersInFeatureTable() -} - -export function changeGroupPlanModalNumberOfLicenses() { - const modalEl = document.querySelector('[data-ol-group-plan-modal]') - const numberOfLicenses = document.querySelector( - '[data-ol-plans-v2-license-picker-select]' - ).value - - const groupPlanModalLicensePickerEl = modalEl.querySelector('#size') - - groupPlanModalLicensePickerEl.value = numberOfLicenses - updateGroupModalPlanPricing() -} - -export function changeGroupPlanModalEducationalDiscount() { - const modalEl = document.querySelector('[data-ol-group-plan-modal]') - const groupPlanModalEducationalDiscountEl = modalEl.querySelector('#usage') - const educationalDiscountChecked = document.querySelector( - '[data-ol-plans-v2-license-picker-educational-discount-input]' - ).checked - - groupPlanModalEducationalDiscountEl.checked = educationalDiscountChecked - updateGroupModalPlanPricing() -} - -export function changeNumberOfUsersInFeatureTable() { - document - .querySelectorAll( - '[data-ol-plans-v2-table-cell-plan^="group"][data-ol-plans-v2-table-cell-feature="number_of_users"]' - ) - .forEach(el => { - const licenseSize = document.querySelector( - '[data-ol-plans-v2-license-picker-select]' - ).value - - el.textContent = el.textContent.replace(/\d+/, licenseSize) - }) -} - -export function changeNumberOfUsersInTableHead() { - document - .querySelectorAll('[data-ol-plans-v2-table-th-group-license-size]') - .forEach(el => { - const licenseSize = el.getAttribute( - 'data-ol-plans-v2-table-th-group-license-size' - ) - const currentLicenseSize = document.querySelector( - '[data-ol-plans-v2-license-picker-select]' - ).value - - el.hidden = licenseSize !== currentLicenseSize - }) -} diff --git a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-hash.js b/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-hash.js deleted file mode 100644 index 2321f83db8..0000000000 --- a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-hash.js +++ /dev/null @@ -1,52 +0,0 @@ -import { GROUP_PLAN_MODAL_HASH } from '@/features/plans/group-plan-modal' - -export function getViewInfoFromHash() { - const hashValue = window.location.hash.replace('#', '') - - const groupPlanModalHashValue = GROUP_PLAN_MODAL_HASH.replace('#', '') - - switch (hashValue) { - case 'individual-monthly': - return ['individual', 'monthly'] - case 'individual-annual': - return ['individual', 'annual'] - case groupPlanModalHashValue: - case 'group': - return ['group', 'annual'] - case 'student-monthly': - return ['student', 'monthly'] - case 'student-annual': - return ['student', 'annual'] - default: - return ['individual', 'monthly'] - } -} - -/** - * - * @param {individual | group | student} viewTab - * @param {monthly | annual} period - */ -export function setHashFromViewTab(viewTab, period) { - const newHash = viewTab === 'group' ? 'group' : `${viewTab}-${period}` - if (window.location.hash.replace('#', '') !== newHash) { - window.location.hash = newHash - } -} - -// this is only for the students link in footer -export function handleForStudentsLinkInFooter() { - const links = document.querySelectorAll('[data-ol-for-students-link]') - - links.forEach(function (link) { - link.addEventListener('click', function () { - if (window.location.pathname === '/user/subscription/plans') { - // reload location with the correct hash - const newURL = - '/user/subscription/plans?itm_referrer=footer-for-students#student-annual' - history.replaceState(null, '', newURL) - location.reload() - } - }) - }) -} diff --git a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-m-a-switch.js b/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-m-a-switch.js deleted file mode 100644 index faa30804b8..0000000000 --- a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-m-a-switch.js +++ /dev/null @@ -1,63 +0,0 @@ -// m-a stands for monthly-annual - -export function toggleMonthlyAnnualSwitching( - view, - currentMonthlyAnnualSwitchValue -) { - const containerEl = document.querySelector( - '[data-ol-plans-v2-m-a-switch-container]' - ) - if (containerEl) { - const checkbox = containerEl.querySelector('input[type="checkbox"]') - - containerEl.classList.toggle('disabled', view === 'group') - - checkbox.disabled = view === 'group' - checkbox.checked = currentMonthlyAnnualSwitchValue === 'monthly' - - switchMonthlyAnnual(currentMonthlyAnnualSwitchValue) - } -} - -export function switchMonthlyAnnual(currentMonthlyAnnualSwitchValue) { - const el = document.querySelector('[data-ol-plans-v2-m-a-tooltip]') - el.classList.toggle( - 'plans-v2-m-a-tooltip-monthly-selected', - currentMonthlyAnnualSwitchValue === 'monthly' - ) - - document.querySelectorAll('[data-ol-tooltip-period]').forEach(el => { - const period = el.getAttribute('data-ol-tooltip-period') - el.hidden = period !== currentMonthlyAnnualSwitchValue - }) - - document.querySelectorAll('[data-ol-plans-v2-period').forEach(el => { - const period = el.getAttribute('data-ol-plans-v2-period') - - el.hidden = currentMonthlyAnnualSwitchValue !== period - }) - - document - .querySelectorAll('[data-ol-plans-v2-m-a-switch-text]') - .forEach(el => { - el.classList.toggle( - 'underline', - el.getAttribute('data-ol-plans-v2-m-a-switch-text') === - currentMonthlyAnnualSwitchValue - ) - }) -} - -function changeMonthlyAnnualTooltipPosition() { - const smallScreen = window.matchMedia('(max-width: 767px)').matches - const el = document.querySelector('[data-ol-plans-v2-m-a-tooltip]') - - el.classList.toggle('bottom', smallScreen) - el.classList.toggle('left', !smallScreen) -} - -// click event listener for monthly-annual switch -export function setUpMonthlyAnnualSwitching() { - changeMonthlyAnnualTooltipPosition() - window.addEventListener('resize', changeMonthlyAnnualTooltipPosition) -} diff --git a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-main.js b/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-main.js deleted file mode 100644 index d3465bee1d..0000000000 --- a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-main.js +++ /dev/null @@ -1,336 +0,0 @@ -import '../../../../marketing' -import '../../../../infrastructure/hotjar' - -import * as eventTracking from '../../../../infrastructure/event-tracking' -import { setUpStickyHeaderObserver } from './plans-v2-sticky-header' -import { - setUpMonthlyAnnualSwitching, - switchMonthlyAnnual, - toggleMonthlyAnnualSwitching, -} from './plans-v2-m-a-switch' -import { - changeGroupPlanModalEducationalDiscount, - changeGroupPlanModalNumberOfLicenses, - updateMainGroupPlanPricing, -} from './plans-v2-group-plan' -import { setUpGroupSubscriptionButtonAction } from './plans-v2-subscription-button' -import { - getViewInfoFromHash, - handleForStudentsLinkInFooter, - setHashFromViewTab, -} from './plans-v2-hash' -import { sendPlansViewEvent } from './plans-v2-tracking' -import getMeta from '../../../../utils/meta' - -const currentCurrencyCode = getMeta('ol-recommendedCurrency') - -function showQuoteForTab(viewTab) { - // hide/display quote rows - document.querySelectorAll('.plans-page-quote-row').forEach(quoteRow => { - const showForPlanTypes = quoteRow.getAttribute('data-ol-show-for-plan-type') - if (showForPlanTypes?.includes(viewTab)) { - quoteRow.classList.remove('plans-page-quote-row-hidden') - } else { - quoteRow.classList.add('plans-page-quote-row-hidden') - } - }) -} - -function setUpSubscriptionTracking(linkEl) { - linkEl.addEventListener('click', function () { - const plan = - linkEl.getAttribute('data-ol-tracking-plan') || - linkEl.getAttribute('data-ol-start-new-subscription') - - const location = linkEl.getAttribute('data-ol-location') - const period = linkEl.getAttribute('data-ol-item-view') - - const DEFAULT_EVENT_TRACKING_KEY = 'plans-page-click' - - const eventTrackingKey = - linkEl.getAttribute('data-ol-event-tracking-key') || - DEFAULT_EVENT_TRACKING_KEY - - const eventTrackingSegmentation = { - button: plan, - location, - 'billing-period': period, - currency: currentCurrencyCode, - } - - eventTracking.sendMB(eventTrackingKey, eventTrackingSegmentation) - }) -} - -const searchParams = new URLSearchParams(window.location.search) - -export function updateLinkTargets() { - document.querySelectorAll('[data-ol-start-new-subscription]').forEach(el => { - if (el.hasAttribute('data-ol-has-custom-href')) return - - const plan = el.getAttribute('data-ol-start-new-subscription') - const view = el.getAttribute('data-ol-item-view') - - const suffix = view === 'annual' ? '-annual' : '_free_trial_7_days' - - const planCode = `${plan}${suffix}` - - const location = el.getAttribute('data-ol-location') - const itmCampaign = searchParams.get('itm_campaign') || 'plans' - const itmContent = - itmCampaign === 'plans' ? location : searchParams.get('itm_content') - - const queryString = new URLSearchParams({ - planCode, - currency: currentCurrencyCode, - itm_campaign: itmCampaign, - }) - - if (itmContent) { - queryString.set('itm_content', itmContent) - } - - if (searchParams.get('itm_referrer')) { - queryString.set('itm_referrer', searchParams.get('itm_referrer')) - } - - el.href = `/user/subscription/new?${queryString.toString()}` - }) -} - -// We need this mutable variable because the group tab only have annual. -// There's some difference between the monthly and annual UI -// and since monthly-annual switch is disabled for the group tab, -// we need to introduce a new variable to store the information -let currentMonthlyAnnualSwitchValue = 'annual' - -function selectTab(viewTab) { - document.querySelectorAll('[data-ol-plans-v2-view-tab]').forEach(el => { - const tab = el.querySelector('[data-ol-plans-v2-view-tab] button') - if (tab) { - const isActive = - tab.parentElement.getAttribute('data-ol-plans-v2-view-tab') === viewTab - tab.parentElement.classList.toggle('active', isActive) - tab.setAttribute('aria-selected', isActive) - } - }) - - document.querySelectorAll('[data-ol-plans-v2-view]').forEach(el => { - el.hidden = el.getAttribute('data-ol-plans-v2-view') !== viewTab - }) - - const tooltipEl = document.querySelector('[data-ol-plans-v2-m-a-tooltip]') - if (tooltipEl) { - tooltipEl.hidden = viewTab === 'group' - } - - const licensePickerEl = document.querySelector( - '[data-ol-plans-v2-license-picker-container]' - ) - if (licensePickerEl) { - licensePickerEl.hidden = viewTab !== 'group' - } - - const monthlyAnnualSwitch = document.querySelector( - '[data-ol-plans-v2-m-a-switch-container]' - ) - if (monthlyAnnualSwitch) { - monthlyAnnualSwitch.setAttribute('data-ol-current-view', viewTab) - } - - if (viewTab === 'group') { - updateMainGroupPlanPricing() - } - - updateMonthlyAnnualSwitchValue(viewTab) - - toggleUniversityInfo(viewTab) - - // update the hash to reflect the current view when switching individual, group, or student tabs - setHashFromViewTab(viewTab, currentMonthlyAnnualSwitchValue) - - showQuoteForTab(viewTab) -} - -function updateMonthlyAnnualSwitchValue(viewTab) { - // group tab is special because group plan only has annual value - // so we need to perform some UI changes whenever user click the group tab - if (viewTab === 'group') { - toggleMonthlyAnnualSwitching(viewTab, 'annual') - } else { - toggleMonthlyAnnualSwitching(viewTab, currentMonthlyAnnualSwitchValue) - } -} - -function setUpTabSwitching() { - document.querySelectorAll('[data-ol-plans-v2-view-tab]').forEach(el => { - const viewTab = el.getAttribute('data-ol-plans-v2-view-tab') - - el.querySelector('button').addEventListener('click', function (e) { - e.preventDefault() - eventTracking.send( - 'subscription-funnel', - 'plans-page', - `${viewTab}-prices` - ) - selectTab(viewTab) - }) - }) - - const tabs = document.querySelectorAll( - '[data-ol-plans-v2-view-tab] [role="tab"]' - ) - - if (tabs) { - tabs.forEach(tab => { - tab.addEventListener('keydown', event => { - if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { - const currentIndex = Array.from(tabs).indexOf(tab) - const nextIndex = - event.key === 'ArrowLeft' ? currentIndex - 1 : currentIndex + 1 - const newIndex = (nextIndex + tabs.length) % tabs.length - tabs[newIndex].focus() - } - }) - }) - } -} - -function setUpGroupPlanPricingChange() { - document - .querySelectorAll('[data-ol-plans-v2-license-picker-select]') - .forEach(el => { - el.addEventListener('change', () => { - updateMainGroupPlanPricing() - changeGroupPlanModalNumberOfLicenses() - }) - }) - - document - .querySelectorAll( - '[data-ol-plans-v2-license-picker-educational-discount-input]' - ) - .forEach(el => - el.addEventListener('change', () => { - updateMainGroupPlanPricing() - changeGroupPlanModalEducationalDiscount() - }) - ) -} - -function toggleUniversityInfo(viewTab) { - const el = document.querySelector('[data-ol-plans-university-info-container]') - if (el) { - el.hidden = viewTab !== 'student' - } -} - -// This is the old scheme for hashing redirection -// This is deprecated and should be removed in the future -// This is only used for backward compatibility -function selectViewFromHashDeprecated() { - try { - const params = new URLSearchParams(window.location.hash.substring(1)) - const view = params.get('view') - if (view) { - // View params are expected to be of the format e.g. individual or individual-monthly - const [tab, period] = view.split('-') - // make sure the selected view is valid - if (document.querySelector(`[data-ol-plans-v2-view-tab="${tab}"]`)) { - selectTab(tab) - - if (['monthly', 'annual'].includes(period)) { - currentMonthlyAnnualSwitchValue = period - } else { - // set annual as the default - currentMonthlyAnnualSwitchValue = 'annual' - } - - updateMonthlyAnnualSwitchValue(tab) - - // change the hash with the new scheme - setHashFromViewTab(tab, currentMonthlyAnnualSwitchValue) - } - } - } catch { - // do nothing - } -} - -function selectViewAndPeriodFromHash() { - const [viewTab, period] = getViewInfoFromHash() - - // the sequence of these three lines is important - // because `currentMonthlyAnnualSwitchValue` is mutable. - // `selectTab` and `updateMonthlyAnnualSwitchValue` depend on the value of `currentMonthlyAnnualSwitchValue` - // to determine the UI state - currentMonthlyAnnualSwitchValue = period - selectTab(viewTab) - updateMonthlyAnnualSwitchValue(viewTab) - - // handle the case where user access plans page while still on the plans page - // current example would the the "For students" link on the footer - const SCROLL_TO_TOP_DELAY = 50 - window.setTimeout(() => { - window.scrollTo({ top: 0, behavior: 'smooth' }) - }, SCROLL_TO_TOP_DELAY) -} - -// call the function to select the view and period from the hash value -// this is called once when the page is loaded -if (window.location.hash) { - if (window.location.hash.includes('view')) { - selectViewFromHashDeprecated() - } else { - selectViewAndPeriodFromHash() - } -} - -document - .querySelector('[data-ol-plans-v2-m-a-switch]') - .addEventListener('click', () => { - const isMonthlyPricing = document.querySelector( - '[data-ol-plans-v2-m-a-switch] input[type="checkbox"]' - ).checked - - if (isMonthlyPricing) { - currentMonthlyAnnualSwitchValue = 'monthly' - } else { - currentMonthlyAnnualSwitchValue = 'annual' - } - - switchMonthlyAnnual(currentMonthlyAnnualSwitchValue) - - // update the hash to reflect the current view when pressing the monthly-annual switch - const DEFAULT_VIEW_TAB = 'individual' - const viewTab = - document - .querySelector('[data-ol-plans-v2-m-a-switch-container]') - .getAttribute('data-ol-current-view') ?? DEFAULT_VIEW_TAB - - setHashFromViewTab(viewTab, currentMonthlyAnnualSwitchValue) - }) - -document - .querySelectorAll('[data-ol-start-new-subscription]') - .forEach(setUpSubscriptionTracking) - -setUpTabSwitching() -setUpGroupPlanPricingChange() -setUpMonthlyAnnualSwitching() -setUpGroupSubscriptionButtonAction() -setUpStickyHeaderObserver() -updateLinkTargets() -handleForStudentsLinkInFooter() - -window.addEventListener('hashchange', () => { - if (window.location.hash) { - if (window.location.hash.includes('view')) { - selectViewFromHashDeprecated() - } else { - selectViewAndPeriodFromHash() - } - } -}) - -sendPlansViewEvent() diff --git a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-sticky-header.js b/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-sticky-header.js deleted file mode 100644 index 47cd51605a..0000000000 --- a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-sticky-header.js +++ /dev/null @@ -1,17 +0,0 @@ -function stickyHeaderObserverCallback(entry) { - document - .querySelectorAll('[data-ol-plans-v2-table-sticky-header]') - .forEach(el => - el.classList.toggle('sticky', entry[0].boundingClientRect.bottom > 0) - ) -} - -export function setUpStickyHeaderObserver() { - const stickyHeaderStopEl = document.querySelector( - '[data-ol-plans-v2-table-sticky-header-stop]' - ) - - const observer = new IntersectionObserver(stickyHeaderObserverCallback) - - observer.observe(stickyHeaderStopEl) -} diff --git a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-subscription-button.js b/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-subscription-button.js deleted file mode 100644 index 66ad71a74f..0000000000 --- a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-subscription-button.js +++ /dev/null @@ -1,32 +0,0 @@ -import { updateGroupModalPlanPricing } from '../../../../features/plans/group-plan-modal' - -function showGroupPlanModal(el) { - const plan = el.getAttribute('data-ol-start-new-subscription') - - // plan is either `group_collaborator` or `group_professional` - // we want to get the suffix (collaborator or professional) - const groupPlan = plan.split('_')[1] - - const groupModalRadioInputEl = document.querySelector( - `[data-ol-group-plan-code="${groupPlan}"]` - ) - - groupModalRadioInputEl.checked = true - updateGroupModalPlanPricing() - - const modalEl = $('[data-ol-group-plan-modal]') - modalEl.modal() -} - -export function setUpGroupSubscriptionButtonAction() { - document.querySelectorAll('[data-ol-start-new-subscription]').forEach(el => { - const plan = el.getAttribute('data-ol-start-new-subscription') - - if (plan === 'group_collaborator' || plan === 'group_professional') { - el.addEventListener('click', e => { - e.preventDefault() - showGroupPlanModal(el) - }) - } - }) -} diff --git a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-tracking.ts b/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-tracking.ts deleted file mode 100644 index 3f10f033ca..0000000000 --- a/services/web/frontend/js/pages/user/subscription/plans-v2/plans-v2-tracking.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { sendMB } from '@/infrastructure/event-tracking' -import { getSplitTestVariant } from '@/utils/splitTestUtils' -import getMeta from '@/utils/meta' - -export function sendPlansViewEvent() { - document.addEventListener( - 'DOMContentLoaded', - function () { - const currency = getMeta('ol-recommendedCurrency') - const countryCode = getMeta('ol-countryCode') - - const groupTabImprovementsVariant = getSplitTestVariant( - 'group-tab-improvements' - ) - - const periodToggleTestVariant = getSplitTestVariant( - 'period-toggle-improvements' - ) - - const device = window.matchMedia('(max-width: 767px)').matches - ? 'mobile' - : 'desktop' - - const queryParams = new URLSearchParams(window.location.search) - const planTabParam = queryParams.get('plan') - - const plansPageViewSegmentation = { - currency, - countryCode, - device, - 'group-tab-improvements': groupTabImprovementsVariant, - plan: planTabParam, - 'period-toggle-improvements': periodToggleTestVariant, - } - - const isPlansPage = window.location.href.includes( - 'user/subscription/plans' - ) - const isInterstitialPaymentPage = window.location.href.includes( - 'user/subscription/choose-your-plan' - ) - - if (isPlansPage) { - sendMB('plans-page-view', plansPageViewSegmentation) - } else if (isInterstitialPaymentPage) { - sendMB('paywall-plans-page-view', plansPageViewSegmentation) - } - }, - { once: true } - ) -} diff --git a/services/web/test/frontend/shared/utils/group-plan-pricing.test.js b/services/web/test/frontend/shared/utils/group-plan-pricing.test.js deleted file mode 100644 index d5d5f19de5..0000000000 --- a/services/web/test/frontend/shared/utils/group-plan-pricing.test.js +++ /dev/null @@ -1,77 +0,0 @@ -import { expect } from 'chai' -import { createLocalizedGroupPlanPrice } from '../../../../frontend/js/features/plans/utils/group-plan-pricing' - -describe('group-plan-pricing', function () { - beforeEach(function () { - window.metaAttributesCache.set('ol-groupPlans', { - enterprise: { - professional: { - CHF: { - 2: { - price_in_cents: 10000, - }, - }, - DKK: { - 2: { - price_in_cents: 20000, - }, - }, - USD: { - 2: { - price_in_cents: 30000, - }, - }, - }, - }, - }) - window.metaAttributesCache.set('ol-i18n', { currentLangCode: 'en' }) - }) - - describe('createLocalizedGroupPlanPrice', function () { - describe('CHF currency', function () { - it('should return the correct localized price', function () { - const localizedGroupPlanPrice = createLocalizedGroupPlanPrice({ - plan: 'professional', - currency: 'CHF', - licenseSize: '2', - usage: 'enterprise', - }) - - expect(localizedGroupPlanPrice).to.deep.equal({ - localizedPrice: 'CHF 100', - localizedPerUserPrice: 'CHF 50', - }) - }) - }) - describe('DKK currency', function () { - it('should return the correct localized price', function () { - const localizedGroupPlanPrice = createLocalizedGroupPlanPrice({ - plan: 'professional', - currency: 'DKK', - licenseSize: '2', - usage: 'enterprise', - }) - - expect(localizedGroupPlanPrice).to.deep.equal({ - localizedPrice: 'kr 200', - localizedPerUserPrice: 'kr 100', - }) - }) - }) - describe('other supported currencies', function () { - it('should return the correct localized price', function () { - const localizedGroupPlanPrice = createLocalizedGroupPlanPrice({ - plan: 'professional', - currency: 'USD', - licenseSize: '2', - usage: 'enterprise', - }) - - expect(localizedGroupPlanPrice).to.deep.equal({ - localizedPrice: '$300', - localizedPerUserPrice: '$150', - }) - }) - }) - }) -}) From 539e96c62b20f78742034971871ad8f63ce01a9a Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Thu, 16 Jan 2025 10:00:06 +0000 Subject: [PATCH 0049/1724] Merge pull request #22802 from overleaf/mj-outline-icons [web] Add some unfilled material symbols GitOrigin-RevId: 2b5c477e6ff32f62ab40cacf666aeb98b311f126 --- ...alSymbolsRoundedUnfilledPartialSlice.woff2 | Bin 0 -> 2336 bytes .../material-symbols/material-symbols.css | 22 +++++++++++++++ .../js/shared/components/material-icon.tsx | 26 ++++++++++++++++-- 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 diff --git a/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 b/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..6eba080dd6f7f346348e385410b131ee6b8cbf63 GIT binary patch literal 2336 zcmV+*3E%d2Pew8T0RR9100|%f4gdfE01%7-00_na0RR9100000000000000000000 z0000Sf&vC$KT}jeRAK;u91#c#lNgUp3p4-$HUcCAGz1_8g$@TG3NT!jp zaQ=S&|L4^16Bzy%*iQJgE-{%Bc2XdaIxNj_z)(V^v;rOm*@L)i7n?aYpf0Nh0o2=} zX4ipwlny(dke%5Gn5B9_zi<#%8d`I8Vs(*d(?+O-NNAL{-QV;Jd?0LufYD$OPz;Zp>;Xd%0fgcuaClKHW#v%JPzn_cWtu?&<xw6<_kba zIaKHR1_%M*KDlr)L;xcp z%$~P!;XeDEvwYf2BG8ttJNADCpFJlVp zt(0n-ru(^H8tN-$r4sk*o~D}Wb#fx1R#T0##_k}~Vb&!Wm*aK^%?WvY!n#lzYs=6p zXUpR47df}jmqc6BfRQSWw47rx9;XmkYJBhIs5aQm|gZa;uz+-IA0(MxOm`19n>4I^e)sv#Nm1>$KGRtWq7zWV*31$hhXAQU8=|)*HGo_Q;E->p{ zaipc?LTukmlPN;+e*Gfxel(e)szqNUa_O!$n{Gd_gq;LU#AynUfrE_>7`OY!6zd_8 zYCj@6?=-`2R;T9mP4ZV5NnPSX=#ZYc%MWY_&TTC-d8HzvXr= zmy5C!`Fx&PuIs|car@hEzwN?Z*LAt9+Bv{*S(9piOJwGm!sQ?|=+K~p&xQz_&}iMD z;8{QlslwSwl@$s?kyLP**A3$%8o0#79&BiWRS##~1X!2v;@xE~vE8hjkhJoYD7XhG zCajhVlUiAKqJ{whmnnki9b{VF-cToeyQkLS8>ay?%alr6)XUYf`tGc2ZIpue@XcXw{(9&}(~ahr z-~Zr7@`Kl!Z!}e3OE%!w0s2f`rD#v-GxbXg8(h+7)^*X+P9~dnwD0MRjFZ-WSSOoF zJKA@1e#m5g$Y#D*M(@VY$ika`z2|gxoX*aySx%=^{^s1UTm3hXI&HuBKsX%^gwx@U z!+pDV?|ZnT(CTx()wNy3+lT$knBo6EYn2$;cRc>iZo^&ju9PO7-Gvvjc-=fV3*(|-Box1TT!j#}pO<-R3U zgjgy8@GY)R+hdBbd`YapDa)f^m=Z!o_;nI0;$J^PMQjC5nXCH0_b}MwJ6_jkjJ6Ug z;xM5iNJ2%FFbs9wb_Z!lH2N>L|6t+1gJt!V4V5bod(s|WG+yc_Zt`eOmG>#q<-N@* zn>-Q&x=gA?i79%N;=XisxJA-gX>$k5+0a+MsEDli|Bf6i=R-gGtwrAA1D<~JR@b&9 zx<5Nj+*_Nv=$Wv*%c#ybjk%P)SGFv}E?e87=f=qJ;%99`iUGs*o82S(bfQHMoe>b( zXweuM8QEyj7>RtY?q5TNF!Eo+IxgjPh*0_SE9RG1*+SpfV7V;+Fk!GDnalPG7B2aS zZ}s;)-Eq?Bexxy5B;IXR~+a!v&&hJ=SF1qPkU&iT$U+wjk2 z_)-7(tE3urg?h-IfPFjmFJJ0hEb1rQjPpNfGO2K<>5#aiS)cvCyE0ilT|I1e#lGzm z-m9#m-O7n-Q}OL3H&cr9-CkqFiI*B zT>kj<<8#41sIB99g3Gv#NzS~C900aZuuvR|0OW&(cww4oLs$)| zRS(;u28~dQI&yfUGZqhPcnpgVeICUUK!M*`LPTNgEss? zREsQuC4eq;do4YEg>(jZ6{=>o=niQdA=s z1t^3Br9IvXL?aiqwrZ3fmY;wou*idw7T17*5_PCNI9LD?{4g!vV8Bw0IuXo8HIj8@ zHS({Os00ZSe6R|B@JAj*%0Z-5k#Z`s2JgEMfE2Nag%kmSH(2OWu*nfnVlA4q;2nUQ z<3(5o5>n|`q8JrqGZImVLIiw3f<%}Em|`91z*dNw5b*i}_lePK>c&rM;%VN;zQe*X G0001vc~$@b literal 0 HcmV?d00001 diff --git a/services/web/frontend/fonts/material-symbols/material-symbols.css b/services/web/frontend/fonts/material-symbols/material-symbols.css index a6a8e093a9..7fda97b6ef 100644 --- a/services/web/frontend/fonts/material-symbols/material-symbols.css +++ b/services/web/frontend/fonts/material-symbols/material-symbols.css @@ -10,6 +10,19 @@ src: url('MaterialSymbolsRoundedSlice.woff2') format('woff2'); } +@font-face { + font-family: 'Material Symbols Rounded Unfilled Partial'; + font-style: normal; + font-weight: 400; + font-display: block; + /* + Generated by accessing + https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=SORTED_SYMBOL_LIST&display=block + with a sorted list of symbols, and downloading the linked woff2 file. + */ + src: url('MaterialSymbolsRoundedUnfilledPartialSlice.woff2') format('woff2'); +} + .material-symbols { font-family: 'Material Symbols Rounded'; font-weight: normal; @@ -37,4 +50,13 @@ &.rotate-180 { transform: rotate(180deg); } + + &.unfilled { + font-family: 'Material Symbols Rounded Unfilled Partial'; + font-variation-settings: + 'FILL' 0, + 'wght' 400, + 'GRAD' 0, + 'opsz' 20; + } } diff --git a/services/web/frontend/js/shared/components/material-icon.tsx b/services/web/frontend/js/shared/components/material-icon.tsx index 6ef71a1d4b..19305b7e60 100644 --- a/services/web/frontend/js/shared/components/material-icon.tsx +++ b/services/web/frontend/js/shared/components/material-icon.tsx @@ -2,23 +2,45 @@ import classNames from 'classnames' import React from 'react' import { bsVersion } from '@/features/utils/bootstrap-5' -type IconProps = React.ComponentProps<'i'> & { - type: string +// NOTE: When updating this list, make sure to update the bundled .woff2 +// file as well. See details in material-symbols.css +export type AvailableUnfilledIcon = + | 'description' + | 'forum' + | 'integration_instructions' + | 'rate_review' + | 'report' + +type BaseIconProps = React.ComponentProps<'i'> & { accessibilityLabel?: string modifier?: string size?: '2x' } +type FilledIconProps = BaseIconProps & { + type: string + unfilled?: false +} + +type UnfilledIconProps = BaseIconProps & { + type: AvailableUnfilledIcon + unfilled: true +} + +type IconProps = FilledIconProps | UnfilledIconProps + function MaterialIcon({ type, className, accessibilityLabel, modifier, size, + unfilled, ...rest }: IconProps) { const iconClassName = classNames('material-symbols', className, modifier, { [`size-${size}`]: size, + unfilled, }) return ( From 849275c4b88b37918fadeb050848d8fbb753e464 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Thu, 16 Jan 2025 10:00:49 +0000 Subject: [PATCH 0050/1724] Merge pull request #22787 from overleaf/mj-ide-rail [web] Create rail tabbed layout GitOrigin-RevId: be54a224087aad8e6e2762d9c26463e37aecd9aa --- .../ide-redesign/components/main-layout.tsx | 16 +-- .../features/ide-redesign/components/rail.tsx | 108 ++++++++++++++++++ .../stylesheets/bootstrap-5/pages/all.scss | 1 + .../bootstrap-5/pages/editor/rail.scss | 57 +++++++++ 4 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 services/web/frontend/js/features/ide-redesign/components/rail.tsx create mode 100644 services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss 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 fb8e6b870c..1f314ad702 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 @@ -3,15 +3,13 @@ import classNames from 'classnames' import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle' import PdfPreview from '@/features/pdf-preview/components/pdf-preview' import { Editor } from './editor' +import { RailLayout } from './rail' export default function MainLayout() { return (
Toolbar
-
- Left menu -
- -
- Side bar -
-
+ File tree, + }, + { + key: 'integrations', + icon: 'integration_instructions', + component: <>Integrations, + }, + { + key: 'review-panel', + icon: 'rate_review', + component: <>Review panel, + }, + { + key: 'chat', + icon: 'forum', + component: <>Chat, + }, + { + key: 'errors', + icon: 'report', + component: <>Errors, + }, +] + +export const RailLayout = () => { + const [selectedTab, setSelectedTab] = useState( + RAIL_TABS[0]?.key + ) + return ( + setSelectedTab(key ?? undefined), [])} + id="ide-rail-tabs" + > +
+ +
+ +
+ + {RAIL_TABS.map(({ key, component }) => ( + + {component} + + ))} + +
+
+
+ ) +} + +const RailTab = ({ + icon, + eventKey, + active, +}: { + icon: AvailableUnfilledIcon + eventKey: string + active: boolean +}) => { + return ( + + + + ) +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss index 682f292cc3..956f28777c 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss @@ -7,6 +7,7 @@ @import 'sidebar-v2-dash-pane'; @import 'editor/ide'; @import 'editor/ide-redesign'; +@import 'editor/rail'; @import 'editor/toolbar'; @import 'editor/online-users'; @import 'editor/hotkeys'; diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss new file mode 100644 index 0000000000..17a8cd2ba7 --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss @@ -0,0 +1,57 @@ +:root { + --ide-rail-background: #fff; + --ide-rail-color: var(--neutral-90); + --ide-rail-link-background: #fff; + --ide-rail-link-active-background: var(--neutral-10); + --ide-rail-link-active-indicator-background: var(--neutral-90); +} + +.ide-rail-tab-link { + border-radius: 12px; + display: block; + height: 48px; + width: 48px; + text-align: center; + padding: 0; + color: var(--ide-rail-color); + background-color: var(--ide-rail-link-background); + position: relative; + overflow-y: hidden; + + .ide-rail-tab-link-icon { + line-height: 48px; + font-size: 20px; + } + + &.active { + color: var(--ide-rail-color); + background-color: var(--ide-rail-link-active-background); + + &::after { + $indicator-height: 3px; + + border-radius: 12px; + content: ''; + position: absolute; + bottom: -$indicator-height; + left: 25%; + box-sizing: border-box; + width: 50%; + height: $indicator-height * 2; + border: $indicator-height solid + var(--ide-rail-link-active-indicator-background); + background-color: var(--ide-rail-link-active-indicator-background); + } + } +} + +.ide-rail { + height: 100%; + padding: var(--spacing-02); + background: var(--ide-rail-background); +} + +.ide-rail-content { + height: 100%; + border: 1px solid var(--border-divider); +} From c919960d2b266b079f1765a04510fbf2a5b81ec7 Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Thu, 16 Jan 2025 10:02:10 +0000 Subject: [PATCH 0051/1724] Merge pull request #22902 from overleaf/dp-remove-presentation-mode-flag Remove pdf-presentation-mode feature flag GitOrigin-RevId: 14e64115c539fe2721150ed6f09dfcc4ce9cbaaa --- services/web/app/src/Features/Project/ProjectController.js | 1 - .../js/features/pdf-preview/components/pdf-zoom-dropdown.tsx | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 0e0bf3a800..c8f88c18d9 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -341,7 +341,6 @@ const _ProjectController = { 'pdf-caching-mode', 'pdf-caching-prefetch-large', 'pdf-caching-prefetching', - 'pdf-presentation-mode', 'revert-file', 'revert-project', 'review-panel-redesign', diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-zoom-dropdown.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-zoom-dropdown.tsx index 6dc05cfc8f..8b3f1f4a76 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-zoom-dropdown.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-zoom-dropdown.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ControlledDropdown from '@/shared/components/controlled-dropdown' import classNames from 'classnames' -import { useFeatureFlag } from '@/shared/context/split-test-context' import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' import { Dropdown, @@ -49,8 +48,6 @@ function PdfZoomDropdown({ }: PdfZoomDropdownProps) { const { t } = useTranslation() - const enablePresentationMode = useFeatureFlag('pdf-presentation-mode') - const [customZoomValue, setCustomZoomValue] = useState( rawScaleToPercentage(rawScale) ) @@ -59,7 +56,7 @@ function PdfZoomDropdown({ setCustomZoomValue(rawScaleToPercentage(rawScale)) }, [rawScale]) - const showPresentOption = enablePresentationMode && document.fullscreenEnabled + const showPresentOption = document.fullscreenEnabled return ( Date: Thu, 16 Jan 2025 07:40:58 -0500 Subject: [PATCH 0052/1724] Merge pull request #22871 from overleaf/em-history-flush-metrics History flush metrics GitOrigin-RevId: eb8b357427942e9816ad92ccd46c0dd8a65ab939 --- services/project-history/app/js/Metrics.js | 15 +++++++++++++++ .../project-history/app/js/UpdatesProcessor.js | 7 +++++++ 2 files changed, 22 insertions(+) create mode 100644 services/project-history/app/js/Metrics.js diff --git a/services/project-history/app/js/Metrics.js b/services/project-history/app/js/Metrics.js new file mode 100644 index 0000000000..b51518963b --- /dev/null +++ b/services/project-history/app/js/Metrics.js @@ -0,0 +1,15 @@ +// @ts-check + +import { prom } from '@overleaf/metrics' + +export const historyFlushDurationSeconds = new prom.Histogram({ + name: 'history_flush_duration_seconds', + help: 'Duration of a history flush in seconds', + buckets: [0.05, 0.1, 0.2, 0.3, 0.5, 1, 2, 5, 10], +}) + +export const historyFlushQueueSize = new prom.Histogram({ + name: 'history_flush_queue_size', + help: 'Size of the queue during history flushes', + buckets: prom.exponentialBuckets(1, 2, 10), +}) diff --git a/services/project-history/app/js/UpdatesProcessor.js b/services/project-history/app/js/UpdatesProcessor.js index df9ccf11ea..ad0dbc816b 100644 --- a/services/project-history/app/js/UpdatesProcessor.js +++ b/services/project-history/app/js/UpdatesProcessor.js @@ -15,6 +15,7 @@ import * as WebApiManager from './WebApiManager.js' import * as SyncManager from './SyncManager.js' import * as Versions from './Versions.js' import * as Errors from './Errors.js' +import * as Metrics from './Metrics.js' import { Profiler } from './Profiler.js' const keys = Settings.redis.lock.key_schema @@ -61,6 +62,7 @@ export function getRawUpdates(projectId, batchSize, callback) { // Process all updates for a project, only check project-level information once export function processUpdatesForProject(projectId, callback) { + const startTimeMs = Date.now() LockManager.runWithLock( keys.projectHistoryLock({ project_id: projectId }), (extendLock, releaseLock) => { @@ -76,6 +78,11 @@ export function processUpdatesForProject(projectId, callback) { OError.tag(error) } ErrorRecorder.record(projectId, queueSize, error, callback) + if (queueSize > 0) { + const duration = (Date.now() - startTimeMs) / 1000 + Metrics.historyFlushDurationSeconds.observe(duration) + Metrics.historyFlushQueueSize.observe(queueSize) + } // clear the timestamp in the background if the queue is now empty RedisManager.clearDanglingFirstOpTimestamp(projectId, () => {}) } From 84bd994345b5048229881bed57211113e2bfc5fb Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Thu, 16 Jan 2025 08:25:16 -0500 Subject: [PATCH 0053/1724] Merge pull request #22892 from overleaf/em-filter-tracked-deletes Filter tracked deletes on project snapshot GitOrigin-RevId: 5146e00b67af8dc15bf17f587cb6173a0a21544d --- services/web/frontend/js/infrastructure/project-snapshot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/frontend/js/infrastructure/project-snapshot.ts b/services/web/frontend/js/infrastructure/project-snapshot.ts index 226d44923c..d16f7b81ea 100644 --- a/services/web/frontend/js/infrastructure/project-snapshot.ts +++ b/services/web/frontend/js/infrastructure/project-snapshot.ts @@ -56,7 +56,7 @@ export class ProjectSnapshot { if (file == null) { return null } - return file.getContent() ?? null + return file.getContent({ filterTrackedDeletes: true }) ?? null } private async loadDocs() { From ab9d4d4c9c8e17d3ab274f33465af524b88b9964 Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Thu, 16 Jan 2025 08:25:27 -0500 Subject: [PATCH 0054/1724] Merge pull request #22845 from overleaf/em-project-snapshot-queue-refreshes Concurrency control in ProjectSnapshot GitOrigin-RevId: b62b886b59a67f2c694ef7cefcff5c32da3e4457 --- .../js/infrastructure/project-snapshot.ts | 212 ++++++++++++++++-- 1 file changed, 191 insertions(+), 21 deletions(-) diff --git a/services/web/frontend/js/infrastructure/project-snapshot.ts b/services/web/frontend/js/infrastructure/project-snapshot.ts index d16f7b81ea..b6300e505b 100644 --- a/services/web/frontend/js/infrastructure/project-snapshot.ts +++ b/services/web/frontend/js/infrastructure/project-snapshot.ts @@ -1,4 +1,5 @@ import pLimit from 'p-limit' +import OError from '@overleaf/o-error' import { Change, Chunk, Snapshot } from 'overleaf-editor-core' import { RawChange, RawChunk } from 'overleaf-editor-core/lib/types' import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json' @@ -12,45 +13,68 @@ export class ProjectSnapshot { private projectId: string private snapshot: Snapshot private version: number - private state: 'init' | 'refreshing' | 'ready' private blobStore: SimpleBlobStore + private refreshPromise: Promise + private queuedRefreshPromise: Promise + private state: ProjectSnapshotState constructor(projectId: string) { this.projectId = projectId this.snapshot = new Snapshot() this.version = 0 - this.state = 'init' + this.refreshPromise = Promise.resolve() + this.queuedRefreshPromise = Promise.resolve() + this.state = new ProjectSnapshotState() this.blobStore = new SimpleBlobStore(this.projectId) } + /** + * Request a refresh of the snapshot. + * + * When the returned promise resolves, the snapshot is guaranteed to have been + * updated at least to the version of the document that was current when the + * function was called. + */ async refresh() { - if (this.state === 'refreshing') { - // Prevent concurrent refreshes - return + switch (this.state.getState()) { + case 'init': + this.refreshPromise = this.initialize() + await this.refreshPromise + break + + case 'ready': + this.refreshPromise = this.loadChanges() + await this.refreshPromise + break + + case 'refreshing': + this.queuedRefreshPromise = this.queueRefresh() + await this.queuedRefreshPromise + break + + case 'queued-ready': + case 'queued-waiting': + await this.queuedRefreshPromise + break + + default: + throw new OError('Unknown state for project snapshot', { + state: this.state.getState(), + }) } - - await flushHistory(this.projectId) - - if (this.state === 'init') { - const chunk = await fetchLatestChunk(this.projectId) - this.snapshot = chunk.getSnapshot() - this.snapshot.applyAll(chunk.getChanges()) - this.version = chunk.getEndVersion() - } else { - const changes = await fetchLatestChanges(this.projectId, this.version) - this.snapshot.applyAll(changes) - this.version += changes.length - } - - this.state = 'ready' - await this.loadDocs() } + /** + * Get the list of paths to editable docs. + */ getDocPaths(): string[] { const allPaths = this.snapshot.getFilePathnames() return allPaths.filter(path => this.snapshot.getFile(path)?.isEditable()) } + /** + * Get the doc content at the given path. + */ getDocContents(path: string): string | null { const file = this.snapshot.getFile(path) if (file == null) { @@ -59,6 +83,52 @@ export class ProjectSnapshot { return file.getContent({ filterTrackedDeletes: true }) ?? null } + /** + * Initialize the snapshot using the project's latest chunk. + * + * This is run on the first refresh. + */ + private async initialize() { + this.state.startRefresh() + await flushHistory(this.projectId) + const chunk = await fetchLatestChunk(this.projectId) + this.snapshot = chunk.getSnapshot() + this.snapshot.applyAll(chunk.getChanges()) + this.version = chunk.getEndVersion() + await this.loadDocs() + this.state.endRefresh() + } + + /** + * Apply changes since the last refresh. + * + * This is run on the second and subsequent refreshes + */ + private async loadChanges() { + this.state.startRefresh() + await flushHistory(this.projectId) + const changes = await fetchLatestChanges(this.projectId, this.version) + this.snapshot.applyAll(changes) + this.version += changes.length + await this.loadDocs() + this.state.endRefresh() + } + + /** + * Wait for the current refresh to complete, then start a refresh. + */ + private async queueRefresh() { + this.state.queueRefresh() + await this.refreshPromise + await this.loadChanges() + } + + /** + * Load all editable docs in the snapshot. + * + * This is done by converting any lazy file data into an "eager" file data. If + * a doc is already loaded, the load is a no-op. + */ private async loadDocs() { const paths = this.getDocPaths() const limit = pLimit(DOWNLOAD_BLOBS_CONCURRENCY) @@ -73,6 +143,106 @@ export class ProjectSnapshot { } } +/** + * State machine for the project snapshot + * + * There are 5 states: + * + * - init: when the snapshot is built + * - refreshing: while the snapshot is refreshing + * - queued-waiting: while the snapshot is refreshing and another refresh is queued + * - queued-ready: when a refresh is queued, but no refresh is running + * - ready: when no refresh is running and no refresh is queued + * + * There are three transitions: + * + * - start: start a refresh operation + * - end: end a refresh operation + * - queue: queue a refresh operation + * + * Valid transitions are as follows: + * + * +------------+ + * | ready | + * +------------+ + * ^ | + * | | + * end start + * | | + * | v + * +------+ +------------+ +----------------+ + * | init |----start---->| refreshing |---queue---> | queued-waiting | + * +------+ +------------+ +----------------+ + * ^ | + * | | + * start end + * | | + * | +--------------+ | + * +-----| queued-ready |<-------+ + * +--------------+ + * + * These transitions ensure that there are never two refreshes running + * concurrently. In every path, "start" and "end" transitions always alternate. + * You never have two consecutive "start" or two consecutive "end". + */ +class ProjectSnapshotState { + private state: + | 'init' + | 'refreshing' + | 'ready' + | 'queued-waiting' + | 'queued-ready' = 'init' + + getState() { + return this.state + } + + startRefresh() { + switch (this.state) { + case 'init': + case 'ready': + case 'queued-ready': + this.state = 'refreshing' + break + + default: + throw new OError("Can't start a snapshot refresh in this state", { + state: this.state, + }) + } + } + + endRefresh() { + switch (this.state) { + case 'refreshing': + this.state = 'ready' + break + + case 'queued-waiting': + this.state = 'queued-ready' + break + + default: + throw new OError("Can't end a snapshot refresh in this state", { + state: this.state, + }) + } + } + + queueRefresh() { + switch (this.state) { + case 'refreshing': + this.state = 'queued-waiting' + break + + default: + throw new OError("Can't queue a snapshot refresh in this state", { + state: this.state, + }) + } + } +} + /** * Blob store that fetches blobs from the history service */ From feacd614087acf71411b25ff8a15a6a630bd4286 Mon Sep 17 00:00:00 2001 From: Jessica Lawshe <5312836+lawshe@users.noreply.github.com> Date: Thu, 16 Jan 2025 09:29:35 -0600 Subject: [PATCH 0055/1724] Merge pull request #22684 from overleaf/jel-gallery-test-end [web] Tear down gallery redesign test GitOrigin-RevId: 907585c7d0e27c0c511e9d5f95096d82225f9aba --- services/web/frontend/extracted-translations.json | 1 - services/web/locales/en.json | 8 -------- 2 files changed, 9 deletions(-) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 0f7a458ed5..4e40711a4c 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1565,7 +1565,6 @@ "tc_switch_user_tip": "", "tell_the_project_owner_and_ask_them_to_upgrade": "", "template": "", - "template_approved_by_publisher": "", "template_description": "", "template_title_taken_from_project_title": "", "templates": "", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 7b55d30854..e617afe901 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -746,12 +746,8 @@ "gallery": "Gallery", "gallery_back_to_all": "Back to all __itemPlural__", "gallery_find_more": "Find More __itemPlural__", - "gallery_items_tagged": "__itemPlural__ tagged __title__", - "gallery_page_items": "Gallery Items", "gallery_page_items_lowercase": "gallery items", - "gallery_page_summary": "A gallery of up-to-date and stylish LaTeX templates, examples to help you learn LaTeX, and papers and presentations published by our community. Search or browse below.", "gallery_page_title": "Gallery - Templates, Examples and Articles written in LaTeX", - "gallery_show_all": "Show all __itemPlural__", "gallery_show_more_tags": "Show more", "generate_token": "Generate token", "generic_if_problem_continues_contact_us": "If the problem continues please contact us", @@ -1081,10 +1077,8 @@ "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__).", - "latex_articles_page_summary": "Papers, presentations, reports and more, written in LaTeX and published by our community. Search or browse below.", "latex_articles_page_title": "Articles - Papers, Presentations, Reports and more", "latex_examples": "LaTeX examples", - "latex_examples_page_summary": "Examples of powerful LaTeX packages and techniques in use — a great way to learn LaTeX by example. Search or browse below.", "latex_examples_page_title": "Examples - Equations, Formatting, TikZ, Packages and More", "latex_in_thirty_minutes": "LaTeX in 30 minutes", "latex_places_figures_according_to_a_special_algorithm": "LaTeX places figures according to a special algorithm. You can use something called ‘placement parameters’ to influence the positioning of the figure. <0>Find out how", @@ -1851,7 +1845,6 @@ "select_user": "Select user", "selected": "Selected", "selected_by_overleaf_staff": "Selected by Overleaf staff", - "selected_by_overleaf_staff_description": "These templates were hand-picked by Overleaf staff for their high quality and positive feedback received from the Overleaf community over the years.", "selection_deleted": "Selection deleted", "send": "Send", "send_first_message": "Send your first message to your collaborators", @@ -2072,7 +2065,6 @@ "templates": "Templates", "templates_admin_source_project": "Admin: Source Project", "templates_lowercase": "templates", - "templates_page_summary": "Start your projects with quality LaTeX templates for journals, CVs, resumes, papers, presentations, assignments, letters, project reports, and more. Search or browse below.", "templates_page_title": "Templates - Journals, CVs, Presentations, Reports and More", "temporarily_hides_the_preview": "Temporarily hides the preview", "ten_collaborators_per_project": "10 collaborators per project", From ae34c4b8cc8ae04de4ce93ebf967905a16d3fc8a Mon Sep 17 00:00:00 2001 From: CloudBuild Date: Fri, 17 Jan 2025 02:03:51 +0000 Subject: [PATCH 0056/1724] auto update translation GitOrigin-RevId: fc3c6ea47100bc446d4a6290c2ba530083d9b717 --- services/web/locales/cs.json | 2 - services/web/locales/da.json | 118 ------------------------------- services/web/locales/de.json | 107 ---------------------------- services/web/locales/es.json | 1 - services/web/locales/fi.json | 2 - services/web/locales/fr.json | 38 ---------- services/web/locales/it.json | 2 - services/web/locales/ja.json | 2 - services/web/locales/ko.json | 3 - services/web/locales/nl.json | 5 -- services/web/locales/pl.json | 2 - services/web/locales/ru.json | 3 - services/web/locales/sv.json | 24 ------- services/web/locales/tr.json | 2 - services/web/locales/zh-CN.json | 119 -------------------------------- 15 files changed, 430 deletions(-) diff --git a/services/web/locales/cs.json b/services/web/locales/cs.json index 6913fd57ae..0edc77c7eb 100644 --- a/services/web/locales/cs.json +++ b/services/web/locales/cs.json @@ -125,7 +125,6 @@ "import_to_sharelatex": "Importovat do __appName__u", "importing": "Importuji", "importing_and_merging_changes_in_github": "Importuji a merguji změny v GitHubu", - "indvidual_plans": "Individuální tarify", "info": "Informace", "institution": "Instituce", "it": "Italština", @@ -181,7 +180,6 @@ "no_selection_select_file": "Nevybrali jste žádný soubor.", "off": "Vypnuto", "ok": "OK", - "one_collaborator": "Jen jeden spolupracovník", "one_free_collab": "Jeden spolupracovník zdarma", "online_latex_editor": "Online LaTeX editor", "optional": "Dobrovolný", diff --git a/services/web/locales/da.json b/services/web/locales/da.json index 33daf32fab..b07753357f 100644 --- a/services/web/locales/da.json +++ b/services/web/locales/da.json @@ -115,7 +115,6 @@ "alignment": "Justering", "all": "Alle", "all_borders": "Alle kanter", - "all_our_group_plans_offer_educational_discount": "Alle vores <0>gruppeabonnementer tilbyder <1>studierabat for studerende samt fakultet", "all_premium_features": "Alle Premium-funktioner", "all_premium_features_including": "Alle Premium-funktioner, inklusiv:", "all_prices_displayed_are_in_currency": "Alle priser er vist i __recommendedCurrency__.", @@ -129,7 +128,6 @@ "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", - "also_available_as_on_premises": "Også tilgængelig som on-premises", "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.", "an_error_occured_while_restoring_project": "En fejl opstod under gendannelsen af projektet", @@ -141,8 +139,6 @@ "anyone_with_link_can_view": "Alle med dette link kan se dette projekt", "app_on_x": "__appName__ på __social__", "apply_educational_discount": "Anvend studierabat", - "apply_educational_discount_info": "Overleaf tilbyder 40% studierabat for grupper på 10 eller flere. Gælder for studerende eller fakultet som bruger Overleaf til undervisning.", - "apply_educational_discount_info_new": "40% rabat for grupper på 10 eller flere som bruger __appName__ til undervisning", "apply_suggestion": "Anvend forslag", "april": "April", "archive": "Arkivér", @@ -174,7 +170,6 @@ "autocompile_disabled_reason": "Grundet høj serverbelastning er baggrunds kompilering midlertidig slået fra. Genkompiler venligst ved at klikke på ovenstående knap.", "autocomplete": "Auto udfyld", "autocomplete_references": "Automatisk reference-udfyldelse (indeni en \\cite{} blok)", - "automatic_user_registration": "Automatisk brugerregistrering", "automatic_user_registration_uppercase": "Automatisk brugeroprettelse", "back": "Tilbage", "back_to_account_settings": "Tilbage til kontoindstillinger", @@ -187,7 +182,6 @@ "basic": "Basis", "basic_compile_timeout_on_fast_servers": "Basis kompileringstidsgrænse på hurtige servere", "become_an_advisor": "Bliv en __appName__ rådgiver", - "best_choices_companies_universities_non_profits": "Det bedste valg for virksomheder, universiteter og almennyttige organisationer", "beta": "Beta", "beta_feature_badge": "Betafunktions-skilt", "beta_program_already_participating": "Du er tilmeldt betaprogrammet", @@ -319,11 +313,9 @@ "compile_larger_projects": "Kompilér større projekter", "compile_mode": "Kompilering metode", "compile_servers": "Kompileringsservere", - "compile_servers_info": "Kompileringer for brugere på vores betalte abonnementer udføres på en dedikeret gruppe af de hurtigst tilgængelige servere", "compile_servers_info_new": "Serverene brugt til at kompilere dit projekt. Kompileringer for brugere på betalte abonnementer udføres altid på de hurtigst tilgængelige servere.", "compile_terminated_by_user": "Kompileringen blev annulleret med knappen ‘Stop kompilering’. Du kan se loggen for at se hvor kompileringen stoppede.", "compile_timeout_short": "Kompileringstidsgrænse", - "compile_timeout_short_info_basic": "Dette er hvor meget tid du har til at kompilere dit projekt på Overleafs servere. Længere eller mere komplekse projekter kan have brug for ekstra tid.", "compile_timeout_short_info_new": "Dette er hvor meget tid du har til at kompilere dit projekt på Overleaf. Længere eller mere komplekse projekter kan have brug for ekstra tid.", "compiler": "Kompilér", "compiling": "Kompilerer", @@ -401,11 +393,8 @@ "currently_subscribed_to_plan": "Du abonnerer pt. på <0>__planName__ abonnementet.", "custom": "Brugerdefineret", "custom_borders": "Brugerdefinerede kanter", - "custom_resource_portal": "Brugerdefineret ressource portal", - "custom_resource_portal_info": "Du kan få din egen brugerdefinerede ressource portal på Overleaf. Dette er et fantastisk sted for dine brugere at finde ud af mere om Overleaf, tilgå projekt-skabeloner, ofte stillede spørgsmål, hjælperessourcer samt oprette en konto hos Overleaf.", "customize": "Tilpas", "customize_your_group_subscription": "Tilpas dit gruppeabonnement", - "customize_your_plan": "Tilpas dit abonnement", "customizing_figures": "Tilpasning af figurer", "customizing_tables": "Tilpasning af tabeller", "da": "Dansk", @@ -415,7 +404,6 @@ "dealing_with_errors": "Fejlhåndtering", "december": "December", "dedicated_account_manager": "Dedikeret account-manager", - "dedicated_account_manager_info": "Vores Account-Management hold vil være tilgængelige til at hjælpe med forespørgseler, spørgsmål og til at hjælpe dig med at sprede ordet om Overleaf med reklamemateriale, træningsmateriale samt webinars.", "default": "Standard", "delete": "Slet", "delete_account": "Slet konto", @@ -504,7 +492,6 @@ "dropbox_duplicate_project_names_suggestion": "Hvis du sørger for, at alle dine projektnavne, for både <0>aktive, arkiverede og kasserede projekter, er unikke, kan du genoprette sammenkædningen med din Dropbox-konto.", "dropbox_email_not_verified": "Vi har ikke kunnet hente opdateringer fra din Dropbox-konto. Dropbox rapporterer, at din e-mailadresse ikke er bekræftet. For at løse dette, må du bekræfte din e-mailadresse overfor Dropbox.", "dropbox_for_link_share_projs": "Du har adgang til dette projekt via link-deling, og det kan derfor ikke synkroniseres til din Dropbox medmindre du bliver inviteret via e-mail af projektets ejer.", - "dropbox_integration_info": "Arbejd online og offline problemfrit med to-vejs Dropbox synkronisering. Ændringer du foretager lokalt vil automatisk blive sendt til Overleaf-versionen og vice versa.", "dropbox_integration_lowercase": "Dropbox-integration", "dropbox_successfully_linked_description": "Tak, vi har linket din Dropboxkonto til __appName__.", "dropbox_sync": "Dropbox synkronisering", @@ -544,11 +531,6 @@ "editor_limit_exceeded_in_this_project": "For mange redaktører i dette projekt", "editor_only_hide_pdf": "Kun skrivevindue <0>(gem PDF)", "editor_theme": "Tema for skrivevinduet", - "educational_discount_applied": "40% studierabat anvendt!", - "educational_discount_available_for_groups_of_ten_or_more": "Studierabatten er tilgængelig for grupper af 10 eller flere", - "educational_discount_disclaimer": "Denne license er for studiemæssig benyttelse (gælder for studerende eller fakultet som bruger Overleaf til undervisning)", - "educational_discount_for_groups_of_ten_or_more": "Overleaf tilbyder 40% studierabat for grupper af 10 eller flere.", - "educational_discount_for_groups_of_x_or_more": "Studierabatten er tilgængelig for grupper af __size__ eller flere", "educational_percent_discount_applied": "__percent__% studierabat anvendt!", "email": "E-mail", "email_address": "E-mailadresse", @@ -618,26 +600,7 @@ "failed_to_send_group_invite_to_email": "En fejl opstod under udsendelse af gruppeinvitation til <0>__email__. Prøv venligst igen senere.", "failed_to_send_managed_user_invite_to_email": "En fejl opstod under udsendelse af styret bruger invitation til <0>__email__. Prøv venligst igen senere.", "failed_to_send_sso_link_invite_to_email": "En fejl opstod under udsendelse af påmindelse af SSO invitation til <0>__email__. Prøv venligst igen senere.", - "faq_change_plans_or_cancel_answer": "Ja det kan du altid gøre i dine abonnementsindstillinger. Du kan ændre abonnement, skifte mellem månedlig og årlige betaling, eller afmelde for at nedgradere til det gratis abonnement. Når du afmelder vil dit abonnement fortsætte indtil slutningen af betalingsperioden. Hvis din konto midligertidigt intet abonnement har, er den eneste ændring de funktioner der er tilgængelige for dig. Dine projekter vil altid være tilgængelige på din konto.", - "faq_change_plans_or_cancel_question": "Kan jeg ændre abonnement eller afmelde senere?", - "faq_do_collab_need_on_paid_plan_answer": "Nej, de kan være på hvilket som helst abonnement, inklusiv det gratis abonnement. Hvis du er på et Premium-abonnement, vil nogle Premium-funktioner være tilgængelige for dine samarbejdspartnere i de projekter du har oprettet, selvom de er på det gratis abonnement. For mere information kan du læse om <0>konti og abonnementer og <1>hvordan Premium-funktioner virker.", - "faq_do_collab_need_on_paid_plan_question": "Skal mine samarbejdspartnere også være på et betalt abonnement?", - "faq_how_does_a_group_plan_work_answer": "Gruppeabonnementer er en måde at opgradere mere end én Overleaf konto. De er nemme at administrere, hjælper med at nedbringe papirarbejdet, og reducerer omkostningen ved at forbundet med at købe flere individuelle abonnementer. For at lære kan du læse om at <0>blive tilknyttet et gruppeabonnement og <1>adminstrering af gruppeabonnement. Du kan købe gruppeabonnementer ovenfor, eller ved at <2>kontakte os.", - "faq_how_does_a_group_plan_work_question": "Hvordan fungerer et gruppeabonnement? Hvordan tilføjer jeg medlemmer til abonnementet?", "faq_how_does_free_trial_works_answer": "Du får fuld adgang til det valgte __appName__ Premium abonnement i din __len__-dages prøveperiode. Der er ingen tvang til at fortsætte efter prøveperioden. Dit betalingskort bliver opkrævet ved slutningen af prøveperioden medmindre du afmelder før dette. Du kan afmelde via dine abonnementsindstillinger.", - "faq_how_free_trial_works_answer_v2": "Du får fuld adgang til dit valgte Premium abonnement i din __len__-dages prøveperiode, og der er ingen tvang til at fortsætte efter prøveperioden. Dit betalingskort bliver opkrævet ved slutningen af prøveperioden medmindre du afmelder før dette. For at atmelde skal du gå til dine abonnementsindstillinger i din konto (prøveperioden fortsætter i den fulde __len__-dages periode).", - "faq_how_free_trial_works_question": "Hvordan fungerer den gratis prøveperiode?", - "faq_i_have_free_account_want_subscription_how_answer_first_paragraph": "I Overleaf opretter og administrerer hver bruger deres egen Overleaf konto. De fleste brugere starter med en gratis konto, men kan opgradere og nyde Premium-funktioner ved at abonnere, tilknytte sig et gruppeabonnement eller ved at tilknytte sig et <0>Commons abonnement. Når du køber, tilknyttes eller forlader et abonnement, kan du stadig bruge den samme Overleaf konto.", - "faq_i_have_free_account_want_subscription_how_answer_second_paragraph": "For at finde ud af mere kan du læse om <0>hvordan konti og abonnementer arbejder sammen i Overleaf.", - "faq_i_have_free_account_want_subscription_how_question": "Jeg har en gratis konto og jeg vil gerne tilknyttes et abonnement. Hvordan gør jeg det?", - "faq_pay_by_invoice_answer_v2": "Ja hvis du vil købe et gruppeabonnement med fem eller flere brugere, eller en organisationsdækkende licens. For individuelle abonnement kan vi kun modtage betalinger online via betalingskort eller PayPal.", - "faq_pay_by_invoice_question": "Kan jeg betale via faktura?", - "faq_the_individual_standard_plan_10_collab_first_paragraph": "Nej. Kun abonnentens konto bliver opgraderet. Et individuel Standard abonnement tillader dig at invitere 10 samarbejdspartnere til hvert projekt som er ejet af dig.", - "faq_the_individual_standard_plan_10_collab_question": "Det individuelle Standard abonnement har 10 projektsamarbejdspartnere. Betyder det at 10 mennesker bliver opgraderet?", - "faq_the_individual_standard_plan_10_collab_second_paragraph": "Mens de arbejder på et projekt som du, en abonnement, deler med dem vil dine samarbejdspartnere få adgang til nogle Premium-funktioner såsom fuld ændringshistorik, samt forhøjet kompileringstidsgrænse for det bestemte projekt. At invitere dem til et bestemt projekt opgraderer dog ikke deres konto som helhed. Læs mere om <0>hvilke funktioner er per-projekt og hvilke der er per-konto.", - "faq_what_is_the_difference_between_users_and_collaborators_answer_first_paragraph": "I Overleaf opretter hver bruger deres egen konto. Du kan oprette projekter som kun du kan arbejde på, og du kan også invitere andre til at se eller samarbejde på projekter du ejer. Brugere som du deler dit projekt med kaldes <0>samarbejdspartnere. Nogle gange refererer vi til dem som projektsamarbejdspartnere.", - "faq_what_is_the_difference_between_users_and_collaborators_answer_second_paragraph": "Med andre ord, samarbejdspartnere er blot andre Overleaf brugere som du arbejder sammen med på et af dine projekter.", - "faq_what_is_the_difference_between_users_and_collaborators_question": "Hvad er forskellen mellem brugere og samarbejdspartnere?", "fast": "Hurtig", "fastest": "Hurtigste", "feature_included": "Funktion inkluderet", @@ -693,13 +656,11 @@ "for_business": "For virksomheder", "for_enterprise": "For virksomheder", "for_government": "For det offentlige", - "for_groups_or_site_wide": "For grupper eller organisationsdækkende", "for_individuals_and_groups": "For individer & grupper", "for_large_institutions_and_organizations_need_sitewide_on_premise": "For store institutioner og organisationer som har brug for en organisationsdækkende eller on-premises løsning.", "for_publishers": "For forlag", "for_small_teams_and_departments_who_want_to_write_collaborate": "For små hold og afdelinger som vil skrive og samarbejde nemt i LaTeX.", "for_students": "For studerende", - "for_students_only": "Kun for studerende", "for_teaching": "For undervisning", "for_teams_and_organizations_who_want_a_streamlined_sso_and_security": "For hold og organisationer som vil have en strømlinet loginprocess og vores stærkeste cloud sikkerhed.", "for_universities": "For universiteter", @@ -707,7 +668,6 @@ "forgot_your_password": "Glemt dit kodeord", "format": "Format", "found_matching_deleted_users": "Fandt __deletedUserCount__ matchende slettede brugere", - "four_minutes": "4 minutter", "fr": "Fransk", "free": "Gratis", "free_dropbox_and_history": "Gratis Dropbox og historik", @@ -723,7 +683,6 @@ "from_provider": "Fra __provider__", "from_url": "Fra URL", "full_doc_history": "Fuld ændringshistorik", - "full_doc_history_info_v2": "Du kan se alle ændinger i dit projekt og hvem der lavede dem. Tilføj et mærkat for hurtigt at kunne tilgå bestemte versioner.", "full_document_history": "Fuld <0>ændringshistorik", "full_project_search": "Fuld projektsøgning", "full_width": "Fuld bredde", @@ -770,8 +729,6 @@ "git_gitHub_dropbox_mendeley_and_zotero_integrations": "Git-, GitHub-, Dropbox-, Mendeley-, og Zotero-integrationer", "git_integration": "Git-integration", "git_integration_info": "Med Git-integration kan du klone dine Overleaf projekter med Git. For komplette instruktioner til hvordan du gør det, læs vores <0>hjælpeside.", - "git_integration_lowercase": "Git-integration", - "git_integration_lowercase_info": "Du kan klone dit Overleaf projekt til et lokalt repository, og behandle Overleaf som et remote repository, som du kan pushe og pulle fra.", "github": "GitHub", "github_commit_message_placeholder": "Commit besked for ændringer i __appName__...", "github_credentials_expired": "Dine GitHub autentificeringsoplysninger er udløbet", @@ -786,8 +743,6 @@ "github_large_files_error": "Merge mislykkedes: Dit GitHub reopsitory indeholder filer, som er større end grænsen på 50MB ", "github_merge_failed": "Dine ændringer i __appName__ og GitHub kunne ikke automatisk merges. Du må merge‘e branch‘en <0>__sharelatex_branch__ ind i default branch‘en i git. Derefter kan du klikke herunder, for at fortsætte.", "github_no_master_branch_error": "Dette repository kan ikke forbindes, da det ikke har nogen default branch. Du må først sørge for, at projektet har en default branch", - "github_only_integration_lowercase": "GitHub-integration", - "github_only_integration_lowercase_info": "Forbind dine Overleaf projekter direkte til et GitHub repository som opfører sig et remote repository for dit Overleaf projekt. Dette tillader dig at samarbejde med partnere uden for Overleaf, og at integrere Overleaf ind i mere komplicerede workflows.", "github_private_description": "Du vælger hvem der kan se, og committe til, dette repository.", "github_public_description": "Alle kan se dette repository. Du kan vælge hvem der kan comitte.", "github_repository_diverged": "Default branch i det forbundne repository er blevet force-push’et. Det kan desynkronisere Overleaf og Github at pull’e ændringer efter et force push. Det vil muligvis være nødvendigt at push’e ændringer efter pullet for blive synkroniseret igen.", @@ -824,21 +779,14 @@ "great_for_small_teams_and_departments": "God for små hold og afdelinger", "group": "Gruppe", "group_admin": "Gruppeadministrator", - "group_admins_get_access_to": "Gruppeadministratorer får adgang til", - "group_admins_get_access_to_info": "Specielle funktioner tilgængelige for gruppeabonnementer", "group_full": "Denne gruppe er allerede fuld", "group_invitations": "Gruppeinvitationer", "group_invite_has_been_sent_to_email": "Gruppeinvitation sendt til <0>__email__", "group_libraries": "Gruppebiblioteker", "group_managed_by_group_administrator": "Brugerkonti i denne gruppe er administreret af gruppeadministratoren.", - "group_members_and_collaborators_get_access_to": "Gruppemedlemmer og deres samarbejdspartnere får adgang til", - "group_members_and_their_collaborators_get_access_to_info": "Disse funktioner er tilgængelige for gruppemedlemmer og deres samarbejdspartnere (andre Overleaf brugere inviteret til projekter ejet af et gruppemedlem).", - "group_members_get_access_to": "Gruppemedlemmer får adgang til", - "group_members_get_access_to_info": "Disse funktioner udelukkende tilgængelige for gruppemedlemmer.", "group_plan_admins_can_easily_add_and_remove_users_from_a_group": "Gruppeadministratorer kan nemt tilføje og slette medlemmer fra en gruppe. For organisationsdækkende abonnementer bliver brugere automatisk opgraderet når de registrerer sig eller tilføjer deres e-mailadresse til Overleaf (domænebaseret tilknytning eller SSO).", "group_plan_tooltip": "Du er på __plan__ abonnementet som medlem af et gruppeabonnement. Klik for at finde ud af hvordan du får det meste ud af dine Overleaf Premium-funktioner.", "group_plan_with_name_tooltip": "Du er på __plan__ abonnementet som medlem af et gruppeabonnement, __groupName__. Klik for at finde ud af hvordan du får det meste ud af dine Overleaf Premium-funktioner.", - "group_plans": "Gruppeabonnementer", "group_professional": "Gruppe Professionel", "group_sso_configuration_idp_metadata": "Informationen du udfylder her kommer fra din Identity Provider (IdP). Det bliver ofte benævnt som <0>SAML metadata. Du kan udfylde det manuelt eller klikke på <1>Importer IdP metadata for at importere en XML fil.", "group_sso_configure_service_provider_in_idp": "Hos nogle IdPs skal du konfigurere Overleaf som en Service Provider for at tilgå dataen til at udfylde denne forumlar. For at gøre dette, skal du download Overleafs metadata.", @@ -935,7 +883,6 @@ "imported_from_zotero_at_date": "Importeret fra Zotero d. __formattedDate__ __relativeDate__", "importing": "Importerer", "importing_and_merging_changes_in_github": "Importerer og sammenfletter ændringer i GitHub", - "in_good_company": "Du er i godt selskab", "in_order_to_have_a_secure_account_make_sure_your_password": "For at holde din konto sikker, sæt din nye kode:", "in_order_to_match_institutional_metadata_2": "For at matche dine institutionelle metadata har vi sammenkædet din konto via <0>__email__.", "in_order_to_match_institutional_metadata_associated": "For at matche dine institutionelle metadata er din konto blevet associeret med e-mailaddressen __email__.", @@ -946,7 +893,6 @@ "include_the_error_message_and_ai_response": "Inkluder fejlbeskeden og AI svaret", "increased_compile_timeout": "Forlænget kompileringstidsgrænse", "individuals": "Individer", - "indvidual_plans": "Individuelle abonnementer", "info": "Info", "insert": "Indsæt", "insert_column_left": "Indsæt kolonne til venstre", @@ -1068,7 +1014,6 @@ "learn_more_about_emails": "<0>Lær mere om at håndtere dine __appName__ e-mailadresser.", "learn_more_about_link_sharing": "Lær mere om linkdeling", "learn_more_about_managed_users": "Lær mere om styrede brugere", - "learn_more_lowercase": "lær mere", "leave": "Forlad", "leave_any_group_subscriptions": "Forlad alle gruppeabonnementer pånær den som skal styre din konto. <0>Forlad dem på din abonnementsside.", "leave_group": "Forlad gruppe", @@ -1081,10 +1026,8 @@ "let_us_know": "Fortæl os om det", "let_us_know_how_we_can_help": "Fortæl os hvordan vi kan hjælpe", "let_us_know_what_you_think": "Fortæl os hvad du synes", - "lets_fix_your_errors": "Lad os rette dine fejl", "library": "Bibliotek", "license": "Licens", - "license_for_educational_purposes": "Denne licens er til uddannelsesformål (gælder for studerende og fakultet som bruger __appName__ til undervisning)", "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", @@ -1186,8 +1129,6 @@ "managed_user_accounts": "Styrede brugerkonti", "managed_user_invite_has_been_sent_to_email": "Styret bruger invitation er blevet sendt til <0>__email__", "managed_users": "Styrede brugere", - "managed_users_accounts": "Styrede brugerkonti", - "managed_users_accounts_plan_info": "Styrede brugere giver jer mere kontrol over jeres gruppes brug af Overleaf. Det sikrer en mere præcis styring af brugeradgang og sletning, og sikrer at I beholder adgang til projekter når en bruger forlader gruppen.", "managed_users_explanation": "Styrede brugere sikrer at I forbliver i kontrol over jeres organisations projekter, og hvem der ejer dem. <0>Læs mere om styrede brugere.", "managed_users_gives_gives_you_more_control_over_your_group": "Styrede brugere giver jer mere kontrol over jeres gruppes brug af __appName__. Det sikrer en mere præcis styring af brugeradgang og sletning, og sikrer at I beholder adgang til projekter når en bruger forlader gruppen.", "managed_users_is_enabled": "Styrede brugere er aktiveret", @@ -1200,8 +1141,6 @@ "marked_as_resolved": "Marker som løst", "math_display": "Vist matematik", "math_inline": "Inkluderet matematik", - "max_collab_per_project": "Maks samarbejdspartnere per projekt", - "max_collab_per_project_info": "Det maksimale antal folk du kan invitere til at samarbejde på hvert projekt. De har blot brug for at have en Overleaf konto. Det kan være forskellige folk i hvert projekt.", "maximum_files_uploaded_together": "Maksimalt __max__ filer uploaded sammen", "may": "Maj", "maybe_later": "Måske senere", @@ -1212,8 +1151,6 @@ "mendeley_groups_loading_error": "Der opstod en fejl i at loade grupper fra Mendeley", "mendeley_groups_relink": "Der opstod en fejl under tilgangen af dit Mendeley data. Dette skete sandsynligvist grundet manglende tilladelser. Gen-forbind venligst din konto og prøv igen.", "mendeley_integration": "Mendeley-integration", - "mendeley_integration_lowercase": "Mendeley-integration", - "mendeley_integration_lowercase_info": "Håndtér dit henvisningsbibliotek i Mendeley og forbind det direkte til .bib filer i Overleaf, så du nemt kan henvise til alt i dine biblioteker.", "mendeley_is_premium": "Integration af Mendeley er en Premium-funktion", "mendeley_reference_loading_error": "Fejl, kunne ikke indlæse referencer fra Mendeley", "mendeley_reference_loading_error_expired": "Mendeley nøgle udløbet, genforbind venligst din konto", @@ -1236,7 +1173,6 @@ "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": "Mest populære", "most_popular_uppercase": "Mest populære", "must_be_email_address": "Skal være en e-mailaddresse", "my_library": "Mit bibliotek", @@ -1257,8 +1193,6 @@ "need_anything_contact_us_at": "Hvis der skulle være noget du har brug for, så kontakt os endeligt direkte på", "need_contact_group_admin_to_make_changes": "Du bliver nødt til at kontakte din gruppeadministrator hvis du vil lave bestemte ændringer til din konto. <0>Læs mere om styrede brugere.", "need_make_changes": "Du bliver nødt til at lave nogle ændringer", - "need_more_than_50_users": "Brug for mere end 50 brugere?", - "need_more_than_to_licenses_get_in_touch": "Brug for mere end 50 licenser? Kontakt os", "need_more_than_x_licenses": "Brug for mere end __x__ licenser?", "need_to_add_new_primary_before_remove": "Du bliver nødt til at tilføje en ny primær e-mailaddresse før du kan slette denne.", "need_to_leave": "Nød til at gå?", @@ -1336,8 +1270,6 @@ "number_collab_info": "Antallet af folk du kan invitere til at samarbejde på et projekt med dig. Grænsen er per projekt, så du kan invitere forskellige folk til hvert enkelt projekt.", "number_of_projects": "Antal projekter", "number_of_users": "Antal brugere", - "number_of_users_info": "Det antal af brugere der kan opgradere deres Overleaf konto hvis du køber dette abonnement.", - "number_of_users_with_colon": "Antal brugere:", "oauth_orcid_description": " Hævd din identitet sikkert, ved at kæde din ORCID iD og din __appName__-konto sammen. Indsendelser til samarbejdende udgivere vil automatisk inkludere dit ORCID iD, hvilket giver en forbedret arbejdsgang og bedre synlighed. ", "october": "Oktober", "off": "Fra", @@ -1347,12 +1279,10 @@ "ok_join_project": "OK, deltag i projekt", "on": "Til", "on_free_plan_upgrade_to_access_features": "Du er på det gratis __appName__ abonnement. Opgrader for at tilgå disse <0>Premium-funktioner", - "one_collaborator": "Kun én samarbejdspartner", "one_collaborator_per_project": "1 samarbejdspartner per projekt", "one_free_collab": "Kun én gratis samarbejdspartner", "one_per_project": "1 per projekt", "one_step_away_from_professional_features": "Du er ét skridt fra at tilgå <0>Overleafs Professionelle funktioner!", - "one_user": "1 bruger", "ongoing_experiments": "Igangværende eksperimenter", "online_latex_editor": "Online LaTeX-skriveprogram", "only_group_admin_or_managers_can_delete_your_account_1": "Ved at blive en styret bruger vil din organisation have administratorrettigheder over din konto samt kontrol over dine ting, inklsuiv rettigheder til at lukke din konto og adgang, samt at dele dine ting. Som resultat:", @@ -1447,12 +1377,10 @@ "per_user_per_year": "per bruger / per år", "per_user_year": "per bruger / år", "per_year": "per år", - "percent_discount_for_groups": "__appName__ tilbyder en __percent__% studierabet for grupper af __size__ medlemmer eller flere", "percent_is_the_percentage_of_the_line_width": "% er procenter af linjebredden", "personal": "Personlig", "personal_library": "Personligt bibliotek", "personalized_onboarding": "Personaliseret onboarding", - "personalized_onboarding_info": "Vi hjælper jer med at få alt sat op, og derefter er vi her for at svare på spørgsmål fra jeres brugere omkring platformen, skabeloner eller LaTeX!", "pl": "Polsk", "plan": "Abonnement", "plan_tooltip": "Du er på __plan__ abonnementet. Klik for at finde ud af hvordan du får mest muligt ud af dine Overleaf Premium-funktioner.", @@ -1494,8 +1422,6 @@ "portal_add_affiliation_to_join": "Du ser ud til allerede at være logget ind i __appName__! Hvis du har en e-mailaddresse fra __portalTitle__ kan du tilføje den nu.", "position": "Stilling", "postal_code": "Postnummer", - "powerful_latex_editor_and_realtime_collaboration": "Højtydende LaTeX-skriveprogram & live samarbejde.", - "powerful_latex_editor_and_realtime_collaboration_info": "Stavekontrol, intelligent autoudførelse, syntaksfremhævning, dusinvis af farvetemaer, vim- og emacs-tastebindinger, hjælp til LaTeX-advarsler og -fejlmeddelelser, med mere. Alle har altid den nyeste version, og du kan se dine samarbejdspartneres markører og ændringer live.", "premium_feature": "Premium-funktion", "premium_features": "Premium-funktioner", "premium_plan_label": "Du bruger Overleaf Premium", @@ -1513,7 +1439,6 @@ "primary_certificate": "Primært certifikat", "primary_email_check_question": "Er <0>__email__ stadig din e-mailaddresse?", "priority_support": "Prioritetssupport", - "priority_support_info": "Vores hjælpsomme Support-hold vil prioritere og eskalere dine support anmodninger når dette er nødvendigt.", "privacy": "Privathed", "privacy_and_terms": "Privatliv and vilkår", "privacy_policy": "Fortrolighedspolitik", @@ -1538,7 +1463,6 @@ "project_layout_sharing_submission": "Projektlayout, deling og indsendelse", "project_name": "Projektnavn", "project_not_linked_to_github": "Dette projekt er ikke linket til et GitHub repository. Du kan skabe et repository for det på GitHub:", - "project_owner_plus_10": "Projektejer + 10", "project_ownership_transfer_confirmation_1": "Er du sikker på, at du vil gøre <0>__user__ til ejer af <1>__project__?", "project_ownership_transfer_confirmation_2": "Denne handling kan ikke fortrydes. Den nye ejer får besked, og vil kunne ændre projektets adgangsindstillinger (inklusive at fratage din egen adgang).", "project_renamed_or_deleted": "Projekt omnavngivet eller slettet", @@ -1565,8 +1489,6 @@ "publisher_account": "Forlagskonto", "publishing": "Publicering", "pull_github_changes_into_sharelatex": "Pull GitHub ændringer ind i __appName__", - "purchase_now": "Køb nu", - "purchase_now_lowercase": "Køb nu", "push_sharelatex_changes_to_github": "Push __appName__ ændringer til GitHub", "quoted_text": "Tekst i gåseøjne", "quoted_text_in": "Tekst i gåseøjne i", @@ -1587,7 +1509,6 @@ "ready_to_use_templates": "Klar-til-brug skabeloner", "real_time_track_changes": "Realtids <0>ændringshistorik", "realtime_track_changes": "Realtids ændringshistorik", - "realtime_track_changes_info_v2": "Slå “Følg ændringer” til for at se hvem der har lavet enhver ændring, accepter eller afvise andres ændringer og skrive kommentarer.", "reasons_for_compile_timeouts": "Årsager til kompileringstimeout", "reauthorize_github_account": "Autoriser din GitHub konto igen", "recaptcha_conditions": "Denne side er beskyttet af reCAPTCHA og Googles <1>Privatlivspolitik og <2>Brugsvilkår gælder.", @@ -1612,7 +1533,6 @@ "reference_managers": "Henvisningsmanager", "reference_search": "Avanceret henvisningssøgning", "reference_search_info_new": "Find nemt dine referencer—søg efter forfatter, titel, år, eller journal.", - "reference_search_info_v2": "Det er nemt at finde dine henvisninger. Du kan søge efter forfatter, titel, udgivelsesår eller journal. Du kan også stadig søge efter citeringsnøglen.", "reference_search_setting": "Henvisningssøgning", "reference_search_settings": "Indstillinger for henvisningssøgning", "reference_search_style": "Søgningsmetode", @@ -1734,17 +1654,10 @@ "saml_response": "SAML svar", "save": "Gem", "save_20_percent": "spar 20%", - "save_20_percent_by_paying_annually": "Spar 20% ved at betale årligt", - "save_30_percent_or_more": "Spar 30% eller mere", - "save_30_percent_or_more_uppercase": "Spar 30% eller mere", - "save_n_percent": "Spar __percentage__%", "save_or_cancel-cancel": "Annuller", "save_or_cancel-or": "eller", "save_or_cancel-save": "Gem", - "save_x_percent_or_more": "Spar __percent__% eller mere", "saving": "Gemmer", - "saving_20_percent": "Sparer 20%!", - "saving_20_percent_no_exclamation": "Sparer 20%", "saving_notification_with_seconds": "Gemmer __docname__... (Ændringerne har ikke været gemt i __seconds__ sekunder)", "search": "Søg", "search_all_project_files": "Søg alle projektfiler", @@ -1851,7 +1764,6 @@ "show_more": "vis mere", "show_outline": "Vis disposition", "show_x_more_projects": "Vis __x__ flere projekter", - "show_your_support": "Vis din støtte", "showing_1_result": "Viser 1 resultat", "showing_1_result_of_total": "Viser 1 resultat ud af __total__", "showing_x_out_of_n_projects": "Viser __x__ af __n__ projekter.", @@ -1864,8 +1776,6 @@ "single_sign_on_sso": "Single Sign-On (SSO)", "site_description": "Et online LaTeX-skriveprogram, der er let at bruge. Ingen installation, live samarbejde, versionskontrol, flere hundrede LaTeX-skabeloner, og meget mere.", "site_wide_option_available": "Organisationsdækkende licens tilgængelig", - "sitewide_option_available": "Organisationsdækkende licens tilgængelig", - "sitewide_option_available_info": "Brugere bliver automatisk opgraderet når de opretter sig eller tilføjer deres e-mailaddresse til Overleaf (domæne-baseret tilmelding eller SSO)", "six_collaborators_per_project": "6 samarbejdspartnere per projekt", "six_per_project": "6 per projekt", "skip": "Spring over", @@ -1917,7 +1827,6 @@ "sso_explanation": "Opsæt single sign-on for jeres gruppe. Denne login-form vil være valgfri for gruppemedlemmer medmindre Styrede Brugere er aktiveret. <0>Lær mere om Overleaf gruppe-SSO.", "sso_here_is_the_data_we_received": "Her er dataen vi modtog i SAML svaret:", "sso_integration": "SSO-integration", - "sso_integration_info": "Overleaf tilbyder en standard SAML-baseret Single Sign On integration.", "sso_is_disabled": "SSO er deaktiveret", "sso_is_disabled_explanation_1": "Gruppemedlemmer vil ikke kunne logge ind via SSO", "sso_is_disabled_explanation_2": "Alle gruppemedlemmer vil have brug for et brugernavn og kode for at logge ind i __appName__", @@ -1960,9 +1869,7 @@ "store_your_work": "Gem jeres arbejde på jeres egen infrastruktur", "stretch_width_to_text": "Stræk bredden efter teksten", "student": "Studerende", - "student_and_faculty_support_make_difference": "Støtte fra studerende og fakultet gør en forskel! Vi kan dele denne information med vores kontakter på jeres universitet når vi diskuterer om en Overleaf institutionel konto.", "student_disclaimer": "Studierabatten er gælder for alle studerende ved gymnasier og videregående uddannelsesinstitutioner. Vi kontakter dig muligvis for at bekræfte at du kvalificerer dig til denne rabat. ", - "student_plans": "Studieabonnementer", "students": "Studerende", "subject": "Emne", "subject_to_additional_vat": "Priser kan skulle pålægges yderligere afgifter, afhængigt af hvor du er.", @@ -1970,7 +1877,6 @@ "submit_title": "Indsend", "subscribe": "Tilmeld", "subscription": "Abonnement", - "subscription_admin_panel": "Administrationspanel", "subscription_admins_cannot_be_deleted": "Du kan ikke slette din konto med et abonnement. Du må annullere dit abonnement, før du kan fortsætte. Hvis du bliver ved med at se denne besked, så kontakt os.", "subscription_canceled": "Abonnement annulleret", "subscription_canceled_and_terminate_on_x": " Dit abonnement er blevet annulleret, og vil blive opsagt på <0>__terminateDate__. Ingen yderligere betalinger vil blive opkrævet.", @@ -1992,7 +1898,6 @@ "switch_to_pdf": "Skift til PDF", "symbol_palette": "Symbolpalet", "symbol_palette_highlighted": "<0>Symbolpalet", - "symbol_palette_info": "En hurtig og bekvemt måde at indsætte matematiske symboler ind i dit dokument.", "sync": "Synkroniser", "sync_dropbox_github": "Synkroniser med Dropbox og GitHub", "sync_project_to_github_explanation": "Ændringer som du har lavet i __appName__ vil blive committed og flettet sammen med opdateringer i GitHub", @@ -2068,8 +1973,6 @@ "this_tool_helps_you_insert_simple_tables_into_your_project_without_writing_latex_code_give_feedback": "Dette værktøj hjælper dig med at indsætte simple tabeller i dit projekt uden at skrive LaTeX kode. Værktøjet er nyt, så <0>giv gerne feedback og hold øje med yderligere funktionalitet som kommer snart.", "this_was_helpful": "Det var hjælpsomt", "this_wasnt_helpful": "Det var ikke hjælpsomt", - "thousands_templates": "Flere tusinde skabeloner", - "thousands_templates_info": "Producér smuke dokumenter startende fra vores galleri af LaTeX skabeloner for journaler, konferencer, afhandlinger, rapporter, CV’er og meget mere.", "three_free_collab": "Tre gratis samarbejdspartnere", "timedout": "Timed out", "tip": "Tip", @@ -2128,7 +2031,6 @@ "total": "Total", "total_per_month": "Total per måned", "total_per_year": "Total per år", - "total_per_year_for_x_users": "total per år for __licenseSize__ brugere", "total_with_subtotal_and_tax": "Total: <0>__total__ (__subtotal__ + __tax__ moms) per år", "total_words": "Totalt antal ord", "tr": "Tyrkisk", @@ -2169,7 +2071,6 @@ "turn_on": "Slå til", "turn_on_link_sharing": "Slå linkdeling til", "tutorials": "Vejledninger", - "two_users": "2 brugere", "uk": "Ukrainsk", "unable_to_extract_the_supplied_zip_file": "Dette indhold kunne ikke åbnes i Overleaf, fordi zip-filen ikke kunne åbnes. Vær sikker på, at din zip-fil er gyldig. Hvis du bliver ved med at opleve det her med links fra en bestemt side, bør du rapportere dette til dem.", "unarchive": "Gendan", @@ -2185,13 +2086,9 @@ "university_school": "Universitets- eller skolenavn", "unknown": "Ukendt", "unlimited": "Ubegrænset", - "unlimited_bold": "<0>Ubegrænset", - "unlimited_collaborators_in_each_project": "Ubegrænset antal samarbejdspartnere i hvert projekt", "unlimited_collaborators_per_project": "Ubegrænset antal samarbejdspartnere per projekt", "unlimited_collabs": "Ubegrænset antal samarbejdspartnere", - "unlimited_collabs_rt": "<0>Ubegrænset antal samarbejdspartnere", "unlimited_projects": "Ubegrænset antal projekter", - "unlimited_projects_info": "Dine projekter er private som udgangspunkt. Det betyder at kun du kan se dem, og kun du kan tillade andre at tilgå dem.", "unlink": "Fjern link", "unlink_all_users": "Afkobl alle brugere", "unlink_dropbox_folder": "Afkobl Dropbox konto", @@ -2215,7 +2112,6 @@ "unsubscribed": "Afmeldt", "unsubscribing": "Afmelder", "untrash": "Gendan", - "up_to": "Op til", "update": "Opdater", "update_account_info": "Opdater kontoinformation", "update_billing_details": "Opdatere betalingsdetaljer", @@ -2238,8 +2134,6 @@ "upload_project": "Overfør projekt", "upload_zipped_project": "Upload komprimeret projekt", "url_to_fetch_the_file_from": "URL som filen skal hentes fra", - "usage_metrics": "Brugsstatistik", - "usage_metrics_info": "Statistikker som viser hvor mange brugere der benytter licensen, hvor mange projekter der bliver lavet og arbejdet på og hvor meget samarbejde der foregår på Overleaf.", "use_a_different_password": "Benyt et andet kodeord", "use_saml_metadata_to_configure_sso_with_idp": "Brug Overleaf SAML metadata til at konfigurere SSO hos jeres identity provider.", "use_your_own_machine": "Brug din egen maskine, med din egen opsætning", @@ -2256,7 +2150,6 @@ "user_is_not_part_of_group": "Bruger er ikke en del af gruppen", "user_last_name_attribute": "Attribut for brugerens efternavn", "user_management": "Brugeradminstration", - "user_management_info": "Gruppeadministratorer har adgang til et administrationspanel hvor brugere nemt kan tilføjes og fjernes. For organisationsdækkende abonnementer bliver brugere automatisk opgraderet når de registerer sig eller tilføjer deres e-mailaddresse til Overleaf (domæne-baseret tilmelding eller SSO).", "user_not_found": "Bruger ikke fundet", "user_sessions": "Brugersessioner", "user_wants_you_to_see_project": "__username__ ønsker at du deltager i __projectname__", @@ -2330,7 +2223,6 @@ "work_offline": "Arbejd offline", "work_or_university_sso": "Arbejds/universitets single sign-on", "work_with_non_overleaf_users": "Arbejd sammen med ikke-Overleaf-brugere", - "would_you_like_to_see_a_university_subscription": "Vil du ønske der var en universitetsdækkende __appName__ abonnement på dit universitet?", "write_and_collaborate_faster_with_features_like": "Skriv og samarbejd hurtigere med funktioner som:", "writefull": "Writefull", "writefull_learn_more": "Lær mere om Writefull for Overleaf", @@ -2339,7 +2231,6 @@ "writefull_settings_description": "Få gratis AI-baseret sprogfeedback skræddersyet for videnskabelig skrivning med Writefull for Overleaf.", "x_changes_in": "__count__ ændring i", "x_changes_in_plural": "__count__ ændringer i", - "x_collaborators_per_project": "__collaboratorsCount__ samarbejdspartnere per projekt", "x_libraries_accessed_in_this_project": "__provider__ biblioteker tilgået i dette projekt", "x_price_for_first_month": "<0>__price__ for din første måned", "x_price_for_first_year": "<0>__price__ for dit første år", @@ -2352,8 +2243,6 @@ "yes_that_is_correct": "Ja, det er korrekt", "you": "Dig", "you_already_have_a_subscription": "Du har allerede et abonnement", - "you_and_collaborators_get_access_to": "Dig og dine samarbejdspartnere får adgang til", - "you_and_collaborators_get_access_to_info": "Disse funktioner er tilgængelige for dig og dine samarbejdspartnere (andre Overleaf brugere som du har inviteret til dine projekter).", "you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "Du er en <1>manager og en <1>bruger af <0>__planName__ gruppeabonnementet <1>__groupName__ administreret af <1>__adminEmail__", "you_are_a_manager_of_commons_at_institution_x": "Du er en <0>manager af et Overleaf Commons abonnement hos <0>__institutionName__", "you_are_a_manager_of_publisher_x": "Du er en <0>manager hos <0>__publisherName__", @@ -2377,16 +2266,11 @@ "you_cant_join_this_group_subscription": "Du kan ikke tilslutte dig dette gruppeabonnement", "you_cant_reset_password_due_to_sso": "Du kan ikke nulstille dit kodeord, da din gruppe eller organisation bruger SSO. <0>Log ind med SSO.", "you_dont_have_any_repositories": "Du har ingen arkiver", - "you_get_access_to": "Du får adgang til", - "you_get_access_to_info": "Disse funktioner er kun tilgængelige for dig (abonnenten).", "you_have_added_x_of_group_size_y": "Du har tilføjet <0>__addedUsersSize__ af <1>__groupSize__ tilgængelige medlemmer", "you_have_been_invited_to_transfer_management_of_your_account": "Du er blevet inviteret til at overdrage styring af din konto.", "you_have_been_invited_to_transfer_management_of_your_account_to": "Du er blevet inviteret til at overdrage styringen af din konto til __groupName__.", "you_have_been_removed_from_this_project_and_will_be_redirected_to_project_dashboard": "Du er blevet fjernet fra dette projekt og vil ikke længere have adgang til det. Du vil blive viderestillet til din projektoversigt om et øjeblik.", "you_need_to_configure_your_sso_settings": "Du skal indstille og teste din SSO konfiguration før du kan aktivere SSO", - "you_plus_1": "Dig + 1", - "you_plus_10": "Dig + 10", - "you_plus_6": "Dig + 6", "you_will_be_able_to_contact_us_any_time_to_share_your_feedback": "<0>Du vil kunne kontakte os når som helst, for at give din feedback", "you_will_be_able_to_reassign_subscription": "Du vil have muligheden for at flytte deres medlemskab til en anden person i jeres organisation", "youll_get_best_results_in_visual_but_can_be_used_in_source": "Du får de bedste resultater ved at bruge dette værktøj i den <0>visuelle editor, men du kan stadig bruge det til at indsætte tabeller i <1>kodeeditoren. Når du valgt det antal rækker og kolonner du har brug for, vil tabellen dukke op i dit dokument, og du kan dobbeltklikke på en celle for at tilføje indhold.", @@ -2446,8 +2330,6 @@ "zotero_groups_loading_error": "Der opstod en fejl under indlæsning af grupper fra Zotero", "zotero_groups_relink": "Der opstod en fejl under tilgangen af dit Zotero data. Dette skete sandsynligvist grundet manglende tilladelser. Gen-forbind venligst din konto og prøv igen", "zotero_integration": "Zotero-Integration", - "zotero_integration_lowercase": "Zotero-integration", - "zotero_integration_lowercase_info": "Håndtér dit henvisningsbibliotek i Zotero og forbind det direkte til .bib filer i Overleaf, så du nemt kan henvise til alt i dine biblioteker.", "zotero_is_premium": "Integration af Zotero er en Premium-funktion", "zotero_reference_loading_error": "Fejl, kunne ikke indlæse referencer fra Zotero", "zotero_reference_loading_error_expired": "Zotero nøgle udløbet, genforbind venligst din konto", diff --git a/services/web/locales/de.json b/services/web/locales/de.json index dc85869de6..1605473ebb 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -79,7 +79,6 @@ "aggregate_changed": "Geändert", "aggregate_to": "zu", "all": "Alle", - "all_our_group_plans_offer_educational_discount": "Alle unsere <0>Gruppen-Abonnements bieten einen <1>Bildungsrabatt für Studenten und Lehrkräfte", "all_premium_features": "Alle Premiumfunktionen", "all_premium_features_including": "Alle Premiumfunktionen, darunter:", "all_prices_displayed_are_in_currency": "Alle Preise sind in __recommendedCurrency__ angezeigt.", @@ -87,7 +86,6 @@ "all_templates": "Alle Vorlagen", "already_have_sl_account": "Hast du bereits ein __appName__-Konto?", "also": "Ebenfalls", - "also_available_as_on_premises": "Auch On-Premises verfügbar", "alternatively_create_new_institution_account": "Alternativ kannst du ein neues Konto mit deiner institutionellen E-Mail-Adresse (__email__) erstellen, indem du auf „__clickText__“ klickst.", "an_error_occurred_when_verifying_the_coupon_code": "Beim Überprüfen des Gutscheincodes ist ein Fehler aufgetreten", "and": "und", @@ -97,7 +95,6 @@ "anyone_with_link_can_view": "Jeder mit diesem Link kann dieses Projekt anzeigen", "app_on_x": "__appName__ bei __social__", "apply_educational_discount": "Bildungsrabatt anwenden", - "apply_educational_discount_info": "Overleaf bietet 40 % Bildungsrabatt für Gruppen ab 10 Personen. Dies gilt für Studenten oder Lehrkräfte, die Overleaf im Unterricht verwenden.", "april": "April", "archive": "Archiv", "archive_projects": "Projekte archivieren", @@ -123,7 +120,6 @@ "autocompile_disabled_reason": "Aufgrund der hohen Serverlast wurde das Neukompilieren im Hintergrund vorübergehend deaktiviert. Bitte neu kompilieren, indem du auf die Schaltfläche oben klickst.", "autocomplete": "Autovervollständigung", "autocomplete_references": "Referenzautovervollständigung (in einem \\cite{}-Block)", - "automatic_user_registration": "Automatische Nutzerregistrierung", "back": "Zurück", "back_to_account_settings": "Zurück zu den Kontoeinstellungen", "back_to_editor": "Zurück zum Editor", @@ -131,7 +127,6 @@ "back_to_subscription": "Zurück zum Abonnement", "back_to_your_projects": "Zurück zu deinen Projekten", "become_an_advisor": "Werde ein __appName__-Berater", - "best_choices_companies_universities_non_profits": "Die beste Wahl für Unternehmen, Universitäten und gemeinnützige Organisationen", "beta": "Beta", "beta_feature_badge": "Betafunktionsmerkmal", "beta_program_already_participating": "Du bist dem Beta-Programm beigetreten", @@ -288,11 +283,8 @@ "current_session": "Aktuelle Sitzung", "currently_seeing_only_24_hrs_history": "Du siehst derzeit die Änderungen der letzten 24 Stunden in diesem Projekt.", "currently_subscribed_to_plan": "Du hast im Moment das <0>__planName__ Produkt abonniert.", - "custom_resource_portal": "Benutzerdefiniertes Ressourcenportal", - "custom_resource_portal_info": "Du kannst deine eigene benutzerdefinierte Portalseite auf Overleaf haben. Dies ist ein großartiger Ort für die Nutzer, um mehr über Overleaf zu erfahren, auf Vorlagen, FAQs und Hilferessourcen zuzugreifen und sich bei Overleaf anzumelden.", "customize": "Anpassen", "customize_your_group_subscription": "Dein Gruppenabonnement anpassen", - "customize_your_plan": "Abonnement anpassen", "customizing_figures": "Abbildung anpassen", "da": "Dänisch", "date": "Datum", @@ -301,7 +293,6 @@ "dealing_with_errors": "Umgang mit Fehlern", "december": "Dezember", "dedicated_account_manager": "Dedizierter Kontomanager", - "dedicated_account_manager_info": "Unser Account-Management-Team wird dir bei Wünschen und Fragen behilflich sein und dir dabei helfen, Overleaf mittels Werbematerialien, Schulungsressourcen und Webinaren bekannt zu machen.", "default": "Standard", "delete": "Löschen", "delete_account": "Konto löschen", @@ -357,7 +348,6 @@ "dropbox_duplicate_project_names_suggestion": "Bitte verwende eindeutige Projektnamen für alle deine <0>aktiven, archivierten und gelöschten Projekte und verknüpfe dann dein Dropbox-Konto erneut.", "dropbox_email_not_verified": "Wir konnten keine Updates von deinem Dropbox-Konto abrufen. Dropbox hat gemeldet, dass deine E-Mail-Adresse unbestätigt ist. Bitte bestätige die E-Mail-Adresse in deinem Dropbox-Konto, um dieses Problem zu lösen.", "dropbox_for_link_share_projs": "Auf dieses Projekt wurde über Linkfreigabe zugegriffen und es wird nicht mit deiner Dropbox synchronisiert, es sei denn, du wirst vom Projektinhaber per E-Mail eingeladen.", - "dropbox_integration_info": "Arbeite nahtlos online und offline mit der bidirektionalen Dropbox-Synchronisierung. Änderungen, die du lokal vornimmst, werden automatisch an die Version auf Overleaf gesendet und umgekehrt.", "dropbox_integration_lowercase": "Dropbox-Integration", "dropbox_successfully_linked_description": "Vielen Dank, wir haben dein Dropbox-Konto erfolgreich mit __appName__ verknüpft.", "dropbox_sync": "Dropbox-Synchronisation", @@ -389,11 +379,6 @@ "editor_disconected_click_to_reconnect": "Editor wurde getrennt", "editor_only_hide_pdf": "Nur Editor <0>(PDF ausblenden)", "editor_theme": "Editor-Thema", - "educational_discount_applied": "40% Bildungsrabatt angewendet!", - "educational_discount_available_for_groups_of_ten_or_more": "Der Bildungsrabatt ist verfügbar für Gruppen ab 10 Personen", - "educational_discount_disclaimer": "Dieses Abonnement ist nur für Bildungseinrichtungen (gilt für Studenten oder Lehrkräfte, die Overleaf im Unterricht verwenden)", - "educational_discount_for_groups_of_ten_or_more": "Overleaf bietet 40% Bildungsrabatt für Gruppen ab 10 Personen.", - "educational_discount_for_groups_of_x_or_more": "Der Bildungsrabatt ist für Gruppen mit __size__ oder mehr Nutzern verfügbar", "educational_percent_discount_applied": "__percent__% Bildungsrabatt angewandt!", "email": "E-Mail", "email_already_associated_with": "Die E-Mail-Adresse __email1__ ist bereits mit dem Konto __email2__ __appName__ verknüpft.", @@ -433,26 +418,7 @@ "export_csv": "CSV-Datei exportieren", "export_project_to_github": "Projekt nach GitHub exportieren", "failed_to_send_managed_user_invite_to_email": "Der Versand der Einladung für Verwaltete Benutzer an <0>__email__ hat nicht funktioniert. Bitte versuche es später noch einmal.", - "faq_change_plans_or_cancel_answer": "Ja, du kannst dies jederzeit über deine Abonnementeinstellungen tun. Du kannst Abonnements ändern, zwischen monatlichen und jährlichen Abrechnungsoptionen wechseln oder kündigen, um ein Downgrade auf die kostenlose Version durchzuführen. Wenn du kündigst, läuft dein Abonnement bis zum Ende des Abrechnungszeitraums. Wenn dein Konto vorübergehend kein Abonnement hat, ändern sich nur die dir zur Verfügung stehenden Funktionen. Deine Projekte sind immer in deinem Konto verfügbar.", - "faq_change_plans_or_cancel_question": "Kann ich Abonnements ändern oder später stornieren?", - "faq_do_collab_need_on_paid_plan_answer": "Nein, sie können in jedem Abonnement enthalten sein, einschließlich der kostenlosen Version. Wenn du einen Premium-Abonnement hast, stehen deinen Mitarbeitern in Projekten, die du erstellt hast, einige Premiumfunktionen zur Verfügung, auch wenn diese Mitarbeiter ein kostenloses Abonnement haben. Weitere Informationen findest du unter <0>Konto und Abonnements und <1>Funktionsweise der Premiumfunktionen.", - "faq_do_collab_need_on_paid_plan_question": "Müssen meine Mitarbeiter auch ein bezahltes Abonnement haben?", - "faq_how_does_a_group_plan_work_answer": "Gruppenabonnements sind eine Möglichkeit, mehr als ein Overleaf-Konto zu aktualisieren. Sie sind einfach zu verwalten, helfen Papierkram zu sparen, und reduzieren die Kosten für den separaten Kauf mehrerer Abonnements. Um mehr zu erfahren, lies über <0>Beitritt zu einem Gruppenabonnement und <1>Verwalten eines Gruppenabonnements. Du kannst Gruppenabonnements oben erwerben oder indem du <2>uns kontaktierst.", - "faq_how_does_a_group_plan_work_question": "Wie funktioniert ein Gruppen-Abonnement? Wie kann ich Personen zum Abonnement hinzufügen?", "faq_how_does_free_trial_works_answer": "Während deines __len__-tägigen Probe-Abonnements erhältst du vollen Zugriff auf die Funktionen des von dir gewählten __appName__-Abonnements. Es besteht keine Verpflichtung, über die Testperiode hinaus fortzufahren. Deine Karte wird am Ende des __len__-tägigen Testzeitraums belastet, sofern du nicht vorher gekündigt hast. Um zu kündigen, gehe zu deinen Abonnementeinstellungen in deinem Konto.", - "faq_how_free_trial_works_answer_v2": "Du erhältst vollen Zugriff auf das von dir gewählte Premium-Abonnement während deines __len__-tägigen kostenlosen Testzeitraums, und es besteht keine Verpflichtung zur Nutzung über die Testzeit hinaus. Deine Karte wird am Ende deiner Testphase belastet, sofern du nicht vorher gekündigt hast. Um zu kündigen, gehe zu deinen Abonnementeinstellungen in deinem Konto (der Testzeitraum endet erst nach den vollen __len__ Tagen).", - "faq_how_free_trial_works_question": "Wie funktioniert das kostenlose Probe-Abonnement?", - "faq_i_have_free_account_want_subscription_how_answer_first_paragraph": "In Overleaf erstellt und verwaltet jeder Nutzer sein eigenes Overleaf-Konto. Die meisten Nutzer beginnen mit der kostenlosen Version, können aber ein Upgrade durchführen und die Premiumfunktionen nutzen, indem sie ein Abonnement abschließen, einem Gruppen-Abonnement oder einer <0>standortweiten Abonnement beitreten. Wenn du ein Abonnement kaufst, einem Abonnement beitrittst oder ein Abonnement verlässt, kannst du immer dasselbe Overleaf-Konto behalten.", - "faq_i_have_free_account_want_subscription_how_answer_second_paragraph": "Um mehr zu erfahren, lies <0>wie Konten und Abonnements in Overleaf zusammenarbeiten.", - "faq_i_have_free_account_want_subscription_how_question": "Ich habe ein kostenloses Konto und möchte einem Abonnement beitreten, wie mache ich das?", - "faq_pay_by_invoice_answer_v2": "Ja, wenn du ein Gruppenabonnement für fünf oder mehr Personen oder eine Standortlizenz erwerben möchtest. Für Einzelabonnements können wir nur Online-Zahlungen per Kredit- oder Debitkarte oder PayPal akzeptieren.", - "faq_pay_by_invoice_question": "Kann ich per Rechnung / Bestellung bezahlen?", - "faq_the_individual_standard_plan_10_collab_first_paragraph": "Nein. Nur das Konto des Abonnenten wird aktualisiert. Mit einem individuellen Standard-Abonnement kannst du 10 Mitarbeiter zu jedem Projekt einladen, das dir gehört.", - "faq_the_individual_standard_plan_10_collab_question": "Das individuelle Standard-Abonnement hat 10 Projektmitarbeiter. Bedeutet das, dass 10 Personen ein Upgrade erhalten?", - "faq_the_individual_standard_plan_10_collab_second_paragraph": "Während der Arbeit an einem Projekt, das du als Abonnent mit ihnen teilst, können deine Mitarbeiter auf einige Premiumfunktionen wie den vollständigen Dokumentverlauf und die verlängerte Kompilierzeit für dieses bestimmte Projekt zugreifen. Wenn du sie zu einem bestimmten Projekt einlädst, wird für ihre Konten jedoch nicht insgesamt ein Upgrade durchgeführt. Lies <0>welche Funktionen pro Projekt und welche pro Konto gelten.", - "faq_what_is_the_difference_between_users_and_collaborators_answer_first_paragraph": "In Overleaf erstellt jeder Nutzer sein eigenes Konto. Du kannst Projekte erstellen, an denen nur du arbeitest, und du kannst auch andere dazu einladen, Projekte anzusehen oder mit dir an Projekten zu arbeiten, die dir gehören. Nutzer, mit denen du dein Projekt teilst, werden <0>Mitarbeiter genannt. Wir bezeichnen sie auch als Projektmitarbeiter.", - "faq_what_is_the_difference_between_users_and_collaborators_answer_second_paragraph": "Mit anderen Worten, Mitarbeiter sind nur andere Overleaf-Nutzer, mit denen du an einem deiner Projekte arbeitest.", - "faq_what_is_the_difference_between_users_and_collaborators_question": "Was ist der Unterschied zwischen Nutzern und Mitarbeitern?", "fast": "Schnell", "feature_included": "Funktion enthalten", "feature_not_included": "Funktion nicht enthalten", @@ -492,16 +458,13 @@ "footer_contact_us": "Kontaktiere uns", "footer_plans_and_pricing": "Abos & Preise", "for_enterprise": "Für Unternehmen", - "for_groups_or_site_wide": "Für Gruppen oder standortweit", "for_individuals_and_groups": "Für Einzelpersonen & Gruppen", "for_more_information_see_managed_accounts_section": "Für weitere Informationen, schau in den Abschnitt \"Verwaltete Accounts\" in unseren <0>Nutzungsbedingungen, dem du zustimmst, wenn du auf Einladung akzeptieren clickst.", "for_publishers": "Für Verlage", "for_students": "Für Studierende", - "for_students_only": "Nur für Studierende", "for_teaching": "Für die Lehre", "for_universities": "Für Universitäten", "forgot_your_password": "Passwort vergessen", - "four_minutes": "4 Minuten", "fr": "Französisch", "free": "Kostenlos", "free_dropbox_and_history": "Kostenloser Dropbox und Dateiversionsverlauf", @@ -511,7 +474,6 @@ "from_external_url": "Von externer URL", "from_provider": "Von __provider__", "full_doc_history": "Vollständiger Versionsverlauf", - "full_doc_history_info_v2": "Du kannst alle Bearbeitungen in deinem Projekt sehen und, wer jede Änderung vorgenommen hat. Füge Labels hinzu, um schnell auf bestimmte Versionen zuzugreifen.", "full_document_history": "Gesamter Dokumenten-<0>Änderungsverlauf", "full_width": "Volle Breite", "gallery": "Gallerie", @@ -542,8 +504,6 @@ "git_bridge_modal_use_previous_token": "Wenn Du nach einem Passwort gefragt wirst, kannst Du einen zuvor generierten Git-Anmeldungs-Token verwenden. Oder Du kannst einen Neuen in den Kontoeinstellungen generieren. Für mehr Hilfe, besuche unsere <0>Hilfe-Seite.", "git_integration": "Git-Integration", "git_integration_info": "Mit der Git-Integration kannst Du Overleaf-Projekte Git-clonen. Für weitere Anweisungen hierfür, besuche <0>unsere Hilfe-Seite.", - "git_integration_lowercase": "Git-Integration", - "git_integration_lowercase_info": "Du kannst dein Overleaf-Projekt in ein lokales Repository klonen und dein Overleaf-Projekt als entferntes Repository behandeln, in das Änderungen verschoben und aus dem diese abgerufen werden können.", "github_commit_message_placeholder": "Commit-Meldung für Änderungen die in __appName__ gemacht wurden", "github_credentials_expired": "Deine GitHub-Autorisierungsschlüssel sind abgelaufen", "github_empty_repository_error": "Es sieht so aus, als sei dein GitHub-Repository leer oder noch nicht verfügbar. Erstelle eine neue Datei auf GitHub.com und versuche es erneut.", @@ -555,8 +515,6 @@ "github_large_files_error": "Zusammenführung fehlgeschlagen: Dein GitHub-Repository enthält Dateien mit einer Dateigröße von mehr als 50 MB", "github_merge_failed": "Deine Änderungen in __appName__ und GitHub konnten nicht automatisch zusammengeführt werden. Bitte führe den <0>__sharelatex_branch__ mit dem Standard-Branch in Git zusammen. Klicke unten um fortzufahren, nachdem du manuell zusammengeführt hast.", "github_no_master_branch_error": "Dieses Repository kann nicht importiert werden, da ihm ein Standard-Branch fehlt. Stell sicher, dass das Projekt einen Standard-Branch hat", - "github_only_integration_lowercase": "GitHub-Integration", - "github_only_integration_lowercase_info": "Verknüpfe deine Overleaf-Projekte direkt mit einem GitHub-Repository, das als Remote-Repository für dein Overleaf-Projekt fungiert. Dies ermöglicht dir die gemeinsame Nutzung mit Mitarbeitern außerhalb von Overleaf und die Integration von Overleaf in komplexere Arbeitsabläufe.", "github_private_description": "Du wählst, wer dieses Repository sehen und etwas übergeben kann.", "github_public_description": "Jeder kann dieses Repository sehen. Du entscheidest wer committen darf.", "github_repository_diverged": "Der Standard-Branch des verknüpften Repositorys wurde forciert gepusht. Das Pullen von GitHub-Änderungen nach einem forciertem Push kann dazu führen, dass Overleaf und GitHub nicht mehr synchron sind. Möglicherweise musst du Änderungen nach dem Pullen erneut Pushen um wieder synchron zu sein", @@ -583,16 +541,10 @@ "go_to_pdf_location_in_code": "Gehe zum Code an der PDF-Position", "go_to_settings": "Zu den Kontoeinstellungen", "group_admin": "Gruppenadministrator", - "group_admins_get_access_to": "Gruppenadministratoren erhalten darauf Zugriff", - "group_admins_get_access_to_info": "Spezielle Funktionen, die nur bei Gruppen-Abonnements verfügbar sind.", "group_full": "Diese Gruppe ist bereits voll", "group_managed_by_group_administrator": "Die Benutzerkonten in diesem Team werden vom Gruppenadministrator verwaltet.", - "group_members_and_collaborators_get_access_to": "Gruppenmitglieder und ihre Projektmitarbeiter erhalten darauf Zugriff", - "group_members_get_access_to": "Gruppenmitglieder erhalten darauf Zugriff", - "group_members_get_access_to_info": "Diese Funktionen stehen nur Gruppenmitgliedern (Abonnenten) zur Verfügung.", "group_plan_tooltip": "Du nutzt ein __plan__-Abonnement als Mitglied eines Gruppen-Abonnements. Klicke hier um herauszufinden, was Dir die Overleaf-Premiumfunktionen ermöglichen.", "group_plan_with_name_tooltip": "Du nutzt ein __plan__-Abonnement als Mitglied des Gruppen-Abonnements __groupName__. Klicke hier um herauszufinden, was Dir die Overleaf Premiumfunktionen ermöglichen.", - "group_plans": "Gruppen-Abonnements", "group_professional": "Gruppe Professionell", "group_standard": "Gruppe Standard", "group_subscription": "Gruppen-Abonnement", @@ -671,14 +623,12 @@ "imported_from_zotero_at_date": "Importiert von Zotero at __formattedDate__ __relativeDate__", "importing": "Importieren", "importing_and_merging_changes_in_github": "Änderungen werden in GitHub importiert und zusammengeführt.", - "in_good_company": "Du bist in guter Gesellschaft", "in_order_to_have_a_secure_account_make_sure_your_password": "Um dein Konto abzusichern, stelle sicher, dass dein Passwort", "in_order_to_match_institutional_metadata_2": "Um deine institutionellen Metadaten abzugleichen, haben wir dein Konto mit <0>__email__ verknüpft.", "in_order_to_match_institutional_metadata_associated": "Um deine institutionellen Metadaten abzugleichen, wird dein Konto mit der E-Mail-Adresse __email__ verknüpft.", "include_caption": "Beschriftung anzeigen", "include_label": "Label anzeigen", "increased_compile_timeout": "Zeitlimit beim Kompilieren erhöhen", - "indvidual_plans": "Einzelnutzer-Abonnements", "info": "Info", "insert_figure": "Abbildung einfügen", "insert_from_another_project": "Von einem anderen Projekt einfügen", @@ -774,7 +724,6 @@ "learn_more_about_emails": "<0>Weitere Informationen zur Verwaltung deiner __appName__-E-Mails.", "learn_more_about_link_sharing": "Erfahre mehr über die Linkfreigabe", "learn_more_about_managed_users": "Weitere Informationen zu Verwaltete Benutzer", - "learn_more_lowercase": "erfahre mehr", "leave": "Verlassen", "leave_group": "Gruppe verlassen", "leave_now": "Jetzt verlassen", @@ -782,7 +731,6 @@ "let_us_know": "Lass uns wissen", "let_us_know_what_you_think": "Teile uns deine Meinung mit", "license": "Lizenz", - "license_for_educational_purposes": "Dieses Abonnement ist für Bildungseinrichtungen (gilt für Studenten oder Lehrkräfte, die __appName__ im Unterricht verwenden)", "line_height": "Zeilenhöhe", "link": "Verknüpfen", "link_account": "Konto verknüpfen", @@ -853,8 +801,6 @@ "managed_user_accounts": "Verwaltete Benutzer Accounts", "managed_user_invite_has_been_sent_to_email": "Die Einladung zu den Verwalteten Benutzern wurde an <0>__email__ versandt.", "managed_users": "Verwaltete Benutzer", - "managed_users_accounts": "Verwaltete Benutzer Accounts", - "managed_users_accounts_plan_info": "Mit Verwaltete Benutzer hast du mehr Kontrolle über die Nutzung von Overleaf durch deine Gruppe. Es gewährleistet eine strengere Verwaltung des Benutzerzugriffs und der Löschung und ermöglicht Ihnen, die Kontrolle über Projekte zu behalten, wenn jemand die Gruppe verlässt.", "managed_users_explanation": "Verwaltete Benutzer stellen sicher, dass du die Kontrolle über die Projekte deines Unternehmens behältst und weißt, wem sie gehören. <0>Lies mehr über Verwaltete Benutzer.", "managed_users_gives_gives_you_more_control_over_your_group": "Mit Verwalteten Benutzern hast du mehr Kontrolle über die Verwendung von __appName__ durch deine Gruppe. Es gewährleistet eine strengere Verwaltung des Benutzerzugriffs und der Löschung und ermöglicht dir, die Kontrolle über deine Projekte zu behalten, wenn jemand die Gruppe verlässt.", "managed_users_is_enabled": "Verwaltete Benutzer sind aktiviert", @@ -866,8 +812,6 @@ "mark_as_resolved": "Als gelöst markieren", "math_display": "Formeln im abgesetzten Modus", "math_inline": "Formeln im Zeilenmodus", - "max_collab_per_project": "Maximale Mitarbeiter pro Projekt", - "max_collab_per_project_info": "Anzahl der Personen, die du zur Arbeit an jedem Projekt einladen kannst, sie müssen lediglich ein Overleaf-Konto haben. Es können in jedem Projekt unterschiedliche Personen sein.", "maximum_files_uploaded_together": "Maximal __max__ Dateien zusammen hochgeladen", "may": "Mai", "members_management": "Mitgliederverwaltung", @@ -875,8 +819,6 @@ "mendeley_groups_loading_error": "Beim Laden von Gruppen von Mendeley ist ein Fehler aufgetreten", "mendeley_groups_relink": "Beim Zugriff auf die Mendeley-Daten ist ein Fehler aufgetreten. Dies wurde wahrscheinlich durch fehlende Berechtigungen verursacht. Bitte verknüpfe dein Konto neu und versuche es erneut.", "mendeley_integration": "Mendeley-Integration", - "mendeley_integration_lowercase": "Mendeley-Integration", - "mendeley_integration_lowercase_info": "Verwalte deine Referenzbibliothek in Mendeley und verknüpfe sie direkt mit .bib-Dateien in Overleaf, sodass du ganz einfach alles aus deinen Bibliotheken zitieren kannst.", "mendeley_is_premium": "Mendeley-Integration ist eine Premiumfunktion", "mendeley_reference_loading_error": "Fehler, Referenzen konnten nicht von Mendeley geladen werden", "mendeley_reference_loading_error_expired": "Mendeley-Token abgelaufen, bitte verknüpfe dein Konto neu", @@ -890,7 +832,6 @@ "more": "Mehr", "more_info": "Mehr Infos", "more_than_one_kind_of_snippet_was_requested": "Der Link zum Öffnen dieses Inhalts auf Overleaf enthielt einige ungültige Parameter. Wenn dies bei Links auf einer bestimmten Website weiterhin auftritt, melde dies bitte dort.", - "most_popular": "am beliebtesten", "must_be_email_address": "Es muss eine E-Mail-Adresse sein!", "n_items": "__count__ Artikel", "n_items_plural": "__count__ Artikel", @@ -900,7 +841,6 @@ "navigation": "Navigation", "nearly_activated": "Du bist einen Schritt davon entfernt, dein __appName__-Konto zu aktivieren!", "need_anything_contact_us_at": "Wenn du irgendetwas benötigst, kannst du uns gern direkt kontaktieren über", - "need_more_than_to_licenses_get_in_touch": "Brauchst Du mehr Lizenzen? Bitte kontaktiere uns", "need_to_add_new_primary_before_remove": "Du musst eine neue primäre E-Mail-Adresse hinzufügen, bevor du diese entfernen kannst.", "need_to_leave": "Du musst gehen?", "need_to_upgrade_for_more_collabs": "Du musst dein Konto upgraden um mehr Mitarbeiter hinzuzufügen", @@ -958,17 +898,13 @@ "november": "November", "number_collab": "Anzahl der Mitarbeiter", "number_of_users": "Nutzeranzahl", - "number_of_users_info": "Die Anzahl der Nutzer, die ihr Overleaf-Konto upgraden können, wenn du dieses Abonnement abschließt.", - "number_of_users_with_colon": "Anzahl der Nutzer:", "oauth_orcid_description": "Deine Identität sicherstellen durch Verknüpfung deiner ORCID-iD mit deinem __appName__-Konto. Einreichungen bei teilnehmenden Verlagen enthalten automatisch deine ORCID-iD für verbesserten Workflow und bessere Sichtbarkeit.", "october": "Oktober", "off": "Aus", "official": "Offiziell", "ok": "OK", "on": "An", - "one_collaborator": "Nur ein Mitarbeiter", "one_free_collab": "Ein kostenloser Mitarbeiter", - "one_user": "1 Nutzer", "online_latex_editor": "Online-LaTeX-Editor", "only_group_admin_or_managers_can_delete_your_account_1": "Wenn du ein verwalteter Benutzer wirst, hat deine Organisation Administratorrechte für deinen Account und die Kontrolle über deine Daten, einschließlich des Rechts, deinen Account zu schließen und auf deine Daten zuzugreifen, sie zu löschen und zu teilen. Das Ergebnis ist:", "only_group_admin_or_managers_can_delete_your_account_4": "Sobald du ein verwalteter Benutzer geworden bist, kannst du nicht mehr zurückwechseln. <0>Erfahren Sie mehr über verwaltete Overleaf-Konten.", @@ -1021,7 +957,6 @@ "per_year": "pro Jahr", "personal": "Persönlich", "personalized_onboarding": "Personalisiertes Onboarding", - "personalized_onboarding_info": "Wir helfen Dir alles einzurichten und dann stehen wir deinen Mitarbeitern bei Fragen zur Plattform, Vorlagen oder LaTeX zur Verfügung!", "pl": "Polnisch", "plan": "Abonnement", "planned_maintenance": "Geplante Wartungsarbeiten", @@ -1049,8 +984,6 @@ "portal_add_affiliation_to_join": "Es sieht so aus, als wärst du bereits bei __appName__ angemeldet! Wenn du eine __portalTitle__-E-Mail-Adresse hast, kannst du diese jetzt hinzufügen.", "position": "Beruf", "postal_code": "PLZ", - "powerful_latex_editor_and_realtime_collaboration": "Leistungsstarker LaTeX-Editor und Zusammenarbeit in Echtzeit", - "powerful_latex_editor_and_realtime_collaboration_info": "Rechtschreibprüfung, intelligente Autovervollständigung, Syntaxhervorhebung, Dutzende von Farbthemen, Vim- und Emacs-Anbindung, Hilfe bei LaTeX-Warnungen und -Fehlermeldungen und mehr. Jeder hat immer die neueste Version, und du kannst die Textpositionen deiner Mitarbeiter und Änderungen in Echtzeit sehen.", "premium_feature": "Premiumfunktion", "premium_features": "Premiumfunktionen", "presentation": "Präsentation", @@ -1058,7 +991,6 @@ "price": "Preis", "primary_email_check_question": "Ist <0>__email__ immer noch deine E-Mail-Adresse?", "priority_support": "Vorrangiger Kundensupport", - "priority_support_info": "Unser hilfsbereites Support-Team priorisiert und eskaliert deine Support-Anfragen bei Bedarf.", "privacy": "Datenschutz", "privacy_and_terms": "Datenschutz und Nutzungsbedingungen", "privacy_policy": "Datenschutz", @@ -1076,7 +1008,6 @@ "project_layout_sharing_submission": "Projektlayout, Freigabe und Einreichung", "project_name": "Projektname", "project_not_linked_to_github": "Dieses Projekt ist nicht mit einem GitHub Repository verlinkt. Du kannst ein neues Repository in GitHub erstellen:", - "project_owner_plus_10": "Projektinhaber + 10", "project_ownership_transfer_confirmation_1": "Möchtest du <0>__user__ wirklich zum Eigentümer von <1>__project__ machen?", "project_ownership_transfer_confirmation_2": "Diese Aktion kann nicht rückgängig gemacht werden. Der neue Eigentümer wird benachrichtigt und kann die Zugriffseinstellungen für das Projekt ändern (einschließlich des Entfernens deines eigenen Zugriffs).", "project_synced_with_git_repo_at": "Das Projekt ist mit dem GitHub Repository verlinkt", @@ -1092,14 +1023,12 @@ "publish_as_template": "Als Vorlage veröffentlichen", "publishing": "Veröffentlichen", "pull_github_changes_into_sharelatex": "GitHub-Änderungen nach __appName__ ziehen", - "purchase_now": "Jetzt kaufen", "push_sharelatex_changes_to_github": "__appName__-Änderungen an GitHub senden", "quoted_text_in": "Zitierter Text in", "raw_logs": "Raw Logs", "raw_logs_description": "Raw Logs vom LaTeX-Compiler", "read_only": "Nur Lesen", "realtime_track_changes": "Änderungen in Echtzeit nachverfolgen", - "realtime_track_changes_info_v2": "Aktiviere die Nachverfolgung von Änderungen, um zu sehen, wer die Änderungen vorgenommen hat, nimm die Änderungen anderer Mitarbeiter an oder lehne sie ab und schreibe Kommentare.", "reauthorize_github_account": "Autorisiere dein GitHub-Konto erneut", "recaptcha_conditions": "Diese Website ist durch reCAPTCHA geschützt und es gelten die <1>Datenschutzerklärung und die <2>Nutzungsbedingungen von Google.", "recent": "Kürzlich", @@ -1120,7 +1049,6 @@ "reference_error_relink_hint": "Wenn dieser Fehler weiterhin auftritt, versuche dein Konto hier neu zu verlinken:", "reference_managers": "Referenzmanager", "reference_search": "Erweiterte Referenzsuche", - "reference_search_info_v2": "Es ist einfach, deine Referenzen zu finden - du kannst nach Autor, Titel, Jahr oder Zeitschrift suchen. Du kannst auch nach Zitationsschlüssel suchen.", "reference_sync": "Referenzmanager synchronisieren", "refresh": "Aktualisieren", "refresh_page_after_linking_dropbox": "Bitte aktualisiere diese Seite, nachdem du dein Konto mit Dropbox verknüpft hast.", @@ -1184,14 +1112,10 @@ "ru": "Russisch", "saml": "SAML", "saml_create_admin_instructions": "Wähle eine E-Mail-Adresse für den ersten __appName__-Admin-Konto. Dieses sollte bereits im SAML-System vorhanden sein. Du wirst dann aufgefordert, dich mit diesem Konto einzuloggen.", - "save_20_percent_by_paying_annually": "Spare 20 % bei jährlicher Zahlung", - "save_30_percent_or_more": "spare 30% oder mehr", - "save_30_percent_or_more_uppercase": "Spare 30% oder mehr", "save_or_cancel-cancel": "Abbrechen", "save_or_cancel-or": "oder", "save_or_cancel-save": "Speichern", "saving": "Speichern", - "saving_20_percent": "Du sparst 20 %!", "saving_notification_with_seconds": "__docname__ speichern... (__seconds__ Sekunden ungespeicherter Änderungen)", "search": "Suchen", "search_bib_files": "Nach Autor, Titel, Jahr suchen", @@ -1244,14 +1168,11 @@ "show_in_pdf": "Im PDF anzeigen", "show_less": "Weniger anzeigen", "show_outline": "Dateigliederung anzeigen", - "show_your_support": "Zeige deine Unterstützung", "showing_1_result": "1 Ergebnis wird angezeigt", "showing_1_result_of_total": "Zeige 1 Ergebnis von __total__", "showing_x_results": "Es werden __x__ Ergebnisse angezeigt", "showing_x_results_of_total": "Es werden __x__ Ergebnisse von __total__ angezeigt", "site_description": "Ein einfach bedienbarer Online-LaTeX-Editor. Keine Installation notwendig, Zusammenarbeit in Echtzeit, Versionskontrolle, Hunderte von LaTeX-Vorlagen und mehr", - "sitewide_option_available": "Standortweite Option verfügbar", - "sitewide_option_available_info": "Nutzern werden automatisch „Professionell“-Funktionen zugewiesen, wenn sie sich registrieren oder ihre E-Mail-Adresse zu Overleaf hinzufügen (domänenbasierte Registrierung oder SSO).", "skip_to_content": "Zum Inhalt springen", "something_went_wrong_canceling_your_subscription": "Beim Kündigen deines Abonnements ist etwas schief gelaufen. Bitte wende dich an den Support.", "something_went_wrong_loading_pdf_viewer": "Beim Laden des PDF-Betrachters ist ein Fehler aufgetreten. Dies kann durch Probleme wie <0>vorübergehende Netzwerkprobleme oder einen <0>veralteten Webbrowser verursacht werden. Bitte befolge die <1>Schritte zur Fehlerbehebung bei Zugriffs-, Lade- und Anzeigeproblemen. Wenn das Problem weiterhin besteht, <2>teile uns dies bitte mit.", @@ -1263,7 +1184,6 @@ "spell_check": "Rechtschreibprüfung", "sso_account_already_linked": "Das Konto ist bereits mit einem anderen __appName__-Nutzer verknüpft", "sso_integration": "SSO-Integration", - "sso_integration_info": "Overleaf bietet eine standardmäßige SAML-basierte Single-Sign-On-Integration.", "sso_link_error": "Fehler beim Verknüpfen des Kontos", "sso_not_linked": "Du hast dein Konto nicht mit __provider__ verknüpft. Bitte melde dich auf einem anderen Weg mit deinem Konto an und verknüpfe dein __provider__-Konto über deine Kontoeinstellungen.", "standard": "Standard", @@ -1279,16 +1199,13 @@ "stop_on_validation_error": "Überprüfe die Syntax vor dem Kompilieren", "store_your_work": "Speichere deine Arbeit auf deiner eigenen Infrastruktur", "student": "Student", - "student_and_faculty_support_make_difference": "Die Unterstützung von Studenten und Mitarbeitern kann den Unterschied machen! Gerne leiten wir deine Nachfrage an unsere Kontakte an deiner Universität weiter, wenn wir ein solches Abonnement mit deiner Universität besprechen.", "student_disclaimer": "Der Bildungsrabatt gilt für alle Studierenden an weiterführenden und höheren Bildungseinrichtungen (Schulen und Universitäten). Wir können dich kontaktieren, damit du den Anspruch auf den Rabatt bestätigst.", - "student_plans": "Studenten-Abonnements", "subject": "Betreff", "subject_to_additional_vat": "Die Preise können je nach Land der zusätzlichen Mehrwertsteuer unterliegen.", "submit": "Absenden", "submit_title": "Einreichen", "subscribe": "Abonnieren", "subscription": "Abonnement", - "subscription_admin_panel": "Verwaltungsoberfläche", "subscription_admins_cannot_be_deleted": "Du kannst dein Konto nicht löschen, während du ein Abonnement besitzt. Kündige dein Abonnement und versuche es erneut. Wenn diese Meldung weiterhin erscheint, kontaktiere uns bitte.", "subscription_canceled": "Abonnement gekündigt", "subscription_canceled_and_terminate_on_x": "Dein Abonnement wurde gekündigt und wird am <0>__terminateDate__ enden. Keine weiteren Zahlungen werden angenommen.", @@ -1300,7 +1217,6 @@ "sure_you_want_to_leave_group": "Bist du sicher, dass du diese Gruppe verlassen möchtest?", "sv": "Schwedisch", "symbol_palette": "Symbolpalette", - "symbol_palette_info": "Eine schnelle und bequeme Möglichkeit, mathematische Symbole in dein Dokument einzufügen.", "sync": "Sync", "sync_dropbox_github": "Mit Dropbox und GitHub synchronisieren", "sync_project_to_github_explanation": "Alle Änderungen die du in __appName__ vornimmst werden in GitHub festgelegt und mit allen Updates in GitHub zusammengeführt.", @@ -1357,8 +1273,6 @@ "this_project_is_public": "Dieses Projekt ist öffentlich und kann von jedem bearbeitet werden, der die URL dazu hat.", "this_project_is_public_read_only": "Dieses Projekt ist öffentlich und kann von jedem, der die URL kennt, angesehen, aber nicht bearbeitet werden.", "this_project_will_appear_in_your_dropbox_folder_at": "Diese Projekt wird in deiner Dropbox in folgendem Ordner erscheinen:", - "thousands_templates": "Tausende Vorlagen", - "thousands_templates_info": "Erstelle schöne Dokumente ausgehend von unserer Galerie mit LaTeX-Vorlagen für Zeitschriften, Konferenzen, Abschlussarbeiten, Berichte, Lebensläufe und vieles mehr.", "three_free_collab": "Drei kostenlose Mitarbeiter", "timedout": "Zeit abgelaufen", "title": "Titel", @@ -1381,7 +1295,6 @@ "total": "Insgesamt", "total_per_month": "Insgesamt pro Monat", "total_per_year": "Insgesamt pro Jahr", - "total_per_year_for_x_users": "insgesamt pro Jahr für __licenseSize__ Nutzer", "total_words": "Gesamtwortanzahl", "tr": "Türkisch", "track_any_change_in_real_time": "Verfolge jegliche Änderung, in Echtzeit", @@ -1406,7 +1319,6 @@ "turn_off_link_sharing": "Deaktiviere die Linkfreigabe", "turn_on_link_sharing": "Aktiviere die Linkfreigabe", "tutorials": "Tutorials", - "two_users": "2 Nutzer", "uk": "Ukrainisch", "unable_to_extract_the_supplied_zip_file": "Das Öffnen dieses Inhalts auf Overleaf ist fehlgeschlagen, da die ZIP-Datei nicht extrahiert werden konnte. Bitte stelle sicher, dass es sich um eine gültige ZIP-Datei handelt. Wenn dies bei Links auf einer bestimmten Website weiterhin auftritt, melde dies bitte dort.", "unarchive": "Wiederherstellen", @@ -1415,12 +1327,8 @@ "unfold_line": "Zeile ausklappen", "university": "Universität", "unlimited": "Unbegrenzt", - "unlimited_bold": "<0>Unbegrenzt", - "unlimited_collaborators_in_each_project": "Unbegrenzte Zahl von Mitarbeitern in jedem Projekt", "unlimited_collabs": "Unbeschränkt viele Mitarbeiter", - "unlimited_collabs_rt": "<0>Unbeschränkt viele Mitarbeiter", "unlimited_projects": "Unbegrenzte Projekte", - "unlimited_projects_info": "Deine Projekte sind standardmäßig privat. Das bedeutet, dass nur du sie sehen kannst und nur du anderen Personen den Zugriff darauf erlauben kannst.", "unlink": "Link löschen", "unlink_dropbox_folder": "Verknüpfung zum Dropbox-Konto aufheben", "unlink_dropbox_warning": "Alle Projekte, die du mit Dropbox synchronisiert hast, werden getrennt und nicht mehr mit Dropbox synchronisiert. Möchtest du die Verknüpfung deines Dropbox-Kontos wirklich aufheben?", @@ -1437,7 +1345,6 @@ "unsubscribed": "Abbestellt", "unsubscribing": "Abbestellen läuft", "untrash": "Wiederherstellen", - "up_to": "Bis zu", "update": "Aktualisieren", "update_account_info": "Kontoinformationen aktualisieren", "update_billing_details": "Zahlungsinformationen aktualisieren", @@ -1454,15 +1361,12 @@ "upload_project": "Projekt hochladen", "upload_zipped_project": "Projekt als ZIP hochladen", "url_to_fetch_the_file_from": "URL, von der die Datei abgerufen werden soll", - "usage_metrics": "Nutzungsmetriken", - "usage_metrics_info": "Metriken, die zeigen, wie viele Nutzer auf die Lizenz zugreifen, wie viele Projekte erstellt und bearbeitet werden und wie viel in Overleaf zusammengearbeitet wird.", "use_a_different_password": "Bitte verwende ein anderes Passwort", "use_your_own_machine": "Verwende deine eigene Maschine mit deinem eigenen Setup", "user_already_added": "Nutzer bereits hinzugefügt", "user_deletion_error": "Entschuldigung, beim Löschen deines Kontos ist etwas schief gelaufen. Bitte versuche es in einer Minute erneut.", "user_deletion_password_reset_tip": "Wenn du dich nicht mehr an dein Passwort erinnern kannst oder wenn du Single-Sign-On mit einem anderen Anbieter verwendest, um dich anzumelden (z.B. ORCID oder Google), <0>setze dein Passwort zurück und versuche es erneut.", "user_management": "Nutzerverwaltung", - "user_management_info": "Gruppen-Abonnement-Administratoren haben Zugriff auf ein Admin-Panel, wo die Nutzer einfach hinzugefügt oder entfernt werden können. Bei standortweiten Abonnements werden die Nutzer automatisch „Professionell“-Funktionen zugewiesen, wenn sie sich registrieren oder ihre E-Mail-Adresse zu Overleaf hinzufügen (domänenbasierte Registrierung oder SSO).", "user_not_found": "Nutzer wurde nicht gefunden", "user_wants_you_to_see_project": "__username__ möchte, dass Du __projectname__ beitreten", "validation_issue_entry_description": "Ein Validierungsproblem, das die Kompilierung dieses Projekts verhindert hat", @@ -1488,24 +1392,15 @@ "word_count": "Wortanzahl", "work_offline": "Offline arbeiten", "work_with_non_overleaf_users": "Arbeite mit Nicht-Overleaf-Nutzern", - "would_you_like_to_see_a_university_subscription": "Interessiert an einem Standortweiten __appName__ Abonnement für deine Universität?", - "x_collaborators_per_project": "__collaboratorsCount__ Mitarbeiter pro Projekt", "x_price_for_first_month": "<0>__price__ für deinen ersten Monat", "x_price_for_first_year": "<0>__price__ für dein erstes Jahr", "x_price_for_y_months": "<0>__price__ für deine ersten __discountMonths__ Monate", "x_price_per_year": "<0>__price__ pro Jahr", "year": "Jahr", "yes_that_is_correct": "Ja, das ist richtig", - "you_and_collaborators_get_access_to": "Du und deine Projektmitarbeiter erhalten darauf Zugriff", - "you_and_collaborators_get_access_to_info": "Diese Funktionen stehen dir und deinen Projektmitarbeitern (anderen Overleaf-Nutzern, die du zu deinen Projekten einlädst) zur Verfügung.", "you_can_now_log_in_sso": "Du kannst dich jetzt über deine Institution anmelden und möglicherweise <0>kostenlose __appName__ „Professionell“-Funktionen erhalten!", "you_can_opt_in_and_out_of_the_program_at_any_time_on_this_page": "Du kannst dich jederzeit auf dieser Seite für das Beta-Programm an- und abmelden", - "you_get_access_to": "Du erhältst darauf Zugriff", - "you_get_access_to_info": "Diese Funktionen stehen nur dir (dem Abonnenten) zur Verfügung.", "you_have_added_x_of_group_size_y": "Du hast <0>__addedUsersSize__ von <1>__groupSize__ verfügbaren Mitgliedern hinzugefügt", - "you_plus_1": "Du + 1", - "you_plus_10": "Du + 10", - "you_plus_6": "Du + 6", "you_will_be_able_to_contact_us_any_time_to_share_your_feedback": "Du kannst uns jederzeit kontaktieren, um uns dein Feedback mitzuteilen", "your_affiliation_is_confirmed": "Deine Zugehörigkeit zu <0>__institutionName__ ist bestätigt.", "your_browser_does_not_support_this_feature": "Entschuldigung, dein Browser unterstützt diese Funktion nicht. Bitte aktualisiere deinen Browser auf die neueste Version.", @@ -1522,8 +1417,6 @@ "zotero_groups_loading_error": "Beim Laden von Gruppen von Zotero ist ein Fehler aufgetreten", "zotero_groups_relink": "Beim Zugriff auf die Zotero-Daten ist ein Fehler aufgetreten. Dies wurde wahrscheinlich durch fehlende Berechtigungen verursacht. Bitte verknüpfe dein Konto neu und versuche es erneut.", "zotero_integration": "Zotero-Integration", - "zotero_integration_lowercase": "Zotero-Integration", - "zotero_integration_lowercase_info": "Verwalte deine Referenzbibliothek in Zotero und verknüpfe sie direkt mit .bib-Dateien in Overleaf, sodass du ganz einfach alles aus deinen Bibliotheken zitieren kannst.", "zotero_is_premium": "Zotero-Integration ist eine Premiumfunktion", "zotero_reference_loading_error": "Fehler, Referenzen konnten nicht von Mendeley geladen werden", "zotero_reference_loading_error_expired": "Zotero-Token abgelaufen, bitte verknüpfe dein Konto neu", diff --git a/services/web/locales/es.json b/services/web/locales/es.json index 1453a345a0..b6ed8c7ebe 100644 --- a/services/web/locales/es.json +++ b/services/web/locales/es.json @@ -130,7 +130,6 @@ "anyone_with_link_can_view": "Cualquiera con este enlace puede ver este proyecto", "app_on_x": "__appName__ en __social__", "apply_educational_discount": "Aplicar descuento educacional", - "apply_educational_discount_info_new": "40% de descuento para grupos de 10 o más personas que utilicen __appName__ para la enseñanza", "apply_suggestion": "Aplicar sugerencia", "april": "Abril", "archive": "Archivar", diff --git a/services/web/locales/fi.json b/services/web/locales/fi.json index d7420f3fd5..a45d028f68 100644 --- a/services/web/locales/fi.json +++ b/services/web/locales/fi.json @@ -127,7 +127,6 @@ "import_to_sharelatex": "Tuo sovellukseen __appName__", "importing": "Tuodaan", "importing_and_merging_changes_in_github": "Tuodaan ja yhdistetään muutoksia GitHubissa", - "indvidual_plans": "Yksilöllinen sopimus", "info": "Tietoa", "institution": "Instituutio", "it": "Italia", @@ -192,7 +191,6 @@ "october": "Lokakuu", "off": "Pois", "ok": "OK", - "one_collaborator": "Vain yksi työtoveri", "one_free_collab": "Yksi ilmainen työtoveri", "online_latex_editor": "Verkossa toimiva LaTeX-editori", "optional": "Valinnainen", diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json index b0dd85fd01..7f3835c053 100644 --- a/services/web/locales/fr.json +++ b/services/web/locales/fr.json @@ -104,7 +104,6 @@ "ai_feedback_the_suggestion_didnt_fix_the_error": "La suggestion n’a pas résolu l’erreur", "ai_feedback_the_suggestion_wasnt_the_best_fix_available": "La suggestion n’était pas la meilleure solution disponible", "ai_feedback_there_was_no_code_fix_suggested": "Aucune correction de code n’a été suggérée", - "all_our_group_plans_offer_educational_discount": "Toutes nos <0>offres de groupe proposent une <1>remise éducation pour les étudiants et universités", "all_premium_features": "Toutes les fonctionnalités premium", "all_premium_features_including": "Toutes les fonctionnalités premium, comprenant:", "all_prices_displayed_are_in_currency": "Tous les prix affichés sont en __recommendedCurrency__.", @@ -112,7 +111,6 @@ "all_templates": "Tous les modèles", "already_have_sl_account": "Avez-vous déjà un compte __appName__ ?", "also": "Aussi", - "also_available_as_on_premises": "Aussi disponible en On-Premises", "alternatively_create_new_institution_account": "Autrement, vous pouvez créer un nouveau compte avec votre adresse courriel institutionnelle (__email__) en cliquant __clickText__.", "an_error_occurred_when_verifying_the_coupon_code": "Une erreur est survenue lors de la vérification du code coupon", "and": "et", @@ -122,7 +120,6 @@ "anyone_with_link_can_view": "Toute personne disposant de ce lien peut voir ce projet", "app_on_x": "__appName__ sur __social__", "apply_educational_discount": "Appliquer la remise éducation", - "apply_educational_discount_info": "Overleaf offre une remise éducation de 40% pour les groupes de 10 ou plus. S’applique aux étudiants ou universités utilisant Overleaf pour l’enseignement.", "april": "Avril", "archive": "Archiver", "archive_projects": "Archiver les projets", @@ -154,7 +151,6 @@ "back_to_subscription": "Retour à l’abonnement", "back_to_your_projects": "Retourner à mes projets", "become_an_advisor": "Devenez un conseiller __appName__", - "best_choices_companies_universities_non_profits": "Le meilleur choix pour les entreprises, les universités et les associations", "beta": "Bêta", "beta_feature_badge": "Badge de fonctionnalité bêta", "beta_program_already_participating": "Vous participez au programme de bêta", @@ -321,11 +317,8 @@ "current_session": "Session courante", "currently_seeing_only_24_hrs_history": "Vous ne pouvez actuellement voir que les modifications des 24 dernières heures dans ce projet.", "currently_subscribed_to_plan": "Vous bénéficiez actuellement de l’offre <0>__planName__.", - "custom_resource_portal": "Portail des ressources personnalisé", - "custom_resource_portal_info": "Pour pouvez avoir votre propre page de portail personnalisée sur Overleaf. C’est l’endroit idéal pour que vos utilisateurs en apprennent plus sur Overleaf, accèdent à des modèles, une FAQ et des resources d’aide, et s’inscrivent sur Overleaf.", "customize": "Personnaliser", "customize_your_group_subscription": "Personnaliser votre abonnement de groupe", - "customize_your_plan": "Personnaliser votre offre", "customizing_figures": "Personnalisation des figures", "da": "Danois", "date": "Date", @@ -334,7 +327,6 @@ "dealing_with_errors": "Gérer les erreurs", "december": "Décembre", "dedicated_account_manager": "Gestionnaire de compte dédié", - "dedicated_account_manager_info": "Toute notre équipe de gestion de compte pourra répondre à vos requêtes ou vos questions et vous aider à faire connaître Overleaf grâce à du contenu promotionel, des resources de formation et des séminaires en ligne.", "default": "Par défaut", "delete": "Supprimer", "delete_account": "Supprimer un compte", @@ -390,7 +382,6 @@ "dropbox_duplicate_project_names_suggestion": "Veuillez vous assurer de l’unicité des noms de tous vos projets <0>actifs, archivés ou à la corbeille puis réassociez votre compte Dropbox.", "dropbox_email_not_verified": "Nous ne parvenons pas à joindre votre compte Dropbox. Le service rapporte que votre adresse courriel n’est pas vérifiée. Veuillez vérifier votre adresse depuis votre compte Dropbox pour résoudre ce problème.", "dropbox_for_link_share_projs": "Vous avez accédé à ce projet par un partage de lien : celui-ci ne sera pas synchronisé à votre Dropbox tant que vous n’aurez pas été invité par courriel par le propriétaire du projet.", - "dropbox_integration_info": "Travaillez avec ou sans connexion sans problème avec la synchronisation bidirectionnelle Dropbox. Les modifications apportées sur votre machine seront automatiquement envoyées à la version Overleaf, et vice versa.", "dropbox_integration_lowercase": "Intégration avec Dropbox", "dropbox_successfully_linked_description": "Merci, nous avons associé votre compte Dropbox à __appName__.", "dropbox_sync": "Synchronisation Dropbox", @@ -422,7 +413,6 @@ "editor_disconected_click_to_reconnect": "L’éditeur a été déconnecté. Cliquez n’importe où pour vous reconnecter", "editor_only_hide_pdf": "Éditeur uniquement <0>(cacher le PDF)", "editor_theme": "Apparence de l’éditeur", - "educational_discount_for_groups_of_x_or_more": "La remise éducation est disponible pour les groupes de __size__ ou plus", "educational_percent_discount_applied": "La remise éducation de __percent__% a été appliquée !", "email": "Courriel", "email_address": "Adresse e-mail", @@ -460,26 +450,7 @@ "expiry": "Date d’expiration", "export_csv": "Exporter en CSV", "export_project_to_github": "Exporter le projet vers GitHub", - "faq_change_plans_or_cancel_answer": "Oui, vous pouvez le faire à n’importe quel moment via votre paramètres d’abonnement. Vous pouvez changer d’offre, changer entre des options de facturation mensuelle ou annuelle, ou résilier pour revenir à l’abonnement gratuit. En résiliant, votre abonnement continuera jusqu’à la fin de la période de facturation en cours. Si votre compte n’a temporairement pas d’abonnement, le seul changement sera les fonctionnalités auxquelles vous avez accès. Vos projets seront toujours accessibles sur votre compte.", - "faq_change_plans_or_cancel_question": "Puis-je changer d’offre ou résilier plus tard ?", - "faq_do_collab_need_on_paid_plan_answer": "Non, vos collaborateurs peuvent être sur n’importe quelle offre, y compris l’offre gratuite. Si vous disposez de l’offre premium, certaines fonctionnalités premium seront disponibles pour vos collaborateurs dans les projets que vous avez créés, même pour les collaborateurs sur l’offre gratuite. Pour plus d’informations, consultez les informations relatives aux <0>account and subscriptions et <1>how premium features work.", - "faq_do_collab_need_on_paid_plan_question": "Mes collaborateurs doivent-ils aussi être sur une offre payante ?", - "faq_how_does_a_group_plan_work_answer": "Les abonnements de groupe sont une manière de mettre à niveau plus d’un compte Overleaf. Ils sont faciles à gérer, aident à réduire les formalités, et diminuent le prix d’achat de plusieurs abonnements séparés. Pour en savoir plus, lisez sur <0>rejoindre un abonnement de group et <1>gérer un abonnement de groupe. Vous pouvez acheter des abonnements de groupe ci-dessus ou en <2>nous contactant.", - "faq_how_does_a_group_plan_work_question": "Comment fonctionne une offre de groupe ? Comment puis-je ajouter des personnes à l’offre ?", "faq_how_does_free_trial_works_answer": "Vous obtenez un accès complet à l’offre __appName__ de votre choix pendant votre essai gratuit de __len__ jours. Il n’y a aucun engagement à poursuivre au delà de l’essai gratuit. Votre carte sera débitée à la fin de votre essai de __len__ jours à moins que vous n’annuliez votre essai auparavant. Vous pouvez annuler depuis les paramètres de votre abonnement.", - "faq_how_free_trial_works_answer_v2": "Vous bénéficiez d’un accès complet à l’offre de votre choix durant les __len__ jours de l’essai gratuit, et il n’y a aucune obligatoire de continuer au delà de l’essai gratuit. Votre carte sera débitée à la fin de votre essai gratuit à moins que vous résiliez avant. Pour résilier, rendez-vous dans les paramètres d’abonnement de votre compte (l’essai continuera jusqu’au bout des __len__ jours).", - "faq_how_free_trial_works_question": "Comment fonctionne l’essai gratuit ?", - "faq_i_have_free_account_want_subscription_how_answer_first_paragraph": "Sur Overleaf, chaque utilisateur crée et gère son propre compte Overleaf. La plupart des utilisateurs commencent sur l’offre gratuite mais peuvent mettre à niveau leur abonnement et profiter des fonctionnalités premium en s’abonnant à une offre, en rejoignant un abonnement de groupe ou en rejoignant un <0>abonnement Commons. Lorsque vous achetez, rejoignez ou quittez un abonnement, vous pouvez tout de même conserver le même compte Overleaf.", - "faq_i_have_free_account_want_subscription_how_answer_second_paragraph": "Pour en savoir plus, lisez-en plus sur <0>comment les comptes et abonnements fonctionnent sur Overleaf.", - "faq_i_have_free_account_want_subscription_how_question": "J’ai un compte gratuit et veux rejoindre un abonnement, comment faire ?", - "faq_pay_by_invoice_answer_v2": "Oui, si vous voulez souscrire un abonnement de groupe pour cinq personnes ou plus, ou une licence de site. Pour les abonnements individuels nous ne pouvons accepter que les paiments en ligne par carte de crédit, de débit ou Paypal.", - "faq_pay_by_invoice_question": "Puis-je payer par facture/bon de commande ?", - "faq_the_individual_standard_plan_10_collab_first_paragraph": "Non. Seulement le compte de l’abonné sera mis à niveau. Un abonnement individuel Standard vous permet d’inviter jusqu’à 10 collaborateurs à chaque projet dont vous êtes le propriétaire.", - "faq_the_individual_standard_plan_10_collab_question": "L’offre individuelle Standard a 10 collaborateurs par projet, est-ce que cela veut dire que 10 personnes vont être mises à niveau ?", - "faq_the_individual_standard_plan_10_collab_second_paragraph": "En travaillant sur un projet que vous, en tant qu’abonné, partagez avec eux, vos collaborateurs auront accès à certaines fonctionnalités premium telles que l’historique complet du document et un temps de compilation étendu pour ce projet spécifique. Les inviter à un projet en particulier ne met pas à niveau leurs comptes, cependant. Lisez-en plus à propos de <0>quelles fonctionnalités sont par projet, et lesquelles sont par compte.", - "faq_what_is_the_difference_between_users_and_collaborators_answer_first_paragraph": "Sur Overleaf, chaque utilisateur crée son propre compte. Vous pouvez créer des projets sur lesquels vous travaillez seul, et vous pouvez aussi inviter d’autres personnes à consulter ou travailler avec vous sur les projets que vous possédez. Les utilisateurs avec qui vous partagez votre projet sont appelés des <0>collaborateurs. Nous y faisons parfois référence en tant que “collaborateurs de projet”.", - "faq_what_is_the_difference_between_users_and_collaborators_answer_second_paragraph": "En d’autres mots, les collaborateurs sont juste d’autres utilisateurs d’Overleaf avec qui vous travaillez sur un de vos projets.", - "faq_what_is_the_difference_between_users_and_collaborators_question": "Quelle est la différence entre des utilisateurs et des collaborateurs ?", "fast": "Rapide", "feature_included": "Fonctionnalité incluse", "feature_not_included": "Fonctionnalité non incluse", @@ -518,11 +489,9 @@ "footer_plans_and_pricing": "Offres & prix", "for_business": "Pour les entreprises", "for_enterprise": "Pour l’entreprise", - "for_groups_or_site_wide": "Pour les groupes ou à l’échelle du site", "for_individuals_and_groups": "Pour les particuliers et groupes", "for_publishers": "Pour les éditeurs", "for_students": "Pour les étudiants", - "for_students_only": "Pour les étudiants uniquement", "for_teaching": "Pour l’enseignement", "for_universities": "Pour les universités", "forgot_your_password": "Mot de passe oublié ", @@ -535,7 +504,6 @@ "from_external_url": "À partir d’une URL externe", "from_provider": "De __provider__", "full_doc_history": "Historique complet des documents", - "full_doc_history_info_v2": "Vous pouvez voir toutes les modifications de votre projet et l’auteur de chaque changement. Ajoutez des étiquettes pour rapidement accéder à des versions spécifiques.", "full_document_history": "<0>Historique complet du document", "full_width": "Pleine largeur", "gallery_back_to_all": "Retour aux __itemPlural__", @@ -560,7 +528,6 @@ "git_bridge_modal_use_previous_token": "Si un mot de passe vous est demandé, vous pouvez utiliser un jeton Git précédent ou en générer un nouveau dans Paramètres du compte. Pour plus d’informations, lisez notre <0>page d’aide.", "git_integration": "Intégration Git", "git_integration_info": "Avec l’intégration Git, vous pouvez cloner vos projets Overleaf avec Git. Pour savoir comment faire, lisez notre <0>page d’aide.", - "git_integration_lowercase": "Intégration avec Git", "github_commit_message_placeholder": "Message de commit pour les changements effectués dans __appName__…", "github_credentials_expired": "Vos identifiants GitHub ont expiré", "github_git_folder_error": "Ce projet contient un répertoire .git à sa racine, ce qui indique qu’il s’agit déjà d’un dépôt Git. Le service de synchronisation GitHub d’Overleaf n’est pas en mesure de synchroniser les historiques Git. Veuillez supprimer le répertoire .git et réessayer.", @@ -591,7 +558,6 @@ "go_to_settings": "Aller aux paramètres", "group_admin": "Administrateur du groupe", "group_full": "Ce groupe est déjà complet", - "group_plans": "Offres de groupes", "groups": "Groupes", "have_an_extra_backup": "Gardez une sauvegarde supplémentaire", "have_more_days_to_try": "Voici __days__ days d’essai en plus !", @@ -653,9 +619,7 @@ "imported_from_zotero_at_date": "Importé de Zotero le __formattedDate__ __relativeDate__", "importing": "Importation", "importing_and_merging_changes_in_github": "Import et fusion des modifications dans GitHub", - "in_good_company": "Vous êtes en bonne compagnie", "in_order_to_match_institutional_metadata_associated": "Afin de faire correspondre vos métadonnées institutionnelles, votre compte est associé avec l’adresse courriel __email__.", - "indvidual_plans": "Offres individuelles", "info": "Info", "institution": "Établissement", "institution_account": "Compte institutionnel", @@ -861,7 +825,6 @@ "off": "Désactivé", "ok": "Ok", "on": "Activé", - "one_collaborator": "Un·e seul·e collaborateur·rice", "one_free_collab": "Un collaborateur offert", "online_latex_editor": "Éditeur LaTeX en ligne", "open_as_template": "Ouvrir en tant que gabarit", @@ -1186,7 +1149,6 @@ "this_project_is_public": "Ce projet est public et peut être édité par n’importe qui disposant de son URL.", "this_project_is_public_read_only": "Ce projet est public et peut être vu, mais non modifié, par toute personne disposant de son URL", "this_project_will_appear_in_your_dropbox_folder_at": "Ce projet apparaîtra dans votre dossier Dropbox à ", - "thousands_templates": "Des milliers de modèles", "three_free_collab": "Trois collaborateurs offerts", "timedout": "Temps expiré", "title": "Titre", diff --git a/services/web/locales/it.json b/services/web/locales/it.json index 5e124290e4..8e8eb5ed10 100644 --- a/services/web/locales/it.json +++ b/services/web/locales/it.json @@ -149,7 +149,6 @@ "import_to_sharelatex": "Importa in __appName__", "importing": "Importazione", "importing_and_merging_changes_in_github": "Importazione e unione modifiche in GitHub", - "indvidual_plans": "Piani Individuali", "info": "Info", "institution": "Istituzione", "it": "Italiano", @@ -219,7 +218,6 @@ "october": "Ottobre", "off": "Off", "ok": "OK", - "one_collaborator": "Solo un collaboratore", "one_free_collab": "Un collaboratore gratuito", "online_latex_editor": "Editor LaTeX online", "optional": "Opzionale", diff --git a/services/web/locales/ja.json b/services/web/locales/ja.json index 2f3ffd9d33..3b5ebf80c2 100644 --- a/services/web/locales/ja.json +++ b/services/web/locales/ja.json @@ -185,7 +185,6 @@ "import_to_sharelatex": "__appName__ にインポート", "importing": "インポート中", "importing_and_merging_changes_in_github": "GitHubの変更をインポートおよび統合中", - "indvidual_plans": "それぞれのプラン", "info": "情報", "institution": "組織", "invalid_file_name": "無効なファイル名", @@ -290,7 +289,6 @@ "october": "10月", "off": "オフ", "ok": "OK", - "one_collaborator": "共同編集者1人のみ", "one_free_collab": "1人の無料共同編集者", "online_latex_editor": "オンラインLaTeXエディター", "open_project": "プロジェクトを開く", diff --git a/services/web/locales/ko.json b/services/web/locales/ko.json index bcda14b05c..da2465897f 100644 --- a/services/web/locales/ko.json +++ b/services/web/locales/ko.json @@ -218,8 +218,6 @@ "import_to_sharelatex": "__appName__에 불러오기", "importing": "불러오는 중", "importing_and_merging_changes_in_github": "GitHub의 변경사항들을 불러오고 합칩니다", - "in_good_company": "좋은 회사에 다니시네요", - "indvidual_plans": "개인 플랜", "info": "정보", "institution": "기관", "invalid_email": "이메일 주소가 잘못되었습니다.", @@ -334,7 +332,6 @@ "october": "10월", "off": "끄기", "ok": "OK", - "one_collaborator": "1명 공유 가능", "one_free_collab": "콜레보레이터 1명 무료", "online_latex_editor": "온라인 LaTex 편집기", "open_project": "프로젝트 열기", diff --git a/services/web/locales/nl.json b/services/web/locales/nl.json index 5bcfc8bcb0..cc7f03f2e5 100644 --- a/services/web/locales/nl.json +++ b/services/web/locales/nl.json @@ -173,7 +173,6 @@ "example_project": "Voorbeeldproject", "expiry": "Vervaldatum", "export_project_to_github": "Project exporteren naar GitHub", - "faq_how_free_trial_works_question": "Hoe werkt de gratis proefperiode?", "fast": "Snel", "features": "Functies", "february": "februari", @@ -204,7 +203,6 @@ "go_to_code_location_in_pdf": "Ga naar codelocatie in de PDF", "go_to_pdf_location_in_code": "Ga naar de PDF-locatie in de code", "group_admin": "Groepsbeheerder", - "group_plans": "Groepspakketten", "groups": "Groepen", "have_more_days_to_try": "Hier zijn __days__ dagen extra Proefperiode!", "headers": "Koppen", @@ -229,8 +227,6 @@ "import_to_sharelatex": "Naar __appName__ importeren", "importing": "Aan het importeren", "importing_and_merging_changes_in_github": "Veranderingen aan het importeren en toevoegen in GitHub", - "in_good_company": "Je bent in goed gezelschap", - "indvidual_plans": "Individuele Abonnementen", "info": "Info", "institution": "Instelling", "institution_and_role": "Instelling en rol", @@ -347,7 +343,6 @@ "october": "oktober", "off": "Uit", "ok": "OK", - "one_collaborator": "Slechts één bijdrager", "one_free_collab": "Één gratis bijdrager", "online_latex_editor": "Online LaTeX-verwerker", "open_project": "Open Project", diff --git a/services/web/locales/pl.json b/services/web/locales/pl.json index e5007ea011..e1e24c505d 100644 --- a/services/web/locales/pl.json +++ b/services/web/locales/pl.json @@ -80,7 +80,6 @@ "github_sync_error": "Przepraszamy, ale wystąpił błąd komunikacji z naszym kontem GitHub. Proszę spróbuj ponownie za parę chwil.", "help": "Pomoc", "hotkeys": "Skróty klawiszowe", - "indvidual_plans": "Plany indywidualne", "info": "Informacje", "institution": "Instytucja", "it": "Włoski", @@ -123,7 +122,6 @@ "no_projects": "Brak projektów", "off": "Wyłączone", "ok": "OK", - "one_collaborator": "Tylko jeden współpracownik", "one_free_collab": "Jeden darmowy współpracownik", "or": "lub", "other_logs_and_files": "Inne logi i pliki", diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json index 21f3e62dd8..e2dd59e932 100644 --- a/services/web/locales/ru.json +++ b/services/web/locales/ru.json @@ -96,7 +96,6 @@ "compile_larger_projects": "Компиляция больших проектов", "compile_mode": "Режим компиляции", "compile_terminated_by_user": "Компиляция была прервана. Вы можете просмотреть необработанную выдачу компиляции, чтобы увидеть место остановки компиляции.", - "compile_timeout_short_info_basic": "Вот сколько времени Вы получаете для компиляции проекта на серверах Overleaf. Вам может потребоваться дополнительное время для более длинных или сложных проектов.", "compiler": "Компилятор", "compiling": "Компиляция", "complete": "Заполнить", @@ -192,7 +191,6 @@ "import_to_sharelatex": "Импортировать в __appName__", "importing": "Импорт", "importing_and_merging_changes_in_github": "Импорт и слияние изменений в GitHub", - "indvidual_plans": "Индивидуальные тарифы", "info": "Информация", "institution": "Организация", "invalid_file_name": "Неверное имя файла", @@ -282,7 +280,6 @@ "october": "Октябрь", "off": "Откл.", "ok": "Хорошо", - "one_collaborator": "Только один автор на проект", "one_free_collab": "Один бесплатный соавтор", "online_latex_editor": "Онлайн редактор LaTeX", "open_project": "Открыть проект", diff --git a/services/web/locales/sv.json b/services/web/locales/sv.json index 06336df998..d4733aaab7 100644 --- a/services/web/locales/sv.json +++ b/services/web/locales/sv.json @@ -236,7 +236,6 @@ "dropbox_duplicate_names_error": "Ditt Dropbox-konto kan inte länkas eftersom du har mer än ett projekt med samma namn: ", "dropbox_email_not_verified": "Vi har inte kunnat hämta uppdateringar från ditt Dropbox-konto. Dropbox rapporterade att din e-postadress inte är verifierad. Verifiera din e-postadress i ditt Dropbox-konto för att lösa detta.", "dropbox_for_link_share_projs": "Det här projektet har nåtts via länkdelning och kommer inte att synkroniseras med din Dropbox om inte projektägaren bjuder in dig via e-post.", - "dropbox_integration_info": "Arbeta smidigt både online och offline med två-vägs Dropbox synk. Ändringar du gör lokalt kommer automatiskt skickas till din __appName__ version och vice versa.", "dropbox_integration_lowercase": "Dropboxintegrering", "dropbox_successfully_linked_description": "Tack, vi har lyckats koppla ditt Dropbox-konto till __appName__.", "dropbox_sync": "Dropbox synkronisering", @@ -279,19 +278,7 @@ "expiry": "Utgångsdatum", "export_csv": "Exportera CSV", "export_project_to_github": "Exportera Projekt till GitHub", - "faq_change_plans_or_cancel_answer": "Ja, du kan göra det när som helst via dina prenumerationsinställningar. Du kan ändra planer, växla mellan månads- och årsfakturering eller avbryta för att nedgradera till en kostnadsfri plan. När du avbryter fortsätter din prenumeration fram till slutet av faktureringsperioden. Om ditt konto tillfälligt inte har någon prenumeration kommer den enda ändringen att gälla de funktioner som är tillgängliga för dig. Dina projekt kommer alltid att vara tillgängliga på ditt konto.", - "faq_change_plans_or_cancel_question": "Kan jag ändra min plan eller avboka senare?", - "faq_do_collab_need_on_paid_plan_answer": "Nej, de kan vara med i vilken plan som helst, inklusive den kostnadsfria planen. Om du har en premiumplan kommer vissa premiumfunktioner att vara tillgängliga för dina medarbetare i projekt som du har skapat, även om dessa medarbetare har en gratisplan. För mer information, läs om <0>konto och prenumerationer och <1>hur premiumfunktioner fungerar.", - "faq_do_collab_need_on_paid_plan_question": "Måste mina medarbetare också ha en betald plan?", - "faq_how_does_a_group_plan_work_answer": "Gruppabonnemang är ett sätt att uppgradera mer än ett Overleaf-konto. De är lätta att hantera, hjälper till att spara på pappersarbete och minskar kostnaden för att köpa flera abonnemang separat. Om du vill veta mer kan du läsa om <0>anslutning till en gruppabonnemang och <1>hantering av ett gruppabonnemang. Du kan köpa gruppabonnemang ovan eller genom att <2>kontakta oss.", - "faq_how_does_a_group_plan_work_question": "Hur fungerar en gruppplan? Hur kan jag lägga till personer i planen?", "faq_how_does_free_trial_works_answer": "Du får full tillgång till din valda __appName__-plan under din __len__-dagars gratis provperiod. Det finns inget krav på att fortsätta efter provperioden. Ditt kort kommer att debiteras i slutet av din __len__-dagars provperiod om du inte avbryter innan dess. Du kan avbryta via dina prenumerationsinställningar.", - "faq_how_free_trial_works_answer_v2": "Du får full tillgång till din valda premiumplan under din __len__-dagars gratis provperiod, och det finns inget krav på att fortsätta efter provperioden. Ditt kort kommer att debiteras i slutet av provperioden om du inte avbryter innan dess. Om du vill avbryta går du till dina prenumerationsinställningar på ditt konto (provperioden fortsätter under de __len__ dagarna).", - "faq_how_free_trial_works_question": "Hur fungerar din gratis prövoperiod?", - "faq_i_have_free_account_want_subscription_how_answer_first_paragraph": "I Overleaf skapar och hanterar varje användare sitt eget Overleaf-konto. De flesta användare börjar med den kostnadsfria planen men kan uppgradera och utnyttja premiumfunktionerna genom att prenumerera på en plan, gå med i en gruppprenumeration eller gå med i en <0>vanlig prenumeration. När du köper, ansluter dig till eller lämnar en prenumeration kan du fortfarande behålla samma Overleaf-konto.", - "faq_pay_by_invoice_question": "Kan jag betala med faktura?", - "faq_the_individual_standard_plan_10_collab_question": "Den individuella standardplanen har 10 projektmedarbetare, betyder det att 10 personer kommer att uppgraderas?", - "faq_what_is_the_difference_between_users_and_collaborators_answer_first_paragraph": "I Overleaf skapar varje användare sitt eget konto. Du kan skapa projekt som bara du själv kan arbeta med, och du kan också bjuda in andra att se eller arbeta med dig i ett projekt som du äger. Användare som du delar ditt projekt med kallas <0>samarbetare. Vi hänvisar ibland till dem som projektmedarbetare.", "fast": "Snabb", "featured_latex_templates": "Utvalda LaTeX-mallar", "features": "Funktioner", @@ -323,7 +310,6 @@ "free_plan_label": "Du har en gratisplan", "free_plan_tooltip": "Klicka för att ta reda på hur du kan dra nytta av Overleafs premiumfunktioner!", "full_doc_history": "Full dokumenthistorik", - "full_doc_history_info_v2": "Du kan se alla ändringar i ditt projekt och vem som har gjort varje ändring. Lägg till etiketter för att snabbt komma åt specifika versioner.", "generic_if_problem_continues_contact_us": "Om problemet kvarstår, vänligen kontakta oss.", "generic_linked_file_compile_error": "Projektets utdatafiler är inte tillgängliga eftersom det inte gick att kompilera. Vänligen öppna projektet för att se information om kompileringsfel.", "generic_something_went_wrong": "Ursäkta, något gick snett", @@ -393,10 +379,8 @@ "import_to_sharelatex": "Importera till __appName__", "importing": "Importerar", "importing_and_merging_changes_in_github": "Importerar och slår samman ändringar i GitHub", - "in_good_company": "Du är i gott sällskap", "in_order_to_match_institutional_metadata_associated": "För att matcha dina institutionella metadata är ditt konto kopplat till e-post-adressen __email__.", "increased_compile_timeout": "Ökad timeout för kompilering", - "indvidual_plans": "Individuella betalningsplaner", "info": "Info", "institution": "Instution", "institution_account": "Institutionellt konto", @@ -590,7 +574,6 @@ "official": "Officiell", "ok": "OK", "on": "På", - "one_collaborator": "Endast en samarbetare", "one_free_collab": "En gratis samarbetsparnter", "online_latex_editor": "Online-LaTeX-editor", "open_project": "Öppna projekt", @@ -650,15 +633,12 @@ "portal_add_affiliation_to_join": "Det ser ut som om du redan är inloggad på __appName__! Om du har en __portalTitle__ e-postadress kan du lägga till den nu.", "position": "Position", "postal_code": "Postnummer", - "powerful_latex_editor_and_realtime_collaboration": "Kraftfull LaTeX-redigerare och samarbete i realtid", - "powerful_latex_editor_and_realtime_collaboration_info": "Stavningskontroll, intelligent autokomplettering, syntaxmarkering, dussintals färgteman, anslutningar till vim och emacs, hjälp med LaTeX-varningar och felmeddelanden och mycket mer. Alla har alltid den senaste versionen, och du kan se dina samarbetspartners markörer och ändringar i realtid.", "premium_feature": "Premium-funktion", "premium_features": "Premiumfunktioner", "premium_plan_label": "Du använder Overleaf Premium", "presentation": "Presentation", "price": "Pris", "priority_support": "Prioriterad support", - "priority_support_info": "Vårt hjälpsamma supportteam prioriterar och eskalerar dina supportförfrågningar vid behov.", "privacy": "Integritet", "privacy_policy": "Användarvillkor", "private": "Privat", @@ -697,7 +677,6 @@ "raw_logs_description": "Ursprungliga loggar från LaTeX-kompilatorn", "read_only": "Endast läs", "realtime_track_changes": "Realtidsspåra ändringar", - "realtime_track_changes_info_v2": "Aktivera Spåra ändringar för att se vem som har gjort varje ändring, acceptera eller förkasta andras ändringar och skriv kommentarer.", "reauthorize_github_account": "Återauktorisera ditt GitHub konto", "recent_commits_in_github": "Senaste commits på GitHub", "recompile": "Kompilera", @@ -713,7 +692,6 @@ "reduce_costs_group_licenses": "Du kan dra ner på pappersarbete och minska kostnader med våra rabatterade grupplicenser.", "reference_error_relink_hint": "Om felet kvarstår, testa att återkoppla ditt konto här:", "reference_search": "Avancerad referenssökning", - "reference_search_info_v2": "Det är lätt att hitta dina referenser - du kan söka på författare, titel, år eller tidskrift. Du kan fortfarande söka efter referensnyckel också.", "reference_sync": "Referenshanterare synk", "refresh": "Uppdatera", "refresh_page_after_starting_free_trial": "Vänligen uppdatera denna sida efter att du startat din gratis provapå period.", @@ -896,8 +874,6 @@ "this_project_is_public": "Detta projekt är publikt och kan redigeras av vem som helst med länken.", "this_project_is_public_read_only": "Det här projektet är publikt och kan visas, men inte redigeras, av vem som helst med länken.", "this_project_will_appear_in_your_dropbox_folder_at": "Detta projekt kommer att synas i din Dropbox mapp på ", - "thousands_templates": "Tusentals mallar", - "thousands_templates_info": "Producera vackra dokument med hjälp av vårt galleri av LaTeX-mallar för tidskrifter, konferenser, avhandlingar, rapporter, CV:n och mycket mer.", "three_free_collab": "Tre gratis samarbetspartners", "timedout": "Timed out", "tip": "Tips", diff --git a/services/web/locales/tr.json b/services/web/locales/tr.json index 74f12ba1f0..ea097d6e41 100644 --- a/services/web/locales/tr.json +++ b/services/web/locales/tr.json @@ -150,7 +150,6 @@ "import_to_sharelatex": "__appName__’e yükle", "importing": "Yükleniyor", "importing_and_merging_changes_in_github": "Değişiklikler GitHub’a aktarılıyor", - "indvidual_plans": "Kişisel Planlar", "info": "Bilgi", "institution": "Enstitü", "it": "İtalyanca", @@ -219,7 +218,6 @@ "october": "Ekim", "off": "Kapalı", "ok": "Tamam", - "one_collaborator": "Yalnızca bir iş ortağı", "one_free_collab": "Fazladan bir iş ortağı", "online_latex_editor": "Çevrimiçi LaTeX Editörü", "optional": "İsteğe bağlı", diff --git a/services/web/locales/zh-CN.json b/services/web/locales/zh-CN.json index 05596e6aae..4ac42a1009 100644 --- a/services/web/locales/zh-CN.json +++ b/services/web/locales/zh-CN.json @@ -113,7 +113,6 @@ "alignment": "对齐", "all": "全部", "all_borders": "全边框", - "all_our_group_plans_offer_educational_discount": "我们的所有<0>团体计划都为学生和教师提供<1>教育折扣", "all_premium_features": "所有高级付费功能", "all_premium_features_including": "所有高级功能,包括:", "all_prices_displayed_are_in_currency": "所有展示的价格都以__recommendedCurrency__计。", @@ -126,7 +125,6 @@ "already_have_sl_account": "已经拥有 __appName__ 账户了吗?", "already_subscribed_try_refreshing_the_page": "已经订阅啦?请刷新界面哦。", "also": "也", - "also_available_as_on_premises": "也可以获取私有部署", "alternatively_create_new_institution_account": "或者,您可以通过单击__clickText__来使用机构电子邮件(__email__)创建一个新帐户。", "an_email_has_already_been_sent_to": "一封电子邮件已经被发送给<0>__email__。请稍后再尝试。", "an_error_occured_while_restoring_project": "还原项目时出错", @@ -138,8 +136,6 @@ "anyone_with_link_can_view": "任何人可以通过此链接浏览此项目。", "app_on_x": "__appName__ 在 __social__", "apply_educational_discount": "使用教育折扣", - "apply_educational_discount_info": "10人或10人以上的团体可享受40%的教育折扣。适用于使用Overleaf教学的学生或教师。", - "apply_educational_discount_info_new": "使用__appName__进行教学的10人或以上团体可享受40%的折扣", "apply_suggestion": "使用建议修改", "april": "四月", "archive": "归档", @@ -170,7 +166,6 @@ "autocompile_disabled_reason": "由于服务器过载,暂时无法自动实时编译,请点击上方按钮进行编译", "autocomplete": "自动补全", "autocomplete_references": "参考文献自动补全(在 \\cite{} 中)", - "automatic_user_registration": "自动用户注册", "automatic_user_registration_uppercase": "自动用户注册", "back": "返回", "back_to_account_settings": "返回帐户设置", @@ -182,7 +177,6 @@ "basic": "免费时长 (20s)", "basic_compile_timeout_on_fast_servers": "在快速服务器上的基本编译时限", "become_an_advisor": "成为__appName__顾问", - "best_choices_companies_universities_non_profits": "公司、大学和非营利组织的最佳选择", "beta": "试用版", "beta_feature_badge": "Beta功能徽章", "beta_program_already_participating": "您加入了 Beta 版测试", @@ -313,11 +307,9 @@ "compile_larger_projects": "编译更大项目", "compile_mode": "编译模式", "compile_servers": "编译服务器", - "compile_servers_info": "高级计划用户的编译始终在最快的可用服务器集群上运行。", "compile_servers_info_new": "用于编译项目的服务器。付费计划用户的编译器始终在最快的可用服务器上运行。", "compile_terminated_by_user": "由于点击了“停止编译”按钮,编译被取消。您可以下载原始日志以查看编译停止的位置。", "compile_timeout_short": "编译时限", - "compile_timeout_short_info_basic": "这是您在Overleaf服务器上编译项目的时限。对于更长或更复杂的项目,您可能需要更多的时间。", "compile_timeout_short_info_new": "这是您在 Overleaf 上编译项目的时间。对于更长或更复杂的项目,您可能需要更多时间。", "compiler": "编译器", "compiling": "正在编译", @@ -395,12 +387,9 @@ "currently_subscribed_to_plan": "您现在订阅的是 <0>__planName__ 套餐。", "custom": "默认 (Custom)", "custom_borders": "自定义边框", - "custom_resource_portal": "定制资源门户", - "custom_resource_portal_info": "您可以在 Overleaf 上拥有自己的自定义门户页面。这是您的用户了解有关 Overleaf 的更多信息、访问模板、常见问题解答和帮助资源以及注册 Overleaf 的好地方。", "customer_resource_portal": "客户资源门户", "customize": "定制", "customize_your_group_subscription": "定制您的团队计划", - "customize_your_plan": "定制您的计划", "customizing_figures": "定制图片", "customizing_tables": "定制表格", "da": "丹麦语", @@ -410,7 +399,6 @@ "dealing_with_errors": "处理错误", "december": "十二月", "dedicated_account_manager": "专属客服", - "dedicated_account_manager_info": "我们的客户管理团队将能够协助您解决请求、问题,并通过宣传材料、培训资源和网络研讨会帮助您宣传 Overleaf。", "default": "默认", "delete": "删除", "delete_account": "删除账户", @@ -496,7 +484,6 @@ "dropbox_duplicate_project_names_suggestion": "请让您的项目名称在您的所有<0>活动、存档和废弃项目中唯一,然后重新关联您的 Dropbox 帐户。", "dropbox_email_not_verified": "我们无法从您的 Dropbox 帐户检索更新。Dropbox 报告您的电子邮件地址未经验证。请在 Dropbox 帐户中验证您的电子邮件地址以解决此问题。", "dropbox_for_link_share_projs": "此项目是通过链接共享访问的,除非项目所有者通过电子邮件邀请您,否则不会同步到您的Dropbox。", - "dropbox_integration_info": "使用双向Dropbox同步,在线和离线无缝工作。您在本地所做的更改将自动发送到Overleaf,反之亦然。", "dropbox_integration_lowercase": "Dropbox 集成", "dropbox_successfully_linked_description": "谢谢,我们已成功将您的Dropbox帐户链接到__appName__。", "dropbox_sync": "Dropbox同步", @@ -536,11 +523,6 @@ "editor_limit_exceeded_in_this_project": "此项目中的编辑者过多", "editor_only_hide_pdf": "仅编辑器 <0>(隐藏 PDF)", "editor_theme": "编辑器主题", - "educational_discount_applied": "40% 教育折扣适用!", - "educational_discount_available_for_groups_of_ten_or_more": "10 人或以上团体可享受教育折扣", - "educational_discount_disclaimer": "该许可证用于教育目的(适用于使用 Overleaf 进行教学的学生或教师)", - "educational_discount_for_groups_of_ten_or_more": "Overleaf 为 10 人或以上团体提供 40% 的教育折扣。", - "educational_discount_for_groups_of_x_or_more": "教育折扣适用于__size__ 人或以上的团体", "educational_percent_discount_applied": "应用 __percent__% 教育折扣!", "email": "电子邮件", "email_address": "邮件地址", @@ -611,26 +593,7 @@ "failed_to_send_group_invite_to_email": "未能向<0>__email__发送团队邀请。请稍后再试。", "failed_to_send_managed_user_invite_to_email": "无法将托管用户邀请发送至 <0>__email__。 请稍后再试。", "failed_to_send_sso_link_invite_to_email": "无法向<0>__email__发送SSO邀请提醒。请稍后再试。", - "faq_change_plans_or_cancel_answer": "是的,您可以随时通过订阅设置执行此操作。您可以更改计划,在月度和年度计费选项之间切换,或者取消以降级为免费计划。取消时,您的订阅将持续到计费期结束。如果您的帐户暂时没有订阅,唯一的更改将是您可以使用的功能。您的项目将始终在您的帐户上可用。", - "faq_change_plans_or_cancel_question": "我可以稍后更改计划或取消吗?", - "faq_do_collab_need_on_paid_plan_answer": "不,他们可以在任何计划中,包括免费计划。如果您使用高级计划,您创建的项目中的合作者将可以使用一些高级功能,即使这些合作者使用免费计划。有关更多信息,请阅读<0>帐户和订阅以及<1>高级功能的工作原理。", - "faq_do_collab_need_on_paid_plan_question": "我的合作者是否也需要拥有付费计划?", - "faq_how_does_a_group_plan_work_answer": "团体订阅是升级多个Overleaf帐户的一种方式。它们易于管理,有助于节省文书工作,并降低单独购买多个订阅的成本。要了解更多信息,请阅读有关<0>加入团队订阅 和 <1>管理团队订阅 的信息。您可以在上面购买团队订阅,也可以通过 <2> 联系我们 购买。", - "faq_how_does_a_group_plan_work_question": "团队计划是如何运作的?如何将人员添加到计划中?", "faq_how_does_free_trial_works_answer": "在为期__len__天的免费试用期间,您可以完全访问所选的__appName__计划。试用结束后不能继续免费。您的卡将在试用期结束时收费,除非您在此之前取消。您可以通过订阅设置取消。", - "faq_how_free_trial_works_answer_v2": "在为期__len__天的免费试用期间,您可以完全访问所选的__appName__高级计划。试用结束后不能继续免费。您的卡将在试用期结束时开始扣费,除非您在此之前取消。若要取消订阅,请转到您帐户中的订阅设置(试用仍将持续到__len__天为止)。", - "faq_how_free_trial_works_question": "如何体验免费使用?", - "faq_i_have_free_account_want_subscription_how_answer_first_paragraph": "在Overleaf中,每个用户都创建并管理自己的Overleaf帐户。大多数用户从免费计划开始,但可以通过订阅计划、加入团队订阅或加入<0>Commons subscription来升级并享用高级功能。当您购买、加入或退出订阅时,您仍然可以保留相同的Overleaf帐户。", - "faq_i_have_free_account_want_subscription_how_answer_second_paragraph": "要了解更多信息,请阅读 <0>在Overleaf中帐户和订阅如何协同工作的有关内容。", - "faq_i_have_free_account_want_subscription_how_question": "我有一个免费帐户并想加入订阅,我该怎么做?", - "faq_pay_by_invoice_answer_v2": "是的,如果你想购买五人或五人以上的团队订阅或者许可证。对于个人订阅,我们只接受通过信用卡、借记卡或PayPal在线支付。", - "faq_pay_by_invoice_question": "可以稍后支付吗", - "faq_the_individual_standard_plan_10_collab_first_paragraph": "不会。只需升级项目拥有者的帐户。个人标准订阅允许您邀请10名合作者加入您拥有的每个项目。", - "faq_the_individual_standard_plan_10_collab_question": "个人标准计划有10个项目合作者,这是否意味着这10个人都需要升级订阅?", - "faq_the_individual_standard_plan_10_collab_second_paragraph": "在加入到您作为订阅者与他们共享的项目后,您的合作者将能够访问一些高级功能,如完整的文档历史记录和特定项目的更长的编译时间。然而,邀请他们参加某个特定项目并不能全面提升他们的帐户。阅读有关<0>每个项目有哪些功能,每个帐户有哪些功能的更多信息。", - "faq_what_is_the_difference_between_users_and_collaborators_answer_first_paragraph": "在Overleaf中,每个用户都创建自己的帐户。您可以创建只有自己处理的项目,也可以邀请其他人查看或与您一起处理您拥有的项目。与您共享项目的用户称为<0>合作者。我们有时称他们为项目合作者。", - "faq_what_is_the_difference_between_users_and_collaborators_answer_second_paragraph": "换言之,合作者只是您在某个项目中合作的其他Overleaf的用户。", - "faq_what_is_the_difference_between_users_and_collaborators_question": "用户和合作者之间有什么区别?", "fast": "快速", "fastest": "最快", "feature_included": "包含的功能", @@ -685,14 +648,12 @@ "for_business": "商业用途", "for_enterprise": "为企业提供", "for_government": "为政府提供", - "for_groups_or_site_wide": "对于团体或整个站点", "for_individuals_and_groups": "为个人 & 团队提供", "for_large_institutions_and_organizations_need_sitewide_on_premise": "对于需要站点范围访问或本地解决方案的大型机构和组织。", "for_more_information_see_managed_accounts_section": "有关详细信息,请参阅<0>我们的使用条款中的“托管帐户”部分,您可以通过点击接受邀请来同意该部分。", "for_publishers": "为出版社提供", "for_small_teams_and_departments_who_want_to_write_collaborate": "适用于希望使用 LaTeX 轻松书写和协作的小型团队和部门。", "for_students": "为学生提供", - "for_students_only": "仅针对学生", "for_teaching": "为教学提供", "for_teams_and_organizations_who_want_a_streamlined_sso_and_security": "针对需要简化登录流程和最强大的云安全性的团队和组织。", "for_universities": "为大学提供", @@ -700,7 +661,6 @@ "forgot_your_password": "忘记密码", "format": "格式", "found_matching_deleted_users": "找到 __deletedUserCount__ 个匹配的已删除用户", - "four_minutes": "4 分钟", "fr": "法语", "free": "免费", "free_7_day_trial_billed_annually": "免费试用 7 天,然后按年付费", @@ -718,7 +678,6 @@ "from_provider": "来自__provider__", "from_url": "从 URL 上传", "full_doc_history": "完整的文档历史", - "full_doc_history_info_v2": "您可以查看项目中的所有编辑以及每项更改的创建者。添加标签以快速访问特定版本。", "full_document_history": "完整的文档<0>历史", "full_project_search": "全项目搜索", "full_width": "全宽", @@ -765,8 +724,6 @@ "git_gitHub_dropbox_mendeley_and_zotero_integrations": "Git、GitHub、Dropbox、Mendeley 和 Zotero 集成", "git_integration": "Git 集成", "git_integration_info": "通过Git集成,你可以用Git克隆你的Overleaf项目。有关完整教程, 请阅读 <0>我们的帮助页面。", - "git_integration_lowercase": "Git 集成", - "git_integration_lowercase_info": "您可以将您的Overleaf项目克隆到本地存储库,将您的Overleaf项目视为远程存储库,可以向其推送更改和从中提取更改。", "github": "GitHub", "github_commit_message_placeholder": "为 __appName__ 中的更改提交信息", "github_credentials_expired": "您的 Github 授权凭证已过期", @@ -781,8 +738,6 @@ "github_large_files_error": "合并失败:您的 Github 存储库包含超过 50mb 文件大小限制的文件 ", "github_merge_failed": "您对 __appName__ 和 GitHub 的更改无法自动合并。 请手动将<0>__sharelatex_branch__分支合并到git中的默认分支中。 手动合并后,单击下面继续。", "github_no_master_branch_error": "无法导入此存储库,因为它缺少主分支。请确保项目有一个主分支", - "github_only_integration_lowercase": "Github 集成", - "github_only_integration_lowercase_info": "将您的 Overleaf 项目直接链接到作为 Overleaf 项目远程存储库的GitHub存储库。这允许您与 Overleaf 之外的合作者共享,并将 Overleaf 集成到更复杂的工作流程中。", "github_private_description": "您可以选择谁可以查看并提交到此存储库。", "github_public_description": "任何人都可以看到该存储库。您可以选择谁有权提交。", "github_repository_diverged": "已强制推送到链接存储库的主分支。在强制推送之后拉取 GitHub 更改可能会导致 Overleaf 和 GitHub 不同步。您可能需要在拉取后推送更改以恢复同步。", @@ -814,21 +769,14 @@ "great_for_small_teams_and_departments": "非常适合小型团队和部门", "group": "团队", "group_admin": "团队管理员", - "group_admins_get_access_to": "团队管理员可以获得", - "group_admins_get_access_to_info": "特有功能仅适用于团体计划。", "group_full": "此组已满", "group_invitations": "团队邀请", "group_invite_has_been_sent_to_email": "团队邀请已发送至<0>__email__", "group_libraries": "团队库", "group_managed_by_group_administrator": "此团队中的用户帐户由团队管理员管理。", - "group_members_and_collaborators_get_access_to": "小组成员及其项目合作者可以访问", - "group_members_and_their_collaborators_get_access_to_info": "这些功能可供小组成员及其合作者(受邀加入小组成员拥有的项目的其他 Overleaf 用户)使用。", - "group_members_get_access_to": "团队成员将会获得", - "group_members_get_access_to_info": "这些功能仅对团队成员(订阅者)可用。", "group_plan_admins_can_easily_add_and_remove_users_from_a_group": "群组计划管理员可以轻松添加和删除群组中的用户。对于全站计划,用户在注册或将电子邮件地址添加到 Overleaf(基于域的注册或 SSO)时会自动升级。", "group_plan_tooltip": "您作为团体订阅的成员加入了 __plan__ 计划。 单击以了解如何充分利用 Overleaf 高级功能。", "group_plan_with_name_tooltip": "您作为团体订阅 __groupName__ 的成员加入了 __plan__ 计划。 单击以了解如何充分利用 Overleaf 高级功能。", - "group_plans": "团队计划", "group_professional": "团队专业版", "group_sso_configuration_idp_metadata": "此处提供的信息来自您的身份提供商(IdP)。这通常被称为其SAML元数据。对于某些IdP,您必须将Overleaf配置为服务提供商,才能获得填写此表格所需的数据。有关更多指导,请参阅<0>我们的文档。", "group_sso_configure_service_provider_in_idp": "对于某些 IdP,您必须将 Overleaf 配置为服务提供商才能获取填写此表单所需的数据。 为此,您需要下载 Overleaf 元数据。", @@ -928,7 +876,6 @@ "imported_from_zotero_at_date": "于 __formattedDate__ __relativeDate__,从Zotero导入", "importing": "正在倒入", "importing_and_merging_changes_in_github": "正在导入合并GitHub中的更改", - "in_good_company": "您有优秀的我们陪伴", "in_order_to_have_a_secure_account_make_sure_your_password": "为了确保您的帐户安全,请确保您的新密码:", "in_order_to_match_institutional_metadata_2": "为了匹配您的机构元数据,我们使用 <0>__email__ 关联您的帐户。", "in_order_to_match_institutional_metadata_associated": "为了匹配您的机构元数据,您的帐户与电子邮件 __email__ 相关联。", @@ -937,7 +884,6 @@ "include_the_error_message_and_ai_response": "包含错误信息和 AI 响应", "increased_compile_timeout": "延长的编译时限", "individuals": "个人", - "indvidual_plans": "个人方案", "info": "信息", "inr_discount_modal_info": "以平价获取文档历史记录、跟踪更改、更多协作者等功能。", "inr_discount_modal_title": "面向印度用户的所有 Overleaf 高级计划七折优惠", @@ -1073,7 +1019,6 @@ "learn_more_about_link_sharing": "了解分享链接", "learn_more_about_managed_users": "学习关于管理用户", "learn_more_about_other_causes_of_compile_timeouts": "<0>了解更多 关于其他导致编译超时的原因以及如何修复。", - "learn_more_lowercase": "了解更多", "leave": "离开", "leave_any_group_subscriptions": "保留除将管理您帐户的组订阅之外的任何团队订阅<0>将它们从“订阅”页面中删除", "leave_group": "退出团队", @@ -1086,10 +1031,8 @@ "let_us_know": "让我们知道", "let_us_know_how_we_can_help": "告诉我们您需要什么帮助", "let_us_know_what_you_think": "让我们知道您的想法", - "lets_fix_your_errors": "来修复您的错误", "library": "库", "license": "许可", - "license_for_educational_purposes": "此许可证用于教育目的(适用于使用__appName__进行教学的学生或教师)", "limited_to_n_editors": "仅限 __count__ 个编辑", "limited_to_n_editors_per_project": "每个项目仅限 __count__ 个编辑者", "limited_to_n_editors_per_project_plural": "每个项目最多可有 __count__ 名编辑者", @@ -1191,8 +1134,6 @@ "managed_user_accounts": "托管的用户账户", "managed_user_invite_has_been_sent_to_email": "托管用户邀请已发送到<0>__email__", "managed_users": "托管用户", - "managed_users_accounts": "托管用户帐户", - "managed_users_accounts_plan_info": "托管用户使您可以更好地控制您的组对 Overleaf 的使用。 它确保对用户访问和删除进行更严格的管理,并允许您在有人离开组时保持对项目的控制。", "managed_users_explanation": "托管用户确保您能够控制组织的项目以及项目的所有者<0>阅读有关托管用户的更多信息", "managed_users_gives_gives_you_more_control_over_your_group": "托管用户让您可以更好地控制您的群组对 __appName__ 的使用。它确保对用户访问和删除进行更严格的管理,并允许您在有人离开群组时继续控制您的项目。", "managed_users_is_enabled": "托管用户已启用", @@ -1206,8 +1147,6 @@ "marked_as_resolved": "标记为已解决", "math_display": "数学表达式", "math_inline": "行内数学符号", - "max_collab_per_project": "每个项目的协作者数量", - "max_collab_per_project_info": "您可以邀请参与每个项目的人数。 他们只需要拥有一个 Overleaf 帐户即可。 他们可以是每个项目中的不同人。", "maximum_files_uploaded_together": "最多可同时上传__max__个文件", "may": "五月", "maybe_later": "或许稍后", @@ -1218,8 +1157,6 @@ "mendeley_groups_loading_error": "从 Mendeley 加载群组时出错", "mendeley_groups_relink": "访问您的 Mendeley 数据时出错。 这可能是由于缺乏权限造成的。 请重新关联您的帐户并重试。", "mendeley_integration": "Mendeley 集成", - "mendeley_integration_lowercase": "Mendeley 集成", - "mendeley_integration_lowercase_info": "在 Mendeley 中管理您的参考文献,并将其直接链接到 Overleaf 中的 .bib 文件,以便您可以轻松引用文献中的任何内容。", "mendeley_is_premium": "Mendeley集成是一个高级功能", "mendeley_reference_loading_error": "错误,无法加载Mendeley的参考文献", "mendeley_reference_loading_error_expired": "Mendeley令牌过期,请重新关联您的账户", @@ -1238,12 +1175,10 @@ "more": "更多", "more_actions": "更多操作", "more_info": "更多信息", - "more_lowercase": "更多", "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": "最受欢迎的", "most_popular_uppercase": "最受欢迎的", "must_be_email_address": "必须是电邮地址", "must_be_purchased_online": "必须通过在线订购", @@ -1265,8 +1200,6 @@ "need_anything_contact_us_at": "您有任何需要,请直接联系我们", "need_contact_group_admin_to_make_changes": "如果您想对帐户进行某些更改,则需要联系群组管理员。 <0>了解有关托管用户的更多信息。", "need_make_changes": "你需要做一些修改", - "need_more_than_50_users": "需要50多个用户?", - "need_more_than_to_licenses_get_in_touch": "需要 50 以上的许可证? 请联系我们", "need_more_than_x_licenses": "需要 __x__ 个以上的许可证?", "need_to_add_new_primary_before_remove": "在删除此电子邮件地址之前,您需要添加一个新的主电子邮件地址。", "need_to_leave": "确定要删除账号?", @@ -1344,8 +1277,6 @@ "number_collab_info": "您可以邀请与您一起处理项目的人数。每个项目都有限制,因此您可以邀请不同的人参与每个项目。", "number_of_projects": "项目的数量", "number_of_users": "用户数量", - "number_of_users_info": "如果你订阅此计划,可以升级的Overleaf账户的用户数量", - "number_of_users_with_colon": "用户数量:", "oauth_orcid_description": " 通过将您的 ORCID iD 链接到您的__appName__帐户,安全地建立您的身份。提交给参与发布者的文件将自动包含您的ORCID iD,以改进工作流和可见性。 ", "october": "十月", "off": "关闭", @@ -1355,12 +1286,10 @@ "ok_join_project": "好的,加入项目", "on": "开", "on_free_plan_upgrade_to_access_features": "您使用的是 __appName__ 免费计划。 升级即可使用这些<0>高级功能", - "one_collaborator": "仅一个合作者", "one_collaborator_per_project": "每个项目 1 名协作者", "one_free_collab": "1个免费的合作者", "one_per_project": "每个项目 1 个", "one_step_away_from_professional_features": "您距离访问<0>Overleaf Professional 功能仅一步之遥!", - "one_user": "1 个用户", "ongoing_experiments": "正在进行的实验", "online_latex_editor": "在线LaTeX编辑器", "only_group_admin_or_managers_can_delete_your_account_1": "通过成为托管用户,您的组织将对您的帐户拥有管理权限,并控制您的内容,包括关闭您的帐户以及访问、删除和共享您的内容的权限。因此:", @@ -1451,11 +1380,9 @@ "per_user_per_year": "每个用户 / 每年", "per_user_year": "每个用户 / 每年", "per_year": "每年", - "percent_discount_for_groups": "__appName__为__size__或以上的团体提供__percent__%的教育折扣。", "percent_is_the_percentage_of_the_line_width": "% 是行宽的百分比", "personal": "个人", "personalized_onboarding": "个性化入门", - "personalized_onboarding_info": "我们将帮助您设置好一切,然后我们将在这里回答您的用户关于平台、模板或LaTeX的问题!", "pl": "波兰语", "plan": "计划", "plan_tooltip": "你在__plan__计划中。点击了解如何充分利用您的 Overleaf 高级功能。", @@ -1497,8 +1424,6 @@ "portal_add_affiliation_to_join": "您似乎已经登录到 __appName__!如果你有一封 __portalTitle__ 邮件,现在就可以添加了。", "position": "职位", "postal_code": "邮政编码", - "powerful_latex_editor_and_realtime_collaboration": "强大的LaTeX编辑器 & 实时协作", - "powerful_latex_editor_and_realtime_collaboration_info": "拼写检查、智能自动完成、语法高亮显示、数十种颜色主题、vim和emacs绑定、LaTeX警告和错误消息的帮助等等。每个人都有最新的版本,您可以实时看到合作者的光标和更改。", "premium_feature": "Premium 功能", "premium_features": "高级功能", "premium_plan_label": "您正在使用 Overleaf Premium", @@ -1516,7 +1441,6 @@ "primary_certificate": "主证书", "primary_email_check_question": "<0>__email__ 还是您的电子邮件地址吗?", "priority_support": "优先支持", - "priority_support_info": "我们乐于助人的支持团队将在必要时优先考虑并升级您的支持请求。", "privacy": "隐私", "privacy_and_terms": "隐私和条款", "privacy_policy": "隐私政策", @@ -1540,7 +1464,6 @@ "project_layout_sharing_submission": "项目布局、分享和提交", "project_name": "项目名称", "project_not_linked_to_github": "该项目未与GitHub任一存储库关联。您可以在GitHub中为该项目创建一个存储库:", - "project_owner_plus_10": "项目作者 + 10人", "project_ownership_transfer_confirmation_1": "是否确定要将 <0>__user__ 设为 <1>__project__ 的所有者?", "project_ownership_transfer_confirmation_2": "此操作无法撤消。新所有者将收到通知,并可以更改项目访问权限设置(包括删除您自己的访问权限)。", "project_renamed_or_deleted": "项目已重命名或删除", @@ -1567,8 +1490,6 @@ "publisher_account": "发布者帐户", "publishing": "正在发表", "pull_github_changes_into_sharelatex": "将GitHub中的更改调入 __appName__", - "purchase_now": "现在订购", - "purchase_now_lowercase": "现在订购", "push_sharelatex_changes_to_github": "将 __appName__ 中的更改推送到GitHub", "quoted_text": "引用文本", "quoted_text_in": "引文内容", @@ -1589,7 +1510,6 @@ "ready_to_use_templates": "现成的模板", "real_time_track_changes": "实时<0>跟踪更改", "realtime_track_changes": "实时跟踪更改", - "realtime_track_changes_info_v2": "打开跟踪更改以查看谁进行了每项更改、接受或拒绝其他人的更改以及撰写评论。", "reasons_for_compile_timeouts": "编译超时的原因", "reauthorize_github_account": "重新授权 GitHub 帐号", "recaptcha_conditions": "本网站受reCAPTCHA保护,谷歌<1>隐私政策和<2>服务条款适用。", @@ -1613,7 +1533,6 @@ "reference_managers": "引文管理", "reference_search": "高级搜索", "reference_search_info_new": "轻松查找您的参考文献——按作者、标题、年份或期刊搜索。", - "reference_search_info_v2": "查找参考文献很容易 - 您可以按作者、标题、年份或期刊进行搜索。 您仍然可以通过引用键进行搜索。", "reference_sync": "同步参考文献", "refresh": "刷新", "refresh_page_after_linking_dropbox": "请在将您的帐户链接到Dropbox后刷新此页。", @@ -1729,17 +1648,10 @@ "saml_response": "SAML 响应:", "save": "保存", "save_20_percent": "节省 20%", - "save_20_percent_by_paying_annually": "按年支付可节省20%", - "save_30_percent_or_more": "节省30%或更多", - "save_30_percent_or_more_uppercase": "节省30%或更多", - "save_n_percent": "节约 __percentage__%", "save_or_cancel-cancel": "取消", "save_or_cancel-or": "或者", "save_or_cancel-save": "保存", - "save_x_percent_or_more": "节省 __percent__% 或更多", "saving": "正在保存", - "saving_20_percent": "节省 20%!", - "saving_20_percent_no_exclamation": "节约20%", "saving_notification_with_seconds": "保存 __docname__... (剩余 __seconds__ 秒)", "search": "搜索", "search_all_project_files": "搜索所有的项目文件", @@ -1845,7 +1757,6 @@ "show_more": "显示更多", "show_outline": "显示文件大纲", "show_x_more_projects": "再显示 __x__ 个项目", - "show_your_support": "表示你的支持", "showing_1_result": "显示 1 个结果", "showing_1_result_of_total": "显示 1 个结果(共计 __total__ )", "showing_x_out_of_n_projects": "显示 __x__ 个项目(共 __n__ 个)", @@ -1856,8 +1767,6 @@ "single_sign_on_sso": "单点登录 (SSO)", "site_description": "一个简洁的在线 LaTeX 编辑器。无需安装,实时共享,版本控制,数百免费模板……", "site_wide_option_available": "提供站点范围的选项", - "sitewide_option_available": "提供站点范围的选项", - "sitewide_option_available_info": "当用户注册或将其电子邮件地址添加到 Overleaf(基于域的注册或 SSO)时,用户会自动升级。", "six_collaborators_per_project": "每个项目6个合作者", "six_per_project": "每个项目6个", "skip": "跳过", @@ -1910,7 +1819,6 @@ "sso_explanation": "为您的组设置单点登录。 除非启用了托管用户,否则此登录方法对于群组成员来说是可选的。 <0>详细了解 Overleaf 组 SSO。", "sso_here_is_the_data_we_received": "以下是我们在 SAML 响应中收到的数据:", "sso_integration": "SSO 集成", - "sso_integration_info": "Overleaf 提供标准的基于 SAML 的单点登录集成。", "sso_is_disabled": "SSO 已经关闭", "sso_is_disabled_explanation_1": "群组成员将无法通过SSO登录", "sso_is_disabled_explanation_2": "该组的所有成员都需要用户名和密码才能登录__appName__", @@ -1956,9 +1864,7 @@ "store_your_work": "将工作存储在自己的硬件上", "stretch_width_to_text": "拉伸宽度适应文本", "student": "学生", - "student_and_faculty_support_make_difference": "学生和教师的支持会带来改变! 在讨论 Overleaf 机构账户时,我们可以与您所在大学的联系人分享此信息。", "student_disclaimer": "教育折扣适用于中学和高等教育机构(学校和大学)的所有学生。 我们可能会与您联系以确认您是否有资格享受折扣。", - "student_plans": "学生计划", "students": "学生", "subject": "主题", "subject_area": "主题区", @@ -1968,7 +1874,6 @@ "subscribe": "提交", "subscribe_to_find_the_symbols_you_need_faster": "订阅以更快地找到您需要的符号", "subscription": "订购", - "subscription_admin_panel": "管理员面板", "subscription_admins_cannot_be_deleted": "订阅时不能删除您的帐户。请取消订阅并重试。如果您一直看到此消息,请与我们联系。", "subscription_canceled": "订阅已取消", "subscription_canceled_and_terminate_on_x": " 您的订阅已被取消,将于 <0>__terminateDate__ 停止。不必支付其他费用。", @@ -1991,7 +1896,6 @@ "switch_to_pdf": "切换到 PDF", "symbol_palette": "数学符号面板", "symbol_palette_highlighted": "<0>符号 面板", - "symbol_palette_info": "一种将数学符号插入文档的快速便捷的方法。", "symbol_palette_info_new": "单击按钮即可将数学符号插入到您的文档中。", "sync": "同步", "sync_dropbox_github": "与dropbox或Github同步", @@ -2087,8 +1991,6 @@ "this_tool_helps_you_insert_simple_tables_into_your_project_without_writing_latex_code_give_feedback": "该工具可帮助您将简单的表格插入项目中,而无需编写 LaTeX 代码。 该工具是新工具,因此请<0>向我们提供反馈并留意即将推出的其他功能。", "this_was_helpful": "很有帮助", "this_wasnt_helpful": "没有帮助", - "thousands_templates": "数千个模板", - "thousands_templates_info": "从我们的 LaTeX 模板库开始,为期刊、会议、论文、报告、简历等制作精美的文档。", "three_free_collab": "3个免费的合作者", "timedout": "超时", "tip": "提示", @@ -2154,7 +2056,6 @@ "total": "总计", "total_per_month": "每月总计", "total_per_year": "每年合计", - "total_per_year_for_x_users": "__licenseSize__ 个用户每年总计", "total_per_year_lowercase": "每年合计", "total_with_subtotal_and_tax": "总计:每年 <0> __total__ (__subtotal__ + __tax__税)", "total_words": "总字数", @@ -2196,7 +2097,6 @@ "turn_on": "打开", "turn_on_link_sharing": "开启通过链接分享功能。", "tutorials": "教程", - "two_users": "2 个用户", "uk": "乌克兰语", "unable_to_extract_the_supplied_zip_file": "在Overleaf打开此内容失败,因为无法提取zip文件。请确保它是有效的zip文件。如果某个网站的链接经常出现这种情况,请向他们报告。", "unarchive": "恢复", @@ -2212,13 +2112,9 @@ "university_school": "大学或学校名称", "unknown": "未知", "unlimited": "无限制", - "unlimited_bold": "<0>无限制的", - "unlimited_collaborators_in_each_project": "每个项目无限的合作者数量", "unlimited_collaborators_per_project": "每个项目的合作者数量不受限制", "unlimited_collabs": "无限制的合作者数", - "unlimited_collabs_rt": "<0>无限个合作者", "unlimited_projects": "项目无限制", - "unlimited_projects_info": "默认情况下,您的项目是私有的。这意味着只有你才能查看它们,只有你才能允许其他人访问它们。", "unlink": "取消关联", "unlink_all_users": "取消所有用户的链接", "unlink_all_users_explanation": "您即将删除组中所有用户的 SSO 登录选项。 如果启用 SSO,这将强制用户使用您的 IdP 重新验证其 Overleaf 帐户。 他们会收到一封电子邮件,要求他们这样做。", @@ -2244,7 +2140,6 @@ "unsubscribed": "订阅被取消", "unsubscribing": "正在取消订阅", "untrash": "恢复", - "up_to": "最多", "update": "更新", "update_account_info": "更新账户信息", "update_billing_details": "更新帐单细节", @@ -2267,8 +2162,6 @@ "upload_project": "上传项目", "upload_zipped_project": "上传项目的压缩包", "url_to_fetch_the_file_from": "获取文件的URL", - "usage_metrics": "使用指标", - "usage_metrics_info": "显示有多少用户正在访问许可证、正在创建和处理多少项目以及 Overleaf 中正在进行多少协作的指标。", "use_a_different_password": "请使用不同的密码", "use_saml_metadata_to_configure_sso_with_idp": "使用 Overleaf SAML 元数据通过您的身份提供商配置 SSO。", "use_your_own_machine": "使用你自己的机器,有你自己的设置", @@ -2285,7 +2178,6 @@ "user_is_not_part_of_group": "用户不属于团队", "user_last_name_attribute": "用户姓氏属性", "user_management": "用户管理", - "user_management_info": "团体计划管理员可以访问管理面板,可以在其中轻松添加和删除用户。 对于站点范围的计划,用户在注册或将其电子邮件地址添加到 Overleaf(基于域的注册或 SSO)时会自动升级。", "user_metrics": "用户数据指标", "user_not_found": "找不到用户", "user_sessions": "用户会话", @@ -2359,7 +2251,6 @@ "work_offline": "离线工作", "work_or_university_sso": "工作/高校账户 单点登录", "work_with_non_overleaf_users": "和非Overleaf用户一起工作", - "would_you_like_to_see_a_university_subscription": "您想在你的大学看到风靡全球各大学的__appName__订阅吗?", "write_and_collaborate_faster_with_features_like": "借助以下功能更快地写作和协作:", "writefull": "Writefull", "writefull_learn_more": "了解更多关于 Writefull for Overleaf", @@ -2368,7 +2259,6 @@ "writefull_settings_description": "使用 Writefull for Overleaf 获得专为研究写作量身定制的基于人工智能的免费语言反馈。 另外,如果您升级到 Writefull Premium,您可以使用 TeXGPT 生成 LaTeX 代码 - 在结账时使用 OVERLEAF10 可获得 10% 的折扣。", "x_changes_in": "__count__ 处变化在", "x_changes_in_plural": "__count__ 处变化在", - "x_collaborators_per_project": "每个项目__collaboratorsCount__个协作者", "x_price_for_first_month": "首月 <0>__price__", "x_price_for_first_year": "首年 <0>__price__", "x_price_for_y_months": "您前 __discountMonths__ 个月的费用:<0>__price__", @@ -2381,8 +2271,6 @@ "yes_that_is_correct": "是正确的", "you": "你", "you_already_have_a_subscription": "你已经有一个订阅啦", - "you_and_collaborators_get_access_to": "你与你的项目协作者将会获得", - "you_and_collaborators_get_access_to_info": "这些功能可供您和您的协作者(您邀请加入项目的其他 Overleaf 用户)使用。", "you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "您是由 <1>__adminEmail__ 管理的 <1>__groupName__ 团队的、<0>__planName__ 计划的 <1>管理员和<1>成员", "you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z_you": "您是<1>您 (__adminEmail__)管理的<0>__planName__团体订阅<1>__groupName__的<1>管理员和<1>成员。", "you_are_a_manager_of_commons_at_institution_x": "您是 <0>__institutionName__ 的 Overleaf Commons 订阅的<0>管理者", @@ -2407,16 +2295,11 @@ "you_cant_join_this_group_subscription": "您无法加入此团队订阅", "you_cant_reset_password_due_to_sso": "您无法重置密码,因为您的群组或组织使用 SSO。 <0>使用单点登录登录。", "you_dont_have_any_repositories": "您没有任何仓库", - "you_get_access_to": "你将获得", - "you_get_access_to_info": "这些功能仅供您(订阅者)使用。", "you_have_added_x_of_group_size_y": "您已经添加 <0>__addedUsersSize__ / <1>__groupSize__ 个可用成员。", "you_have_been_invited_to_transfer_management_of_your_account": "您已被邀请转移您帐户的管理权。", "you_have_been_invited_to_transfer_management_of_your_account_to": "您已被邀请将帐户管理转移到__groupName__。", "you_have_been_removed_from_this_project_and_will_be_redirected_to_project_dashboard": "您已从该项目中删除,将不再有权访问该项目。您将被立即重定向到项目面板。", "you_need_to_configure_your_sso_settings": "在启用SSO之前,您需要配置并测试SSO设置", - "you_plus_1": "你 + 1人", - "you_plus_10": "你 + 10人", - "you_plus_6": "你 + 6人", "you_will_be_able_to_contact_us_any_time_to_share_your_feedback": "您可以随时联系我们分享您的反馈", "you_will_be_able_to_reassign_subscription": "您可以将他们的订阅成员资格重新分配给组织中的其他人", "youll_get_best_results_in_visual_but_can_be_used_in_source": "尽管您仍可使用此工具在<1>代码编辑器中插入表格,但在<0>可视化编辑器中使用此工具将获得最佳结果。 选择所需的行数和列数后,表格将出现在文档中,您可以双击单元格向其中添加内容。", @@ -2476,8 +2359,6 @@ "zotero_groups_loading_error": "从 Zotero 加载群组时出错", "zotero_groups_relink": "访问您的Zotero数据时出错。这可能是由于缺乏权限造成的。请重新链接您的帐户,然后重试。", "zotero_integration": "Zotero 集成", - "zotero_integration_lowercase": "Zotero集成", - "zotero_integration_lowercase_info": "在Zotero中管理您的参考库,并将其直接链接到Overleaf中的.bib文件,这样您就可以轻松引用库中的任何内容。", "zotero_is_premium": "Zotero 集成是一个高级(付费)功能", "zotero_reference_loading_error": "错误,无法加载Zotero的参考文献", "zotero_reference_loading_error_expired": "Zotero令牌过期,请重新关联您的账户", From b9fb636f0b299f1d1b05ca6466d4c82df526ffce Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Fri, 17 Jan 2025 09:06:05 +0100 Subject: [PATCH 0057/1724] [web] Remove `promises` exports from Controller modules (#22242) * Remove promises object from CollaboratorsInviteController.mjs * Define functions at root * Remove mentions of undefined `revokeInviteForUser` * Remove unused `doLogout` * Remove promises object from UserController.js * Remove unused `makeChangePreview` * Remove promises object from SubscriptionController.js (`getRecommendedCurrency` and `getLatamCountryBannerDetails`) * Remove promises object from CollabratecController.mjs * Remove promises object from SSOController.mjs * Remove promises object from ReferencesApiController.mjs * Remove promises object from MetricsEmailController.mjs * Remove promises object from InstitutionHubsController.mjs * Remove promises object from DocumentUpdaterController.mjs * Remove promises object from SubscriptionAdminController.mjs * Fixup unit tests * Add expects that controllers don't error * Promisify `ensureAffiliationMiddleware` GitOrigin-RevId: 311c8afa7d5c8e4f051408d305b6b4147a020edc --- .../CollaboratorsInviteController.mjs | 675 +++++++++--------- .../DocumentUpdaterController.mjs | 3 - .../Subscription/SubscriptionController.js | 8 +- .../app/src/Features/User/UserController.js | 16 +- services/web/app/src/router.mjs | 2 +- services/web/scripts/ensure_affiliations.mjs | 2 +- .../CollaboratorsInviteControllerTests.mjs | 298 ++++---- .../DocumentUpdaterControllerTests.mjs | 11 +- .../test/unit/src/User/UserControllerTests.js | 12 +- 9 files changed, 487 insertions(+), 540 deletions(-) diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs index 241ed2cb75..f0be9e7063 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs @@ -1,4 +1,3 @@ -import { callbackify } from 'node:util' import ProjectGetter from '../Project/ProjectGetter.js' import LimitationsManager from '../Subscription/LimitationsManager.js' import UserGetter from '../User/UserGetter.js' @@ -34,336 +33,218 @@ const rateLimiter = new RateLimiter('invite-to-project-by-user-id', { duration: 60 * 30, }) -const CollaboratorsInviteController = { - async getAllInvites(req, res) { - const projectId = req.params.Project_id - logger.debug({ projectId }, 'getting all active invites for project') - const invites = - await CollaboratorsInviteGetter.promises.getAllInvites(projectId) - res.json({ invites }) - }, +async function getAllInvites(req, res) { + const projectId = req.params.Project_id + logger.debug({ projectId }, 'getting all active invites for project') + const invites = + await CollaboratorsInviteGetter.promises.getAllInvites(projectId) + res.json({ invites }) +} - async _checkShouldInviteEmail(email) { - if (Settings.restrictInvitesToExistingAccounts === true) { - logger.debug({ email }, 'checking if user exists with this email') - const user = await UserGetter.promises.getUserByAnyEmail(email, { - _id: 1, - }) - const userExists = user?._id != null - return userExists - } else { - return true - } - }, - - async _checkRateLimit(userId) { - let collabLimit = - await LimitationsManager.promises.allowedNumberOfCollaboratorsForUser( - userId - ) - - if (collabLimit == null || collabLimit === 0) { - collabLimit = 1 - } else if (collabLimit < 0 || collabLimit > 20) { - collabLimit = 20 - } - - // Consume enough points to hit the rate limit at 10 * collabLimit - const maxRequests = 10 * collabLimit - const points = Math.floor(RATE_LIMIT_POINTS / maxRequests) - try { - await rateLimiter.consume(userId, points, { method: 'userId' }) - } catch (err) { - if (err instanceof Error) { - throw err - } else { - return false - } - } - return true - }, - - async inviteToProject(req, res) { - const projectId = req.params.Project_id - let { email, privileges } = req.body - const sendingUser = SessionManager.getSessionUser(req.session) - const sendingUserId = sendingUser._id - req.logger.addFields({ email, sendingUserId }) - - if (email === sendingUser.email) { - logger.debug( - { projectId, email, sendingUserId }, - 'cannot invite yourself to project' - ) - return res.json({ invite: null, error: 'cannot_invite_self' }) - } - - logger.debug({ projectId, email, sendingUserId }, 'inviting to project') - - const project = await ProjectGetter.promises.getProject(projectId, { - owner_ref: 1, +async function _checkShouldInviteEmail(email) { + if (Settings.restrictInvitesToExistingAccounts === true) { + logger.debug({ email }, 'checking if user exists with this email') + const user = await UserGetter.promises.getUserByAnyEmail(email, { + _id: 1, }) - const linkSharingChanges = - await SplitTestHandler.promises.getAssignmentForUser( - project.owner_ref, - 'link-sharing-warning' - ) + const userExists = user?._id != null + return userExists + } else { + return true + } +} - let allowed = false - if (linkSharingChanges?.variant === 'active') { - // if link-sharing-warning is active, can always invite read-only collaborators - if (privileges === PrivilegeLevels.READ_ONLY) { - allowed = true - } else { - allowed = await LimitationsManager.promises.canAddXEditCollaborators( - projectId, - 1 - ) - } +async function _checkRateLimit(userId) { + let collabLimit = + await LimitationsManager.promises.allowedNumberOfCollaboratorsForUser( + userId + ) + + if (collabLimit == null || collabLimit === 0) { + collabLimit = 1 + } else if (collabLimit < 0 || collabLimit > 20) { + collabLimit = 20 + } + + // Consume enough points to hit the rate limit at 10 * collabLimit + const maxRequests = 10 * collabLimit + const points = Math.floor(RATE_LIMIT_POINTS / maxRequests) + try { + await rateLimiter.consume(userId, points, { method: 'userId' }) + } catch (err) { + if (err instanceof Error) { + throw err } else { - allowed = await LimitationsManager.promises.canAddXCollaborators( + return false + } + } + return true +} + +async function inviteToProject(req, res) { + const projectId = req.params.Project_id + let { email, privileges } = req.body + const sendingUser = SessionManager.getSessionUser(req.session) + const sendingUserId = sendingUser._id + req.logger.addFields({ email, sendingUserId }) + + if (email === sendingUser.email) { + logger.debug( + { projectId, email, sendingUserId }, + 'cannot invite yourself to project' + ) + return res.json({ invite: null, error: 'cannot_invite_self' }) + } + + logger.debug({ projectId, email, sendingUserId }, 'inviting to project') + + const project = await ProjectGetter.promises.getProject(projectId, { + owner_ref: 1, + }) + const linkSharingChanges = + await SplitTestHandler.promises.getAssignmentForUser( + project.owner_ref, + 'link-sharing-warning' + ) + + let allowed = false + if (linkSharingChanges?.variant === 'active') { + // if link-sharing-warning is active, can always invite read-only collaborators + if (privileges === PrivilegeLevels.READ_ONLY) { + allowed = true + } else { + allowed = await LimitationsManager.promises.canAddXEditCollaborators( projectId, 1 ) } - - if (!allowed) { - logger.debug( - { projectId, email, sendingUserId }, - 'not allowed to invite more users to project' - ) - return res.json({ invite: null }) - } - - email = EmailHelper.parseEmail(email, true) - if (email == null || email === '') { - logger.debug({ projectId, email, sendingUserId }, 'invalid email address') - return res.status(400).json({ errorReason: 'invalid_email' }) - } - - const underRateLimit = - await CollaboratorsInviteController._checkRateLimit(sendingUserId) - if (!underRateLimit) { - return res.sendStatus(429) - } - - const shouldAllowInvite = - await CollaboratorsInviteController._checkShouldInviteEmail(email) - if (!shouldAllowInvite) { - logger.debug( - { email, projectId, sendingUserId }, - 'not allowed to send an invite to this email address' - ) - return res.json({ - invite: null, - error: 'cannot_invite_non_user', - }) - } - - const invite = await CollaboratorsInviteHandler.promises.inviteToProject( + } else { + allowed = await LimitationsManager.promises.canAddXCollaborators( projectId, - sendingUser, - email, - privileges + 1 ) + } + if (!allowed) { + logger.debug( + { projectId, email, sendingUserId }, + 'not allowed to invite more users to project' + ) + return res.json({ invite: null }) + } + + email = EmailHelper.parseEmail(email, true) + if (email == null || email === '') { + logger.debug({ projectId, email, sendingUserId }, 'invalid email address') + return res.status(400).json({ errorReason: 'invalid_email' }) + } + + const underRateLimit = + await CollaboratorsInviteController._checkRateLimit(sendingUserId) + if (!underRateLimit) { + return res.sendStatus(429) + } + + const shouldAllowInvite = + await CollaboratorsInviteController._checkShouldInviteEmail(email) + if (!shouldAllowInvite) { + logger.debug( + { email, projectId, sendingUserId }, + 'not allowed to send an invite to this email address' + ) + return res.json({ + invite: null, + error: 'cannot_invite_non_user', + }) + } + + const invite = await CollaboratorsInviteHandler.promises.inviteToProject( + projectId, + sendingUser, + email, + privileges + ) + + ProjectAuditLogHandler.addEntryInBackground( + projectId, + 'send-invite', + sendingUserId, + req.ip, + { + inviteId: invite._id, + privileges, + } + ) + + logger.debug({ projectId, email, sendingUserId }, 'invite created') + + EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', { + invites: true, + }) + res.json({ invite }) +} +async function revokeInvite(req, res) { + const projectId = req.params.Project_id + const inviteId = req.params.invite_id + const user = SessionManager.getSessionUser(req.session) + + logger.debug({ projectId, inviteId }, 'revoking invite') + + const invite = await CollaboratorsInviteHandler.promises.revokeInvite( + projectId, + inviteId + ) + + if (invite != null) { ProjectAuditLogHandler.addEntryInBackground( projectId, - 'send-invite', - sendingUserId, + 'revoke-invite', + user._id, req.ip, { inviteId: invite._id, - privileges, + privileges: invite.privileges, } ) - - logger.debug({ projectId, email, sendingUserId }, 'invite created') - EditorRealTimeController.emitToRoom( projectId, 'project:membership:changed', { invites: true } ) - res.json({ invite }) - }, - async revokeInvite(req, res) { - const projectId = req.params.Project_id - const inviteId = req.params.invite_id - const user = SessionManager.getSessionUser(req.session) + } - logger.debug({ projectId, inviteId }, 'revoking invite') + res.sendStatus(204) +} - const invite = await CollaboratorsInviteHandler.promises.revokeInvite( +async function generateNewInvite(req, res) { + const projectId = req.params.Project_id + const inviteId = req.params.invite_id + const user = SessionManager.getSessionUser(req.session) + + logger.debug({ projectId, inviteId }, 'resending invite') + const sendingUser = SessionManager.getSessionUser(req.session) + const underRateLimit = await CollaboratorsInviteController._checkRateLimit( + sendingUser._id + ) + if (!underRateLimit) { + return res.sendStatus(429) + } + + const invite = await CollaboratorsInviteHandler.promises.generateNewInvite( + projectId, + sendingUser, + inviteId + ) + + EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', { + invites: true, + }) + + if (invite != null) { + ProjectAuditLogHandler.addEntryInBackground( projectId, - inviteId - ) - - if (invite != null) { - ProjectAuditLogHandler.addEntryInBackground( - projectId, - 'revoke-invite', - user._id, - req.ip, - { - inviteId: invite._id, - privileges: invite.privileges, - } - ) - EditorRealTimeController.emitToRoom( - projectId, - 'project:membership:changed', - { invites: true } - ) - } - - res.sendStatus(204) - }, - - async generateNewInvite(req, res) { - const projectId = req.params.Project_id - const inviteId = req.params.invite_id - const user = SessionManager.getSessionUser(req.session) - - logger.debug({ projectId, inviteId }, 'resending invite') - const sendingUser = SessionManager.getSessionUser(req.session) - const underRateLimit = await CollaboratorsInviteController._checkRateLimit( - sendingUser._id - ) - if (!underRateLimit) { - return res.sendStatus(429) - } - - const invite = await CollaboratorsInviteHandler.promises.generateNewInvite( - projectId, - sendingUser, - inviteId - ) - - EditorRealTimeController.emitToRoom( - projectId, - 'project:membership:changed', - { invites: true } - ) - - if (invite != null) { - ProjectAuditLogHandler.addEntryInBackground( - projectId, - 'resend-invite', - user._id, - req.ip, - { - inviteId: invite._id, - privileges: invite.privileges, - } - ) - - res.sendStatus(201) - } else { - res.sendStatus(404) - } - }, - - async viewInvite(req, res) { - const projectId = req.params.Project_id - const { token } = req.params - const _renderInvalidPage = function () { - res.status(404) - logger.debug({ projectId }, 'invite not valid, rendering not-valid page') - res.render('project/invite/not-valid', { title: 'Invalid Invite' }) - } - - // check if the user is already a member of the project - const currentUser = SessionManager.getSessionUser(req.session) - if (currentUser) { - const isMember = - await CollaboratorsGetter.promises.isUserInvitedMemberOfProject( - currentUser._id, - projectId - ) - if (isMember) { - logger.debug( - { projectId, userId: currentUser._id }, - 'user is already a member of this project, redirecting' - ) - return res.redirect(`/project/${projectId}`) - } - } - - // get the invite - const invite = await CollaboratorsInviteGetter.promises.getInviteByToken( - projectId, - token - ) - - // check if invite is gone, or otherwise non-existent - if (invite == null) { - logger.debug({ projectId }, 'no invite found for this token') - return _renderInvalidPage() - } - - // check the user who sent the invite exists - const owner = await UserGetter.promises.getUser( - { _id: invite.sendingUserId }, - { email: 1, first_name: 1, last_name: 1 } - ) - if (owner == null) { - logger.debug({ projectId }, 'no project owner found') - return _renderInvalidPage() - } - - // fetch the project name - const project = await ProjectGetter.promises.getProject(projectId, { - name: 1, - }) - if (project == null) { - logger.debug({ projectId }, 'no project found') - return _renderInvalidPage() - } - - if (!currentUser) { - req.session.sharedProjectData = { - project_name: project.name, - user_first_name: owner.first_name, - } - AuthenticationController.setRedirectInSession(req) - return res.redirect('/register') - } - - // cleanup if set for register page - delete req.session.sharedProjectData - - // finally render the invite - res.render('project/invite/show', { - invite, - token, - project, - owner, - title: 'Project Invite', - }) - }, - - async acceptInvite(req, res) { - const { Project_id: projectId, token } = req.params - const currentUser = SessionManager.getSessionUser(req.session) - logger.debug( - { projectId, userId: currentUser._id }, - 'got request to accept invite' - ) - - const invite = await CollaboratorsInviteGetter.promises.getInviteByToken( - projectId, - token - ) - - if (invite == null) { - throw new Errors.NotFoundError('no matching invite found') - } - - await ProjectAuditLogHandler.promises.addEntry( - projectId, - 'accept-invite', - currentUser._id, + 'resend-invite', + user._id, req.ip, { inviteId: invite._id, @@ -371,48 +252,154 @@ const CollaboratorsInviteController = { } ) - await CollaboratorsInviteHandler.promises.acceptInvite( - invite, - projectId, - currentUser - ) + res.sendStatus(201) + } else { + res.sendStatus(404) + } +} - await EditorRealTimeController.emitToRoom( - projectId, - 'project:membership:changed', - { invites: true, members: true } - ) - AnalyticsManager.recordEventForUserInBackground( - currentUser._id, - 'project-invite-accept', - { - projectId, - } - ) +async function viewInvite(req, res) { + const projectId = req.params.Project_id + const { token } = req.params + const _renderInvalidPage = function () { + res.status(404) + logger.debug({ projectId }, 'invite not valid, rendering not-valid page') + res.render('project/invite/not-valid', { title: 'Invalid Invite' }) + } - if (req.xhr) { - res.sendStatus(204) // Done async via project page notification - } else { - res.redirect(`/project/${projectId}`) + // check if the user is already a member of the project + const currentUser = SessionManager.getSessionUser(req.session) + if (currentUser) { + const isMember = + await CollaboratorsGetter.promises.isUserInvitedMemberOfProject( + currentUser._id, + projectId + ) + if (isMember) { + logger.debug( + { projectId, userId: currentUser._id }, + 'user is already a member of this project, redirecting' + ) + return res.redirect(`/project/${projectId}`) } - }, + } + + // get the invite + const invite = await CollaboratorsInviteGetter.promises.getInviteByToken( + projectId, + token + ) + + // check if invite is gone, or otherwise non-existent + if (invite == null) { + logger.debug({ projectId }, 'no invite found for this token') + return _renderInvalidPage() + } + + // check the user who sent the invite exists + const owner = await UserGetter.promises.getUser( + { _id: invite.sendingUserId }, + { email: 1, first_name: 1, last_name: 1 } + ) + if (owner == null) { + logger.debug({ projectId }, 'no project owner found') + return _renderInvalidPage() + } + + // fetch the project name + const project = await ProjectGetter.promises.getProject(projectId, { + name: 1, + }) + if (project == null) { + logger.debug({ projectId }, 'no project found') + return _renderInvalidPage() + } + + if (!currentUser) { + req.session.sharedProjectData = { + project_name: project.name, + user_first_name: owner.first_name, + } + AuthenticationController.setRedirectInSession(req) + return res.redirect('/register') + } + + // cleanup if set for register page + delete req.session.sharedProjectData + + // finally render the invite + res.render('project/invite/show', { + invite, + token, + project, + owner, + title: 'Project Invite', + }) } -export default { - promises: CollaboratorsInviteController, - getAllInvites: expressify(CollaboratorsInviteController.getAllInvites), - inviteToProject: expressify(CollaboratorsInviteController.inviteToProject), - revokeInvite: expressify(CollaboratorsInviteController.revokeInvite), - revokeInviteForUser: expressify( - CollaboratorsInviteController.revokeInviteForUser - ), - generateNewInvite: expressify( - CollaboratorsInviteController.generateNewInvite - ), - viewInvite: expressify(CollaboratorsInviteController.viewInvite), - acceptInvite: expressify(CollaboratorsInviteController.acceptInvite), - _checkShouldInviteEmail: callbackify( - CollaboratorsInviteController._checkShouldInviteEmail - ), - _checkRateLimit: callbackify(CollaboratorsInviteController._checkRateLimit), +async function acceptInvite(req, res) { + const { Project_id: projectId, token } = req.params + const currentUser = SessionManager.getSessionUser(req.session) + logger.debug( + { projectId, userId: currentUser._id }, + 'got request to accept invite' + ) + + const invite = await CollaboratorsInviteGetter.promises.getInviteByToken( + projectId, + token + ) + + if (invite == null) { + throw new Errors.NotFoundError('no matching invite found') + } + + await ProjectAuditLogHandler.promises.addEntry( + projectId, + 'accept-invite', + currentUser._id, + req.ip, + { + inviteId: invite._id, + privileges: invite.privileges, + } + ) + + await CollaboratorsInviteHandler.promises.acceptInvite( + invite, + projectId, + currentUser + ) + + await EditorRealTimeController.emitToRoom( + projectId, + 'project:membership:changed', + { invites: true, members: true } + ) + AnalyticsManager.recordEventForUserInBackground( + currentUser._id, + 'project-invite-accept', + { + projectId, + } + ) + + if (req.xhr) { + res.sendStatus(204) // Done async via project page notification + } else { + res.redirect(`/project/${projectId}`) + } } + +const CollaboratorsInviteController = { + getAllInvites: expressify(getAllInvites), + inviteToProject: expressify(inviteToProject), + revokeInvite: expressify(revokeInvite), + generateNewInvite: expressify(generateNewInvite), + viewInvite: expressify(viewInvite), + acceptInvite: expressify(acceptInvite), + _checkShouldInviteEmail, + _checkRateLimit, +} + +export default CollaboratorsInviteController diff --git a/services/web/app/src/Features/DocumentUpdater/DocumentUpdaterController.mjs b/services/web/app/src/Features/DocumentUpdater/DocumentUpdaterController.mjs index 80f42e26c6..d02b5a71f0 100644 --- a/services/web/app/src/Features/DocumentUpdater/DocumentUpdaterController.mjs +++ b/services/web/app/src/Features/DocumentUpdater/DocumentUpdaterController.mjs @@ -44,7 +44,4 @@ async function getDoc(req, res) { export default { getDoc: expressify(getDoc), - promises: { - getDoc, - }, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index bbf3802606..987acd7fe5 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -544,7 +544,7 @@ async function redirectToHostedPage(req, res) { res.redirect(url) } -async function _getRecommendedCurrency(req, res) { +async function getRecommendedCurrency(req, res) { const userId = SessionManager.getLoggedInUserId(req.session) let ip = req.ip if ( @@ -683,8 +683,6 @@ module.exports = { purchaseAddon, removeAddon, makeChangePreview, - promises: { - getRecommendedCurrency: _getRecommendedCurrency, - getLatamCountryBannerDetails, - }, + getRecommendedCurrency, + getLatamCountryBannerDetails, } diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js index 3c1818979f..79347a03b7 100644 --- a/services/web/app/src/Features/User/UserController.js +++ b/services/web/app/src/Features/User/UserController.js @@ -16,7 +16,7 @@ const HttpErrorHandler = require('../Errors/HttpErrorHandler') const OError = require('@overleaf/o-error') const EmailHandler = require('../Email/EmailHandler') const UrlHelper = require('../Helpers/UrlHelper') -const { promisify, callbackify } = require('util') +const { promisify } = require('util') const { expressify } = require('@overleaf/promise-utils') const { acceptsJson, @@ -212,11 +212,7 @@ async function ensureAffiliationMiddleware(req, res, next) { return next() } } - try { - await ensureAffiliation(user) - } catch (error) { - return next(error) - } + await ensureAffiliation(user) return next() } @@ -505,13 +501,9 @@ module.exports = { subscribe: expressify(subscribe), unsubscribe: expressify(unsubscribe), updateUserSettings: expressify(updateUserSettings), - doLogout: callbackify(doLogout), logout: expressify(logout), expireDeletedUser: expressify(expireDeletedUser), expireDeletedUsersAfterDuration: expressify(expireDeletedUsersAfterDuration), - promises: { - doLogout, - ensureAffiliation, - ensureAffiliationMiddleware, - }, + ensureAffiliationMiddleware: expressify(ensureAffiliationMiddleware), + ensureAffiliation, } diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index da3ac2958b..44175b455a 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -314,7 +314,7 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { '/user/emails', AuthenticationController.requireLogin(), PermissionsController.useCapabilities(), - UserController.promises.ensureAffiliationMiddleware, + UserController.ensureAffiliationMiddleware, UserEmailsController.list ) webRouter.get( diff --git a/services/web/scripts/ensure_affiliations.mjs b/services/web/scripts/ensure_affiliations.mjs index f6376614e7..98d9fd8b04 100644 --- a/services/web/scripts/ensure_affiliations.mjs +++ b/services/web/scripts/ensure_affiliations.mjs @@ -15,7 +15,7 @@ const query = { async function _handleEnsureAffiliation(user) { try { - await UserController.promises.ensureAffiliation(user) + await UserController.ensureAffiliation(user) console.log(`✔ ${user._id}`) success.push(user._id) } catch (error) { diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.mjs index 1ce63d4793..b806d5c773 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.mjs @@ -238,17 +238,17 @@ describe('CollaboratorsInviteController', function () { }) describe('when all goes well', function (done) { - beforeEach(function (done) { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail = - sinon.stub().resolves(true) - this.CollaboratorsInviteController.promises._checkRateLimit = sinon + beforeEach(async function () { + this.CollaboratorsInviteController._checkShouldInviteEmail = sinon .stub() .resolves(true) - this.res.callback = () => done() - this.CollaboratorsInviteController.inviteToProject( + this.CollaboratorsInviteController._checkRateLimit = sinon + .stub() + .resolves(true) + + await this.CollaboratorsInviteController.inviteToProject( this.req, - this.res, - done + this.res ) }) @@ -269,10 +269,11 @@ describe('CollaboratorsInviteController', function () { }) it('should have called _checkShouldInviteEmail', function () { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail.callCount.should.equal( + this.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( 1 ) - this.CollaboratorsInviteController.promises._checkShouldInviteEmail + + this.CollaboratorsInviteController._checkShouldInviteEmail .calledWith(this.targetEmail) .should.equal(true) }) @@ -322,9 +323,10 @@ describe('CollaboratorsInviteController', function () { describe('readAndWrite collaborator', function () { beforeEach(function (done) { this.privileges = 'readAndWrite' - this.CollaboratorsInviteController.promises._checkShouldInviteEmail = - sinon.stub().resolves(true) - this.CollaboratorsInviteController.promises._checkRateLimit = sinon + this.CollaboratorsInviteController._checkShouldInviteEmail = sinon + .stub() + .resolves(true) + this.CollaboratorsInviteController._checkRateLimit = sinon .stub() .resolves(true) this.res.callback = () => done() @@ -343,10 +345,10 @@ describe('CollaboratorsInviteController', function () { }) it('should not have called _checkShouldInviteEmail', function () { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail.callCount.should.equal( + this.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( 0 ) - this.CollaboratorsInviteController.promises._checkShouldInviteEmail + this.CollaboratorsInviteController._checkShouldInviteEmail .calledWith(this.currentUser, this.targetEmail) .should.equal(false) }) @@ -364,9 +366,10 @@ describe('CollaboratorsInviteController', function () { email: this.targetEmail, privileges: (this.privileges = 'readOnly'), } - this.CollaboratorsInviteController.promises._checkShouldInviteEmail = - sinon.stub().resolves(true) - this.CollaboratorsInviteController.promises._checkRateLimit = sinon + this.CollaboratorsInviteController._checkShouldInviteEmail = sinon + .stub() + .resolves(true) + this.CollaboratorsInviteController._checkRateLimit = sinon .stub() .resolves(true) this.res.callback = () => done() @@ -391,10 +394,10 @@ describe('CollaboratorsInviteController', function () { }) it('should have called _checkShouldInviteEmail', function () { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail.callCount.should.equal( + this.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( 1 ) - this.CollaboratorsInviteController.promises._checkShouldInviteEmail + this.CollaboratorsInviteController._checkShouldInviteEmail .calledWith(this.targetEmail) .should.equal(true) }) @@ -438,9 +441,10 @@ describe('CollaboratorsInviteController', function () { describe('when all goes well', function (done) { beforeEach(function (done) { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail = - sinon.stub().resolves(true) - this.CollaboratorsInviteController.promises._checkRateLimit = sinon + this.CollaboratorsInviteController._checkShouldInviteEmail = sinon + .stub() + .resolves(true) + this.CollaboratorsInviteController._checkRateLimit = sinon .stub() .resolves(true) this.res.callback = () => done() @@ -468,10 +472,10 @@ describe('CollaboratorsInviteController', function () { }) it('should have called _checkShouldInviteEmail', function () { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail.callCount.should.equal( + this.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( 1 ) - this.CollaboratorsInviteController.promises._checkShouldInviteEmail + this.CollaboratorsInviteController._checkShouldInviteEmail .calledWith(this.targetEmail) .should.equal(true) }) @@ -513,9 +517,10 @@ describe('CollaboratorsInviteController', function () { describe('when the user is not allowed to add more collaborators', function () { beforeEach(function (done) { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail = - sinon.stub().resolves(true) - this.CollaboratorsInviteController.promises._checkRateLimit = sinon + this.CollaboratorsInviteController._checkShouldInviteEmail = sinon + .stub() + .resolves(true) + this.CollaboratorsInviteController._checkRateLimit = sinon .stub() .resolves(true) this.LimitationsManager.promises.canAddXCollaborators.resolves(false) @@ -533,10 +538,10 @@ describe('CollaboratorsInviteController', function () { }) it('should not have called _checkShouldInviteEmail', function () { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail.callCount.should.equal( + this.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( 0 ) - this.CollaboratorsInviteController.promises._checkShouldInviteEmail + this.CollaboratorsInviteController._checkShouldInviteEmail .calledWith(this.currentUser, this.targetEmail) .should.equal(false) }) @@ -550,9 +555,10 @@ describe('CollaboratorsInviteController', function () { describe('when canAddXCollaborators produces an error', function () { beforeEach(function (done) { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail = - sinon.stub().resolves(true) - this.CollaboratorsInviteController.promises._checkRateLimit = sinon + this.CollaboratorsInviteController._checkShouldInviteEmail = sinon + .stub() + .resolves(true) + this.CollaboratorsInviteController._checkRateLimit = sinon .stub() .resolves(true) this.LimitationsManager.promises.canAddXCollaborators.rejects( @@ -572,10 +578,10 @@ describe('CollaboratorsInviteController', function () { }) it('should not have called _checkShouldInviteEmail', function () { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail.callCount.should.equal( + this.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( 0 ) - this.CollaboratorsInviteController.promises._checkShouldInviteEmail + this.CollaboratorsInviteController._checkShouldInviteEmail .calledWith(this.currentUser, this.targetEmail) .should.equal(false) }) @@ -589,9 +595,10 @@ describe('CollaboratorsInviteController', function () { describe('when inviteToProject produces an error', function () { beforeEach(function (done) { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail = - sinon.stub().resolves(true) - this.CollaboratorsInviteController.promises._checkRateLimit = sinon + this.CollaboratorsInviteController._checkShouldInviteEmail = sinon + .stub() + .resolves(true) + this.CollaboratorsInviteController._checkRateLimit = sinon .stub() .resolves(true) this.CollaboratorsInviteHandler.promises.inviteToProject.rejects( @@ -620,10 +627,10 @@ describe('CollaboratorsInviteController', function () { }) it('should have called _checkShouldInviteEmail', function () { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail.callCount.should.equal( + this.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( 1 ) - this.CollaboratorsInviteController.promises._checkShouldInviteEmail + this.CollaboratorsInviteController._checkShouldInviteEmail .calledWith(this.targetEmail) .should.equal(true) }) @@ -645,9 +652,10 @@ describe('CollaboratorsInviteController', function () { describe('when _checkShouldInviteEmail disallows the invite', function () { beforeEach(function (done) { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail = - sinon.stub().resolves(false) - this.CollaboratorsInviteController.promises._checkRateLimit = sinon + this.CollaboratorsInviteController._checkShouldInviteEmail = sinon + .stub() + .resolves(false) + this.CollaboratorsInviteController._checkRateLimit = sinon .stub() .resolves(true) this.res.callback = () => done() @@ -667,10 +675,10 @@ describe('CollaboratorsInviteController', function () { }) it('should have called _checkShouldInviteEmail', function () { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail.callCount.should.equal( + this.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( 1 ) - this.CollaboratorsInviteController.promises._checkShouldInviteEmail + this.CollaboratorsInviteController._checkShouldInviteEmail .calledWith(this.targetEmail) .should.equal(true) }) @@ -684,9 +692,10 @@ describe('CollaboratorsInviteController', function () { describe('when _checkShouldInviteEmail produces an error', function () { beforeEach(function (done) { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail = - sinon.stub().rejects(new Error('woops')) - this.CollaboratorsInviteController.promises._checkRateLimit = sinon + this.CollaboratorsInviteController._checkShouldInviteEmail = sinon + .stub() + .rejects(new Error('woops')) + this.CollaboratorsInviteController._checkRateLimit = sinon .stub() .resolves(true) this.next.callsFake(() => done()) @@ -703,10 +712,10 @@ describe('CollaboratorsInviteController', function () { }) it('should have called _checkShouldInviteEmail', function () { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail.callCount.should.equal( + this.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( 1 ) - this.CollaboratorsInviteController.promises._checkShouldInviteEmail + this.CollaboratorsInviteController._checkShouldInviteEmail .calledWith(this.targetEmail) .should.equal(true) }) @@ -721,9 +730,10 @@ describe('CollaboratorsInviteController', function () { describe('when the user invites themselves to the project', function () { beforeEach(function () { this.req.body.email = this.currentUser.email - this.CollaboratorsInviteController.promises._checkShouldInviteEmail = - sinon.stub().resolves(true) - this.CollaboratorsInviteController.promises._checkRateLimit = sinon + this.CollaboratorsInviteController._checkShouldInviteEmail = sinon + .stub() + .resolves(true) + this.CollaboratorsInviteController._checkRateLimit = sinon .stub() .resolves(true) this.CollaboratorsInviteController.inviteToProject( @@ -748,7 +758,7 @@ describe('CollaboratorsInviteController', function () { }) it('should not have called _checkShouldInviteEmail', function () { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail.callCount.should.equal( + this.CollaboratorsInviteController._checkShouldInviteEmail.callCount.should.equal( 0 ) }) @@ -765,14 +775,14 @@ describe('CollaboratorsInviteController', function () { }) describe('when _checkRateLimit returns false', function () { - beforeEach(function (done) { - this.CollaboratorsInviteController.promises._checkShouldInviteEmail = - sinon.stub().resolves(true) - this.CollaboratorsInviteController.promises._checkRateLimit = sinon + beforeEach(async function () { + this.CollaboratorsInviteController._checkShouldInviteEmail = sinon + .stub() + .resolves(true) + this.CollaboratorsInviteController._checkRateLimit = sinon .stub() .resolves(false) - this.res.callback = () => done() - this.CollaboratorsInviteController.inviteToProject( + await this.CollaboratorsInviteController.inviteToProject( this.req, this.res, this.next @@ -1281,7 +1291,7 @@ describe('CollaboratorsInviteController', function () { Project_id: this.projectId, invite_id: this.invite._id.toString(), } - this.CollaboratorsInviteController.promises._checkRateLimit = sinon + this.CollaboratorsInviteController._checkRateLimit = sinon .stub() .resolves(true) }) @@ -1316,7 +1326,7 @@ describe('CollaboratorsInviteController', function () { }) it('should check the rate limit', function () { - this.CollaboratorsInviteController.promises._checkRateLimit.callCount.should.equal( + this.CollaboratorsInviteController._checkRateLimit.callCount.should.equal( 1 ) }) @@ -1600,12 +1610,8 @@ describe('CollaboratorsInviteController', function () { describe('when we should be restricting to existing accounts', function () { beforeEach(function () { this.settings.restrictInvitesToExistingAccounts = true - this.call = callback => { - this.CollaboratorsInviteController._checkShouldInviteEmail( - this.email, - callback - ) - } + this.call = () => + this.CollaboratorsInviteController._checkShouldInviteEmail(this.email) }) describe('when user account is present', function () { @@ -1614,12 +1620,12 @@ describe('CollaboratorsInviteController', function () { this.UserGetter.promises.getUserByAnyEmail.resolves(this.user) }) - it('should callback with `true`', function (done) { - this.call((err, shouldAllow) => { - expect(err).to.equal(null) - expect(shouldAllow).to.equal(true) - done() - }) + it('should callback with `true`', async function () { + const shouldAllow = + await this.CollaboratorsInviteController._checkShouldInviteEmail( + this.email + ) + expect(shouldAllow).to.equal(true) }) }) @@ -1629,25 +1635,22 @@ describe('CollaboratorsInviteController', function () { this.UserGetter.promises.getUserByAnyEmail.resolves(this.user) }) - it('should callback with `false`', function (done) { - this.call((err, shouldAllow) => { - expect(err).to.equal(null) - expect(shouldAllow).to.equal(false) - done() - }) + it('should callback with `false`', async function () { + const shouldAllow = + await this.CollaboratorsInviteController._checkShouldInviteEmail( + this.email + ) + expect(shouldAllow).to.equal(false) }) - it('should have called getUser', function (done) { - this.call((err, shouldAllow) => { - if (err) { - return done(err) - } - this.UserGetter.promises.getUserByAnyEmail.callCount.should.equal(1) - this.UserGetter.promises.getUserByAnyEmail - .calledWith(this.email, { _id: 1 }) - .should.equal(true) - done() - }) + it('should have called getUser', async function () { + await this.CollaboratorsInviteController._checkShouldInviteEmail( + this.email + ) + this.UserGetter.promises.getUserByAnyEmail.callCount.should.equal(1) + this.UserGetter.promises.getUserByAnyEmail + .calledWith(this.email, { _id: 1 }) + .should.equal(true) }) }) @@ -1657,13 +1660,12 @@ describe('CollaboratorsInviteController', function () { this.UserGetter.promises.getUserByAnyEmail.rejects(new Error('woops')) }) - it('should callback with an error', function (done) { - this.call((err, shouldAllow) => { - expect(err).to.not.equal(null) - expect(err).to.be.instanceof(Error) - expect(shouldAllow).to.equal(undefined) - done() - }) + it('should callback with an error', async function () { + await expect( + this.CollaboratorsInviteController._checkShouldInviteEmail( + this.email + ) + ).to.be.rejected }) }) }) @@ -1678,90 +1680,60 @@ describe('CollaboratorsInviteController', function () { .resolves(17) }) - it('should callback with `true` when rate limit under', function (done) { - this.CollaboratorsInviteController._checkRateLimit( - this.currentUserId, - (err, result) => { - if (err) { - return done(err) - } - expect(this.rateLimiter.consume).to.have.been.calledWith( - this.currentUserId - ) - result.should.equal(true) - done() - } + it('should callback with `true` when rate limit under', async function () { + const result = await this.CollaboratorsInviteController._checkRateLimit( + this.currentUserId ) + expect(this.rateLimiter.consume).to.have.been.calledWith( + this.currentUserId + ) + result.should.equal(true) }) - it('should callback with `false` when rate limit hit', function (done) { + it('should callback with `false` when rate limit hit', async function () { this.rateLimiter.consume.rejects({ remainingPoints: 0 }) - this.CollaboratorsInviteController._checkRateLimit( + const result = await this.CollaboratorsInviteController._checkRateLimit( + this.currentUserId + ) + expect(this.rateLimiter.consume).to.have.been.calledWith( + this.currentUserId + ) + result.should.equal(false) + }) + + it('should allow 10x the collaborators', async function () { + await this.CollaboratorsInviteController._checkRateLimit( + this.currentUserId + ) + expect(this.rateLimiter.consume).to.have.been.calledWith( this.currentUserId, - (err, result) => { - if (err) { - return done(err) - } - expect(this.rateLimiter.consume).to.have.been.calledWith( - this.currentUserId - ) - result.should.equal(false) - done() - } + Math.floor(40000 / 170) ) }) - it('should allow 10x the collaborators', function (done) { - this.CollaboratorsInviteController._checkRateLimit( - this.currentUserId, - (err, result) => { - if (err) { - return done(err) - } - expect(this.rateLimiter.consume).to.have.been.calledWith( - this.currentUserId, - Math.floor(40000 / 170) - ) - done() - } - ) - }) - - it('should allow 200 requests when collaborators is -1', function (done) { + it('should allow 200 requests when collaborators is -1', async function () { this.LimitationsManager.promises.allowedNumberOfCollaboratorsForUser .withArgs(this.currentUserId) .resolves(-1) - this.CollaboratorsInviteController._checkRateLimit( + await this.CollaboratorsInviteController._checkRateLimit( + this.currentUserId + ) + expect(this.rateLimiter.consume).to.have.been.calledWith( this.currentUserId, - (err, result) => { - if (err) { - return done(err) - } - expect(this.rateLimiter.consume).to.have.been.calledWith( - this.currentUserId, - Math.floor(40000 / 200) - ) - done() - } + Math.floor(40000 / 200) ) }) - it('should allow 10 requests when user has no collaborators set', function (done) { + it('should allow 10 requests when user has no collaborators set', async function () { this.LimitationsManager.promises.allowedNumberOfCollaboratorsForUser .withArgs(this.currentUserId) .resolves(null) - this.CollaboratorsInviteController._checkRateLimit( + await this.CollaboratorsInviteController._checkRateLimit( + this.currentUserId + ) + expect(this.rateLimiter.consume).to.have.been.calledWith( this.currentUserId, - (err, result) => { - if (err) { - return done(err) - } - expect(this.rateLimiter.consume).to.have.been.calledWith( - this.currentUserId, - Math.floor(40000 / 10) - ) - done() - } + Math.floor(40000 / 10) ) }) }) diff --git a/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterControllerTests.mjs b/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterControllerTests.mjs index d78b01e467..6a783d452e 100644 --- a/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterControllerTests.mjs +++ b/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterControllerTests.mjs @@ -38,6 +38,7 @@ describe('DocumentUpdaterController', function () { } this.lines = ['test', '', 'testing'] this.res = new MockResponse() + this.next = sinon.stub() this.doc = { name: 'myfile.tex' } }) @@ -53,8 +54,7 @@ describe('DocumentUpdaterController', function () { }) it('should call the document updater handler with the project_id and doc_id', async function () { - await this.controller.promises.getDoc(this.req, this.res) - + await this.controller.getDoc(this.req, this.res, this.next) expect( this.DocumentUpdaterHandler.promises.getDocument ).to.have.been.calledOnceWith( @@ -65,13 +65,14 @@ describe('DocumentUpdaterController', function () { }) it('should return the content', async function () { - await this.controller.promises.getDoc(this.req, this.res) + await this.controller.getDoc(this.req, this.res) + expect(this.next).to.not.have.been.called expect(this.res.statusCode).to.equal(200) expect(this.res.body).to.equal('test\n\ntesting') }) it('should find the doc in the project', async function () { - await this.controller.promises.getDoc(this.req, this.res) + await this.controller.getDoc(this.req, this.res) expect( this.ProjectLocator.promises.findElement ).to.have.been.calledOnceWith({ @@ -82,7 +83,7 @@ describe('DocumentUpdaterController', function () { }) it('should set the Content-Disposition header', async function () { - await this.controller.promises.getDoc(this.req, this.res) + await this.controller.getDoc(this.req, this.res) expect(this.res.setContentDisposition).to.have.been.calledWith( 'attachment', { filename: this.doc.name } diff --git a/services/web/test/unit/src/User/UserControllerTests.js b/services/web/test/unit/src/User/UserControllerTests.js index 874220f3cd..717d136a09 100644 --- a/services/web/test/unit/src/User/UserControllerTests.js +++ b/services/web/test/unit/src/User/UserControllerTests.js @@ -916,7 +916,7 @@ describe('UserController', function () { describe('ensureAffiliationMiddleware', function () { describe('without affiliations feature', function () { beforeEach(async function () { - await this.UserController.promises.ensureAffiliationMiddleware( + await this.UserController.ensureAffiliationMiddleware( this.req, this.res, this.next @@ -938,7 +938,7 @@ describe('UserController', function () { describe('without ensureAffiliation query parameter', function () { beforeEach(async function () { this.Features.hasFeature.withArgs('affiliations').returns(true) - await this.UserController.promises.ensureAffiliationMiddleware( + await this.UserController.ensureAffiliationMiddleware( this.req, this.res, this.next @@ -968,7 +968,7 @@ describe('UserController', function () { ] this.Features.hasFeature.withArgs('affiliations').returns(true) this.req.query.ensureAffiliation = true - await this.UserController.promises.ensureAffiliationMiddleware( + await this.UserController.ensureAffiliationMiddleware( this.req, this.res, this.next @@ -1005,7 +1005,7 @@ describe('UserController', function () { this.Features.hasFeature.withArgs('affiliations').returns(true) this.req.query.ensureAffiliation = true this.req.assertPermission = sinon.stub() - await this.UserController.promises.ensureAffiliationMiddleware( + await this.UserController.ensureAffiliationMiddleware( this.req, this.res, this.next @@ -1047,7 +1047,7 @@ describe('UserController', function () { this.Features.hasFeature.withArgs('affiliations').returns(true) this.req.query.ensureAffiliation = true this.req.assertPermission = sinon.stub() - await this.UserController.promises.ensureAffiliationMiddleware( + await this.UserController.ensureAffiliationMiddleware( this.req, this.res, this.next @@ -1089,7 +1089,7 @@ describe('UserController', function () { this.Features.hasFeature.withArgs('affiliations').returns(true) this.req.query.ensureAffiliation = true this.req.assertPermission = sinon.stub() - await this.UserController.promises.ensureAffiliationMiddleware( + await this.UserController.ensureAffiliationMiddleware( this.req, this.res, this.next From f9cc0c3bf4218fe122549b6679c408d2f6e1d7d8 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Fri, 17 Jan 2025 09:06:22 +0100 Subject: [PATCH 0058/1724] [web] Scope ds-nav split test to project list (#22689) * Add `dsNavStyle` prop, so `sidebar-navigation-ui-update` doesn't change all pages * Use `useIsDsNav` instead of `useSplitTestContext` * Create a `useDsNavStyle` hook * Use `useDsNavStyle` * Add comment on `useIsDsNav` and `NavStyleContext` * Revert "Hide nav dropdown chevron icon in welcome page" This reverts commit 78b5ba85 * Move `DsNavStyleProvider` usage to project-list-ds-nav.tsx * Fix typo * Simplify `useDsNavStyle` conditions GitOrigin-RevId: df3fe66d772919c40df69d357bee6949ab413928 --- .../components/add-affiliation.tsx | 6 ++--- .../components/project-list-root.tsx | 20 +++++++------- .../components/sidebar/sidebar-filters.tsx | 6 ++--- .../project-list/components/use-is-ds-nav.ts | 6 ----- .../project-list/components/use-is-ds-nav.tsx | 27 +++++++++++++++++++ .../bootstrap-5/navbar/account-menu-items.tsx | 6 ++--- .../bootstrap-5/navbar/nav-dropdown-menu.tsx | 8 +++--- .../pages/project-list-ds-nav.scss | 5 ---- .../bootstrap-5/pages/project-list.scss | 5 ---- 9 files changed, 49 insertions(+), 40 deletions(-) delete mode 100644 services/web/frontend/js/features/project-list/components/use-is-ds-nav.ts create mode 100644 services/web/frontend/js/features/project-list/components/use-is-ds-nav.tsx diff --git a/services/web/frontend/js/features/project-list/components/add-affiliation.tsx b/services/web/frontend/js/features/project-list/components/add-affiliation.tsx index 0a60301b1a..64a653d697 100644 --- a/services/web/frontend/js/features/project-list/components/add-affiliation.tsx +++ b/services/web/frontend/js/features/project-list/components/add-affiliation.tsx @@ -3,7 +3,7 @@ import { useProjectListContext } from '../context/project-list-context' import getMeta from '../../../utils/meta' import classNames from 'classnames' import OLButton from '@/features/ui/components/ol/ol-button' -import { useIsDsNav } from '@/features/project-list/components/use-is-ds-nav' +import { useDsNavStyle } from '@/features/project-list/components/use-is-ds-nav' export function useAddAffiliation() { const { totalProjectsCount } = useProjectListContext() @@ -22,7 +22,7 @@ type AddAffiliationProps = { function AddAffiliation({ className }: AddAffiliationProps) { const { t } = useTranslation() const { show } = useAddAffiliation() - const isDsNav = useIsDsNav() + const dsNavStyle = useDsNavStyle() if (!show) { return null @@ -32,7 +32,7 @@ function AddAffiliation({ className }: AddAffiliationProps) { return (
-

+

{t('are_you_affiliated_with_an_institution')}

diff --git a/services/web/frontend/js/features/project-list/components/project-list-root.tsx b/services/web/frontend/js/features/project-list/components/project-list-root.tsx index 684aaa8025..420b2454ab 100644 --- a/services/web/frontend/js/features/project-list/components/project-list-root.tsx +++ b/services/web/frontend/js/features/project-list/components/project-list-root.tsx @@ -3,10 +3,7 @@ import { ProjectListProvider, useProjectListContext, } from '../context/project-list-context' -import { - SplitTestProvider, - useSplitTestContext, -} from '@/shared/context/split-test-context' +import { SplitTestProvider } from '@/shared/context/split-test-context' import { ColorPickerProvider } from '../context/color-picker-context' import * as eventTracking from '../../../infrastructure/event-tracking' import { useTranslation } from 'react-i18next' @@ -21,6 +18,10 @@ import Footer from '@/features/ui/components/bootstrap-5/footer/footer' import WelcomePageContent from '@/features/project-list/components/welcome-page-content' import ProjectListDefault from '@/features/project-list/components/project-list-default' import { ProjectListDsNav } from '@/features/project-list/components/project-list-ds-nav' +import { + DsNavStyleProvider, + useIsDsNav, +} from '@/features/project-list/components/use-is-ds-nav' function ProjectListRoot() { const { isReady } = useWaitForI18n() @@ -75,16 +76,13 @@ function ProjectListPageContent() { const { totalProjectsCount, isLoading, loadProgress } = useProjectListContext() - const { splitTestVariants } = useSplitTestContext() - useEffect(() => { eventTracking.sendMB('loads_v2_dash', {}) }, []) const { t } = useTranslation() - const hasDsNav = - splitTestVariants['sidebar-navigation-ui-update'] === 'active' + const hasDsNav = useIsDsNav() if (isLoading) { const loadingComponent = ( @@ -109,7 +107,11 @@ function ProjectListPageContent() { ) } else if (hasDsNav) { - return + return ( + + + + ) } else { return ( diff --git a/services/web/frontend/js/features/project-list/components/sidebar/sidebar-filters.tsx b/services/web/frontend/js/features/project-list/components/sidebar/sidebar-filters.tsx index 34d8e4c1b3..799ccbd6f3 100644 --- a/services/web/frontend/js/features/project-list/components/sidebar/sidebar-filters.tsx +++ b/services/web/frontend/js/features/project-list/components/sidebar/sidebar-filters.tsx @@ -5,7 +5,7 @@ import { } from '../../context/project-list-context' import TagsList from './tags-list' import ProjectsFilterMenu from '../projects-filter-menu' -import { useSplitTestContext } from '@/shared/context/split-test-context' +import { useIsDsNav } from '@/features/project-list/components/use-is-ds-nav' type SidebarFilterProps = { filter: Filter @@ -30,9 +30,7 @@ export function SidebarFilter({ filter, text }: SidebarFilterProps) { export default function SidebarFilters() { const { t } = useTranslation() - const { splitTestVariants } = useSplitTestContext() - const hasDsNav = - splitTestVariants['sidebar-navigation-ui-update'] === 'active' + const hasDsNav = useIsDsNav() return (
    diff --git a/services/web/frontend/js/features/project-list/components/use-is-ds-nav.ts b/services/web/frontend/js/features/project-list/components/use-is-ds-nav.ts deleted file mode 100644 index 0d44c46a08..0000000000 --- a/services/web/frontend/js/features/project-list/components/use-is-ds-nav.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { useSplitTestContext } from '@/shared/context/split-test-context' - -export const useIsDsNav = () => { - const { splitTestVariants } = useSplitTestContext() - return splitTestVariants['sidebar-navigation-ui-update'] === 'active' -} diff --git a/services/web/frontend/js/features/project-list/components/use-is-ds-nav.tsx b/services/web/frontend/js/features/project-list/components/use-is-ds-nav.tsx new file mode 100644 index 0000000000..9687f906c3 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/use-is-ds-nav.tsx @@ -0,0 +1,27 @@ +import { createContext, type FC, type ReactNode, useContext } from 'react' +import { useSplitTestContext } from '@/shared/context/split-test-context' + +/** + * This hook returns whether the user has the split-test assignment 'sidebar-navigation-ui-update' + */ +export const useIsDsNav = () => { + const { splitTestVariants } = useSplitTestContext() + return splitTestVariants['sidebar-navigation-ui-update'] === 'active' +} + +/** + * This context wraps elements that should be styled according to the sidebar-navigation-ui-update redesign + * It doesn't exactly match the split-test assignment because it's only used in the project-list page + */ +const DsNavStyleContext = createContext(undefined) + +export const DsNavStyleProvider: FC<{ + children: ReactNode +}> = ({ children }) => ( + {children} +) + +export const useDsNavStyle = () => { + const context = useContext(DsNavStyleContext) + return context ?? false +} 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 cbd0d2c73f..825d56c100 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 @@ -5,7 +5,7 @@ import type { NavbarSessionUser } from '@/features/ui/components/types/navbar' import DropdownListItem from '@/features/ui/components/bootstrap-5/dropdown-list-item' import NavDropdownDivider from './nav-dropdown-divider' import NavDropdownLinkItem from './nav-dropdown-link-item' -import { useIsDsNav } from '@/features/project-list/components/use-is-ds-nav' +import { useDsNavStyle } from '@/features/project-list/components/use-is-ds-nav' import { SignOut } from '@phosphor-icons/react' export function AccountMenuItems({ @@ -17,7 +17,7 @@ export function AccountMenuItems({ }) { const { t } = useTranslation() const logOutFormId = 'logOutForm' - const isDsNav = useIsDsNav() + const dsNavStyle = useDsNavStyle() return ( <> @@ -48,7 +48,7 @@ export function AccountMenuItems({ className="d-flex align-items-center justify-content-between" > {t('log_out')} - {isDsNav && } + {dsNavStyle && }
    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 97f457ef9f..6c588eef46 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,7 +1,7 @@ import { type ReactNode, useState } from 'react' import { Dropdown } from 'react-bootstrap-5' import { CaretUp, CaretDown } from '@phosphor-icons/react' -import { useIsDsNav } from '@/features/project-list/components/use-is-ds-nav' +import { useDsNavStyle } from '@/features/project-list/components/use-is-ds-nav' export default function NavDropdownMenu({ title, @@ -15,7 +15,7 @@ export default function NavDropdownMenu({ onToggle?: (nextShow: boolean) => void }) { const [show, setShow] = useState(false) - const isDsNav = useIsDsNav() + const dsNavStyle = useDsNavStyle() // Can't use a NavDropdown here because it's impossible to render the menu as // a
      element using NavDropdown const Caret = show ? CaretUp : CaretDown @@ -31,9 +31,7 @@ export default function NavDropdownMenu({ > {title} - {isDsNav && ( - - )} + {dsNavStyle && } {children} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list-ds-nav.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list-ds-nav.scss index f9126d17b0..dbca1d51f4 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list-ds-nav.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list-ds-nav.scss @@ -316,9 +316,4 @@ @include body-xs; } } - - // Only show the dropdown caret icon in the DS nav version of the project dashboard - .navbar-dropdown-caret { - display: unset; - } } 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 4427cbdfba..c6e61099c7 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss @@ -775,8 +775,3 @@ form.project-search { inset: 0; } } - -// Hide expander caret icon by default so that it only ever appears in the DS nav version of the project dashboard -.navbar-dropdown-caret { - display: none; -} From 7bef003c5673b5bf8f0bc400d8d7e372779c716d Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Fri, 17 Jan 2025 09:06:55 +0100 Subject: [PATCH 0059/1724] [real-time, web] Create a UI to test socket connection (#22907) * Create a UI to test socket connection * Add Clock Delta to the measurements * Add colors to DiagnosticItem * Update icon * Add more info to the diagnostics screen * Add logs in backend on debug messages, disconnections and connection * Add last received ping info * Reorder DiagnosticItems * Remove "warning" text color (too light) * Replace Phosphor icons by Material Icons GitOrigin-RevId: 6a015b4928cd19849ff287cf254f671840ed44af --- services/real-time/app/js/Router.js | 93 +++++--- .../SocketDiagnostics/SocketDiagnostics.mjs | 11 + services/web/app/src/router.mjs | 3 + .../project/editor/socket_diagnostics.pug | 20 ++ .../components/diagnostic-component.tsx | 71 +++++++ .../components/socket-diagnostics.tsx | 201 ++++++++++++++++++ .../socket-diagnostics/components/types.ts | 29 +++ .../components/use-socket-manager.ts | 152 +++++++++++++ .../frontend/js/pages/socket-diagnostics.tsx | 10 + 9 files changed, 564 insertions(+), 26 deletions(-) create mode 100644 services/web/app/src/Features/SocketDiagnostics/SocketDiagnostics.mjs create mode 100644 services/web/app/views/project/editor/socket_diagnostics.pug create mode 100644 services/web/frontend/js/features/socket-diagnostics/components/diagnostic-component.tsx create mode 100644 services/web/frontend/js/features/socket-diagnostics/components/socket-diagnostics.tsx create mode 100644 services/web/frontend/js/features/socket-diagnostics/components/types.ts create mode 100644 services/web/frontend/js/features/socket-diagnostics/components/use-socket-manager.ts create mode 100644 services/web/frontend/js/pages/socket-diagnostics.tsx diff --git a/services/real-time/app/js/Router.js b/services/real-time/app/js/Router.js index 8aaad2a164..37845015b0 100644 --- a/services/real-time/app/js/Router.js +++ b/services/real-time/app/js/Router.js @@ -169,20 +169,27 @@ module.exports = Router = { } return } - + const isDebugging = !!client.handshake?.query?.debugging const projectId = client.handshake?.query?.projectId - try { - Joi.assert(projectId, JOI_OBJECT_ID) - } catch (error) { - metrics.inc('socket-io.connection', 1, { - status: client.transport, - method: projectId ? 'bad-project-id' : 'missing-project-id', - }) - client.emit('connectionRejected', { - message: 'missing/bad ?projectId=... query flag on handshake', - }) - client.disconnect() - return + + if (isDebugging) { + client.connectedAt = Date.now() + } + + if (!isDebugging) { + try { + Joi.assert(projectId, JOI_OBJECT_ID) + } catch (error) { + metrics.inc('socket-io.connection', 1, { + status: client.transport, + method: projectId ? 'bad-project-id' : 'missing-project-id', + }) + client.emit('connectionRejected', { + message: 'missing/bad ?projectId=... query flag on handshake', + }) + client.disconnect() + return + } } // The client.id is security sensitive. Generate a publicId for sending to other clients. @@ -198,7 +205,10 @@ module.exports = Router = { }) metrics.gauge('socket-io.clients', io.sockets.clients().length) - logger.debug({ session, clientId: client.id }, 'client connected') + logger.debug( + { session, clientId: client.id, isDebugging }, + 'client connected' + ) let user if (session && session.passport && session.passport.user) { @@ -222,7 +232,30 @@ module.exports = Router = { callback(HOSTNAME) }) } + client.on('debug', (data, callback) => { + if (typeof callback !== 'function') { + return Router._handleInvalidArguments(client, 'debug', arguments) + } + logger.debug({ clientId: client.id }, 'received debug message') + + const response = { + serverTime: Date.now(), + data, + client: { + publicId: client.publicId, + remoteIp: client.remoteIp, + userAgent: client.userAgent, + connected: !client.disconnected, + connectedAt: client.connectedAt, + }, + server: { + hostname: settings.exposeHostname ? HOSTNAME : undefined, + }, + } + + callback(response) + }) const joinProject = function (callback) { WebsocketController.joinProject( client, @@ -245,6 +278,12 @@ module.exports = Router = { metrics.inc('socket-io.disconnect', 1, { status: client.transport }) metrics.gauge('socket-io.clients', io.sockets.clients().length) + if (client.isDebugging) { + const duration = Date.now() - client.connectedAt + metrics.timing('socket-io.debugging.duration', duration) + logger.debug({ duration }, 'debug client disconnected') + } + WebsocketController.leaveProject(io, client, function (err) { if (err) { Router._handleError(function () {}, err, client, 'leaveProject') @@ -435,19 +474,21 @@ module.exports = Router = { ) }) - joinProject((err, project, permissionsLevel, protocolVersion) => { - if (err) { - client.emit('connectionRejected', err) - client.disconnect() - return - } - client.emit('joinProjectResponse', { - publicId: client.publicId, - project, - permissionsLevel, - protocolVersion, + if (!isDebugging) { + joinProject((err, project, permissionsLevel, protocolVersion) => { + if (err) { + client.emit('connectionRejected', err) + client.disconnect() + return + } + client.emit('joinProjectResponse', { + publicId: client.publicId, + project, + permissionsLevel, + protocolVersion, + }) }) - }) + } }) }, } diff --git a/services/web/app/src/Features/SocketDiagnostics/SocketDiagnostics.mjs b/services/web/app/src/Features/SocketDiagnostics/SocketDiagnostics.mjs new file mode 100644 index 0000000000..74672bde4e --- /dev/null +++ b/services/web/app/src/Features/SocketDiagnostics/SocketDiagnostics.mjs @@ -0,0 +1,11 @@ +import { expressify } from '@overleaf/promise-utils' + +const index = async (req, res) => { + res.render('project/editor/socket_diagnostics') +} + +const SocketDiagnostics = { + index: expressify(index), +} + +export default SocketDiagnostics diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 44175b455a..125fdfd385 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -66,6 +66,7 @@ import logger from '@overleaf/logger' import _ from 'lodash' import { plainTextResponse } from './infrastructure/Response.js' import PublicAccessLevels from './Features/Authorization/PublicAccessLevels.js' +import SocketDiagnostics from './Features/SocketDiagnostics/SocketDiagnostics.mjs' const ClsiCookieManager = ClsiCookieManagerFactory( Settings.apis.clsi != null ? Settings.apis.clsi.backendGroupName : undefined ) @@ -231,6 +232,8 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { webRouter.get('/account-suspended', UserPagesController.accountSuspended) + webRouter.get('/socket-diagnostics', SocketDiagnostics.index) + if (Settings.enableLegacyLogin) { AuthenticationController.addEndpointToLoginWhitelist('/login/legacy') webRouter.get('/login/legacy', UserPagesController.loginPage) diff --git a/services/web/app/views/project/editor/socket_diagnostics.pug b/services/web/app/views/project/editor/socket_diagnostics.pug new file mode 100644 index 0000000000..7093bc8343 --- /dev/null +++ b/services/web/app/views/project/editor/socket_diagnostics.pug @@ -0,0 +1,20 @@ +extends ../../layout-marketing + +block vars + - var suppressNavbar = true + - var suppressFooter = true + - var suppressGoogleAnalytics = true + - bootstrap5PageStatus = 'enabled' + - isWebsiteRedesign = 'true' + +block entrypointVar + - entrypoint = 'pages/socket-diagnostics' + +block append meta + +block content + main.content.content-alt#main-content + #socket-diagnostics + +block prepend foot-scripts + script(type="text/javascript", nonce=scriptNonce, src=(wsUrl || '/socket.io') + '/socket.io.js', defer=deferScripts) 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 new file mode 100644 index 0000000000..7adddc5329 --- /dev/null +++ b/services/web/frontend/js/features/socket-diagnostics/components/diagnostic-component.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import classnames from 'classnames' +import type { ConnectionStatus } from './types' +import { Badge, Button } from 'react-bootstrap-5' +import OLNotification from '@/features/ui/components/ol/ol-notification' +import MaterialIcon from '@/shared/components/material-icon' + +const variants = { + connected: 'success', + connecting: 'warning', + disconnected: 'danger', +} + +export const ConnectionBadge = ({ state }: { state: ConnectionStatus }) => ( + + {state} + +) + +export const DiagnosticItem = ({ + icon, + label, + value, + type, +}: { + icon: string + label: string + value: React.ReactNode + type?: 'success' | 'danger' +}) => ( +
      +
      + + {label} +
      +
      {value}
      +
      +) + +export function ErrorAlert({ message }: { message: string }) { + return +} + +export function ActionButton({ + label, + icon, + onClick, + disabled, +}: { + label: string + icon: string + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} 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 new file mode 100644 index 0000000000..03d16427cf --- /dev/null +++ b/services/web/frontend/js/features/socket-diagnostics/components/socket-diagnostics.tsx @@ -0,0 +1,201 @@ +import React from 'react' +import type { ConnectionStatus } from './types' +import { useSocketManager } from './use-socket-manager' +import { + ActionButton, + ConnectionBadge, + DiagnosticItem, + ErrorAlert, +} from './diagnostic-component' +import { Container } from 'react-bootstrap-5' +import MaterialIcon from '@/shared/components/material-icon' + +type NetworkInformation = { + downlink: number + effectiveType: string + rtt: number + saveData: boolean + type: string +} + +const NavigatorInfo = () => { + if (!('connection' in navigator)) { + return
      Network Information API not supported
      + } + + const connection = navigator.connection as NetworkInformation + return ( + <> +
      Downlink: {connection.downlink} Mbps
      +
      Effective Type: {connection.effectiveType}
      +
      Round Trip Time: {connection.rtt} ms
      +
      Save Data: {connection.saveData ? 'Enabled' : 'Disabled'}
      +
      Platform: {navigator.platform}
      + {/* @ts-ignore */} +
      Device Memory: {navigator.deviceMemory}
      +
      Hardware Concurrency: {navigator.hardwareConcurrency}
      + + ) +} + +export const SocketDiagnostics = () => { + const { socketState, debugInfo, disconnectSocket, forceReconnect, socket } = + useSocketManager() + + const getConnectionState = (): ConnectionStatus => { + if (socketState.connected) return 'connected' + if (socketState.connecting) return 'connecting' + return 'disconnected' + } + + const lastReceivedS = debugInfo.lastReceived + ? Math.round((Date.now() - debugInfo.lastReceived) / 1000) + : null + + return ( + +

      Socket Diagnostics

      + + +
      + + +
      + + {socketState.lastError && } + +
      +

      + Connection Stats +

      +
      + + {debugInfo.received} / {debugInfo.sent} + {lastReceivedS !== null && ( + <> +
      + Last received {lastReceivedS}s ago + + )} + + } + type={ + lastReceivedS !== null + ? lastReceivedS < 4 + ? 'success' + : 'danger' + : undefined + } + /> + + + {debugInfo.latency} ms +
      + Max: {debugInfo.maxLatency} ms + + ) : ( + '-' + ) + } + type={ + debugInfo.latency + ? debugInfo.latency < 150 + ? 'success' + : 'danger' + : undefined + } + /> + + + + + + {new Date(debugInfo.client.connectedAt).toUTCString()} ( + {Math.round( + (Date.now() - debugInfo.client.connectedAt) / 1000 + )} + s) + + ) : ( + '-' + ) + } + /> + + + + + + } + /> +
      +
      +
      + ) +} diff --git a/services/web/frontend/js/features/socket-diagnostics/components/types.ts b/services/web/frontend/js/features/socket-diagnostics/components/types.ts new file mode 100644 index 0000000000..edfc9bdf44 --- /dev/null +++ b/services/web/frontend/js/features/socket-diagnostics/components/types.ts @@ -0,0 +1,29 @@ +export interface SocketState { + connected: boolean + connecting: boolean + lastError: string +} + +export interface DebugInfo { + sent: number + received: number + latency: number | null + maxLatency: number | null + clockDelta: number | null + onLine: boolean | null + client: Client | null + lastReceived: number | null +} + +interface Client { + id: string + publicId: string + remoteIp: string + userAgent: string + connected: boolean + readable: boolean + ackPackets: number + connectedAt: number +} + +export type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' diff --git a/services/web/frontend/js/features/socket-diagnostics/components/use-socket-manager.ts b/services/web/frontend/js/features/socket-diagnostics/components/use-socket-manager.ts new file mode 100644 index 0000000000..4ff97cc7c1 --- /dev/null +++ b/services/web/frontend/js/features/socket-diagnostics/components/use-socket-manager.ts @@ -0,0 +1,152 @@ +import { useState, useEffect, useCallback } from 'react' +import SocketIoShim from '@/ide/connection/SocketIoShim' +import type { Socket } from '@/features/ide-react/connection/types/socket' +import type { DebugInfo, SocketState } from './types' + +export function useSocketManager() { + const [socket, setSocket] = useState(null) + + const [socketState, setSocketState] = useState({ + connected: false, + connecting: false, + lastError: '', + }) + + const [debugInfo, setDebugInfo] = useState({ + sent: 0, + received: 0, + latency: null, + maxLatency: null, + onLine: null, + clockDelta: null, + client: null, + lastReceived: null, + }) + + const connectSocket = useCallback(() => { + const parsedURL = new URL('/socket.io', window.origin) + + setSocketState(prev => ({ + ...prev, + connecting: true, + lastAttempt: Date.now(), + })) + + const newSocket = SocketIoShim.connect(parsedURL.origin, { + resource: parsedURL.pathname.slice(1), + 'auto connect': false, + 'connect timeout': 30 * 1000, + 'force new connection': true, + query: new URLSearchParams({ debugging: 'true' }).toString(), + reconnect: false, + }) as unknown as Socket + + setSocket(newSocket) + return newSocket + }, []) + + const disconnectSocket = useCallback(() => { + socket?.disconnect() + setSocket(null) + setSocketState(prev => ({ + ...prev, + connected: false, + connecting: false, + lastError: 'Manually disconnected', + })) + }, [socket]) + + const forceReconnect = useCallback(() => { + disconnectSocket() + setTimeout(connectSocket, 1000) + }, [disconnectSocket, connectSocket]) + + useEffect(() => { + connectSocket() + }, [connectSocket]) + + useEffect(() => { + if (!socket) return + + const statsInterval = setInterval(() => { + if (socket.socket.connected) { + setDebugInfo(prev => ({ ...prev, sent: prev.sent + 1 })) + socket.emit('debug', { time: Date.now() }, (info: any) => { + const beforeTime = info.data.time + const now = Date.now() + const latency = now - beforeTime + const clockDelta = (beforeTime + beforeTime) / 2 - info.serverTime + setDebugInfo(prev => ({ + ...prev, + received: prev.received + 1, + latency, + maxLatency: Math.max(prev.maxLatency ?? 0, latency), + clockDelta, + client: info.client, + lastReceived: now, + })) + }) + } + }, 2000) + + socket.on('connect', () => { + setSocketState(prev => ({ + ...prev, + connected: true, + connecting: false, + lastSuccess: Date.now(), + lastError: '', + })) + }) + + socket.on('disconnect', (reason: string) => { + setSocketState(prev => ({ + ...prev, + connected: false, + connecting: false, + lastError: `Disconnected: ${reason}`, + })) + }) + + socket.on('connect_error', (error: Error) => { + setSocketState(prev => ({ + ...prev, + connecting: false, + lastError: `Connection error: ${error?.message || 'Unknown'}`, + })) + }) + + socket.socket.connect() + + return () => { + clearInterval(statsInterval) + socket.disconnect() + } + }, [socket]) + + useEffect(() => { + const updateNetworkInfo = () => { + if ('connection' in navigator) { + setDebugInfo(prev => ({ ...prev, onLine: navigator.onLine })) + } + } + + window.addEventListener('online', updateNetworkInfo) + window.addEventListener('offline', updateNetworkInfo) + updateNetworkInfo() + + return () => { + window.removeEventListener('online', updateNetworkInfo) + window.removeEventListener('offline', updateNetworkInfo) + } + }, []) + + return { + socketState, + debugInfo, + connectSocket, + disconnectSocket, + forceReconnect, + socket, + } +} diff --git a/services/web/frontend/js/pages/socket-diagnostics.tsx b/services/web/frontend/js/pages/socket-diagnostics.tsx new file mode 100644 index 0000000000..982209b378 --- /dev/null +++ b/services/web/frontend/js/pages/socket-diagnostics.tsx @@ -0,0 +1,10 @@ +import '../marketing' + +import ReactDOM from 'react-dom' +import { SocketDiagnostics } from '@/features/socket-diagnostics/components/socket-diagnostics' + +const socketDiagnosticsContainer = document.getElementById('socket-diagnostics') + +if (socketDiagnosticsContainer) { + ReactDOM.render(, socketDiagnosticsContainer) +} From 58c9f6e76e2f5ce192ff39253f6e908f48b4f740 Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Fri, 17 Jan 2025 11:20:33 +0100 Subject: [PATCH 0060/1724] Hide resolve/edit/delete comment options for users without permissions (#22891) GitOrigin-RevId: b3d2c1ba03ee836596abfc3da1260dec5a0a7714 --- .../review-panel-comment-options.tsx | 29 ++++++++++++------- .../components/review-panel-message.tsx | 12 ++++++-- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-comment-options.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-comment-options.tsx index d0f4fb8f66..eacb1fa134 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-comment-options.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-comment-options.tsx @@ -28,10 +28,15 @@ const ReviewPanelCommentOptions: FC<{ onEdit: () => void onDelete: () => void id: string - belongsToCurrentUser: boolean -}> = ({ onEdit, onDelete, id, belongsToCurrentUser }) => { + canEdit: boolean + canDelete: boolean +}> = ({ onEdit, onDelete, id, canEdit, canDelete }) => { const { t } = useTranslation() + if (!canEdit && !canDelete) { + return null + } + return ( - {belongsToCurrentUser && ( - {t('edit')} + {canEdit && {t('edit')}} + {canDelete && ( + {t('delete')} )} - {t('delete')} } @@ -70,18 +75,20 @@ const ReviewPanelCommentOptions: FC<{ /> - {belongsToCurrentUser && ( + {canEdit && (
    • {t('edit')}
    • )} -
    • - - {t('delete')} - -
    • + {canDelete && ( +
    • + + {t('delete')} + +
    • + )}
      } diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-message.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-message.tsx index 295633a492..34cb336be9 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-message.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-message.tsx @@ -13,6 +13,7 @@ import { ExpandableContent } from './review-panel-expandable-content' import ReviewPanelDeleteCommentModal from './review-panel-delete-comment-modal' import { useUserContext } from '@/shared/context/user-context' import ReviewPanelEntryUser from './review-panel-entry-user' +import { usePermissionsContext } from '@/features/ide-react/context/permissions-context' export const ReviewPanelMessage: FC<{ message: ReviewPanelCommentThreadMessage @@ -36,6 +37,12 @@ export const ReviewPanelMessage: FC<{ const [deleting, setDeleting] = useState(false) const [content, setContent] = useState(message.content) const user = useUserContext() + const { write, trackedWrite } = usePermissionsContext() + + const isCommentAuthor = user.id === message.user.id + const canEdit = isCommentAuthor + const canResolve = write || (trackedWrite && isCommentAuthor) + const canDelete = write || (trackedWrite && isCommentAuthor) const handleEditOption = useCallback(() => setEditing(true), []) const showDeleteModal = useCallback(() => setDeleting(true), []) @@ -62,7 +69,7 @@ export const ReviewPanelMessage: FC<{
- {!editing && !isReply && !isThreadResolved && ( + {!editing && !isReply && !isThreadResolved && canResolve && ( Date: Fri, 17 Jan 2025 10:25:07 +0000 Subject: [PATCH 0061/1724] Merge pull request #22882 from overleaf/jpa-file-tree-script [web] scripts/find_malformed_filetrees: flag missing file hash and folder arrays GitOrigin-RevId: 8561a59856486bd6903f84a99434d0bd81acb175 --- .../web/scripts/find_malformed_filetrees.mjs | 89 +++++++++---------- .../web/scripts/fix_malformed_filetree.mjs | 9 ++ 2 files changed, 52 insertions(+), 46 deletions(-) diff --git a/services/web/scripts/find_malformed_filetrees.mjs b/services/web/scripts/find_malformed_filetrees.mjs index 2b3901b510..a5cae0bda4 100644 --- a/services/web/scripts/find_malformed_filetrees.mjs +++ b/services/web/scripts/find_malformed_filetrees.mjs @@ -47,62 +47,59 @@ function findBadPaths(folder) { result.push('name') } - if (folder.folders) { - if (Array.isArray(folder.folders)) { - for (const [i, subfolder] of folder.folders.entries()) { - if (!subfolder || typeof subfolder !== 'object') { - result.push(`folders.${i}`) - continue - } - for (const badPath of findBadPaths(subfolder)) { - result.push(`folders.${i}.${badPath}`) - } + if (folder.folders && Array.isArray(folder.folders)) { + for (const [i, subfolder] of folder.folders.entries()) { + if (!subfolder || typeof subfolder !== 'object') { + result.push(`folders.${i}`) + continue + } + for (const badPath of findBadPaths(subfolder)) { + result.push(`folders.${i}.${badPath}`) } - } else { - result.push('folders') } + } else { + result.push('folders') } - if (folder.docs) { - if (Array.isArray(folder.docs)) { - for (const [i, doc] of folder.docs.entries()) { - if (!doc || typeof doc !== 'object') { - result.push(`docs.${i}`) - continue - } - if (!doc._id) { - result.push(`docs.${i}._id`) - // no need to check further: this doc can be deleted - continue - } - if (typeof doc.name !== 'string' || !doc.name) { - result.push(`docs.${i}.name`) - } + if (folder.docs && Array.isArray(folder.docs)) { + for (const [i, doc] of folder.docs.entries()) { + if (!doc || typeof doc !== 'object') { + result.push(`docs.${i}`) + continue + } + if (!doc._id) { + result.push(`docs.${i}._id`) + // no need to check further: this doc can be deleted + continue + } + if (typeof doc.name !== 'string' || !doc.name) { + result.push(`docs.${i}.name`) } - } else { - result.push('docs') } + } else { + result.push('docs') } - if (folder.fileRefs) { - if (Array.isArray(folder.fileRefs)) { - for (const [i, file] of folder.fileRefs.entries()) { - if (!file || typeof file !== 'object') { - result.push(`fileRefs.${i}`) - continue - } - if (!file._id) { - result.push(`fileRefs.${i}._id`) - // no need to check further: this file can be deleted - continue - } - if (typeof file.name !== 'string' || !file.name) { - result.push(`fileRefs.${i}.name`) - } + if (folder.fileRefs && Array.isArray(folder.fileRefs)) { + for (const [i, file] of folder.fileRefs.entries()) { + if (!file || typeof file !== 'object') { + result.push(`fileRefs.${i}`) + continue + } + if (!file._id) { + result.push(`fileRefs.${i}._id`) + // no need to check further: this file can be deleted + continue + } + if (typeof file.name !== 'string' || !file.name) { + result.push(`fileRefs.${i}.name`) + } + if (typeof file.hash !== 'string' || !file.hash) { + result.push(`fileRefs.${i}.hash`) } - } else { - result.push('fileRefs') } + } else { + result.push('fileRefs') } return result } diff --git a/services/web/scripts/fix_malformed_filetree.mjs b/services/web/scripts/fix_malformed_filetree.mjs index 4182fa8c76..2caf26aac7 100644 --- a/services/web/scripts/fix_malformed_filetree.mjs +++ b/services/web/scripts/fix_malformed_filetree.mjs @@ -29,6 +29,11 @@ async function main() { ) } else if (isName(mongoPath)) { modifiedCount = await fixName(projectId, mongoPath) + } else if (isHash(mongoPath)) { + console.error(`Missing file hash: ${mongoPath}`) + console.error('SaaS: likely needs filestore restore') + console.error('Server Pro: please reach out to support') + process.exit(1) } else { console.error(`Unexpected mongo path: ${mongoPath}`) process.exit(1) @@ -72,6 +77,10 @@ function isName(path) { return /\.name$/.test(path) } +function isHash(path) { + return /\.hash$/.test(path) +} + function parentPath(path) { return path.slice(0, path.lastIndexOf('.')) } From fdf7a34f8f2adf887a7dcf902a80ee2966c67166 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Fri, 17 Jan 2025 10:32:23 +0000 Subject: [PATCH 0062/1724] Avoid shifting the layout of the project dashboard when items are selected (#22937) GitOrigin-RevId: 98f153efddfcc3c11712010e607cc1a308e74279 --- .../frontend/stylesheets/bootstrap-5/pages/project-list.scss | 3 +++ 1 file changed, 3 insertions(+) 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 c6e61099c7..c71ab65be6 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss @@ -155,6 +155,9 @@ .project-tools { flex-shrink: 0; margin-left: auto; + min-height: 38px; + display: flex; + align-items: center; } @include media-breakpoint-down(md) { From ce1d63d92c48b7eed487523f3ead0d7fd3ac6017 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Fri, 17 Jan 2025 10:32:45 +0000 Subject: [PATCH 0063/1724] Create a shared module for CSS styles from user settings (#22925) GitOrigin-RevId: 1e62258e1e38d8ab2ce8debc51c53a98f4e915f6 --- .../settings/settings-font-family.tsx | 2 +- .../settings/settings-line-height.tsx | 2 +- .../settings/settings-overall-theme.tsx | 2 +- .../js/features/history/extensions/theme.ts | 29 ++++---------- .../source-editor/extensions/theme.ts | 40 +++++++------------ .../hooks/use-codemirror-scope.ts | 7 +--- .../web/frontend/js/shared/utils/styles.ts | 29 ++++++++++++++ services/web/types/project-settings.ts | 2 +- services/web/types/user-settings.ts | 6 +-- 9 files changed, 59 insertions(+), 60 deletions(-) create mode 100644 services/web/frontend/js/shared/utils/styles.ts diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx index cfa62a1826..5a327093a4 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx @@ -1,8 +1,8 @@ import { useTranslation } from 'react-i18next' -import { FontFamily } from '../../../source-editor/extensions/theme' import { useProjectSettingsContext } from '../../context/project-settings-context' import SettingsMenuSelect from './settings-menu-select' import BetaBadge from '@/shared/components/beta-badge' +import { FontFamily } from '@/shared/utils/styles' export default function SettingsFontFamily() { const { t } = useTranslation() diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-line-height.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-line-height.tsx index 6694306fe5..b0d5201771 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-line-height.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-line-height.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next' -import type { LineHeight } from '../../../source-editor/extensions/theme' import { useProjectSettingsContext } from '../../context/project-settings-context' import SettingsMenuSelect from './settings-menu-select' +import { LineHeight } from '@/shared/utils/styles' export default function SettingsLineHeight() { const { t } = useTranslation() diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-overall-theme.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-overall-theme.tsx index 9e2d846a35..f63be25433 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-overall-theme.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-overall-theme.tsx @@ -5,8 +5,8 @@ import getMeta from '../../../../utils/meta' import SettingsMenuSelect, { Option } from './settings-menu-select' import { useProjectSettingsContext } from '../../context/project-settings-context' import type { OverallThemeMeta } from '../../../../../../types/project-settings' -import type { OverallTheme } from '../../../source-editor/extensions/theme' import { isIEEEBranded } from '@/utils/is-ieee-branded' +import { OverallTheme } from '@/shared/utils/styles' export default function SettingsOverallTheme() { const { t } = useTranslation() diff --git a/services/web/frontend/js/features/history/extensions/theme.ts b/services/web/frontend/js/features/history/extensions/theme.ts index fda9c3185b..268b9d2dfd 100644 --- a/services/web/frontend/js/features/history/extensions/theme.ts +++ b/services/web/frontend/js/features/history/extensions/theme.ts @@ -1,8 +1,6 @@ import { EditorView } from '@codemirror/view' import { Compartment, TransactionSpec } from '@codemirror/state' - -export type FontFamily = 'monaco' | 'lucida' | 'opendyslexicmono' -export type LineHeight = 'compact' | 'normal' | 'wide' +import { FontFamily, LineHeight, userStyles } from '@/shared/utils/styles' export type Options = { fontSize: number @@ -17,31 +15,20 @@ export const theme = (options: Options) => [ optionsThemeConf.of(createThemeFromOptions(options)), ] -export const lineHeights: Record = { - compact: 1.33, - normal: 1.6, - wide: 2, -} - -const fontFamilies: Record = { - monaco: ['Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'monospace'], - lucida: ['Lucida Console', 'Source Code Pro', 'monospace'], - opendyslexicmono: ['OpenDyslexic Mono', 'monospace'], -} - const createThemeFromOptions = ({ fontSize = 12, fontFamily = 'monaco', lineHeight = 'normal', }: Options) => { // Theme styles that depend on settings - const fontFamilyValue = fontFamilies[fontFamily]?.join(', ') + const styles = userStyles({ fontSize, fontFamily, lineHeight }) + return [ EditorView.editorAttributes.of({ style: Object.entries({ - '--font-size': `${fontSize}px`, - '--source-font-family': fontFamilyValue, - '--line-height': lineHeights[lineHeight], + '--font-size': styles.fontSize, + '--source-font-family': styles.fontFamily, + '--line-height': styles.lineHeight, }) .map(([key, value]) => `${key}: ${value}`) .join(';'), @@ -50,8 +37,8 @@ const createThemeFromOptions = ({ // TODO: set these on document.body, or a new container element for the tooltips, without using a style mod EditorView.theme({ '.cm-tooltip': { - '--font-size': `${fontSize}px`, - '--source-font-family': fontFamilyValue, + '--font-size': styles.fontSize, + '--source-font-family': styles.fontFamily, }, }), ] diff --git a/services/web/frontend/js/features/source-editor/extensions/theme.ts b/services/web/frontend/js/features/source-editor/extensions/theme.ts index f38289cce2..233f2c8dac 100644 --- a/services/web/frontend/js/features/source-editor/extensions/theme.ts +++ b/services/web/frontend/js/features/source-editor/extensions/theme.ts @@ -3,15 +3,17 @@ import { Annotation, Compartment, TransactionSpec } from '@codemirror/state' import { syntaxHighlighting } from '@codemirror/language' import { classHighlighter } from './class-highlighter' import classNames from 'classnames' +import { + FontFamily, + LineHeight, + OverallTheme, + userStyles, +} from '@/shared/utils/styles' const optionsThemeConf = new Compartment() const selectedThemeConf = new Compartment() export const themeOptionsChange = Annotation.define() -export type FontFamily = 'monaco' | 'lucida' | 'opendyslexicmono' -export type LineHeight = 'compact' | 'normal' | 'wide' -export type OverallTheme = '' | 'light-' - type Options = { fontSize: number fontFamily: FontFamily @@ -53,18 +55,6 @@ const svgUrl = (content: string) => `${content}` )}')` -export const lineHeights: Record = { - compact: 1.33, - normal: 1.6, - wide: 2, -} - -const fontFamilies: Record = { - monaco: ['Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'monospace'], - lucida: ['Lucida Console', 'Source Code Pro', 'monospace'], - opendyslexicmono: ['OpenDyslexic Mono', 'monospace'], -} - const createThemeFromOptions = ({ fontSize = 12, fontFamily = 'monaco', @@ -72,9 +62,9 @@ const createThemeFromOptions = ({ overallTheme = '', bootstrapVersion = 3, }: Options) => { - /** - * Theme styles that depend on settings. - */ + // Theme styles that depend on settings. + const styles = userStyles({ fontSize, fontFamily, lineHeight }) + return [ EditorView.editorAttributes.of({ class: classNames( @@ -82,9 +72,9 @@ const createThemeFromOptions = ({ 'bootstrap-' + bootstrapVersion ), style: Object.entries({ - '--font-size': `${fontSize}px`, - '--source-font-family': fontFamilies[fontFamily]?.join(', '), - '--line-height': lineHeights[lineHeight], + '--font-size': styles.fontSize, + '--source-font-family': styles.fontFamily, + '--line-height': styles.lineHeight, }) .map(([key, value]) => `${key}: ${value}`) .join(';'), @@ -93,9 +83,9 @@ const createThemeFromOptions = ({ // TODO: set these on document.body, or a new container element for the tooltips, without using a style mod EditorView.theme({ '.cm-tooltip': { - '--font-size': `${fontSize}px`, - '--source-font-family': fontFamilies[fontFamily]?.join(', '), - '--line-height': lineHeights[lineHeight], + '--font-size': styles.fontSize, + '--source-font-family': styles.fontFamily, + '--line-height': styles.lineHeight, }, }), ] diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index 7d6a8e6589..c4a1090431 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts @@ -5,11 +5,7 @@ import useScopeEventEmitter from '../../../shared/hooks/use-scope-event-emitter' import useEventListener from '../../../shared/hooks/use-event-listener' import useScopeEventListener from '../../../shared/hooks/use-scope-event-listener' import { createExtensions } from '../extensions' -import { - lineHeights, - setEditorTheme, - setOptionsTheme, -} from '../extensions/theme' +import { setEditorTheme, setOptionsTheme } from '../extensions/theme' import { restoreCursorPosition, setCursorLineAndScroll, @@ -63,6 +59,7 @@ import { useThreadsContext } from '@/features/review-panel-new/context/threads-c import { useHunspell } from '@/features/source-editor/hooks/use-hunspell' import { isBootstrap5 } from '@/features/utils/bootstrap-5' import { Permissions } from '@/features/ide-react/types/permissions' +import { lineHeights } from '@/shared/utils/styles' function useCodeMirrorScope(view: EditorView) { const { fileTreeData } = useFileTreeData() diff --git a/services/web/frontend/js/shared/utils/styles.ts b/services/web/frontend/js/shared/utils/styles.ts new file mode 100644 index 0000000000..2b30b3ed7c --- /dev/null +++ b/services/web/frontend/js/shared/utils/styles.ts @@ -0,0 +1,29 @@ +export type OverallTheme = '' | 'light-' + +export const fontFamilies = { + monaco: ['Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'monospace'], + lucida: ['Lucida Console', 'Source Code Pro', 'monospace'], + opendyslexicmono: ['OpenDyslexic Mono', 'monospace'], +} + +export type FontFamily = keyof typeof fontFamilies + +export const lineHeights = { + compact: 1.33, + normal: 1.6, + wide: 2, +} + +export type LineHeight = keyof typeof lineHeights + +type Options = { + fontFamily: FontFamily + fontSize: number + lineHeight: LineHeight +} + +export const userStyles = ({ fontFamily, fontSize, lineHeight }: Options) => ({ + fontFamily: fontFamilies[fontFamily]?.join(','), + fontSize: `${fontSize}px`, + lineHeight: lineHeights[lineHeight], +}) diff --git a/services/web/types/project-settings.ts b/services/web/types/project-settings.ts index dd649033b8..ec4f006197 100644 --- a/services/web/types/project-settings.ts +++ b/services/web/types/project-settings.ts @@ -1,5 +1,5 @@ -import { OverallTheme } from '../frontend/js/features/source-editor/extensions/theme' import { Brand } from './helpers/brand' +import { OverallTheme } from '@/shared/utils/styles' export type AllowedImageName = { imageDesc: string diff --git a/services/web/types/user-settings.ts b/services/web/types/user-settings.ts index a574454ddd..0f49d11dad 100644 --- a/services/web/types/user-settings.ts +++ b/services/web/types/user-settings.ts @@ -1,8 +1,4 @@ -import { - FontFamily, - LineHeight, - OverallTheme, -} from '@/features/source-editor/extensions/theme' +import { FontFamily, LineHeight, OverallTheme } from '@/shared/utils/styles' export type Keybindings = 'none' | 'default' | 'vim' | 'emacs' export type PdfViewer = 'pdfjs' | 'native' From 17ae05a055ce0f7785123d1a33cc68e037bf50ba Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Fri, 17 Jan 2025 10:33:17 +0000 Subject: [PATCH 0064/1724] Add cause to "spell check loading failed" error (#22923) GitOrigin-RevId: 37772c842bb9d92200c08fa7ce89ec0b85fe1b94 --- .../source-editor/hunspell/HunspellManager.ts | 11 ++++++++--- .../source-editor/hunspell/hunspell.worker.ts | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/services/web/frontend/js/features/source-editor/hunspell/HunspellManager.ts b/services/web/frontend/js/features/source-editor/hunspell/HunspellManager.ts index 690cee45b5..6513a07a49 100644 --- a/services/web/frontend/js/features/source-editor/hunspell/HunspellManager.ts +++ b/services/web/frontend/js/features/source-editor/hunspell/HunspellManager.ts @@ -160,9 +160,14 @@ export class HunspellManager { } else if (rest.loaded) { this.loaded = true } else if (rest.loadingFailed) { - captureException(new Error('Spell check loading failed'), { - tags: { ol_spell_check_language: this.language }, - }) + captureException( + new Error('Spell check loading failed', { + cause: rest.loadingFailed, + }), + { + tags: { ol_spell_check_language: this.language }, + } + ) this.loadingFailed = true this.pendingMessages.length = 0 } diff --git a/services/web/frontend/js/features/source-editor/hunspell/hunspell.worker.ts b/services/web/frontend/js/features/source-editor/hunspell/hunspell.worker.ts index a287bbdb4a..b056434b37 100644 --- a/services/web/frontend/js/features/source-editor/hunspell/hunspell.worker.ts +++ b/services/web/frontend/js/features/source-editor/hunspell/hunspell.worker.ts @@ -171,7 +171,7 @@ self.addEventListener('message', async event => { self.postMessage({ loaded: true }) } catch (error) { console.error(error) - self.postMessage({ loadingFailed: true }) + self.postMessage({ loadingFailed: error }) } break From 413c108c28396c211ac7393ee57ef9303e9f4c9b Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Fri, 17 Jan 2025 10:42:01 +0000 Subject: [PATCH 0065/1724] Improve project search UI (#22909) GitOrigin-RevId: 83bc59269250afef3d25434b14151dbde5be5e5c --- services/web/frontend/extracted-translations.json | 4 ++++ .../js/features/ui/components/types/icon-button-props.ts | 2 +- services/web/frontend/js/shared/context/layout-context.tsx | 3 +-- services/web/locales/en.json | 4 ++++ 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 4e40711a4c..5d3bbf00df 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1168,6 +1168,10 @@ "project_ownership_transfer_confirmation_2": "", "project_renamed_or_deleted": "", "project_renamed_or_deleted_detail": "", + "project_search_file_count": "", + "project_search_file_count_plural": "", + "project_search_result_count": "", + "project_search_result_count_plural": "", "project_synced_with_git_repo_at": "", "project_synchronisation": "", "project_timed_out_enable_stop_on_first_error": "", diff --git a/services/web/frontend/js/features/ui/components/types/icon-button-props.ts b/services/web/frontend/js/features/ui/components/types/icon-button-props.ts index ec39180c7c..ec75bb6115 100644 --- a/services/web/frontend/js/features/ui/components/types/icon-button-props.ts +++ b/services/web/frontend/js/features/ui/components/types/icon-button-props.ts @@ -3,5 +3,5 @@ import { ButtonProps } from './button-props' export type IconButtonProps = ButtonProps & { accessibilityLabel?: string icon: string - type?: 'button' + type?: 'button' | 'submit' } diff --git a/services/web/frontend/js/shared/context/layout-context.tsx b/services/web/frontend/js/shared/context/layout-context.tsx index 05dcb28131..e285cb6b46 100644 --- a/services/web/frontend/js/shared/context/layout-context.tsx +++ b/services/web/frontend/js/shared/context/layout-context.tsx @@ -19,6 +19,7 @@ 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' export type IdeLayout = 'sideBySide' | 'flat' export type IdeView = 'editor' | 'file' | 'pdf' | 'history' @@ -55,8 +56,6 @@ type LayoutContextValue = { setProjectSearchIsOpen: Dispatch> } -const isMac = /Mac/.test(window.navigator?.platform) - const debugPdfDetach = getMeta('ol-debugPdfDetach') export const LayoutContext = createContext( diff --git a/services/web/locales/en.json b/services/web/locales/en.json index e617afe901..f758c24979 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1579,6 +1579,10 @@ "project_ownership_transfer_confirmation_2": "This action cannot be undone. The new owner will be notified and will be able to change project access settings (including removing your own access).", "project_renamed_or_deleted": "Project Renamed or Deleted", "project_renamed_or_deleted_detail": "This project has either been renamed or deleted by an external data source such as Dropbox. We don’t want to delete your data on Overleaf, so this project still contains your history and collaborators. If the project has been renamed please look in your project list for a new project under the new name.", + "project_search_file_count": "in __count__ file", + "project_search_file_count_plural": "in __count__ files", + "project_search_result_count": "__count__ result", + "project_search_result_count_plural": "__count__ results", "project_synced_with_git_repo_at": "This project is synced with the GitHub repository at", "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.", From ab4d1e0986011d2698253287bef3dbd58e88d18b Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Fri, 17 Jan 2025 11:22:37 +0000 Subject: [PATCH 0066/1724] Merge pull request #22870 from overleaf/jpa-back-fill-fix-up [history-v1] add script for fixing up back-fill errors GitOrigin-RevId: 118992a32c1f6da4289cd35399ddd07a741da4ee --- .../scripts/back_fill_file_hash_fix_up.mjs | 569 +++++++++++++ .../back_fill_file_hash_fix_up.test.mjs | 761 ++++++++++++++++++ 2 files changed, 1330 insertions(+) create mode 100644 services/history-v1/storage/scripts/back_fill_file_hash_fix_up.mjs create mode 100644 services/history-v1/test/acceptance/js/storage/back_fill_file_hash_fix_up.test.mjs diff --git a/services/history-v1/storage/scripts/back_fill_file_hash_fix_up.mjs b/services/history-v1/storage/scripts/back_fill_file_hash_fix_up.mjs new file mode 100644 index 0000000000..fc9ad2aa0f --- /dev/null +++ b/services/history-v1/storage/scripts/back_fill_file_hash_fix_up.mjs @@ -0,0 +1,569 @@ +// @ts-check +import Events from 'node:events' +import fs from 'node:fs' +import Stream from 'node:stream' +import { ObjectId } from 'mongodb' +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import { + BlobStore, + getStringLengthOfFile, + GLOBAL_BLOBS, + makeBlobForFile, +} from '../lib/blob_store/index.js' +import { db } from '../lib/mongodb.js' +import commandLineArgs from 'command-line-args' +import readline from 'node:readline' +import { _blobIsBackedUp, backupBlob } from '../lib/backupBlob.mjs' +import { NotFoundError } from '@overleaf/object-persistor/src/Errors.js' +import filestorePersistor from '../lib/persistor.js' + +// Silence warning. +Events.setMaxListeners(20) + +// Enable caching for ObjectId.toString() +ObjectId.cacheHexString = true + +/** + * @typedef {import("overleaf-editor-core").Blob} Blob + * @typedef {import("mongodb").Collection} Collection + * @typedef {import("mongodb").Collection} ProjectsCollection + * @typedef {import("mongodb").Collection<{project: Project}>} DeletedProjectsCollection + */ + +/** + * @typedef {Object} FileRef + * @property {ObjectId} _id + * @property {string} hash + */ + +/** + * @typedef {Object} Folder + * @property {Array} folders + * @property {Array} fileRefs + */ + +/** + * @typedef {Object} Project + * @property {ObjectId} _id + * @property {Array} rootFolder + * @property {{history: {id: (number|string)}}} overleaf + */ + +/** + * @return {{FIX_NOT_FOUND: boolean, FIX_HASH_MISMATCH: boolean, FIX_DELETE_PERMISSION: boolean, LOGS: string}} + */ +function parseArgs() { + const args = commandLineArgs([ + { name: 'fixNotFound', type: String, defaultValue: 'true' }, + { name: 'fixDeletePermission', type: String, defaultValue: 'true' }, + { name: 'fixHashMismatch', type: String, defaultValue: 'true' }, + { name: 'logs', type: String, defaultValue: '' }, + ]) + /** + * commandLineArgs cannot handle --foo=false, so go the long way + * @param {string} name + * @return {boolean} + */ + function boolVal(name) { + const v = args[name] + if (['true', 'false'].includes(v)) return v === 'true' + throw new Error(`expected "true" or "false" for boolean option ${name}`) + } + return { + FIX_HASH_MISMATCH: boolVal('fixNotFound'), + FIX_DELETE_PERMISSION: boolVal('fixDeletePermission'), + FIX_NOT_FOUND: boolVal('fixHashMismatch'), + LOGS: args.logs, + } +} + +const { FIX_HASH_MISMATCH, FIX_DELETE_PERMISSION, FIX_NOT_FOUND, LOGS } = + parseArgs() +if (!LOGS) { + throw new Error('--logs parameter missing') +} +const BUFFER_DIR = fs.mkdtempSync( + process.env.BUFFER_DIR_PREFIX || '/tmp/back_fill_file_hash-' +) +const USER_FILES_BUCKET_NAME = process.env.USER_FILES_BUCKET_NAME || '' +if (!USER_FILES_BUCKET_NAME) { + throw new Error('env var USER_FILES_BUCKET_NAME is missing') +} +// https://nodejs.org/api/stream.html#streamgetdefaulthighwatermarkobjectmode +const STREAM_HIGH_WATER_MARK = parseInt( + process.env.STREAM_HIGH_WATER_MARK || (64 * 1024).toString(), + 10 +) + +/** @type {ProjectsCollection} */ +const projectsCollection = db.collection('projects') +/** @type {DeletedProjectsCollection} */ +const deletedProjectsCollection = db.collection('deletedProjects') + +let gracefulShutdownInitiated = false + +process.on('SIGINT', handleSignal) +process.on('SIGTERM', handleSignal) + +function handleSignal() { + gracefulShutdownInitiated = true + console.warn('graceful shutdown initiated, draining queue') +} + +class FileDeletedError extends OError {} + +/** @type {Map} */ +const PROJECT_CACHE = new Map() + +/** + * @param {string} projectId + * @return {Promise<{project: Project, projectSoftDeleted: boolean}>} + */ +async function getProject(projectId) { + const cached = PROJECT_CACHE.get(projectId) + if (cached) return cached + + let projectSoftDeleted + let project = await projectsCollection.findOne({ + _id: new ObjectId(projectId), + }) + if (project) { + projectSoftDeleted = false + } else { + const softDeleted = await deletedProjectsCollection.findOne({ + 'deleterData.deletedProjectId': new ObjectId(projectId), + project: { $exists: true }, + }) + if (!softDeleted) { + throw new OError('project hard-deleted') + } + project = softDeleted.project + projectSoftDeleted = true + } + PROJECT_CACHE.set(projectId, { projectSoftDeleted, project }) + return { projectSoftDeleted, project } +} + +/** + * @param {Folder} folder + * @param {string} fileId + * @return {{path: string, fileRef: FileRef, folder: Folder}|null} + */ +function getFileTreePath(folder, fileId) { + if (!folder) return null + let idx = 0 + if (Array.isArray(folder.fileRefs)) { + for (const fileRef of folder.fileRefs) { + if (fileRef?._id.toString() === fileId) { + return { + fileRef, + path: `.fileRefs.${idx}`, + folder, + } + } + idx++ + } + } + idx = 0 + if (Array.isArray(folder.folders)) { + for (const child of folder.folders) { + const match = getFileTreePath(child, fileId) + if (match) { + return { + fileRef: match.fileRef, + folder: match.folder, + path: `.folders.${idx}${match.path}`, + } + } + idx++ + } + } + return null +} + +/** + * @param {string} projectId + * @param {string} fileId + * @return {Promise<{fileRef: FileRef, folder: Folder, fullPath: string, query: Object, projectSoftDeleted: boolean}>} + */ +async function findFile(projectId, fileId) { + const { projectSoftDeleted, project } = await getProject(projectId) + const match = getFileTreePath(project.rootFolder[0], fileId) + if (!match) { + throw new FileDeletedError('file not found in file-tree', { + projectSoftDeleted, + }) + } + const { path, fileRef, folder } = match + let fullPath + let query + if (projectSoftDeleted) { + fullPath = `project.rootFolder.0${path}` + query = { + 'deleterData.deletedProjectId': new ObjectId(projectId), + [`${fullPath}._id`]: new ObjectId(fileId), + } + } else { + fullPath = `rootFolder.0${path}` + query = { + _id: new ObjectId(projectId), + [`${fullPath}._id`]: new ObjectId(fileId), + } + } + return { + projectSoftDeleted, + query, + fullPath, + fileRef, + folder, + } +} + +/** + * @param {string} line + * @return {Promise} + */ +async function fixNotFound(line) { + const { projectId, fileId, bucketName } = JSON.parse(line) + if (bucketName !== USER_FILES_BUCKET_NAME) { + throw new OError('not found case for another bucket') + } + + const { projectSoftDeleted, query, fullPath, fileRef, folder } = + await findFile(projectId, fileId) + logger.info({ projectId, fileId, fileRef }, 'removing fileRef') + // Copied from _removeElementFromMongoArray (https://github.com/overleaf/internal/blob/11e09528c153de6b7766d18c3c90d94962190371/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js) + const nonArrayPath = fullPath.slice(0, fullPath.lastIndexOf('.')) + let result + if (projectSoftDeleted) { + result = await deletedProjectsCollection.updateOne(query, { + $pull: { [nonArrayPath]: { _id: new ObjectId(fileId) } }, + $inc: { 'project.version': 1 }, + }) + } else { + result = await projectsCollection.updateOne(query, { + $pull: { [nonArrayPath]: { _id: new ObjectId(fileId) } }, + $inc: { version: 1 }, + }) + } + if (result.matchedCount !== 1) { + throw new OError('file-tree write did not match', { result }) + } + // Update the cache. The mongo-path of the next file will be off otherwise. + folder.fileRefs = folder.fileRefs.filter(f => !f._id.equals(fileId)) + return true +} + +/** + * @param {string} projectId + * @param {string} fileId + * @param {string} hash + * @return {Promise} + */ +async function setHashInMongo(projectId, fileId, hash) { + const { projectSoftDeleted, query, fullPath, fileRef } = await findFile( + projectId, + fileId + ) + if (fileRef.hash === hash) return + logger.info({ projectId, fileId, fileRef, hash }, 'setting fileRef hash') + let result + if (projectSoftDeleted) { + result = await deletedProjectsCollection.updateOne(query, { + $set: { [`${fullPath}.hash`]: hash }, + $inc: { 'project.version': 1 }, + }) + } else { + result = await projectsCollection.updateOne(query, { + $set: { [`${fullPath}.hash`]: hash }, + $inc: { version: 1 }, + }) + } + if (result.matchedCount !== 1) { + throw new OError('file-tree write did not match', { result }) + } + fileRef.hash = hash // Update cache for completeness. +} + +/** + * @param {string} projectId + * @param {string} fileId + * @param {string} historyId + * @return {Promise} + */ +async function importRestoredFilestoreFile(projectId, fileId, historyId) { + const filestoreKey = `${projectId}/${fileId}` + const path = `${BUFFER_DIR}/${projectId}_${fileId}` + try { + let s + try { + s = await filestorePersistor.getObjectStream( + USER_FILES_BUCKET_NAME, + filestoreKey + ) + } catch (err) { + if (err instanceof NotFoundError) { + throw new OError('missing blob, need to restore filestore file', { + filestoreKey, + }) + } + throw err + } + await Stream.promises.pipeline( + s, + fs.createWriteStream(path, { highWaterMark: STREAM_HIGH_WATER_MARK }) + ) + const blobStore = new BlobStore(historyId) + const blob = await blobStore.putFile(path) + await backupBlob(historyId, blob, path) + await setHashInMongo(projectId, fileId, blob.getHash()) + } finally { + await fs.promises.rm(path, { force: true }) + } +} + +/** + * @param {string} projectId + * @param {string} fileId + * @return {Promise} + */ +async function computeFilestoreFileHash(projectId, fileId) { + const filestoreKey = `${projectId}/${fileId}` + const path = `${BUFFER_DIR}/${projectId}_${fileId}` + try { + let s + try { + s = await filestorePersistor.getObjectStream( + USER_FILES_BUCKET_NAME, + filestoreKey + ) + } catch (err) { + if (err instanceof NotFoundError) { + throw new OError('missing blob, need to restore filestore file', { + filestoreKey, + }) + } + throw err + } + await Stream.promises.pipeline( + s, + fs.createWriteStream(path, { highWaterMark: STREAM_HIGH_WATER_MARK }) + ) + const blob = await makeBlobForFile(path) + return blob.getHash() + } finally { + await fs.promises.rm(path, { force: true }) + } +} + +/** + * @param {string} line + * @return {Promise} + */ +async function fixHashMismatch(line) { + const { + projectId, + fileId, + hash: computedHash, + entry: { + hash: fileTreeHash, + ctx: { historyId }, + }, + } = JSON.parse(line) + const blobStore = new BlobStore(historyId) + if (await blobStore.getBlob(fileTreeHash)) { + throw new OError('found blob with computed filestore object hash') + } + if (!(await blobStore.getBlob(computedHash))) { + await importRestoredFilestoreFile(projectId, fileId, historyId) + return true + } + return await ensureBlobExistsForFileAndUploadToAWS( + projectId, + fileId, + computedHash + ) +} + +/** + * @param {string} projectId + * @param {string} fileId + * @param {string} hash + * @return {Promise} + */ +async function hashAlreadyUpdatedInFileTree(projectId, fileId, hash) { + const { fileRef } = await findFile(projectId, fileId) + return fileRef.hash === hash +} + +/** + * @param {string} projectId + * @param {string} hash + * @return {Promise} + */ +async function needsBackingUpToAWS(projectId, hash) { + if (GLOBAL_BLOBS.has(hash)) return false + return !(await _blobIsBackedUp(projectId, hash)) +} + +/** + * @param {string} projectId + * @param {string} fileId + * @param {string} hash + * @return {Promise} + */ +async function ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash) { + const { project } = await getProject(projectId) + const historyId = project.overleaf.history.id.toString() + const blobStore = new BlobStore(historyId) + if ( + (await hashAlreadyUpdatedInFileTree(projectId, fileId, hash)) && + (await blobStore.getBlob(hash)) && + !(await needsBackingUpToAWS(projectId, hash)) + ) { + return false // already processed + } + + const stream = await blobStore.getStream(hash) + const path = `${BUFFER_DIR}/${historyId}_${hash}` + try { + await Stream.promises.pipeline( + stream, + fs.createWriteStream(path, { + highWaterMark: STREAM_HIGH_WATER_MARK, + }) + ) + + const writtenBlob = await makeBlobForFile(path) + writtenBlob.setStringLength( + await getStringLengthOfFile(writtenBlob.getByteLength(), path) + ) + if (writtenBlob.getHash() !== hash) { + // Double check download, better safe than sorry. + throw new OError('blob corrupted', { writtenBlob }) + } + + let blob = await blobStore.getBlob(hash) + if (!blob) { + // Calling blobStore.putBlob would result in the same error again. + // HACK: Skip upload to GCS and finalize putBlob operation directly. + await blobStore.backend.insertBlob(historyId, writtenBlob) + } + await backupBlob(historyId, writtenBlob, path) + } finally { + await fs.promises.rm(path, { force: true }) + } + await setHashInMongo(projectId, fileId, hash) + return true +} + +/** + * @param {string} line + * @return {Promise} + */ +async function fixDeletePermission(line) { + let { projectId, fileId, hash } = JSON.parse(line) + if (!hash) hash = await computeFilestoreFileHash(projectId, fileId) + return await ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash) +} + +const CASES = { + 'not found': { + match: 'NotFoundError', + flag: FIX_NOT_FOUND, + action: fixNotFound, + }, + 'hash mismatch': { + match: 'OError: hash mismatch', + flag: FIX_HASH_MISMATCH, + action: fixHashMismatch, + }, + 'delete permission': { + match: 'storage.objects.delete', + flag: FIX_DELETE_PERMISSION, + action: fixDeletePermission, + }, +} + +const STATS = { + processedLines: 0, + success: 0, + alreadyProcessed: 0, + fileDeleted: 0, + skipped: 0, + failed: 0, + unmatched: 0, +} +function logStats() { + console.log( + JSON.stringify({ + time: new Date(), + gracefulShutdownInitiated, + ...STATS, + }) + ) +} +setInterval(logStats, 10_000) + +async function processLog() { + const rl = readline.createInterface({ + input: fs.createReadStream(LOGS), + }) + nextLine: for await (const line of rl) { + if (gracefulShutdownInitiated) break + STATS.processedLines++ + if (!line.includes('"failed to process file"')) continue + + for (const [name, { match, flag, action }] of Object.entries(CASES)) { + if (!line.includes(match)) continue + if (flag) { + try { + if (await action(line)) { + STATS.success++ + } else { + STATS.alreadyProcessed++ + } + } catch (err) { + if (err instanceof FileDeletedError) { + STATS.fileDeleted++ + logger.info({ err, line }, 'file deleted, skipping') + } else { + STATS.failed++ + logger.error({ err, line }, `failed to fix ${name}`) + } + } + } else { + STATS.skipped++ + } + continue nextLine + } + STATS.unmatched++ + logger.warn({ line }, 'unknown fatal error') + } +} + +async function main() { + try { + await processLog() + } finally { + logStats() + try { + await fs.promises.rm(BUFFER_DIR, { recursive: true, force: true }) + } catch (err) { + console.error(`Cleanup of BUFFER_DIR=${BUFFER_DIR} failed`, err) + } + } + const { skipped, failed, unmatched } = STATS + if (failed > 0) { + process.exit(Math.min(failed, 99)) + } else if (unmatched > 0) { + process.exit(100) + } else if (skipped > 0) { + process.exit(101) + } else { + process.exit(0) + } +} + +await main() diff --git a/services/history-v1/test/acceptance/js/storage/back_fill_file_hash_fix_up.test.mjs b/services/history-v1/test/acceptance/js/storage/back_fill_file_hash_fix_up.test.mjs new file mode 100644 index 0000000000..37b0fd5350 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/back_fill_file_hash_fix_up.test.mjs @@ -0,0 +1,761 @@ +import fs from 'node:fs' +import Crypto from 'node:crypto' +import Stream from 'node:stream' +import { promisify } from 'node:util' +import { Binary, ObjectId } from 'mongodb' +import { Blob } from 'overleaf-editor-core' +import { backedUpBlobs, blobs, db } from '../../../../storage/lib/mongodb.js' +import cleanup from './support/cleanup.js' +import testProjects from '../api/support/test_projects.js' +import { execFile } from 'node:child_process' +import { expect } from 'chai' +import config from 'config' +import { WritableBuffer } from '@overleaf/stream-utils' +import { + backupPersistor, + projectBlobsBucket, +} from '../../../../storage/lib/backupPersistor.mjs' +import projectKey from '../../../../storage/lib/project_key.js' +import { + BlobStore, + makeProjectKey, +} from '../../../../storage/lib/blob_store/index.js' +import ObjectPersistor from '@overleaf/object-persistor' + +const TIMEOUT = 20 * 1_000 + +const { deksBucket } = config.get('backupStore') +const { tieringStorageClass } = config.get('backupPersistor') + +const projectsCollection = db.collection('projects') +const deletedProjectsCollection = db.collection('deletedProjects') + +const FILESTORE_PERSISTOR = ObjectPersistor({ + backend: 'gcs', + gcs: { + endpoint: { + apiEndpoint: process.env.GCS_API_ENDPOINT, + projectId: process.env.GCS_PROJECT_ID, + }, + }, +}) + +/** + * @param {ObjectId} objectId + * @return {string} + */ +function gitBlobHash(objectId) { + return gitBlobHashBuffer(Buffer.from(objectId.toString())) +} + +/** + * @param {Buffer} buf + * @return {string} + */ +function gitBlobHashBuffer(buf) { + const sha = Crypto.createHash('sha1') + sha.update(`blob ${buf.byteLength}\x00`) + sha.update(buf) + return sha.digest('hex') +} + +/** + * @param {string} gitBlobHash + * @return {Binary} + */ +function binaryForGitBlobHash(gitBlobHash) { + return new Binary(Buffer.from(gitBlobHash, 'hex')) +} + +async function listS3Bucket(bucket, wantStorageClass) { + const client = backupPersistor._getClientForBucket(bucket) + const response = await client.listObjectsV2({ Bucket: bucket }).promise() + + for (const object of response.Contents || []) { + expect(object).to.have.property('StorageClass', wantStorageClass) + } + + return (response.Contents || []).map(item => item.Key || '') +} + +function objectIdFromTime(timestamp) { + return ObjectId.createFromTime(new Date(timestamp).getTime() / 1000) +} + +const PRINT_IDS_AND_HASHES_FOR_DEBUGGING = false + +describe('back_fill_file_hash_fix_up script', function () { + this.timeout(TIMEOUT) + const USER_FILES_BUCKET_NAME = 'fake-user-files-gcs' + + const projectId0 = objectIdFromTime('2017-01-01T00:00:00Z') + const projectIdDeleted0 = objectIdFromTime('2017-01-01T00:04:00Z') + const historyId0 = 42 // stored as number is mongo + const historyIdDeleted0 = projectIdDeleted0.toString() + const fileIdWithDifferentHashFound = objectIdFromTime('2017-02-01T00:00:00Z') + const fileIdInGoodState = objectIdFromTime('2017-02-01T00:01:00Z') + const fileIdBlobExistsInGCS0 = objectIdFromTime('2017-02-01T00:02:00Z') + const fileIdWithDifferentHashNotFound0 = objectIdFromTime( + '2017-02-01T00:03:00Z' + ) + const fileIdWithDifferentHashNotFound1 = objectIdFromTime( + '2017-02-01T00:04:00Z' + ) + const fileIdBlobExistsInGCSCorrupted = objectIdFromTime( + '2017-02-01T00:05:00Z' + ) + const fileIdMissing0 = objectIdFromTime('2024-02-01T00:06:00Z') + const fileIdMissing1 = objectIdFromTime('2017-02-01T00:07:00Z') + const fileIdWithDifferentHashRestore = objectIdFromTime( + '2017-02-01T00:08:00Z' + ) + const fileIdBlobExistsInGCS1 = objectIdFromTime('2017-02-01T00:09:00Z') + const fileIdRestoreFromFilestore0 = objectIdFromTime('2017-02-01T00:10:00Z') + const fileIdRestoreFromFilestore1 = objectIdFromTime('2017-02-01T00:11:00Z') + const fileIdMissing2 = objectIdFromTime('2017-02-01T00:12:00Z') + const contentCorruptedBlob = 'string that produces another hash' + const contentDoesNotExistAsBlob = 'does not exist as blob' + const hashDoesNotExistAsBlob = gitBlobHashBuffer( + Buffer.from(contentDoesNotExistAsBlob) + ) + const deleteProjectsRecordId0 = new ObjectId() + const writtenBlobs = [ + { + projectId: projectId0, + historyId: historyId0, + fileId: fileIdBlobExistsInGCS0, + }, + { + projectId: projectId0, + historyId: historyId0, + fileId: fileIdBlobExistsInGCS1, + }, + { + projectId: projectId0, + historyId: historyId0, + fileId: fileIdWithDifferentHashNotFound0, + }, + { + projectId: projectId0, + historyId: historyId0, + fileId: fileIdRestoreFromFilestore0, + }, + { + projectId: projectId0, + historyId: historyId0, + fileId: fileIdRestoreFromFilestore1, + }, + { + projectId: projectIdDeleted0, + historyId: historyIdDeleted0, + fileId: fileIdWithDifferentHashNotFound1, + }, + ] + const logs = [ + { + projectId: projectId0, + fileId: fileIdWithDifferentHashFound, + err: { message: 'OError: hash mismatch' }, + hash: gitBlobHash(fileIdMissing0), // does not matter + entry: { + ctx: { historyId: historyId0.toString() }, + hash: gitBlobHash(fileIdInGoodState), + }, + msg: 'failed to process file', + }, + { + projectId: projectId0, + fileId: fileIdWithDifferentHashRestore, + err: { message: 'OError: hash mismatch' }, + hash: hashDoesNotExistAsBlob, + entry: { + ctx: { historyId: historyId0.toString() }, + hash: gitBlobHash(fileIdMissing0), // does not matter + }, + msg: 'failed to process file', + }, + { + projectId: projectId0, + fileId: fileIdWithDifferentHashNotFound0, + err: { message: 'OError: hash mismatch' }, + hash: gitBlobHash(fileIdWithDifferentHashNotFound0), + entry: { + ctx: { historyId: historyId0.toString() }, + hash: hashDoesNotExistAsBlob, + }, + msg: 'failed to process file', + }, + { + projectId: projectId0, + fileId: fileIdRestoreFromFilestore0, + err: { message: 'OError: hash mismatch' }, + hash: gitBlobHash(fileIdRestoreFromFilestore0), + entry: { + ctx: { historyId: historyId0.toString() }, + hash: hashDoesNotExistAsBlob, + }, + msg: 'failed to process file', + }, + { + projectId: projectIdDeleted0, + fileId: fileIdWithDifferentHashNotFound1, + err: { message: 'OError: hash mismatch' }, + hash: gitBlobHash(fileIdWithDifferentHashNotFound1), + entry: { + ctx: { historyId: historyIdDeleted0.toString() }, + hash: hashDoesNotExistAsBlob, + }, + msg: 'failed to process file', + }, + { + projectId: projectId0, + fileId: fileIdMissing0, + bucketName: USER_FILES_BUCKET_NAME, + err: { message: 'NotFoundError' }, + msg: 'failed to process file', + }, + { + projectId: projectId0, + fileId: fileIdMissing2, + bucketName: USER_FILES_BUCKET_NAME, + err: { message: 'NotFoundError' }, + msg: 'failed to process file', + }, + { + projectId: projectId0, + fileId: fileIdBlobExistsInGCS0, + hash: gitBlobHash(fileIdBlobExistsInGCS0), + err: { message: 'storage.objects.delete' }, + msg: 'failed to process file', + }, + { + projectId: projectId0, + fileId: fileIdBlobExistsInGCSCorrupted, + hash: gitBlobHash(fileIdBlobExistsInGCSCorrupted), + err: { message: 'storage.objects.delete' }, + msg: 'failed to process file', + }, + { + projectId: projectId0, + fileId: fileIdBlobExistsInGCS1, + hash: gitBlobHash(fileIdBlobExistsInGCS1), + err: { message: 'storage.objects.delete' }, + msg: 'failed to process file', + }, + { + projectId: projectId0, + fileId: fileIdRestoreFromFilestore1, + err: { message: 'storage.objects.delete' }, + msg: 'failed to process file', + }, + { + projectId: projectIdDeleted0, + fileId: fileIdMissing1, + bucketName: USER_FILES_BUCKET_NAME, + err: { message: 'NotFoundError' }, + msg: 'failed to process file', + }, + { + err: { message: 'spurious error' }, + msg: 'failed to process file, trying again', + }, + { + err: { message: 'some other error' }, + msg: 'failed to process file', + }, + ] + if (PRINT_IDS_AND_HASHES_FOR_DEBUGGING) { + const fileIds = { + fileIdWithDifferentHashFound, + fileIdInGoodState, + fileIdBlobExistsInGCS0, + fileIdBlobExistsInGCS1, + fileIdWithDifferentHashNotFound0, + fileIdWithDifferentHashNotFound1, + fileIdBlobExistsInGCSCorrupted, + fileIdMissing0, + fileIdMissing1, + fileIdMissing2, + fileIdWithDifferentHashRestore, + fileIdRestoreFromFilestore0, + fileIdRestoreFromFilestore1, + } + console.log({ + projectId0, + projectIdDeleted0, + historyId0, + historyIdDeleted0, + ...fileIds, + hashDoesNotExistAsBlob, + }) + for (const [name, v] of Object.entries(fileIds)) { + console.log( + name, + gitBlobHash(v), + Array.from(binaryForGitBlobHash(gitBlobHash(v)).value()) + ) + } + } + + before(cleanup.everything) + before('cleanup s3 buckets', async function () { + await backupPersistor.deleteDirectory(deksBucket, '') + await backupPersistor.deleteDirectory(projectBlobsBucket, '') + expect(await listS3Bucket(deksBucket)).to.have.length(0) + expect(await listS3Bucket(projectBlobsBucket)).to.have.length(0) + }) + + before('populate blobs/GCS', async function () { + await FILESTORE_PERSISTOR.sendStream( + USER_FILES_BUCKET_NAME, + `${projectId0}/${fileIdRestoreFromFilestore0}`, + Stream.Readable.from([fileIdRestoreFromFilestore0.toString()]) + ) + await FILESTORE_PERSISTOR.sendStream( + USER_FILES_BUCKET_NAME, + `${projectId0}/${fileIdRestoreFromFilestore1}`, + Stream.Readable.from([fileIdRestoreFromFilestore1.toString()]) + ) + await new BlobStore(historyId0.toString()).putString( + fileIdBlobExistsInGCS0.toString() + ) + await new BlobStore(historyId0.toString()).putString( + fileIdBlobExistsInGCS1.toString() + ) + await new BlobStore(historyId0.toString()).putString( + fileIdRestoreFromFilestore1.toString() + ) + const path = '/tmp/test-blob-corrupted' + try { + await fs.promises.writeFile(path, contentCorruptedBlob) + await new BlobStore(historyId0.toString()).putBlob( + path, + new Blob(gitBlobHash(fileIdBlobExistsInGCSCorrupted), 42) + ) + } finally { + await fs.promises.rm(path, { force: true }) + } + await cleanup.postgres() + await cleanup.mongo() + await Promise.all([ + testProjects.createEmptyProject(historyId0.toString()), + testProjects.createEmptyProject(historyIdDeleted0), + ]) + await new BlobStore(historyId0.toString()).putString( + fileIdWithDifferentHashNotFound0.toString() + ) + await new BlobStore(historyIdDeleted0.toString()).putString( + fileIdWithDifferentHashNotFound1.toString() + ) + await new BlobStore(historyId0.toString()).putString( + fileIdInGoodState.toString() + ) + }) + + before('populate mongo', async function () { + await projectsCollection.insertMany([ + { + _id: projectId0, + rootFolder: [ + { + fileRefs: [ + { _id: fileIdMissing0 }, + { _id: fileIdMissing0 }, // bad file-tree, duplicated fileRef. + { _id: fileIdMissing2 }, + { + _id: fileIdWithDifferentHashFound, + hash: gitBlobHash(fileIdInGoodState), + }, + { + _id: fileIdWithDifferentHashRestore, + hash: gitBlobHash(fileIdMissing0), + }, + ], + folders: [ + { + docs: [], + }, + null, + { + fileRefs: [ + null, + { + _id: fileIdInGoodState, + hash: gitBlobHash(fileIdInGoodState), + }, + { + _id: fileIdWithDifferentHashNotFound0, + hash: hashDoesNotExistAsBlob, + }, + { + _id: fileIdRestoreFromFilestore0, + hash: hashDoesNotExistAsBlob, + }, + { + _id: fileIdRestoreFromFilestore1, + }, + { + _id: fileIdBlobExistsInGCS0, + hash: gitBlobHash(fileIdBlobExistsInGCS0), + }, + { + _id: fileIdBlobExistsInGCSCorrupted, + hash: gitBlobHash(fileIdBlobExistsInGCSCorrupted), + }, + { _id: fileIdBlobExistsInGCS1 }, + ], + folders: [], + }, + ], + }, + ], + overleaf: { history: { id: historyId0 } }, + version: 0, + }, + ]) + await deletedProjectsCollection.insertMany([ + { + _id: deleteProjectsRecordId0, + project: { + _id: projectIdDeleted0, + rootFolder: [ + { + fileRefs: [ + { + _id: fileIdWithDifferentHashNotFound1, + hash: hashDoesNotExistAsBlob, + }, + ], + folders: [ + { + fileRefs: [], + folders: [ + { fileRefs: [{ _id: fileIdMissing1 }], folders: [] }, + ], + }, + ], + }, + ], + overleaf: { history: { id: historyIdDeleted0 } }, + version: 100, + }, + deleterData: { + deletedProjectId: projectIdDeleted0, + }, + }, + ]) + }) + + /** + * @param {Array} args + * @param {Record} env + * @return {Promise<{ stdout: string, stderr: string, status: number }>} + */ + async function tryRunScript(args = [], env = {}) { + let result + try { + result = await promisify(execFile)( + process.argv0, + ['storage/scripts/back_fill_file_hash_fix_up.mjs', ...args], + { + encoding: 'utf-8', + timeout: TIMEOUT - 500, + env: { + ...process.env, + USER_FILES_BUCKET_NAME, + ...env, + LOG_LEVEL: 'warn', // Override LOG_LEVEL of acceptance tests + }, + } + ) + result.status = 0 + } catch (err) { + const { stdout, stderr, code } = err + if (typeof code !== 'number') { + console.log(err) + } + result = { stdout, stderr, status: code } + } + expect((await fs.promises.readdir('/tmp')).join(';')).to.not.match( + /back_fill_file_hash/ + ) + return result + } + async function runScriptWithLogs() { + const logsPath = '/tmp/test-script-logs' + let result + try { + await fs.promises.writeFile( + logsPath, + logs.map(e => JSON.stringify(e)).join('\n') + ) + result = await tryRunScript([`--logs=${logsPath}`]) + } finally { + await fs.promises.rm(logsPath, { force: true }) + } + const stats = JSON.parse(result.stdout.trim().split('\n').pop()) + return { + result, + stats, + } + } + + let result, stats + before(async function () { + ;({ result, stats } = await runScriptWithLogs()) + }) + it('should print stats', function () { + expect(stats).to.contain({ + processedLines: 14, + success: 9, + alreadyProcessed: 0, + fileDeleted: 0, + skipped: 0, + failed: 3, + unmatched: 1, + }) + }) + it('should handle re-run on same logs', async function () { + ;({ stats } = await runScriptWithLogs()) + expect(stats).to.contain({ + processedLines: 14, + success: 0, + alreadyProcessed: 6, + fileDeleted: 3, + skipped: 0, + failed: 3, + unmatched: 1, + }) + }) + it('should flag the unknown fatal error', function () { + const unknown = result.stdout + .split('\n') + .filter(l => l.includes('unknown fatal error')) + expect(unknown).to.have.length(1) + const [line] = unknown + expect(line).to.exist + expect(line).to.include('some other error') + }) + it('should flag the unexpected blob on mismatched hash', function () { + const line = result.stdout + .split('\n') + .find(l => l.includes('found blob with computed filestore object hash')) + expect(line).to.exist + expect(line).to.include(projectId0.toString()) + expect(line).to.include(fileIdWithDifferentHashFound.toString()) + expect(line).to.include(gitBlobHash(fileIdInGoodState)) + }) + it('should flag the need to restore', function () { + const line = result.stdout + .split('\n') + .find(l => l.includes('missing blob, need to restore filestore file')) + expect(line).to.exist + expect(line).to.include(projectId0.toString()) + expect(line).to.include(fileIdWithDifferentHashRestore.toString()) + expect(line).to.include(hashDoesNotExistAsBlob) + }) + it('should flag the corrupted blob', function () { + const line = result.stdout + .split('\n') + .find(l => l.includes('blob corrupted')) + expect(line).to.exist + expect(line).to.include(projectId0.toString()) + expect(line).to.include(fileIdBlobExistsInGCSCorrupted.toString()) + expect(line).to.include( + gitBlobHashBuffer(Buffer.from(contentCorruptedBlob)) + ) + expect(line).to.include(gitBlobHash(fileIdBlobExistsInGCSCorrupted)) + }) + it('should update mongo', async function () { + expect(await projectsCollection.find({}).toArray()).to.deep.equal([ + { + _id: projectId0, + rootFolder: [ + { + fileRefs: [ + // Removed + // { _id: fileIdMissing0 }, + // Removed + // { _id: fileIdMissing2 }, + // No change, should warn about the find. + { + _id: fileIdWithDifferentHashFound, + hash: gitBlobHash(fileIdInGoodState), + }, + // No change, should warn about the need to restore. + { + _id: fileIdWithDifferentHashRestore, + hash: gitBlobHash(fileIdMissing0), + }, + ], + folders: [ + { + docs: [], + }, + null, + { + fileRefs: [ + null, + // No change + { + _id: fileIdInGoodState, + hash: gitBlobHash(fileIdInGoodState), + }, + // Updated hash + { + _id: fileIdWithDifferentHashNotFound0, + hash: gitBlobHash(fileIdWithDifferentHashNotFound0), + }, + // Updated hash + { + _id: fileIdRestoreFromFilestore0, + hash: gitBlobHash(fileIdRestoreFromFilestore0), + }, + // Added hash + { + _id: fileIdRestoreFromFilestore1, + hash: gitBlobHash(fileIdRestoreFromFilestore1), + }, + // No change, blob created + { + _id: fileIdBlobExistsInGCS0, + hash: gitBlobHash(fileIdBlobExistsInGCS0), + }, + // No change, flagged + { + _id: fileIdBlobExistsInGCSCorrupted, + hash: gitBlobHash(fileIdBlobExistsInGCSCorrupted), + }, + // Added hash + { + _id: fileIdBlobExistsInGCS1, + hash: gitBlobHash(fileIdBlobExistsInGCS1), + }, + ], + folders: [], + }, + ], + }, + ], + overleaf: { history: { id: historyId0 } }, + // Incremented when removing file/updating hash + version: 6, + }, + ]) + expect(await deletedProjectsCollection.find({}).toArray()).to.deep.equal([ + { + _id: deleteProjectsRecordId0, + project: { + _id: projectIdDeleted0, + rootFolder: [ + { + fileRefs: [ + // Updated hash + { + _id: fileIdWithDifferentHashNotFound1, + hash: gitBlobHash(fileIdWithDifferentHashNotFound1), + }, + ], + folders: [ + { + fileRefs: [], + folders: [ + { + fileRefs: [ + // Removed + // { _id: fileIdMissing1 }, + ], + folders: [], + }, + ], + }, + ], + }, + ], + overleaf: { history: { id: historyIdDeleted0 } }, + // Incremented when removing file/updating hash + version: 102, + }, + deleterData: { + deletedProjectId: projectIdDeleted0, + }, + }, + ]) + const writtenBlobsByProject = new Map() + for (const { projectId, fileId } of writtenBlobs) { + writtenBlobsByProject.set( + projectId, + (writtenBlobsByProject.get(projectId) || []).concat([fileId]) + ) + } + expect( + (await backedUpBlobs.find({}, { sort: { _id: 1 } }).toArray()).map( + entry => { + // blobs are pushed unordered into mongo. Sort the list for consistency. + entry.blobs.sort() + return entry + } + ) + ).to.deep.equal( + Array.from(writtenBlobsByProject.entries()).map( + ([projectId, fileIds]) => { + return { + _id: projectId, + blobs: fileIds + .map(fileId => binaryForGitBlobHash(gitBlobHash(fileId))) + .sort(), + } + } + ) + ) + }) + it('should have backed up all the files', async function () { + expect(tieringStorageClass).to.exist + const objects = await listS3Bucket(projectBlobsBucket, tieringStorageClass) + expect(objects.sort()).to.deep.equal( + writtenBlobs + .map(({ historyId, fileId, hash }) => + makeProjectKey(historyId, hash || gitBlobHash(fileId)) + ) + .sort() + ) + for (let { historyId, fileId } of writtenBlobs) { + const hash = gitBlobHash(fileId.toString()) + const s = await backupPersistor.getObjectStream( + projectBlobsBucket, + makeProjectKey(historyId, hash), + { autoGunzip: true } + ) + const buf = new WritableBuffer() + await Stream.promises.pipeline(s, buf) + expect(gitBlobHashBuffer(buf.getContents())).to.equal(hash) + const id = buf.getContents().toString('utf-8') + expect(id).to.equal(fileId.toString()) + // double check we are not comparing 'undefined' or '[object Object]' above + expect(id).to.match(/^[a-f0-9]{24}$/) + } + const deks = await listS3Bucket(deksBucket, 'STANDARD') + expect(deks.sort()).to.deep.equal( + Array.from( + new Set( + writtenBlobs.map( + ({ historyId }) => projectKey.format(historyId) + '/dek' + ) + ) + ).sort() + ) + }) + it('should have written the back filled files to history v1', async function () { + for (const { historyId, fileId } of writtenBlobs) { + const blobStore = new BlobStore(historyId.toString()) + const hash = gitBlobHash(fileId.toString()) + const blob = await blobStore.getBlob(hash) + expect(blob).to.exist + expect(blob.getByteLength()).to.equal(24) + const id = await blobStore.getString(hash) + expect(id).to.equal(fileId.toString()) + // double check we are not comparing 'undefined' or '[object Object]' above + expect(id).to.match(/^[a-f0-9]{24}$/) + } + }) +}) From b735cac588555bcfd68ed7cf2af8084b444dfdeb Mon Sep 17 00:00:00 2001 From: Jessica Lawshe <5312836+lawshe@users.noreply.github.com> Date: Fri, 17 Jan 2025 09:01:49 -0600 Subject: [PATCH 0067/1724] Merge pull request #22912 from overleaf/jel-cms-tabs-bs5 [web] Update admin panel to use `.ol-tabs` style GitOrigin-RevId: c9e808c5534e5f033a8e829b35b0c6bd865cb596 --- .../app/views/_mixins/bookmarkable_tabset.pug | 2 +- services/web/app/views/admin/index.pug | 4 +- .../bootstrap-5/components/tabs.scss | 47 +------------------ 3 files changed, 4 insertions(+), 49 deletions(-) diff --git a/services/web/app/views/_mixins/bookmarkable_tabset.pug b/services/web/app/views/_mixins/bookmarkable_tabset.pug index b2cef1b171..27ac74ef66 100644 --- a/services/web/app/views/_mixins/bookmarkable_tabset.pug +++ b/services/web/app/views/_mixins/bookmarkable_tabset.pug @@ -1,6 +1,6 @@ mixin bookmarkable-tabset-header(id, title, active) li(role="presentation") - a( + a.nav-link( href='#' + id class=(active ? 'active' : '') aria-controls=id diff --git a/services/web/app/views/admin/index.pug b/services/web/app/views/admin/index.pug index 19ce9edf86..e5a9072503 100644 --- a/services/web/app/views/admin/index.pug +++ b/services/web/app/views/admin/index.pug @@ -13,9 +13,9 @@ block content .card-body .page-header h1 Admin Panel - div(data-ol-bookmarkable-tabset) + .ol-tabs(data-ol-bookmarkable-tabset) .nav-tabs-container - ul.nav.nav-tabs.d-flex(role="tablist") + ul.nav.nav-tabs(role="tablist") +bookmarkable-tabset-header('system-messages', 'System Messages', true) +bookmarkable-tabset-header('open-sockets', 'Open Sockets') +bookmarkable-tabset-header('open-close-editor', 'Open/Close Editor') diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/tabs.scss b/services/web/frontend/stylesheets/bootstrap-5/components/tabs.scss index c7df677bb2..0dd2c626dc 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/tabs.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/tabs.scss @@ -36,6 +36,7 @@ margin-right: unset; padding: var(--spacing-04); line-height: var(--line-height-03); + text-decoration: none; &:focus, &:hover { @@ -78,49 +79,3 @@ [data-ol-bookmarkable-tabset] .tab-pane { scroll-margin-top: 120px; } - -[data-ol-bookmarkable-tabset] { - .nav-tabs-container { - .nav-tabs { - gap: 8px; - flex-wrap: nowrap; - - > li { - float: left; - - // Make the list-items overlay the bottom border - margin-bottom: -1px; - - // Actual tabs (as links) - > a { - color: var(--content-secondary); - border: 1px solid transparent; - border-radius: var(--border-radius-base) var(--border-radius-base) 0 0; - padding: var(--spacing-04); - text-decoration: none; - - &:hover, - &:focus { - cursor: pointer; - background-color: var(--bg-light-secondary); - } - } - - // Active state, and its :hover to override normal :hover - > a.active { - color: var(--content-primary); - background-color: var(--bg-light-primary); - border: 1px solid var(--border-divider); - border-bottom-color: transparent; - cursor: default; - } - } - } - - .tab-content { - border: 1px solid #ddd; - border-top: none; - padding: var(--spacing-05); - } - } -} From 9332b42edd889b5a53a550f108d6545755a9d969 Mon Sep 17 00:00:00 2001 From: CloudBuild Date: Sat, 18 Jan 2025 02:03:19 +0000 Subject: [PATCH 0068/1724] auto update translation GitOrigin-RevId: 449c0fd0236464e412723bacc9bfeba2522a4a73 --- services/web/locales/da.json | 8 -------- services/web/locales/de.json | 8 -------- services/web/locales/zh-CN.json | 8 -------- 3 files changed, 24 deletions(-) diff --git a/services/web/locales/da.json b/services/web/locales/da.json index b07753357f..9348d1acbc 100644 --- a/services/web/locales/da.json +++ b/services/web/locales/da.json @@ -689,11 +689,7 @@ "gallery": "Galleri", "gallery_back_to_all": "Tilbage til alle __itemPlural__", "gallery_find_more": "Find flere __itemPlural__", - "gallery_items_tagged": "__itemPlural__ tagget __title__", - "gallery_page_items": "Gallerigenstande", - "gallery_page_summary": "Et galleri af opdaterede og stilfulde LaTeX skabeloner, eksempler som kan hjælpe dig med at lære LaTeX, og artikler og præsentationer udgivet af vores fællesskab. Søg eller gennemse nedenfor.", "gallery_page_title": "Galleri - Skabeloner, eksempler og artikler skrevet i LaTeX", - "gallery_show_all": "Vis alle __itemPlural__", "gallery_show_more_tags": "Vis mere", "generate_token": "Generér nøgle", "generic_if_problem_continues_contact_us": "Kontakt os hvis problemet fortsætter", @@ -998,9 +994,7 @@ "last_updated": "Sidst opdateret", "last_updated_date_by_x": "__lastUpdatedDate__ af __person__", "last_used": "sidst benyttet", - "latex_articles_page_summary": "Artikler, præsentationer, rapporter og mere, skrevet i LaTeX og udgivet af vores fællesskab. Søg eller gennemse herunder.", "latex_articles_page_title": "Artikler, Præsentationer, Rapporter og mere", - "latex_examples_page_summary": "Eksempler på kraftfulde LaTeX pakker og teknikker i brug - en god måde at lære LaTeX på gennem eksempler. Søg eller gennemse herunder. ", "latex_examples_page_title": "Eksempler - Formler, Formattering, TikZ, Pakker og mere", "latex_in_thirty_minutes": "LaTeX på 30 minutter", "latex_places_figures_according_to_a_special_algorithm": "LaTeX placerer figurer ved hjælp af en speciel algoritme. Du kan bruge noget ved navn ‘placement parameters’ til at have indflydelse på positioneringen af figuren. <0>Find ud hvordan", @@ -1721,7 +1715,6 @@ "select_user": "Vælg bruger", "selected": "Valgt", "selected_by_overleaf_staff": "Valgt af Overleafs ansatte", - "selected_by_overleaf_staff_description": "Disse skabeloner er blevet håndplukket af Overleafs ansatte grundet deres høje kvalitet og positive feedback fra Overleaf fællesskabet gennem årene.", "selection_deleted": "Markering slettet", "send": "Send", "send_first_message": "Send din første besked til dine samarbejdspartnere", @@ -1926,7 +1919,6 @@ "template_title_taken_from_project_title": "Skabelonstitlen bliver automatisk taget fra projekttitlen", "template_top_pick_by_overleaf": "Denne skabelon er blevet håndplukket af Overleaf for dens høje kvalitet", "templates": "Skabeloner", - "templates_page_summary": "Start dine projekter med LaTeX kvalitets-skabeloner for journaler, CV’er, artikler, præsentationer, opgaver, projektrapporter og flere. Søg eller gennemse herunder.", "templates_page_title": "Skabeloner - Journaler, CV’er, præsentationer, rapporter og mere", "ten_collaborators_per_project": "10 samarbejdspartnere per projekt", "ten_per_project": "10 per projekt", diff --git a/services/web/locales/de.json b/services/web/locales/de.json index 1605473ebb..8ea3fa8d00 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -478,11 +478,7 @@ "full_width": "Volle Breite", "gallery": "Gallerie", "gallery_find_more": "Mehr __itemPlural__ anzeigen", - "gallery_items_tagged": "__itemPlural__ in der Kategorie __title__", - "gallery_page_items": "Galerieelemente", - "gallery_page_summary": "Ein Gallerie mit aktuellen und stilvollen LaTeX-Vorlagen, Beispielen, die beim Lernen von LaTeX unterstützen, und Papers und Präsentationen, veröffentlicht von unseren Nutzern. Suchen oder unten durchblättern.", "gallery_page_title": "Gallerie – Vorlagen, Beispiele und Artikel verfasst in LaTeX", - "gallery_show_all": "Zeige alle __itemPlural__", "generate_token": "Token generieren", "generic_if_problem_continues_contact_us": "Wenn das Problem weiterhin besteht, kontaktiere uns bitte", "generic_linked_file_compile_error": "Die Ausgabedateien dieses Projekts sind nicht verfügbar, da sie nicht kompiliert werden konnten. Öffne das Projekt, um die Fehlerdetails des Kompiliervorgangs anzuzeigen.", @@ -708,9 +704,7 @@ "last_updated": "Letzte Aktualisierung", "last_updated_date_by_x": "__lastUpdatedDate__ von __person__", "last_used": "Zuletzt verwendet", - "latex_articles_page_summary": "Papers, Präsentationen, Berichte und mehr, verfasst in LaTeX und veröffentlicht von unseren Nutzern. Suchen oder unten durchblättern.", "latex_articles_page_title": "Artikel – Papers, Präsentationen, Berichte und mehr", - "latex_examples_page_summary": "Beispiele für mächtigen LaTeX Paketen and Anwendung von Techniken — eine tolle Möglichkeit an Hand von Beispielen LaTeX zu lernen. Suchen oder unten durchblättern.", "latex_examples_page_title": "Beispiele - Gleichungen, Formatierung, TikZ, Pakete und mehr", "latex_in_thirty_minutes": "LaTeX in 30 Minuten", "latex_places_figures_according_to_a_special_algorithm": "LaTeX platziert Abbildungen nach einem speziellen Algorithmus. Du kannst mit sogenannten ‘placement parameters’ die Position deiner Abbildungen beeinflussen. <0>Finde heraus wie", @@ -1142,7 +1136,6 @@ "select_project": "__project__ auswählen", "selected": "Ausgewählt", "selected_by_overleaf_staff": "Ausgewählt von Overleaf-Mitarbeitern", - "selected_by_overleaf_staff_description": "Diese Vorlagen wurden von Overleaf-Mitarbeitern für ihre hohe Qualität und positiven Rückmeldungen von Overleaf-Nutzern in den letzten Jahren ausgewählt", "send": "Absenden", "send_first_message": "Sende deine erste Nachricht", "send_test_email": "Test-Mail senden", @@ -1243,7 +1236,6 @@ "template_top_pick_by_overleaf": "Diese Vorlage wurde von Overleaf-Mitarbeitern aufgrund ihrer hohen Qualität ausgewählt", "templates": "Vorlagen", "templates_admin_source_project": "Administration: Quellprojekt", - "templates_page_summary": "Starte deine Projekte mit hochwertigen LaTeX-Vorlagen für Zeitschriften, Lebensläufe, Zusammenfassungen, Papers, Präsentationen, Aufgaben, Briefe, Projektberichte und mehr. Suchen oder unten durchblättern.", "templates_page_title": "Vorlagen - Zeitschriften, Lebensläufe, Präsentationen, Berichte und mehr", "terminated": "Kompiliervorgang abgebrochen", "terms": "Nutzungsbedingungen", diff --git a/services/web/locales/zh-CN.json b/services/web/locales/zh-CN.json index 4ac42a1009..15aa142d0f 100644 --- a/services/web/locales/zh-CN.json +++ b/services/web/locales/zh-CN.json @@ -683,11 +683,7 @@ "full_width": "全宽", "gallery": "模版集", "gallery_find_more": "查找更多__itemPlural__", - "gallery_items_tagged": "__itemPlural__ 标记为 __title__", - "gallery_page_items": "模版项目", - "gallery_page_summary": "最新的LaTeX模板库,帮助您学习LaTeX的示例,以及我们社区发布的论文和演示。在下面搜索或浏览吧!", "gallery_page_title": "模版集 - 用LaTeX编写的模板、示例和文章", - "gallery_show_all": "显示所有的__itemPlural__", "generate_token": "生成令牌", "generic_if_problem_continues_contact_us": "如果问题仍然存在,请与我们联系", "generic_linked_file_compile_error": "此项目的输出文件不可用,因为它未能成功编译。请打开项目以查看编译错误的详细信息。", @@ -999,10 +995,8 @@ "latam_discount_modal_info": "使用__currencyName__支付的高级订阅可享受__discount__%的折扣,充分释放Overleaf的潜力。获得更长的编译超时时间、完整的文档历史记录、跟踪更改、额外的合作者等等。", "latam_discount_modal_title": "高级订阅折扣", "latam_discount_offer_plans_page_banner": "__flag__好消息 我们已经为__country__的用户在此页面上的高级计划应用了__discount__折扣。看看新的低价 (in __currency__)。", - "latex_articles_page_summary": "用 LaTeX 编写并由我们社区发布的论文、演示文稿、报告等。 在下面搜索或浏览。", "latex_articles_page_title": "文章 - 论文、演示、报告等", "latex_examples": "LaTeX 样例", - "latex_examples_page_summary": "强大的LaTeX软件包和使用中的技术样例——通过示例学习LaTeX的好方法。在下面搜索或浏览。", "latex_examples_page_title": "样例 - Equations, Formatting, TikZ, 软件包等", "latex_in_thirty_minutes": "30分钟学会 LaTeX", "latex_places_figures_according_to_a_special_algorithm": "LaTeX 根据特殊算法放置图形。 您可以使用“放置参数”来调整图形的位置。 <0>了解具体方法", @@ -1714,7 +1708,6 @@ "select_user": "选择用户", "selected": "选择的", "selected_by_overleaf_staff": "由 Overleaf 工作人员精选", - "selected_by_overleaf_staff_description": "这些模板是由 Overleaf 工作人员精心挑选的,因为它们的质量很高,并且多年来从 Overleaf 社区收到了积极的反馈。", "selection_deleted": "所选内容已删除", "send": "发送", "send_first_message": "向你的合作者发送第一条信息", @@ -1928,7 +1921,6 @@ "template_top_pick_by_overleaf": "该模板是由 Overleaf 工作人员精心挑选的高质量模版", "templates": "模板", "templates_admin_source_project": "管理员:源项目", - "templates_page_summary": "使用高质量的LaTeX模板开始您的项目,包括期刊、个人履历、个人简历、论文、展示Pre、作业、信件、项目报告等。在下面搜索或浏览。", "templates_page_title": "模板 - 期刊、简历、演示文稿、报告等", "ten_collaborators_per_project": "每个项目 10 位协作者", "ten_per_project": "每个项目 10 个", From de7cd8900a7c3441da59502c0b4de4bc4cd0b592 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Mon, 20 Jan 2025 09:02:11 +0000 Subject: [PATCH 0069/1724] Merge pull request #22948 from overleaf/ar-ac-mongoose-8.9.5 Upgrade mongoose to 8.9.5 GitOrigin-RevId: 0b58af36e3732c18f58fde7f3e8d33234d4b4629 --- package-lock.json | 596 +++++++++++++++++++------------------- services/web/package.json | 2 +- 2 files changed, 301 insertions(+), 297 deletions(-) diff --git a/package-lock.json b/package-lock.json index aef654140e..66f703afc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -604,6 +604,7 @@ "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", "integrity": "sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==", "optional": true, + "peer": true, "dependencies": { "@aws-crypto/util": "^3.0.0", "@aws-sdk/types": "^3.222.0", @@ -614,13 +615,15 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true + "optional": true, + "peer": true }, "node_modules/@aws-crypto/ie11-detection": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz", "integrity": "sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==", "optional": true, + "peer": true, "dependencies": { "tslib": "^1.11.1" } @@ -629,13 +632,15 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true + "optional": true, + "peer": true }, "node_modules/@aws-crypto/sha256-browser": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-3.0.0.tgz", "integrity": "sha512-8VLmW2B+gjFbU5uMeqtQM6Nj0/F1bro80xQXCW6CQBWgosFWXTx77aeOF5CAIAmbOK64SdMBJdNr6J41yP5mvQ==", "optional": true, + "peer": true, "dependencies": { "@aws-crypto/ie11-detection": "^3.0.0", "@aws-crypto/sha256-js": "^3.0.0", @@ -651,13 +656,15 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true + "optional": true, + "peer": true }, "node_modules/@aws-crypto/sha256-js": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-3.0.0.tgz", "integrity": "sha512-PnNN7os0+yd1XvXAy23CFOmTbMaDxgxXtTKHybrJ39Y8kGzBATgBFibWJKH6BhytLI/Zyszs87xCOBNyBig6vQ==", "optional": true, + "peer": true, "dependencies": { "@aws-crypto/util": "^3.0.0", "@aws-sdk/types": "^3.222.0", @@ -668,13 +675,15 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true + "optional": true, + "peer": true }, "node_modules/@aws-crypto/supports-web-crypto": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-3.0.0.tgz", "integrity": "sha512-06hBdMwUAb2WFTuGG73LSC0wfPu93xWwo5vL2et9eymgmu3Id5vFAHBbajVWiGhPO37qcsdCap/FqXvJGJWPIg==", "optional": true, + "peer": true, "dependencies": { "tslib": "^1.11.1" } @@ -683,13 +692,15 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true + "optional": true, + "peer": true }, "node_modules/@aws-crypto/util": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz", "integrity": "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-utf8-browser": "^3.0.0", @@ -700,13 +711,15 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true + "optional": true, + "peer": true }, "node_modules/@aws-sdk/client-cognito-identity": { "version": "3.363.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.363.0.tgz", "integrity": "sha512-tsJzgBSCpna85IVsuS7FBIK9wkSl7fs8TJ/QzapIgu8rKss0ySHVO6TeMVAdw2BvaQl7CxU9c3PosjhLWHu6KQ==", "optional": true, + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", @@ -754,6 +767,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.363.0.tgz", "integrity": "sha512-PZ+HfKSgS4hlMnJzG+Ev8/mgHd/b/ETlJWPSWjC/f2NwVoBQkBnqHjdyEx7QjF6nksJozcVh5Q+kkYLKc/QwBQ==", "optional": true, + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", @@ -798,6 +812,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.363.0.tgz", "integrity": "sha512-V3Ebiq/zNtDS/O92HUWGBa7MY59RYSsqWd+E0XrXv6VYTA00RlMTbNcseivNgp2UghOgB9a20Nkz6EqAeIN+RQ==", "optional": true, + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", @@ -842,6 +857,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.363.0.tgz", "integrity": "sha512-0jj14WvBPJQ8xr72cL0mhlmQ90tF0O0wqXwSbtog6PsC8+KDE6Yf+WsxsumyI8E5O8u3eYijBL+KdqG07F/y/w==", "optional": true, + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", @@ -890,6 +906,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.363.0.tgz", "integrity": "sha512-5x42JvqEsBUrm6/qdf0WWe4mlmJjPItxamQhRjuOzeQD/BxsA2W5VS/7n0Ws0e27DNhlnUErcIJd+bBy6j1fqA==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/client-cognito-identity": "3.363.0", "@aws-sdk/types": "3.357.0", @@ -906,6 +923,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.363.0.tgz", "integrity": "sha512-VAQ3zITT2Q0acht0HezouYnMFKZ2vIOa20X4zQA3WI0HfaP4D6ga6KaenbDcb/4VFiqfqiRHfdyXHP0ThcDRMA==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/types": "3.357.0", "@smithy/property-provider": "^1.0.1", @@ -921,6 +939,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.363.0.tgz", "integrity": "sha512-ZYN+INoqyX5FVC3rqUxB6O8nOWkr0gHRRBm1suoOlmuFJ/WSlW/uUGthRBY5x1AQQnBF8cpdlxZzGHd41lFVNw==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/credential-provider-env": "3.363.0", "@aws-sdk/credential-provider-process": "3.363.0", @@ -942,6 +961,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.363.0.tgz", "integrity": "sha512-C1qXFIN2yMxD6pGgug0vR1UhScOki6VqdzuBHzXZAGu7MOjvgHNdscEcb3CpWnITHaPL2ztkiw75T1sZ7oIgQg==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/credential-provider-env": "3.363.0", "@aws-sdk/credential-provider-ini": "3.363.0", @@ -964,6 +984,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.363.0.tgz", "integrity": "sha512-fOKAINU7Rtj2T8pP13GdCt+u0Ml3gYynp8ki+1jMZIQ+Ju/MdDOqZpKMFKicMn3Z1ttUOgqr+grUdus6z8ceBQ==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/types": "3.357.0", "@smithy/property-provider": "^1.0.1", @@ -980,6 +1001,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.363.0.tgz", "integrity": "sha512-5RUZ5oM0lwZSo3EehT0dXggOjgtxFogpT3cZvoLGtIwrPBvm8jOQPXQUlaqCj10ThF1sYltEyukz/ovtDwYGew==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/client-sso": "3.363.0", "@aws-sdk/token-providers": "3.363.0", @@ -998,6 +1020,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.363.0.tgz", "integrity": "sha512-Z6w7fjgy79pAax580wdixbStQw10xfyZ+hOYLcPudoYFKjoNx0NQBejg5SwBzCF/HQL23Ksm9kDfbXDX9fkPhA==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/types": "3.357.0", "@smithy/property-provider": "^1.0.1", @@ -1013,6 +1036,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.363.0.tgz", "integrity": "sha512-hVa1DdYasnLud2EKjDAlDHiV/+H/Zq52chHU00c/R8XwPu1s0kZX3NMmlt0D2HhYqC1mUwtdmE58Jra2POviQQ==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/client-cognito-identity": "3.363.0", "@aws-sdk/client-sso": "3.363.0", @@ -1039,6 +1063,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.363.0.tgz", "integrity": "sha512-FobpclDCf5Y1ueyJDmb9MqguAdPssNMlnqWQpujhYVABq69KHu73fSCWSauFPUrw7YOpV8kG1uagDF0POSxHzA==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/types": "3.357.0", "@smithy/protocol-http": "^1.1.0", @@ -1054,6 +1079,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.363.0.tgz", "integrity": "sha512-SSGgthScYnFGTOw8EzbkvquqweFmvn7uJihkpFekbtBNGC/jGOGO+8ziHjTQ8t/iI/YKubEwv+LMi0f77HKSEg==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/types": "3.357.0", "@smithy/types": "^1.1.0", @@ -1068,6 +1094,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.363.0.tgz", "integrity": "sha512-MWD/57QgI/N7fG8rtzDTUdSqNpYohQfgj9XCFAoVeI/bU4usrkOrew43L4smJG4XrDxlNT8lSJlDtd64tuiUZA==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/types": "3.357.0", "@smithy/protocol-http": "^1.1.0", @@ -1083,6 +1110,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sts/-/middleware-sdk-sts-3.363.0.tgz", "integrity": "sha512-1yy2Ac50FO8BrODaw5bPWvVrRhaVLqXTFH6iHB+dJLPUkwtY5zLM3Mp+9Ilm7kME+r7oIB1wuO6ZB1Lf4ZszIw==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/middleware-signing": "3.363.0", "@aws-sdk/types": "3.357.0", @@ -1098,6 +1126,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.363.0.tgz", "integrity": "sha512-/7qia715pt9JKYIPDGu22WmdZxD8cfF/5xB+1kmILg7ZtjO0pPuTaCNJ7xiIuFd7Dn7JXp5lop08anX/GOhNRQ==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/types": "3.357.0", "@smithy/property-provider": "^1.0.1", @@ -1116,6 +1145,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.363.0.tgz", "integrity": "sha512-ri8YaQvXP6odteVTMfxPqFR26Q0h9ejtqhUDv47P34FaKXedEM4nC6ix6o+5FEYj6l8syGyktftZ5O70NoEhug==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/types": "3.357.0", "@aws-sdk/util-endpoints": "3.357.0", @@ -1132,6 +1162,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.363.0.tgz", "integrity": "sha512-6+0aJ1zugNgsMmhTtW2LBWxOVSaXCUk2q3xyTchSXkNzallYaRiZMRkieW+pKNntnu0g5H1T0zyfCO0tbXwxEA==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/client-sso-oidc": "3.363.0", "@aws-sdk/types": "3.357.0", @@ -1149,6 +1180,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.357.0.tgz", "integrity": "sha512-/riCRaXg3p71BeWnShrai0y0QTdXcouPSM0Cn1olZbzTf7s71aLEewrc96qFrL70XhY4XvnxMpqQh+r43XIL3g==", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.5.0" }, @@ -1161,6 +1193,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.357.0.tgz", "integrity": "sha512-XHKyS5JClT9su9hDif715jpZiWHQF9gKZXER8tW0gOizU3R9cyWc9EsJ2BRhFNhi7nt/JF/CLUEc5qDx3ETbUw==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/types": "3.357.0", "tslib": "^2.5.0" @@ -1174,6 +1207,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.310.0.tgz", "integrity": "sha512-qo2t/vBTnoXpjKxlsC2e1gBrRm80M3bId27r0BRB2VniSSe7bL1mmzM+/HFtujm0iAxtPM+aLEflLJlJeDPg0w==", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.5.0" }, @@ -1186,6 +1220,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.363.0.tgz", "integrity": "sha512-fk9ymBUIYbxiGm99Cn+kAAXmvMCWTf/cHAcB79oCXV4ELXdPa9lN5xQhZRFNxLUeXG4OAMEuCAUUuZEj8Fnc1Q==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/types": "3.357.0", "@smithy/types": "^1.1.0", @@ -1198,6 +1233,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.363.0.tgz", "integrity": "sha512-Fli/dvgGA9hdnQUrYb1//wNSFlK2jAfdJcfNXA6SeBYzSeH5pVGYF4kXF0FCdnMA3Fef+Zn1zAP/hw9v8VJHWQ==", "optional": true, + "peer": true, "dependencies": { "@aws-sdk/types": "3.357.0", "@smithy/node-config-provider": "^1.0.1", @@ -1221,6 +1257,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz", "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.3.1" } @@ -9318,6 +9355,7 @@ "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-1.0.2.tgz", "integrity": "sha512-tb2h0b+JvMee+eAxTmhnyqyNk51UXIK949HnE14lFeezKsVJTB30maan+CO2IMwnig2wVYQH84B5qk6ylmKCuA==", "optional": true, + "peer": true, "dependencies": { "@smithy/types": "^1.1.1", "tslib": "^2.5.0" @@ -9331,6 +9369,7 @@ "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-1.0.2.tgz", "integrity": "sha512-8Bk7CgnVKg1dn5TgnjwPz2ebhxeR7CjGs5yhVYH3S8x0q8yPZZVWwpRIglwXaf5AZBzJlNO1lh+lUhMf2e73zQ==", "optional": true, + "peer": true, "dependencies": { "@smithy/types": "^1.1.1", "@smithy/util-config-provider": "^1.0.2", @@ -9346,6 +9385,7 @@ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-1.0.2.tgz", "integrity": "sha512-fLjCya+JOu2gPJpCiwSUyoLvT8JdNJmOaTOkKYBZoGf7CzqR6lluSyI+eboZnl/V0xqcfcqBG4tgqCISmWS3/w==", "optional": true, + "peer": true, "dependencies": { "@smithy/node-config-provider": "^1.0.2", "@smithy/property-provider": "^1.0.2", @@ -9362,6 +9402,7 @@ "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-1.0.2.tgz", "integrity": "sha512-eW/XPiLauR1VAgHKxhVvgvHzLROUgTtqat2lgljztbH8uIYWugv7Nz+SgCavB+hWRazv2iYgqrSy74GvxXq/rg==", "optional": true, + "peer": true, "dependencies": { "@aws-crypto/crc32": "3.0.0", "@smithy/types": "^1.1.1", @@ -9374,6 +9415,7 @@ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-1.0.2.tgz", "integrity": "sha512-kynyofLf62LvR8yYphPPdyHb8fWG3LepFinM/vWUTG2Q1pVpmPCM530ppagp3+q2p+7Ox0UvSqldbKqV/d1BpA==", "optional": true, + "peer": true, "dependencies": { "@smithy/protocol-http": "^1.1.1", "@smithy/querystring-builder": "^1.0.2", @@ -9387,6 +9429,7 @@ "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-1.0.2.tgz", "integrity": "sha512-K6PKhcUNrJXtcesyzhIvNlU7drfIU7u+EMQuGmPw6RQDAg/ufUcfKHz4EcUhFAodUmN+rrejhRG9U6wxjeBOQA==", "optional": true, + "peer": true, "dependencies": { "@smithy/types": "^1.1.1", "@smithy/util-buffer-from": "^1.0.2", @@ -9402,6 +9445,7 @@ "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-1.0.2.tgz", "integrity": "sha512-B1Y3Tsa6dfC+Vvb+BJMhTHOfFieeYzY9jWQSTR1vMwKkxsymD0OIAnEw8rD/RiDj/4E4RPGFdx9Mdgnyd6Bv5Q==", "optional": true, + "peer": true, "dependencies": { "@smithy/types": "^1.1.1", "tslib": "^2.5.0" @@ -9412,6 +9456,7 @@ "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-1.0.2.tgz", "integrity": "sha512-pkyBnsBRpe+c/6ASavqIMRBdRtZNJEVJOEzhpxZ9JoAXiZYbkfaSMRA/O1dUxGdJ653GHONunnZ4xMo/LJ7utQ==", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.5.0" }, @@ -9424,6 +9469,7 @@ "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-1.0.2.tgz", "integrity": "sha512-pa1/SgGIrSmnEr2c9Apw7CdU4l/HW0fK3+LKFCPDYJrzM0JdYpqjQzgxi31P00eAkL0EFBccpus/p1n2GF9urw==", "optional": true, + "peer": true, "dependencies": { "@smithy/protocol-http": "^1.1.1", "@smithy/types": "^1.1.1", @@ -9438,6 +9484,7 @@ "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-1.0.3.tgz", "integrity": "sha512-GsWvTXMFjSgl617PCE2km//kIjjtvMRrR2GAuRDIS9sHiLwmkS46VWaVYy+XE7ubEsEtzZ5yK2e8TKDR6Qr5Lw==", "optional": true, + "peer": true, "dependencies": { "@smithy/middleware-serde": "^1.0.2", "@smithy/types": "^1.1.1", @@ -9454,6 +9501,7 @@ "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-1.0.4.tgz", "integrity": "sha512-G7uRXGFL8c3F7APnoIMTtNAHH8vT4F2qVnAWGAZaervjupaUQuRRHYBLYubK0dWzOZz86BtAXKieJ5p+Ni2Xpg==", "optional": true, + "peer": true, "dependencies": { "@smithy/protocol-http": "^1.1.1", "@smithy/service-error-classification": "^1.0.3", @@ -9472,6 +9520,7 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "optional": true, + "peer": true, "bin": { "uuid": "dist/bin/uuid" } @@ -9481,6 +9530,7 @@ "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-1.0.2.tgz", "integrity": "sha512-T4PcdMZF4xme6koUNfjmSZ1MLi7eoFeYCtodQNQpBNsS77TuJt1A6kt5kP/qxrTvfZHyFlj0AubACoaUqgzPeg==", "optional": true, + "peer": true, "dependencies": { "@smithy/types": "^1.1.1", "tslib": "^2.5.0" @@ -9494,6 +9544,7 @@ "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-1.0.2.tgz", "integrity": "sha512-H7/uAQEcmO+eDqweEFMJ5YrIpsBwmrXSP6HIIbtxKJSQpAcMGY7KrR2FZgZBi1FMnSUOh+rQrbOyj5HQmSeUBA==", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.5.0" }, @@ -9506,6 +9557,7 @@ "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-1.0.2.tgz", "integrity": "sha512-HU7afWpTToU0wL6KseGDR2zojeyjECQfr8LpjAIeHCYIW7r360ABFf4EaplaJRMVoC3hD9FeltgI3/NtShOqCg==", "optional": true, + "peer": true, "dependencies": { "@smithy/property-provider": "^1.0.2", "@smithy/shared-ini-file-loader": "^1.0.2", @@ -9521,6 +9573,7 @@ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-1.0.3.tgz", "integrity": "sha512-PcPUSzTbIb60VCJCiH0PU0E6bwIekttsIEf5Aoo/M0oTfiqsxHTn0Rcij6QoH6qJy6piGKXzLSegspXg5+Kq6g==", "optional": true, + "peer": true, "dependencies": { "@smithy/abort-controller": "^1.0.2", "@smithy/protocol-http": "^1.1.1", @@ -9537,6 +9590,7 @@ "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-1.0.2.tgz", "integrity": "sha512-pXDPyzKX8opzt38B205kDgaxda6LHcTfPvTYQZnwP6BAPp1o9puiCPjeUtkKck7Z6IbpXCPUmUQnzkUzWTA42Q==", "optional": true, + "peer": true, "dependencies": { "@smithy/types": "^1.1.1", "tslib": "^2.5.0" @@ -9550,6 +9604,7 @@ "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-1.1.1.tgz", "integrity": "sha512-mFLFa2sSvlUxm55U7B4YCIsJJIMkA6lHxwwqOaBkral1qxFz97rGffP/mmd4JDuin1EnygiO5eNJGgudiUgmDQ==", "optional": true, + "peer": true, "dependencies": { "@smithy/types": "^1.1.1", "tslib": "^2.5.0" @@ -9563,6 +9618,7 @@ "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-1.0.2.tgz", "integrity": "sha512-6P/xANWrtJhMzTPUR87AbXwSBuz1SDHIfL44TFd/GT3hj6rA+IEv7rftEpPjayUiWRocaNnrCPLvmP31mobOyA==", "optional": true, + "peer": true, "dependencies": { "@smithy/types": "^1.1.1", "@smithy/util-uri-escape": "^1.0.2", @@ -9577,6 +9633,7 @@ "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-1.0.2.tgz", "integrity": "sha512-IWxwxjn+KHWRRRB+K2Ngl+plTwo2WSgc2w+DvLy0DQZJh9UGOpw40d6q97/63GBlXIt4TEt5NbcFrO30CKlrsA==", "optional": true, + "peer": true, "dependencies": { "@smithy/types": "^1.1.1", "tslib": "^2.5.0" @@ -9590,6 +9647,7 @@ "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-1.0.3.tgz", "integrity": "sha512-2eglIYqrtcUnuI71yweu7rSfCgt6kVvRVf0C72VUqrd0LrV1M0BM0eYN+nitp2CHPSdmMI96pi+dU9U/UqAMSA==", "optional": true, + "peer": true, "engines": { "node": ">=14.0.0" } @@ -9599,6 +9657,7 @@ "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-1.0.2.tgz", "integrity": "sha512-bdQj95VN+lCXki+P3EsDyrkpeLn8xDYiOISBGnUG/AGPYJXN8dmp4EhRRR7XOoLoSs8anZHR4UcGEOzFv2jwGw==", "optional": true, + "peer": true, "dependencies": { "@smithy/types": "^1.1.1", "tslib": "^2.5.0" @@ -9612,6 +9671,7 @@ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-1.0.2.tgz", "integrity": "sha512-rpKUhmCuPmpV5dloUkOb9w1oBnJatvKQEjIHGmkjRGZnC3437MTdzWej9TxkagcZ8NRRJavYnEUixzxM1amFig==", "optional": true, + "peer": true, "dependencies": { "@smithy/eventstream-codec": "^1.0.2", "@smithy/is-array-buffer": "^1.0.2", @@ -9631,6 +9691,7 @@ "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-1.0.4.tgz", "integrity": "sha512-gpo0Xl5Nyp9sgymEfpt7oa9P2q/GlM3VmQIdm+FeH0QEdYOQx3OtvwVmBYAMv2FIPWxkMZlsPYRTnEiBTK5TYg==", "optional": true, + "peer": true, "dependencies": { "@smithy/middleware-stack": "^1.0.2", "@smithy/types": "^1.1.1", @@ -9646,6 +9707,7 @@ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-1.1.1.tgz", "integrity": "sha512-tMpkreknl2gRrniHeBtdgQwaOlo39df8RxSrwsHVNIGXULy5XP6KqgScUw2m12D15wnJCKWxVhCX+wbrBW/y7g==", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.5.0" }, @@ -9658,6 +9720,7 @@ "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-1.0.2.tgz", "integrity": "sha512-0JRsDMQe53F6EHRWksdcavKDRjyqp8vrjakg8EcCUOa7PaFRRB1SO/xGZdzSlW1RSTWQDEksFMTCEcVEKmAoqA==", "optional": true, + "peer": true, "dependencies": { "@smithy/querystring-parser": "^1.0.2", "@smithy/types": "^1.1.1", @@ -9669,6 +9732,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-1.0.2.tgz", "integrity": "sha512-BCm15WILJ3SL93nusoxvJGMVfAMWHZhdeDZPtpAaskozuexd0eF6szdz4kbXaKp38bFCSenA6bkUHqaE3KK0dA==", "optional": true, + "peer": true, "dependencies": { "@smithy/util-buffer-from": "^1.0.2", "tslib": "^2.5.0" @@ -9682,6 +9746,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-1.0.2.tgz", "integrity": "sha512-Xh8L06H2anF5BHjSYTg8hx+Itcbf4SQZnVMl4PIkCOsKtneMJoGjPRLy17lEzfoh/GOaa0QxgCP6lRMQWzNl4w==", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.5.0" } @@ -9691,6 +9756,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-1.0.2.tgz", "integrity": "sha512-nXHbZsUtvZeyfL4Ceds9nmy2Uh2AhWXohG4vWHyjSdmT8cXZlJdmJgnH6SJKDjyUecbu+BpKeVvSrA4cWPSOPA==", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.5.0" }, @@ -9703,6 +9769,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-1.0.2.tgz", "integrity": "sha512-lHAYIyrBO9RANrPvccnPjU03MJnWZ66wWuC5GjWWQVfsmPwU6m00aakZkzHdUT6tGCkGacXSgArP5wgTgA+oCw==", "optional": true, + "peer": true, "dependencies": { "@smithy/is-array-buffer": "^1.0.2", "tslib": "^2.5.0" @@ -9716,6 +9783,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-1.0.2.tgz", "integrity": "sha512-HOdmDm+3HUbuYPBABLLHtn8ittuRyy+BSjKOA169H+EMc+IozipvXDydf+gKBRAxUa4dtKQkLraypwppzi+PRw==", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.5.0" }, @@ -9728,6 +9796,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-1.0.2.tgz", "integrity": "sha512-J1u2PO235zxY7dg0+ZqaG96tFg4ehJZ7isGK1pCBEA072qxNPwIpDzUVGnLJkHZvjWEGA8rxIauDtXfB0qxeAg==", "optional": true, + "peer": true, "dependencies": { "@smithy/property-provider": "^1.0.2", "@smithy/types": "^1.1.1", @@ -9743,6 +9812,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-1.0.2.tgz", "integrity": "sha512-9/BN63rlIsFStvI+AvljMh873Xw6bbI6b19b+PVYXyycQ2DDQImWcjnzRlHW7eP65CCUNGQ6otDLNdBQCgMXqg==", "optional": true, + "peer": true, "dependencies": { "@smithy/config-resolver": "^1.0.2", "@smithy/credential-provider-imds": "^1.0.2", @@ -9760,6 +9830,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-1.0.2.tgz", "integrity": "sha512-Bxydb5rMJorMV6AuDDMOxro3BMDdIwtbQKHpwvQFASkmr52BnpDsWlxgpJi8Iq7nk1Bt4E40oE1Isy/7ubHGzg==", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.5.0" }, @@ -9772,6 +9843,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-1.0.2.tgz", "integrity": "sha512-vtXK7GOR2BoseCX8NCGe9SaiZrm9M2lm/RVexFGyPuafTtry9Vyv7hq/vw8ifd/G/pSJ+msByfJVb1642oQHKw==", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.5.0" }, @@ -9784,6 +9856,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-1.0.4.tgz", "integrity": "sha512-RnZPVFvRoqdj2EbroDo3OsnnQU8eQ4AlnZTOGusbYKybH3269CFdrZfZJloe60AQjX7di3J6t/79PjwCLO5Khw==", "optional": true, + "peer": true, "dependencies": { "@smithy/service-error-classification": "^1.0.3", "tslib": "^2.5.0" @@ -9797,6 +9870,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-1.0.2.tgz", "integrity": "sha512-qyN2M9QFMTz4UCHi6GnBfLOGYKxQZD01Ga6nzaXFFC51HP/QmArU72e4kY50Z/EtW8binPxspP2TAsGbwy9l3A==", "optional": true, + "peer": true, "dependencies": { "@smithy/fetch-http-handler": "^1.0.2", "@smithy/node-http-handler": "^1.0.3", @@ -9816,6 +9890,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-1.0.2.tgz", "integrity": "sha512-k8C0BFNS9HpBMHSgUDnWb1JlCQcFG+PPlVBq9keP4Nfwv6a9Q0yAfASWqUCtzjuMj1hXeLhn/5ADP6JxnID1Pg==", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.5.0" }, @@ -9828,6 +9903,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-1.0.2.tgz", "integrity": "sha512-V4cyjKfJlARui0dMBfWJMQAmJzoW77i4N3EjkH/bwnE2Ngbl4tqD2Y0C/xzpzY/J1BdxeCKxAebVFk8aFCaSCw==", "optional": true, + "peer": true, "dependencies": { "@smithy/util-buffer-from": "^1.0.2", "tslib": "^2.5.0" @@ -22261,6 +22337,7 @@ } ], "optional": true, + "peer": true, "dependencies": { "strnum": "^1.0.5" }, @@ -25330,6 +25407,8 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "optional": true, + "peer": true, "dependencies": { "jsbn": "1.1.0", "sprintf-js": "^1.1.3" @@ -25341,12 +25420,16 @@ "node_modules/ip-address/node_modules/jsbn": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "optional": true, + "peer": true }, "node_modules/ip-address/node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "optional": true, + "peer": true }, "node_modules/ip-regex": { "version": "2.1.0", @@ -27102,6 +27185,7 @@ "version": "2.6.3", "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "license": "Apache-2.0", "engines": { "node": ">=12.0.0" } @@ -29491,6 +29575,74 @@ "node": ">=16.20.1" } }, + "node_modules/mongoose": { + "version": "8.9.5", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.9.5.tgz", + "integrity": "sha512-SPhOrgBm0nKV3b+IIHGqpUTOmgVL5Z3OO9AwkFEmvOZznXTvplbomstCnPOGAyungtRXE5pJTgKpKcZTdjeESg==", + "license": "MIT", + "dependencies": { + "bson": "^6.10.1", + "kareem": "2.6.3", + "mongodb": "~6.12.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mongoose/node_modules/mongodb": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.12.0.tgz", + "integrity": "sha512-RM7AHlvYfS7jv7+BXund/kR64DryVI+cHbVAy9P61fnb1RcWZqOW1/Wj2YhqMCx+MuYhqTRGv7AwHBzmsCKBfA==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.1", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, "node_modules/morgan": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", @@ -29535,6 +29687,7 @@ "version": "0.9.0", "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", "engines": { "node": ">=4.0.0" } @@ -29543,6 +29696,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "license": "MIT", "dependencies": { "debug": "4.x" }, @@ -36226,7 +36380,8 @@ "node_modules/sift": { "version": "17.1.3", "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", - "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==" + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" }, "node_modules/sigmund": { "version": "1.0.1", @@ -36502,6 +36657,8 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "optional": true, + "peer": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -36713,6 +36870,8 @@ "version": "2.8.3", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "optional": true, + "peer": true, "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" @@ -37338,7 +37497,8 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", - "optional": true + "optional": true, + "peer": true }, "node_modules/stubs": { "version": "3.0.0", @@ -43059,7 +43219,7 @@ "lodash": "^4.17.21", "marked": "^4.1.0", "method-override": "^3.0.0", - "mongoose": "^8.8.3", + "mongoose": "^8.9.5", "request": "^2.88.2" }, "devDependencies": { @@ -43071,143 +43231,6 @@ "typescript": "^5.0.4" } }, - "services/templates/node_modules/@types/whatwg-url": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", - "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", - "dependencies": { - "@types/node": "*", - "@types/webidl-conversions": "*" - } - }, - "services/templates/node_modules/bson": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.2.tgz", - "integrity": "sha512-Ry9wCtIZ5kGqkJoi6aD8KjxFZEx78guTQDnpXWiNthsxzrxAK/i8E6pCHAIZTbaEFWcOCvbecMukfK7XUvyLpQ==", - "dependencies": { - "buffer": "^5.6.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "services/templates/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "services/templates/node_modules/kareem": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", - "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==", - "engines": { - "node": ">=12.0.0" - } - }, - "services/templates/node_modules/mongodb": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.17.2.tgz", - "integrity": "sha512-mLV7SEiov2LHleRJPMPrK2PMyhXFZt2UQLC4VD4pnth3jMjYKHhtqfwwkkvS/NXuo/Fp3vbhaNcXrIDaLRb9Tg==", - "dependencies": { - "bson": "^4.7.2", - "mongodb-connection-string-url": "^2.6.0", - "socks": "^2.7.1" - }, - "engines": { - "node": ">=12.9.0" - }, - "optionalDependencies": { - "@aws-sdk/credential-providers": "^3.186.0", - "@mongodb-js/saslprep": "^1.1.0" - } - }, - "services/templates/node_modules/mongodb-connection-string-url": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", - "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", - "dependencies": { - "@types/whatwg-url": "^8.2.1", - "whatwg-url": "^11.0.0" - } - }, - "services/templates/node_modules/mongoose": { - "version": "6.13.3", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.13.3.tgz", - "integrity": "sha512-TCB/k6ZmkLZGZY/HJ78Ep45Za63591ZuZu5+HCISTe+0lsqbDeomqwezh+Ir7gMLa0wJwIy6CNkl5dxhCXTu9Q==", - "dependencies": { - "bson": "^4.7.2", - "kareem": "2.5.1", - "mongodb": "4.17.2", - "mpath": "0.9.0", - "mquery": "4.0.3", - "ms": "2.1.3", - "sift": "16.0.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mongoose" - } - }, - "services/templates/node_modules/mquery": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-4.0.3.tgz", - "integrity": "sha512-J5heI+P08I6VJ2Ky3+33IpCdAvlYGTSUjwTPxkAr8i8EoduPMBX2OY/wa3IKZIQl7MU4SbFk8ndgSKyB/cl1zA==", - "dependencies": { - "debug": "4.x" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "services/templates/node_modules/sift": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", - "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==" - }, - "services/templates/node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "dependencies": { - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "services/templates/node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "dependencies": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "services/third-party-datastore": { "name": "@overleaf/third-party-datastore", "dependencies": { @@ -43500,7 +43523,7 @@ "mmmagic": "^0.5.3", "moment": "^2.29.4", "mongodb-legacy": "6.1.3", - "mongoose": "8.8.3", + "mongoose": "8.9.5", "multer": "overleaf/multer#e1df247fbf8e7590520d20ae3601eaef9f3d2e9e", "nocache": "^2.1.0", "node-fetch": "^2.7.0", @@ -44742,28 +44765,6 @@ "node": ">=16 || 14 >=14.17" } }, - "services/web/node_modules/mongoose": { - "version": "8.8.3", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.8.3.tgz", - "integrity": "sha512-/I4n/DcXqXyIiLRfAmUIiTjj3vXfeISke8dt4U4Y8Wfm074Wa6sXnQrXN49NFOFf2mM1kUdOXryoBvkuCnr+Qw==", - "license": "MIT", - "dependencies": { - "bson": "^6.7.0", - "kareem": "2.6.3", - "mongodb": "~6.10.0", - "mpath": "0.9.0", - "mquery": "5.0.0", - "ms": "2.1.3", - "sift": "17.1.3" - }, - "engines": { - "node": ">=16.20.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mongoose" - } - }, "services/web/node_modules/multer": { "version": "1.4.5-lts.1", "resolved": "git+ssh://git@github.com/overleaf/multer.git#e1df247fbf8e7590520d20ae3601eaef9f3d2e9e", @@ -45373,6 +45374,7 @@ "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", "integrity": "sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==", "optional": true, + "peer": true, "requires": { "@aws-crypto/util": "^3.0.0", "@aws-sdk/types": "^3.222.0", @@ -45383,7 +45385,8 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true + "optional": true, + "peer": true } } }, @@ -45392,6 +45395,7 @@ "resolved": "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz", "integrity": "sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==", "optional": true, + "peer": true, "requires": { "tslib": "^1.11.1" }, @@ -45400,7 +45404,8 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true + "optional": true, + "peer": true } } }, @@ -45409,6 +45414,7 @@ "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-3.0.0.tgz", "integrity": "sha512-8VLmW2B+gjFbU5uMeqtQM6Nj0/F1bro80xQXCW6CQBWgosFWXTx77aeOF5CAIAmbOK64SdMBJdNr6J41yP5mvQ==", "optional": true, + "peer": true, "requires": { "@aws-crypto/ie11-detection": "^3.0.0", "@aws-crypto/sha256-js": "^3.0.0", @@ -45424,7 +45430,8 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true + "optional": true, + "peer": true } } }, @@ -45433,6 +45440,7 @@ "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-3.0.0.tgz", "integrity": "sha512-PnNN7os0+yd1XvXAy23CFOmTbMaDxgxXtTKHybrJ39Y8kGzBATgBFibWJKH6BhytLI/Zyszs87xCOBNyBig6vQ==", "optional": true, + "peer": true, "requires": { "@aws-crypto/util": "^3.0.0", "@aws-sdk/types": "^3.222.0", @@ -45443,7 +45451,8 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true + "optional": true, + "peer": true } } }, @@ -45452,6 +45461,7 @@ "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-3.0.0.tgz", "integrity": "sha512-06hBdMwUAb2WFTuGG73LSC0wfPu93xWwo5vL2et9eymgmu3Id5vFAHBbajVWiGhPO37qcsdCap/FqXvJGJWPIg==", "optional": true, + "peer": true, "requires": { "tslib": "^1.11.1" }, @@ -45460,7 +45470,8 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true + "optional": true, + "peer": true } } }, @@ -45469,6 +45480,7 @@ "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz", "integrity": "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==", "optional": true, + "peer": true, "requires": { "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-utf8-browser": "^3.0.0", @@ -45479,7 +45491,8 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "optional": true + "optional": true, + "peer": true } } }, @@ -45488,6 +45501,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.363.0.tgz", "integrity": "sha512-tsJzgBSCpna85IVsuS7FBIK9wkSl7fs8TJ/QzapIgu8rKss0ySHVO6TeMVAdw2BvaQl7CxU9c3PosjhLWHu6KQ==", "optional": true, + "peer": true, "requires": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", @@ -45532,6 +45546,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.363.0.tgz", "integrity": "sha512-PZ+HfKSgS4hlMnJzG+Ev8/mgHd/b/ETlJWPSWjC/f2NwVoBQkBnqHjdyEx7QjF6nksJozcVh5Q+kkYLKc/QwBQ==", "optional": true, + "peer": true, "requires": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", @@ -45573,6 +45588,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.363.0.tgz", "integrity": "sha512-V3Ebiq/zNtDS/O92HUWGBa7MY59RYSsqWd+E0XrXv6VYTA00RlMTbNcseivNgp2UghOgB9a20Nkz6EqAeIN+RQ==", "optional": true, + "peer": true, "requires": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", @@ -45614,6 +45630,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.363.0.tgz", "integrity": "sha512-0jj14WvBPJQ8xr72cL0mhlmQ90tF0O0wqXwSbtog6PsC8+KDE6Yf+WsxsumyI8E5O8u3eYijBL+KdqG07F/y/w==", "optional": true, + "peer": true, "requires": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", @@ -45659,6 +45676,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.363.0.tgz", "integrity": "sha512-5x42JvqEsBUrm6/qdf0WWe4mlmJjPItxamQhRjuOzeQD/BxsA2W5VS/7n0Ws0e27DNhlnUErcIJd+bBy6j1fqA==", "optional": true, + "peer": true, "requires": { "@aws-sdk/client-cognito-identity": "3.363.0", "@aws-sdk/types": "3.357.0", @@ -45672,6 +45690,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.363.0.tgz", "integrity": "sha512-VAQ3zITT2Q0acht0HezouYnMFKZ2vIOa20X4zQA3WI0HfaP4D6ga6KaenbDcb/4VFiqfqiRHfdyXHP0ThcDRMA==", "optional": true, + "peer": true, "requires": { "@aws-sdk/types": "3.357.0", "@smithy/property-provider": "^1.0.1", @@ -45684,6 +45703,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.363.0.tgz", "integrity": "sha512-ZYN+INoqyX5FVC3rqUxB6O8nOWkr0gHRRBm1suoOlmuFJ/WSlW/uUGthRBY5x1AQQnBF8cpdlxZzGHd41lFVNw==", "optional": true, + "peer": true, "requires": { "@aws-sdk/credential-provider-env": "3.363.0", "@aws-sdk/credential-provider-process": "3.363.0", @@ -45702,6 +45722,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.363.0.tgz", "integrity": "sha512-C1qXFIN2yMxD6pGgug0vR1UhScOki6VqdzuBHzXZAGu7MOjvgHNdscEcb3CpWnITHaPL2ztkiw75T1sZ7oIgQg==", "optional": true, + "peer": true, "requires": { "@aws-sdk/credential-provider-env": "3.363.0", "@aws-sdk/credential-provider-ini": "3.363.0", @@ -45721,6 +45742,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.363.0.tgz", "integrity": "sha512-fOKAINU7Rtj2T8pP13GdCt+u0Ml3gYynp8ki+1jMZIQ+Ju/MdDOqZpKMFKicMn3Z1ttUOgqr+grUdus6z8ceBQ==", "optional": true, + "peer": true, "requires": { "@aws-sdk/types": "3.357.0", "@smithy/property-provider": "^1.0.1", @@ -45734,6 +45756,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.363.0.tgz", "integrity": "sha512-5RUZ5oM0lwZSo3EehT0dXggOjgtxFogpT3cZvoLGtIwrPBvm8jOQPXQUlaqCj10ThF1sYltEyukz/ovtDwYGew==", "optional": true, + "peer": true, "requires": { "@aws-sdk/client-sso": "3.363.0", "@aws-sdk/token-providers": "3.363.0", @@ -45749,6 +45772,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.363.0.tgz", "integrity": "sha512-Z6w7fjgy79pAax580wdixbStQw10xfyZ+hOYLcPudoYFKjoNx0NQBejg5SwBzCF/HQL23Ksm9kDfbXDX9fkPhA==", "optional": true, + "peer": true, "requires": { "@aws-sdk/types": "3.357.0", "@smithy/property-provider": "^1.0.1", @@ -45761,6 +45785,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.363.0.tgz", "integrity": "sha512-hVa1DdYasnLud2EKjDAlDHiV/+H/Zq52chHU00c/R8XwPu1s0kZX3NMmlt0D2HhYqC1mUwtdmE58Jra2POviQQ==", "optional": true, + "peer": true, "requires": { "@aws-sdk/client-cognito-identity": "3.363.0", "@aws-sdk/client-sso": "3.363.0", @@ -45784,6 +45809,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.363.0.tgz", "integrity": "sha512-FobpclDCf5Y1ueyJDmb9MqguAdPssNMlnqWQpujhYVABq69KHu73fSCWSauFPUrw7YOpV8kG1uagDF0POSxHzA==", "optional": true, + "peer": true, "requires": { "@aws-sdk/types": "3.357.0", "@smithy/protocol-http": "^1.1.0", @@ -45796,6 +45822,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.363.0.tgz", "integrity": "sha512-SSGgthScYnFGTOw8EzbkvquqweFmvn7uJihkpFekbtBNGC/jGOGO+8ziHjTQ8t/iI/YKubEwv+LMi0f77HKSEg==", "optional": true, + "peer": true, "requires": { "@aws-sdk/types": "3.357.0", "@smithy/types": "^1.1.0", @@ -45807,6 +45834,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.363.0.tgz", "integrity": "sha512-MWD/57QgI/N7fG8rtzDTUdSqNpYohQfgj9XCFAoVeI/bU4usrkOrew43L4smJG4XrDxlNT8lSJlDtd64tuiUZA==", "optional": true, + "peer": true, "requires": { "@aws-sdk/types": "3.357.0", "@smithy/protocol-http": "^1.1.0", @@ -45819,6 +45847,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sts/-/middleware-sdk-sts-3.363.0.tgz", "integrity": "sha512-1yy2Ac50FO8BrODaw5bPWvVrRhaVLqXTFH6iHB+dJLPUkwtY5zLM3Mp+9Ilm7kME+r7oIB1wuO6ZB1Lf4ZszIw==", "optional": true, + "peer": true, "requires": { "@aws-sdk/middleware-signing": "3.363.0", "@aws-sdk/types": "3.357.0", @@ -45831,6 +45860,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.363.0.tgz", "integrity": "sha512-/7qia715pt9JKYIPDGu22WmdZxD8cfF/5xB+1kmILg7ZtjO0pPuTaCNJ7xiIuFd7Dn7JXp5lop08anX/GOhNRQ==", "optional": true, + "peer": true, "requires": { "@aws-sdk/types": "3.357.0", "@smithy/property-provider": "^1.0.1", @@ -45846,6 +45876,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.363.0.tgz", "integrity": "sha512-ri8YaQvXP6odteVTMfxPqFR26Q0h9ejtqhUDv47P34FaKXedEM4nC6ix6o+5FEYj6l8syGyktftZ5O70NoEhug==", "optional": true, + "peer": true, "requires": { "@aws-sdk/types": "3.357.0", "@aws-sdk/util-endpoints": "3.357.0", @@ -45859,6 +45890,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.363.0.tgz", "integrity": "sha512-6+0aJ1zugNgsMmhTtW2LBWxOVSaXCUk2q3xyTchSXkNzallYaRiZMRkieW+pKNntnu0g5H1T0zyfCO0tbXwxEA==", "optional": true, + "peer": true, "requires": { "@aws-sdk/client-sso-oidc": "3.363.0", "@aws-sdk/types": "3.357.0", @@ -45873,6 +45905,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.357.0.tgz", "integrity": "sha512-/riCRaXg3p71BeWnShrai0y0QTdXcouPSM0Cn1olZbzTf7s71aLEewrc96qFrL70XhY4XvnxMpqQh+r43XIL3g==", "optional": true, + "peer": true, "requires": { "tslib": "^2.5.0" } @@ -45882,6 +45915,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.357.0.tgz", "integrity": "sha512-XHKyS5JClT9su9hDif715jpZiWHQF9gKZXER8tW0gOizU3R9cyWc9EsJ2BRhFNhi7nt/JF/CLUEc5qDx3ETbUw==", "optional": true, + "peer": true, "requires": { "@aws-sdk/types": "3.357.0", "tslib": "^2.5.0" @@ -45892,6 +45926,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.310.0.tgz", "integrity": "sha512-qo2t/vBTnoXpjKxlsC2e1gBrRm80M3bId27r0BRB2VniSSe7bL1mmzM+/HFtujm0iAxtPM+aLEflLJlJeDPg0w==", "optional": true, + "peer": true, "requires": { "tslib": "^2.5.0" } @@ -45901,6 +45936,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.363.0.tgz", "integrity": "sha512-fk9ymBUIYbxiGm99Cn+kAAXmvMCWTf/cHAcB79oCXV4ELXdPa9lN5xQhZRFNxLUeXG4OAMEuCAUUuZEj8Fnc1Q==", "optional": true, + "peer": true, "requires": { "@aws-sdk/types": "3.357.0", "@smithy/types": "^1.1.0", @@ -45913,6 +45949,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.363.0.tgz", "integrity": "sha512-Fli/dvgGA9hdnQUrYb1//wNSFlK2jAfdJcfNXA6SeBYzSeH5pVGYF4kXF0FCdnMA3Fef+Zn1zAP/hw9v8VJHWQ==", "optional": true, + "peer": true, "requires": { "@aws-sdk/types": "3.357.0", "@smithy/node-config-provider": "^1.0.1", @@ -45925,6 +45962,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz", "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==", "optional": true, + "peer": true, "requires": { "tslib": "^2.3.1" } @@ -51753,108 +51791,11 @@ "marked": "^4.1.0", "method-override": "^3.0.0", "mocha": "^10.2.0", - "mongoose": "^8.8.3", + "mongoose": "^8.9.5", "request": "^2.88.2", "sandboxed-module": "^2.0.4", "sinon": "^9.2.4", "typescript": "^5.0.4" - }, - "dependencies": { - "@types/whatwg-url": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", - "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", - "requires": { - "@types/node": "*", - "@types/webidl-conversions": "*" - } - }, - "bson": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.2.tgz", - "integrity": "sha512-Ry9wCtIZ5kGqkJoi6aD8KjxFZEx78guTQDnpXWiNthsxzrxAK/i8E6pCHAIZTbaEFWcOCvbecMukfK7XUvyLpQ==", - "requires": { - "buffer": "^5.6.0" - } - }, - "buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "kareem": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", - "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==" - }, - "mongodb": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.17.2.tgz", - "integrity": "sha512-mLV7SEiov2LHleRJPMPrK2PMyhXFZt2UQLC4VD4pnth3jMjYKHhtqfwwkkvS/NXuo/Fp3vbhaNcXrIDaLRb9Tg==", - "requires": { - "@aws-sdk/credential-providers": "^3.186.0", - "@mongodb-js/saslprep": "^1.1.0", - "bson": "^4.7.2", - "mongodb-connection-string-url": "^2.6.0", - "socks": "^2.7.1" - } - }, - "mongodb-connection-string-url": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", - "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", - "requires": { - "@types/whatwg-url": "^8.2.1", - "whatwg-url": "^11.0.0" - } - }, - "mongoose": { - "version": "https://registry.npmjs.org/mongoose/-/mongoose-6.13.3.tgz", - "integrity": "sha512-TCB/k6ZmkLZGZY/HJ78Ep45Za63591ZuZu5+HCISTe+0lsqbDeomqwezh+Ir7gMLa0wJwIy6CNkl5dxhCXTu9Q==", - "requires": { - "bson": "^4.7.2", - "kareem": "2.5.1", - "mongodb": "4.17.2", - "mpath": "0.9.0", - "mquery": "4.0.3", - "ms": "2.1.3", - "sift": "16.0.1" - } - }, - "mquery": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-4.0.3.tgz", - "integrity": "sha512-J5heI+P08I6VJ2Ky3+33IpCdAvlYGTSUjwTPxkAr8i8EoduPMBX2OY/wa3IKZIQl7MU4SbFk8ndgSKyB/cl1zA==", - "requires": { - "debug": "4.x" - } - }, - "sift": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", - "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==" - }, - "tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "requires": { - "punycode": "^2.1.1" - } - }, - "whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "requires": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - } - } } }, "@overleaf/third-party-datastore": { @@ -52265,7 +52206,7 @@ "mock-fs": "^5.1.2", "moment": "^2.29.4", "mongodb-legacy": "6.1.3", - "mongoose": "8.8.3", + "mongoose": "8.9.5", "multer": "overleaf/multer#e1df247fbf8e7590520d20ae3601eaef9f3d2e9e", "nocache": "^2.1.0", "nock": "^13.5.6", @@ -53118,20 +53059,6 @@ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "dev": true }, - "mongoose": { - "version": "8.8.3", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.8.3.tgz", - "integrity": "sha512-/I4n/DcXqXyIiLRfAmUIiTjj3vXfeISke8dt4U4Y8Wfm074Wa6sXnQrXN49NFOFf2mM1kUdOXryoBvkuCnr+Qw==", - "requires": { - "bson": "^6.7.0", - "kareem": "2.6.3", - "mongodb": "~6.10.0", - "mpath": "0.9.0", - "mquery": "5.0.0", - "ms": "2.1.3", - "sift": "17.1.3" - } - }, "multer": { "version": "git+ssh://git@github.com/overleaf/multer.git#e1df247fbf8e7590520d20ae3601eaef9f3d2e9e", "integrity": "sha512-3fJSnWF3iBZJ6Z9y8AjFVY+O4DUKspxSnzXidb3zCKqBYyEKRrpGp7OXjT9th2gWPd+9u64ZyRWUf+YRYn1GCw==", @@ -53999,6 +53926,7 @@ "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-1.0.2.tgz", "integrity": "sha512-tb2h0b+JvMee+eAxTmhnyqyNk51UXIK949HnE14lFeezKsVJTB30maan+CO2IMwnig2wVYQH84B5qk6ylmKCuA==", "optional": true, + "peer": true, "requires": { "@smithy/types": "^1.1.1", "tslib": "^2.5.0" @@ -54009,6 +53937,7 @@ "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-1.0.2.tgz", "integrity": "sha512-8Bk7CgnVKg1dn5TgnjwPz2ebhxeR7CjGs5yhVYH3S8x0q8yPZZVWwpRIglwXaf5AZBzJlNO1lh+lUhMf2e73zQ==", "optional": true, + "peer": true, "requires": { "@smithy/types": "^1.1.1", "@smithy/util-config-provider": "^1.0.2", @@ -54021,6 +53950,7 @@ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-1.0.2.tgz", "integrity": "sha512-fLjCya+JOu2gPJpCiwSUyoLvT8JdNJmOaTOkKYBZoGf7CzqR6lluSyI+eboZnl/V0xqcfcqBG4tgqCISmWS3/w==", "optional": true, + "peer": true, "requires": { "@smithy/node-config-provider": "^1.0.2", "@smithy/property-provider": "^1.0.2", @@ -54034,6 +53964,7 @@ "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-1.0.2.tgz", "integrity": "sha512-eW/XPiLauR1VAgHKxhVvgvHzLROUgTtqat2lgljztbH8uIYWugv7Nz+SgCavB+hWRazv2iYgqrSy74GvxXq/rg==", "optional": true, + "peer": true, "requires": { "@aws-crypto/crc32": "3.0.0", "@smithy/types": "^1.1.1", @@ -54046,6 +53977,7 @@ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-1.0.2.tgz", "integrity": "sha512-kynyofLf62LvR8yYphPPdyHb8fWG3LepFinM/vWUTG2Q1pVpmPCM530ppagp3+q2p+7Ox0UvSqldbKqV/d1BpA==", "optional": true, + "peer": true, "requires": { "@smithy/protocol-http": "^1.1.1", "@smithy/querystring-builder": "^1.0.2", @@ -54059,6 +53991,7 @@ "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-1.0.2.tgz", "integrity": "sha512-K6PKhcUNrJXtcesyzhIvNlU7drfIU7u+EMQuGmPw6RQDAg/ufUcfKHz4EcUhFAodUmN+rrejhRG9U6wxjeBOQA==", "optional": true, + "peer": true, "requires": { "@smithy/types": "^1.1.1", "@smithy/util-buffer-from": "^1.0.2", @@ -54071,6 +54004,7 @@ "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-1.0.2.tgz", "integrity": "sha512-B1Y3Tsa6dfC+Vvb+BJMhTHOfFieeYzY9jWQSTR1vMwKkxsymD0OIAnEw8rD/RiDj/4E4RPGFdx9Mdgnyd6Bv5Q==", "optional": true, + "peer": true, "requires": { "@smithy/types": "^1.1.1", "tslib": "^2.5.0" @@ -54081,6 +54015,7 @@ "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-1.0.2.tgz", "integrity": "sha512-pkyBnsBRpe+c/6ASavqIMRBdRtZNJEVJOEzhpxZ9JoAXiZYbkfaSMRA/O1dUxGdJ653GHONunnZ4xMo/LJ7utQ==", "optional": true, + "peer": true, "requires": { "tslib": "^2.5.0" } @@ -54090,6 +54025,7 @@ "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-1.0.2.tgz", "integrity": "sha512-pa1/SgGIrSmnEr2c9Apw7CdU4l/HW0fK3+LKFCPDYJrzM0JdYpqjQzgxi31P00eAkL0EFBccpus/p1n2GF9urw==", "optional": true, + "peer": true, "requires": { "@smithy/protocol-http": "^1.1.1", "@smithy/types": "^1.1.1", @@ -54101,6 +54037,7 @@ "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-1.0.3.tgz", "integrity": "sha512-GsWvTXMFjSgl617PCE2km//kIjjtvMRrR2GAuRDIS9sHiLwmkS46VWaVYy+XE7ubEsEtzZ5yK2e8TKDR6Qr5Lw==", "optional": true, + "peer": true, "requires": { "@smithy/middleware-serde": "^1.0.2", "@smithy/types": "^1.1.1", @@ -54114,6 +54051,7 @@ "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-1.0.4.tgz", "integrity": "sha512-G7uRXGFL8c3F7APnoIMTtNAHH8vT4F2qVnAWGAZaervjupaUQuRRHYBLYubK0dWzOZz86BtAXKieJ5p+Ni2Xpg==", "optional": true, + "peer": true, "requires": { "@smithy/protocol-http": "^1.1.1", "@smithy/service-error-classification": "^1.0.3", @@ -54128,7 +54066,8 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "optional": true + "optional": true, + "peer": true } } }, @@ -54137,6 +54076,7 @@ "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-1.0.2.tgz", "integrity": "sha512-T4PcdMZF4xme6koUNfjmSZ1MLi7eoFeYCtodQNQpBNsS77TuJt1A6kt5kP/qxrTvfZHyFlj0AubACoaUqgzPeg==", "optional": true, + "peer": true, "requires": { "@smithy/types": "^1.1.1", "tslib": "^2.5.0" @@ -54147,6 +54087,7 @@ "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-1.0.2.tgz", "integrity": "sha512-H7/uAQEcmO+eDqweEFMJ5YrIpsBwmrXSP6HIIbtxKJSQpAcMGY7KrR2FZgZBi1FMnSUOh+rQrbOyj5HQmSeUBA==", "optional": true, + "peer": true, "requires": { "tslib": "^2.5.0" } @@ -54156,6 +54097,7 @@ "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-1.0.2.tgz", "integrity": "sha512-HU7afWpTToU0wL6KseGDR2zojeyjECQfr8LpjAIeHCYIW7r360ABFf4EaplaJRMVoC3hD9FeltgI3/NtShOqCg==", "optional": true, + "peer": true, "requires": { "@smithy/property-provider": "^1.0.2", "@smithy/shared-ini-file-loader": "^1.0.2", @@ -54168,6 +54110,7 @@ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-1.0.3.tgz", "integrity": "sha512-PcPUSzTbIb60VCJCiH0PU0E6bwIekttsIEf5Aoo/M0oTfiqsxHTn0Rcij6QoH6qJy6piGKXzLSegspXg5+Kq6g==", "optional": true, + "peer": true, "requires": { "@smithy/abort-controller": "^1.0.2", "@smithy/protocol-http": "^1.1.1", @@ -54181,6 +54124,7 @@ "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-1.0.2.tgz", "integrity": "sha512-pXDPyzKX8opzt38B205kDgaxda6LHcTfPvTYQZnwP6BAPp1o9puiCPjeUtkKck7Z6IbpXCPUmUQnzkUzWTA42Q==", "optional": true, + "peer": true, "requires": { "@smithy/types": "^1.1.1", "tslib": "^2.5.0" @@ -54191,6 +54135,7 @@ "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-1.1.1.tgz", "integrity": "sha512-mFLFa2sSvlUxm55U7B4YCIsJJIMkA6lHxwwqOaBkral1qxFz97rGffP/mmd4JDuin1EnygiO5eNJGgudiUgmDQ==", "optional": true, + "peer": true, "requires": { "@smithy/types": "^1.1.1", "tslib": "^2.5.0" @@ -54201,6 +54146,7 @@ "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-1.0.2.tgz", "integrity": "sha512-6P/xANWrtJhMzTPUR87AbXwSBuz1SDHIfL44TFd/GT3hj6rA+IEv7rftEpPjayUiWRocaNnrCPLvmP31mobOyA==", "optional": true, + "peer": true, "requires": { "@smithy/types": "^1.1.1", "@smithy/util-uri-escape": "^1.0.2", @@ -54212,6 +54158,7 @@ "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-1.0.2.tgz", "integrity": "sha512-IWxwxjn+KHWRRRB+K2Ngl+plTwo2WSgc2w+DvLy0DQZJh9UGOpw40d6q97/63GBlXIt4TEt5NbcFrO30CKlrsA==", "optional": true, + "peer": true, "requires": { "@smithy/types": "^1.1.1", "tslib": "^2.5.0" @@ -54221,13 +54168,15 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-1.0.3.tgz", "integrity": "sha512-2eglIYqrtcUnuI71yweu7rSfCgt6kVvRVf0C72VUqrd0LrV1M0BM0eYN+nitp2CHPSdmMI96pi+dU9U/UqAMSA==", - "optional": true + "optional": true, + "peer": true }, "@smithy/shared-ini-file-loader": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-1.0.2.tgz", "integrity": "sha512-bdQj95VN+lCXki+P3EsDyrkpeLn8xDYiOISBGnUG/AGPYJXN8dmp4EhRRR7XOoLoSs8anZHR4UcGEOzFv2jwGw==", "optional": true, + "peer": true, "requires": { "@smithy/types": "^1.1.1", "tslib": "^2.5.0" @@ -54238,6 +54187,7 @@ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-1.0.2.tgz", "integrity": "sha512-rpKUhmCuPmpV5dloUkOb9w1oBnJatvKQEjIHGmkjRGZnC3437MTdzWej9TxkagcZ8NRRJavYnEUixzxM1amFig==", "optional": true, + "peer": true, "requires": { "@smithy/eventstream-codec": "^1.0.2", "@smithy/is-array-buffer": "^1.0.2", @@ -54254,6 +54204,7 @@ "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-1.0.4.tgz", "integrity": "sha512-gpo0Xl5Nyp9sgymEfpt7oa9P2q/GlM3VmQIdm+FeH0QEdYOQx3OtvwVmBYAMv2FIPWxkMZlsPYRTnEiBTK5TYg==", "optional": true, + "peer": true, "requires": { "@smithy/middleware-stack": "^1.0.2", "@smithy/types": "^1.1.1", @@ -54266,6 +54217,7 @@ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-1.1.1.tgz", "integrity": "sha512-tMpkreknl2gRrniHeBtdgQwaOlo39df8RxSrwsHVNIGXULy5XP6KqgScUw2m12D15wnJCKWxVhCX+wbrBW/y7g==", "optional": true, + "peer": true, "requires": { "tslib": "^2.5.0" } @@ -54275,6 +54227,7 @@ "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-1.0.2.tgz", "integrity": "sha512-0JRsDMQe53F6EHRWksdcavKDRjyqp8vrjakg8EcCUOa7PaFRRB1SO/xGZdzSlW1RSTWQDEksFMTCEcVEKmAoqA==", "optional": true, + "peer": true, "requires": { "@smithy/querystring-parser": "^1.0.2", "@smithy/types": "^1.1.1", @@ -54286,6 +54239,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-1.0.2.tgz", "integrity": "sha512-BCm15WILJ3SL93nusoxvJGMVfAMWHZhdeDZPtpAaskozuexd0eF6szdz4kbXaKp38bFCSenA6bkUHqaE3KK0dA==", "optional": true, + "peer": true, "requires": { "@smithy/util-buffer-from": "^1.0.2", "tslib": "^2.5.0" @@ -54296,6 +54250,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-1.0.2.tgz", "integrity": "sha512-Xh8L06H2anF5BHjSYTg8hx+Itcbf4SQZnVMl4PIkCOsKtneMJoGjPRLy17lEzfoh/GOaa0QxgCP6lRMQWzNl4w==", "optional": true, + "peer": true, "requires": { "tslib": "^2.5.0" } @@ -54305,6 +54260,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-1.0.2.tgz", "integrity": "sha512-nXHbZsUtvZeyfL4Ceds9nmy2Uh2AhWXohG4vWHyjSdmT8cXZlJdmJgnH6SJKDjyUecbu+BpKeVvSrA4cWPSOPA==", "optional": true, + "peer": true, "requires": { "tslib": "^2.5.0" } @@ -54314,6 +54270,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-1.0.2.tgz", "integrity": "sha512-lHAYIyrBO9RANrPvccnPjU03MJnWZ66wWuC5GjWWQVfsmPwU6m00aakZkzHdUT6tGCkGacXSgArP5wgTgA+oCw==", "optional": true, + "peer": true, "requires": { "@smithy/is-array-buffer": "^1.0.2", "tslib": "^2.5.0" @@ -54324,6 +54281,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-1.0.2.tgz", "integrity": "sha512-HOdmDm+3HUbuYPBABLLHtn8ittuRyy+BSjKOA169H+EMc+IozipvXDydf+gKBRAxUa4dtKQkLraypwppzi+PRw==", "optional": true, + "peer": true, "requires": { "tslib": "^2.5.0" } @@ -54333,6 +54291,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-1.0.2.tgz", "integrity": "sha512-J1u2PO235zxY7dg0+ZqaG96tFg4ehJZ7isGK1pCBEA072qxNPwIpDzUVGnLJkHZvjWEGA8rxIauDtXfB0qxeAg==", "optional": true, + "peer": true, "requires": { "@smithy/property-provider": "^1.0.2", "@smithy/types": "^1.1.1", @@ -54345,6 +54304,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-1.0.2.tgz", "integrity": "sha512-9/BN63rlIsFStvI+AvljMh873Xw6bbI6b19b+PVYXyycQ2DDQImWcjnzRlHW7eP65CCUNGQ6otDLNdBQCgMXqg==", "optional": true, + "peer": true, "requires": { "@smithy/config-resolver": "^1.0.2", "@smithy/credential-provider-imds": "^1.0.2", @@ -54359,6 +54319,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-1.0.2.tgz", "integrity": "sha512-Bxydb5rMJorMV6AuDDMOxro3BMDdIwtbQKHpwvQFASkmr52BnpDsWlxgpJi8Iq7nk1Bt4E40oE1Isy/7ubHGzg==", "optional": true, + "peer": true, "requires": { "tslib": "^2.5.0" } @@ -54368,6 +54329,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-1.0.2.tgz", "integrity": "sha512-vtXK7GOR2BoseCX8NCGe9SaiZrm9M2lm/RVexFGyPuafTtry9Vyv7hq/vw8ifd/G/pSJ+msByfJVb1642oQHKw==", "optional": true, + "peer": true, "requires": { "tslib": "^2.5.0" } @@ -54377,6 +54339,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-1.0.4.tgz", "integrity": "sha512-RnZPVFvRoqdj2EbroDo3OsnnQU8eQ4AlnZTOGusbYKybH3269CFdrZfZJloe60AQjX7di3J6t/79PjwCLO5Khw==", "optional": true, + "peer": true, "requires": { "@smithy/service-error-classification": "^1.0.3", "tslib": "^2.5.0" @@ -54387,6 +54350,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-1.0.2.tgz", "integrity": "sha512-qyN2M9QFMTz4UCHi6GnBfLOGYKxQZD01Ga6nzaXFFC51HP/QmArU72e4kY50Z/EtW8binPxspP2TAsGbwy9l3A==", "optional": true, + "peer": true, "requires": { "@smithy/fetch-http-handler": "^1.0.2", "@smithy/node-http-handler": "^1.0.3", @@ -54403,6 +54367,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-1.0.2.tgz", "integrity": "sha512-k8C0BFNS9HpBMHSgUDnWb1JlCQcFG+PPlVBq9keP4Nfwv6a9Q0yAfASWqUCtzjuMj1hXeLhn/5ADP6JxnID1Pg==", "optional": true, + "peer": true, "requires": { "tslib": "^2.5.0" } @@ -54412,6 +54377,7 @@ "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-1.0.2.tgz", "integrity": "sha512-V4cyjKfJlARui0dMBfWJMQAmJzoW77i4N3EjkH/bwnE2Ngbl4tqD2Y0C/xzpzY/J1BdxeCKxAebVFk8aFCaSCw==", "optional": true, + "peer": true, "requires": { "@smithy/util-buffer-from": "^1.0.2", "tslib": "^2.5.0" @@ -63681,6 +63647,7 @@ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz", "integrity": "sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g==", "optional": true, + "peer": true, "requires": { "strnum": "^1.0.5" } @@ -66012,6 +65979,8 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "optional": true, + "peer": true, "requires": { "jsbn": "1.1.0", "sprintf-js": "^1.1.3" @@ -66020,12 +65989,16 @@ "jsbn": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "optional": true, + "peer": true }, "sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "optional": true, + "peer": true } } }, @@ -69815,6 +69788,32 @@ "mongodb": "^6.0.0" } }, + "mongoose": { + "version": "8.9.5", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.9.5.tgz", + "integrity": "sha512-SPhOrgBm0nKV3b+IIHGqpUTOmgVL5Z3OO9AwkFEmvOZznXTvplbomstCnPOGAyungtRXE5pJTgKpKcZTdjeESg==", + "requires": { + "bson": "^6.10.1", + "kareem": "2.6.3", + "mongodb": "~6.12.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "dependencies": { + "mongodb": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.12.0.tgz", + "integrity": "sha512-RM7AHlvYfS7jv7+BXund/kR64DryVI+cHbVAy9P61fnb1RcWZqOW1/Wj2YhqMCx+MuYhqTRGv7AwHBzmsCKBfA==", + "requires": { + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.1", + "mongodb-connection-string-url": "^3.0.0" + } + } + } + }, "morgan": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", @@ -74973,7 +74972,9 @@ "smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==" + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "optional": true, + "peer": true }, "snapdragon": { "version": "0.8.2", @@ -75149,6 +75150,8 @@ "version": "2.8.3", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "optional": true, + "peer": true, "requires": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" @@ -75625,7 +75628,8 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", - "optional": true + "optional": true, + "peer": true }, "stubs": { "version": "3.0.0", diff --git a/services/web/package.json b/services/web/package.json index fa8b1e646b..ab082fba04 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -136,7 +136,7 @@ "mmmagic": "^0.5.3", "moment": "^2.29.4", "mongodb-legacy": "6.1.3", - "mongoose": "8.8.3", + "mongoose": "8.9.5", "multer": "overleaf/multer#e1df247fbf8e7590520d20ae3601eaef9f3d2e9e", "nocache": "^2.1.0", "node-fetch": "^2.7.0", From c8be2e25cf57d04937a5a28e05009ec8ab8ed6fb Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Mon, 20 Jan 2025 11:04:15 +0100 Subject: [PATCH 0070/1724] [web] Promisify `ensureAffiliationMiddleware` and refactor `InstitutionHubsController` (#22242 feedback) (#22261) * Promisify `ensureAffiliationMiddleware` * In `ensureAffiliationMiddleware`, throw when UserNotFoundError * Unnest object `_InstitutionHubsController` * Format fix GitOrigin-RevId: 5b3c6c24724520353540b8d8dd05005b6fa749ff --- .../web/app/src/Features/User/UserController.js | 2 +- .../test/unit/src/User/UserControllerTests.js | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js index 79347a03b7..a4d886915a 100644 --- a/services/web/app/src/Features/User/UserController.js +++ b/services/web/app/src/Features/User/UserController.js @@ -202,7 +202,7 @@ async function ensureAffiliationMiddleware(req, res, next) { try { user = await UserGetter.promises.getUser(userId) } catch (error) { - return new Errors.UserNotFoundError({ info: { userId } }) + throw new Errors.UserNotFoundError({ info: { userId } }) } // if the user does not have permission to add an affiliation, we skip this middleware try { diff --git a/services/web/test/unit/src/User/UserControllerTests.js b/services/web/test/unit/src/User/UserControllerTests.js index 717d136a09..160e877ad2 100644 --- a/services/web/test/unit/src/User/UserControllerTests.js +++ b/services/web/test/unit/src/User/UserControllerTests.js @@ -1106,5 +1106,22 @@ describe('UserController', function () { expect(this.next).to.be.calledWith(sinon.match.instanceOf(Error)) }) }) + + describe('when user is not found', function () { + beforeEach(async function () { + this.UserGetter.promises.getUser.rejects(new Error('not found')) + this.Features.hasFeature.withArgs('affiliations').returns(true) + this.req.query.ensureAffiliation = true + await this.UserController.ensureAffiliationMiddleware( + this.req, + this.res, + this.next + ) + }) + + it('should return the error', function () { + expect(this.next).to.be.calledWith(sinon.match.instanceOf(Error)) + }) + }) }) }) From d4a10c7b417cda143e8e4393344bab86eeb41ff7 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Mon, 20 Jan 2025 11:04:30 +0100 Subject: [PATCH 0071/1724] [web] Socket diagnostics updates (#22951) * Increase threshold for "latency in red color" * Fix online status in Chrome and Safari * Add "Auto ping" checkbox * Put `/socket-diagnostics` behind `AuthenticationController.requireLogin` * Set logs to `logger.info` when debugging * Add `publicId` and `clientId` to logs * Fix disconnect logs when debugging * Refresh UI every second. Display red "Ping Count" if unanswered for 3s * Update services/web/frontend/js/features/socket-diagnostics/components/socket-diagnostics.tsx Co-authored-by: Jakob Ackermann * Update services/web/frontend/js/features/socket-diagnostics/components/socket-diagnostics.tsx Co-authored-by: Jakob Ackermann * `npm run format:fix` --------- Co-authored-by: Jakob Ackermann GitOrigin-RevId: 9faf2abdac51fa4b87c67d8fe89c4125d01d826f --- services/real-time/app/js/Router.js | 31 ++++++-- services/web/app/src/router.mjs | 6 +- .../components/socket-diagnostics.tsx | 48 +++++++++---- .../socket-diagnostics/components/types.ts | 1 + .../components/use-socket-manager.ts | 70 ++++++++++++------- 5 files changed, 109 insertions(+), 47 deletions(-) diff --git a/services/real-time/app/js/Router.js b/services/real-time/app/js/Router.js index 37845015b0..756732112d 100644 --- a/services/real-time/app/js/Router.js +++ b/services/real-time/app/js/Router.js @@ -127,7 +127,10 @@ module.exports = Router = { if (client) { client.on('error', function (err) { - logger.err({ clientErr: err }, 'socket.io client error') + logger.err( + { clientErr: err, publicId: client.publicId, clientId: client.id }, + 'socket.io client error' + ) if (client.connected) { client.emit('reconnectGracefully') client.disconnect() @@ -174,6 +177,7 @@ module.exports = Router = { if (isDebugging) { client.connectedAt = Date.now() + client.isDebugging = true } if (!isDebugging) { @@ -205,10 +209,17 @@ module.exports = Router = { }) metrics.gauge('socket-io.clients', io.sockets.clients().length) - logger.debug( - { session, clientId: client.id, isDebugging }, - 'client connected' - ) + const info = { + session, + publicId: client.publicId, + clientId: client.id, + isDebugging, + } + if (isDebugging) { + logger.info(info, 'client connected') + } else { + logger.debug(info, 'client connected') + } let user if (session && session.passport && session.passport.user) { @@ -237,7 +248,10 @@ module.exports = Router = { return Router._handleInvalidArguments(client, 'debug', arguments) } - logger.debug({ clientId: client.id }, 'received debug message') + logger.info( + { publicId: client.publicId, clientId: client.id }, + 'received debug message' + ) const response = { serverTime: Date.now(), @@ -281,7 +295,10 @@ module.exports = Router = { if (client.isDebugging) { const duration = Date.now() - client.connectedAt metrics.timing('socket-io.debugging.duration', duration) - logger.debug({ duration }, 'debug client disconnected') + logger.info( + { duration, publicId: client.publicId, clientId: client.id }, + 'debug client disconnected' + ) } WebsocketController.leaveProject(io, client, function (err) { diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 125fdfd385..ab322fc8f4 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -232,7 +232,11 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { webRouter.get('/account-suspended', UserPagesController.accountSuspended) - webRouter.get('/socket-diagnostics', SocketDiagnostics.index) + webRouter.get( + '/socket-diagnostics', + AuthenticationController.requireLogin(), + SocketDiagnostics.index + ) if (Settings.enableLegacyLogin) { AuthenticationController.addEndpointToLoginWhitelist('/login/legacy') 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 03d16427cf..cbf16bcb4e 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 @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useEffect } from 'react' import type { ConnectionStatus } from './types' import { useSocketManager } from './use-socket-manager' import { @@ -9,6 +9,7 @@ import { } from './diagnostic-component' import { Container } from 'react-bootstrap-5' import MaterialIcon from '@/shared/components/material-icon' +import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox' type NetworkInformation = { downlink: number @@ -38,9 +39,26 @@ const NavigatorInfo = () => { ) } +const useCurrentTime = () => { + const [time, setTime] = React.useState(new Date()) + useEffect(() => { + const interval = setInterval(() => setTime(new Date()), 1000) + return () => clearInterval(interval) + }, []) + return time +} + export const SocketDiagnostics = () => { - const { socketState, debugInfo, disconnectSocket, forceReconnect, socket } = - useSocketManager() + const { + socketState, + debugInfo, + disconnectSocket, + forceReconnect, + socket, + autoping, + setAutoping, + } = useSocketManager() + const now = useCurrentTime() const getConnectionState = (): ConnectionStatus => { if (socketState.connected) return 'connected' @@ -49,9 +67,13 @@ export const SocketDiagnostics = () => { } const lastReceivedS = debugInfo.lastReceived - ? Math.round((Date.now() - debugInfo.lastReceived) / 1000) + ? Math.round((now.getTime() - debugInfo.lastReceived) / 1000) : null + const isLate = + !!debugInfo.unansweredSince && + now.getTime() - debugInfo.unansweredSince >= 3000 + return (

Socket Diagnostics

@@ -78,6 +100,12 @@ export const SocketDiagnostics = () => { Connection Stats
+ setAutoping(e.target.checked)} + /> { )} } - type={ - lastReceivedS !== null - ? lastReceivedS < 4 - ? 'success' - : 'danger' - : undefined - } + type={isLate === null ? undefined : isLate ? 'danger' : 'success'} /> { } type={ debugInfo.latency - ? debugInfo.latency < 150 + ? debugInfo.latency < 450 ? 'success' : 'danger' : undefined @@ -149,7 +171,7 @@ export const SocketDiagnostics = () => { (null) + const [autoping, setAutoping] = useState(false) const [socketState, setSocketState] = useState({ connected: false, @@ -20,6 +21,7 @@ export function useSocketManager() { onLine: null, clockDelta: null, client: null, + unansweredSince: null, lastReceived: null, }) @@ -65,30 +67,46 @@ export function useSocketManager() { connectSocket() }, [connectSocket]) + const sendPing = useCallback(() => { + if (socket?.socket.connected) { + const time = Date.now() + setDebugInfo(prev => ({ + ...prev, + sent: prev.sent + 1, + unansweredSince: prev.unansweredSince ?? time, + })) + socket.emit('debug', { time }, (info: any) => { + const beforeTime = info.data.time + const now = Date.now() + const latency = now - beforeTime + const clockDelta = (beforeTime + beforeTime) / 2 - info.serverTime + setDebugInfo(prev => ({ + ...prev, + received: prev.received + 1, + latency, + maxLatency: Math.max(prev.maxLatency ?? 0, latency), + clockDelta, + client: info.client, + lastReceived: now, + unansweredSince: null, + })) + }) + } + }, [socket]) + + useEffect(() => { + if (!socket || !autoping) return + + const statsInterval = setInterval(sendPing, 2000) + + return () => { + clearInterval(statsInterval) + } + }, [socket, autoping, sendPing]) + useEffect(() => { if (!socket) return - const statsInterval = setInterval(() => { - if (socket.socket.connected) { - setDebugInfo(prev => ({ ...prev, sent: prev.sent + 1 })) - socket.emit('debug', { time: Date.now() }, (info: any) => { - const beforeTime = info.data.time - const now = Date.now() - const latency = now - beforeTime - const clockDelta = (beforeTime + beforeTime) / 2 - info.serverTime - setDebugInfo(prev => ({ - ...prev, - received: prev.received + 1, - latency, - maxLatency: Math.max(prev.maxLatency ?? 0, latency), - clockDelta, - client: info.client, - lastReceived: now, - })) - }) - } - }, 2000) - socket.on('connect', () => { setSocketState(prev => ({ ...prev, @@ -97,6 +115,7 @@ export function useSocketManager() { lastSuccess: Date.now(), lastError: '', })) + sendPing() }) socket.on('disconnect', (reason: string) => { @@ -119,16 +138,13 @@ export function useSocketManager() { socket.socket.connect() return () => { - clearInterval(statsInterval) socket.disconnect() } - }, [socket]) + }, [sendPing, socket]) useEffect(() => { const updateNetworkInfo = () => { - if ('connection' in navigator) { - setDebugInfo(prev => ({ ...prev, onLine: navigator.onLine })) - } + setDebugInfo(prev => ({ ...prev, onLine: navigator.onLine })) } window.addEventListener('online', updateNetworkInfo) @@ -148,5 +164,7 @@ export function useSocketManager() { disconnectSocket, forceReconnect, socket, + autoping, + setAutoping, } } From 01ab32c029c8a759623ac169126597759fd5075f Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Mon, 20 Jan 2025 10:45:19 +0000 Subject: [PATCH 0072/1724] [web] bump copyright year for Server Pro/CE to 2025 (#22950) GitOrigin-RevId: 7747e64e787e22beb5caf6e47255ab6eeeb74d23 --- services/web/app/views/layout/thin-footer-bootstrap-5.pug | 2 +- services/web/app/views/layout/thin-footer.pug | 2 +- .../features/ui/components/bootstrap-5/footer/thin-footer.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/app/views/layout/thin-footer-bootstrap-5.pug b/services/web/app/views/layout/thin-footer-bootstrap-5.pug index 0baef6a3fb..1f06a054fc 100644 --- a/services/web/app/views/layout/thin-footer-bootstrap-5.pug +++ b/services/web/app/views/layout/thin-footer-bootstrap-5.pug @@ -7,7 +7,7 @@ footer.site-footer if !settings.nav.hide_powered_by li //- year of Server Pro release, static - | © 2024 + | © 2025 | a(href='https://www.overleaf.com/for/enterprises') Powered by Overleaf diff --git a/services/web/app/views/layout/thin-footer.pug b/services/web/app/views/layout/thin-footer.pug index bc9ff70764..6eeecf628a 100644 --- a/services/web/app/views/layout/thin-footer.pug +++ b/services/web/app/views/layout/thin-footer.pug @@ -9,7 +9,7 @@ footer.site-footer else if !settings.nav.hide_powered_by li //- year of Server Pro release, static - | © 2024 + | © 2025 | a(href='https://www.overleaf.com/for/enterprises') Powered by Overleaf diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/footer/thin-footer.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/footer/thin-footer.tsx index 11c84aaf1a..f8ce7d369b 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/footer/thin-footer.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/footer/thin-footer.tsx @@ -60,7 +60,7 @@ function ThinFooter({ {showPoweredBy ? ( <>
  • - {/* year of Server Pro release, static */}© 2024{' '} + {/* year of Server Pro release, static */}© 2025{' '} Powered by Overleaf From 5b7ca476a704e5d55a78605a52f97c261b8c2689 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 20 Jan 2025 11:50:27 +0000 Subject: [PATCH 0073/1724] Merge pull request #22940 from overleaf/mj-font-script [web] Add script for updating unfilled font GitOrigin-RevId: c25a470e5263f62a9d445b10e66fa222b9fa8fa5 --- .../fonts/material-symbols/build-unfilled.mjs | 48 +++++++++++++++++++ .../material-symbols/material-symbols.css | 4 +- .../material-symbols/unfilled-symbols.mjs | 11 +++++ .../js/shared/components/material-icon.tsx | 10 +--- 4 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 services/web/frontend/fonts/material-symbols/build-unfilled.mjs create mode 100644 services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs diff --git a/services/web/frontend/fonts/material-symbols/build-unfilled.mjs b/services/web/frontend/fonts/material-symbols/build-unfilled.mjs new file mode 100644 index 0000000000..1c37bc9ae4 --- /dev/null +++ b/services/web/frontend/fonts/material-symbols/build-unfilled.mjs @@ -0,0 +1,48 @@ +import path from 'node:path' +import fs from 'node:fs/promises' +import icons from './unfilled-symbols.mjs' + +const iconList = [...new Set(icons)].sort().map(encodeURIComponent).join(',') + +const url = `https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=${iconList}&display=block` +console.log(`Fetching font configuration from ${url}`) + +const cssFile = await ( + await fetch(url, { + headers: { + // Specify a user agent to get a woff2 file + 'User-Agent': + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + }, + }) +).text() + +const woff2UrlText = cssFile.match(/url\(([^)]+)\) format\('woff2'\)/)?.[1] + +if (!woff2UrlText) { + throw new Error( + 'Could not find woff2 URL in CSS file, try accessing the font configuration URL to check whether an error is reported' + ) +} + +const woff2Url = new URL(woff2UrlText) +if (woff2Url.protocol !== 'https:') { + throw new Error(`Expected HTTPS URL, got ${woff2Url.protocol}`) +} +if (woff2Url.hostname !== 'fonts.gstatic.com') { + throw new Error( + `Expected to download font from fonts.gstatic.com, got ${woff2Url.hostname}` + ) +} + +console.log(`Fetching woff2 file: ${woff2Url}`) + +const outputPath = path.join( + import.meta.dirname, + 'MaterialSymbolsRoundedUnfilledPartialSlice.woff2' +) + +const res = await fetch(woff2Url) + +console.log(`Saving font file to ${outputPath}`) +await fs.writeFile(outputPath, res.body) diff --git a/services/web/frontend/fonts/material-symbols/material-symbols.css b/services/web/frontend/fonts/material-symbols/material-symbols.css index 7fda97b6ef..6ffcdd4288 100644 --- a/services/web/frontend/fonts/material-symbols/material-symbols.css +++ b/services/web/frontend/fonts/material-symbols/material-symbols.css @@ -16,9 +16,7 @@ font-weight: 400; font-display: block; /* - Generated by accessing - https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=SORTED_SYMBOL_LIST&display=block - with a sorted list of symbols, and downloading the linked woff2 file. + Generated by frontend/fonts/material-symbols/build-unfilled.mjs */ src: url('MaterialSymbolsRoundedUnfilledPartialSlice.woff2') format('woff2'); } diff --git a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs new file mode 100644 index 0000000000..7463addc64 --- /dev/null +++ b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs @@ -0,0 +1,11 @@ +// @ts-check +// Make sure to run the build-unfilled.mjs script after updating this list +// to update the font file with the latest icons. + +export default /** @type {const} */ ([ + 'description', + 'forum', + 'integration_instructions', + 'rate_review', + 'report', +]) diff --git a/services/web/frontend/js/shared/components/material-icon.tsx b/services/web/frontend/js/shared/components/material-icon.tsx index 19305b7e60..3f5a499853 100644 --- a/services/web/frontend/js/shared/components/material-icon.tsx +++ b/services/web/frontend/js/shared/components/material-icon.tsx @@ -1,15 +1,9 @@ import classNames from 'classnames' import React from 'react' import { bsVersion } from '@/features/utils/bootstrap-5' +import unfilledIconTypes from '../../../fonts/material-symbols/unfilled-symbols.mjs' -// NOTE: When updating this list, make sure to update the bundled .woff2 -// file as well. See details in material-symbols.css -export type AvailableUnfilledIcon = - | 'description' - | 'forum' - | 'integration_instructions' - | 'rate_review' - | 'report' +export type AvailableUnfilledIcon = (typeof unfilledIconTypes)[number] type BaseIconProps = React.ComponentProps<'i'> & { accessibilityLabel?: string From 144334ec58694aab9ed7535b11324745d034883e Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 20 Jan 2025 11:50:42 +0000 Subject: [PATCH 0074/1724] Merge pull request #22866 from overleaf/mj-typing-delay [web] Remove activateOnTypingDelay from CM6 autocomplete GitOrigin-RevId: 79c39932fce818f13bada824ceaecedd15d36b21 --- .../js/features/source-editor/extensions/auto-complete.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web/frontend/js/features/source-editor/extensions/auto-complete.ts b/services/web/frontend/js/features/source-editor/extensions/auto-complete.ts index f7a6a2435e..f4e3568fa3 100644 --- a/services/web/frontend/js/features/source-editor/extensions/auto-complete.ts +++ b/services/web/frontend/js/features/source-editor/extensions/auto-complete.ts @@ -74,6 +74,7 @@ const createAutoComplete = ({ enabled, ...rest }: AutoCompleteOptions) => { return `ol-cm-completion-${completion.type}` }, interactionDelay: 0, + activateOnTypingDelay: 0, }), /** * A keymap which adds Tab for accepting a completion and Ctrl-Space for opening autocomplete. From d34d15242e7e5522f42df3f256efeb02c32576ea Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 20 Jan 2025 12:17:49 +0000 Subject: [PATCH 0075/1724] Merge pull request #22855 from overleaf/mj-ide-settings [web] Add settings modal skeleton to editor redesign GitOrigin-RevId: bc2e7f07f7ab737a67965fa615a04c8ee88b1271 --- .../web/frontend/extracted-translations.json | 7 + ...alSymbolsRoundedUnfilledPartialSlice.woff2 | Bin 2336 -> 3120 bytes .../material-symbols/unfilled-symbols.mjs | 5 + .../ide-react/components/layout/ide-page.tsx | 4 + .../features/ide-redesign/components/rail.tsx | 71 ++++++++- .../settings/settings-modal-body.tsx | 147 ++++++++++++++++++ .../components/settings/settings-modal.tsx | 31 ++++ .../stylesheets/bootstrap-5/pages/all.scss | 1 + .../bootstrap-5/pages/editor/rail.scss | 9 ++ .../bootstrap-5/pages/editor/settings.scss | 45 ++++++ services/web/locales/en.json | 7 + 11 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 services/web/frontend/js/features/ide-redesign/components/settings/settings-modal-body.tsx create mode 100644 services/web/frontend/js/features/ide-redesign/components/settings/settings-modal.tsx create mode 100644 services/web/frontend/stylesheets/bootstrap-5/pages/editor/settings.scss diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 5d3bbf00df..df68da6ec1 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -441,6 +441,7 @@ "editor_disconected_click_to_reconnect": "", "editor_limit_exceeded_in_this_project": "", "editor_only_hide_pdf": "", + "editor_settings": "", "editor_theme": "", "educational_disclaimer": "", "educational_disclaimer_heading": "", @@ -554,6 +555,8 @@ "full_project_search": "", "full_width": "", "future_payments": "", + "general": "", + "general_settings": "", "generate_token": "", "generic_if_problem_continues_contact_us": "", "generic_linked_file_compile_error": "", @@ -757,6 +760,8 @@ "institutional_leavers_survey_notification": "", "integrations": "", "interested_in_cheaper_personal_plan": "", + "interface": "", + "interface_settings": "", "invalid_confirmation_code": "", "invalid_email": "", "invalid_file_name": "", @@ -1083,6 +1088,7 @@ "pay_now": "", "payment_provider_unreachable_error": "", "payment_summary": "", + "pdf": "", "pdf_compile_in_progress_error": "", "pdf_compile_rate_limit_hit": "", "pdf_compile_try_again": "", @@ -1090,6 +1096,7 @@ "pdf_only_hide_editor": "", "pdf_preview_error": "", "pdf_rendering_error": "", + "pdf_settings": "", "pdf_unavailable_for_download": "", "pdf_viewer": "", "pdf_viewer_error": "", diff --git a/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 b/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 index 6eba080dd6f7f346348e385410b131ee6b8cbf63..68ddbc999db5c6183cfc0521ce266eb957104d1c 100644 GIT binary patch literal 3120 zcmV-04A1j-Pew8T0RR9101Pky4gdfE02d$t01Mav0RR9100000000000000000000 z0000SgGvTqKT}jeRAK;uJP`;Ao+Oxj3q$|`HUcCALcXEt|9~4u^=|-+!9h;$WNXBTM0-q3Yz)9?#HeQPdfW>Au;IFy?5}e5 zD&nU(eaq|#jGS_F6S8@^UYEQPT|fI?1uGB%yTFy4WM z6ZVb=K>z_A=!YCVcJgfW<`uiQqESeCSE#jf^M-YJ6TY>V!Ud$z1PeC-55XU7o^X<( zbit^YgYUI$Lq(^D3Y46Z3bf)uVBWFRj7Jc(L6UA!AGX#x9bg-tOl@bETq-I8u8dU^pvY%uRo3mUa5jAZ>3>Cn# z5o#9H=(9~KXrbN)o0g5S-iX84v}4$Tu!Mx;H_+s`qjA%wjpGg3znLn_FN5mDqT?Kl z^~QR`GP(-`pohE1Sik_}*a?#-=egpznIlFEf!MZv*FhnX`_`@BEiAuQI}#iwZKZI< zVURCK%LGz{HA7iC;{+#?EOHd!tGF$9B0HB}O~x`du$_JHHoSZNe)*@?&&$7U`?l-b zp>L1AJ^A+k@9cjw^PKBOFJlFp*;(YZ&>NuLVLlF^+@C1-DatKIxo^xmNm6No zG-dgpKomz&he&6h7b~Nx#w4btB{h-JI&LnSpsXC*Z_+x^u}+ey-J));GQQs>2%?}< z6gi1@o@2oeebvWHf<%RG!9&^o8`qzeUcFj<9d1-Tu(N@3W2 z!E3x~CNdd%0I)Y#ags4Ro(kVhi0(?b8>5mbkd%`L(9U|n$}(zP@RdBX5datw>x6Ss zR4J(#AQ{G*;_vLPIe=n3WYu<`6zM&BzZwyW5upkZiV(RufKr@gcAAtRLam)9dWt@W zj7)%nyFclSm<5w?r&F<&H^fY}Ih}f&tB9ElCUH7_NOP4##(KXq`?bLAJ}J?A^mT1u zTWVWlQUDBY2F#;Ph4+rWOSqfJ0_G3QOeQCGTIXxa(3@WgJ*S*>IrN-%2GF0}J*MI; zV|F?f-vvqe z+Mna{xajI5y1&3rsOi7w)m0-ZkqHnQ$7WJHuk(UEqtBlzRV!F&^@kTOKq(lep~A1U zCd(EOjUhA_YiR-h;MSHgaTE6=7q+*z?a9r}%}o-MJ|D=Wndo@}J=1Iyj+V^HfruD# zLO@t^9)jcuxg?Lzr8p#EZ6+W@5b&%)nvpzFi!2WygH%~3I`RFB`N}}P1BWytyGX}i zH-0zYfuot8j~T;oWdnJS}?w|&CN|sBUw{G7S)mU1r=nu1L>raZ?BAB;mDVk z6%Zo)1D^}J9bzAjFJ{rRu)_j3McswEPMzDImrtX9<@QA^Z4c9Kla zCu6O~iX&gpEd6FL-ZxU-1W-@EO|vx5W_Uarr)tM#WYgyE+BdEn_81I%3`t3b-BP?W zH`--Sh28G6vj%9EwxLlDeG8$YrXeQY)4`&O^nG@tFf|bE&NBJot2eq?ZbymhGn#n;eU;P;NBNL zW1~#n=xDV6i_0oehaZ($zv`F1(YWt{vNDXKih_>)5plNd5R-*ld2Q z^;D)`hbSlK{Y9iWKUP^7?=q;e-y=`RsTPZ?bL0toRQ7}ME``w3a7AY(Dladpw@5NQ zFuhJ(SuU5zG9W7*5VkY(7+6VN5-ho0k|Kkr$hL zD*ZriR%B3UR&eeC{6OU&^z1KAn^KmhP4*WZ^vsuy*|rDf7g;mPf22f|I;sV3af42k zNl|r-ertN#u~wjwrpcm}W=ugzZ{UnAw8jbc%e7U(>X)mmd#cCKxuIFX+_`}v4`_|M zN)0aC zqvAVGm=@+=cxh-(Ib*Z$kVpCynHl&m7if)2SAW+sW0}lf23dNqU2l4Ndaqq?I&@dn zEq{@t%GmX#jgpuCrA?Qy=%-ko?)@JXAN;7|`TYc~u_7Rz7sD+ys(fR3_J80_YVDij zmqeGoIli{~&B+U*3*VePnK^lVNH5Y~AD*1PHndl?_xd2TMzg>gajsu*woDe3t?;W6 z2O7x&BQqM9mp2}eV1(8vD&MQAYg|&RFWadv$K=N*BostNUskE#a#GoR8jN?T3qKZg zm@Ca=*JOs{r$T~U>H?>wLyuK$_t`dk$mOv)8x=2oz1mV}UTPlyOsL_Q-Y0x#rh60J zE>8N3{lntAve%Tjpq`Rf$|ux#lS-X)ZOV%8$sJG*Ol)X7+AiqE28suA<9eF1W?iwt z+c;Y*`zgAa7uy{9ljhP{yv8$~Ennh1%X{`|PSp#X7kIOCoHEndvgfU?^Zx#v6?~NP zYB>M^-%)%1lZtUX;;j-!eP_w}ZY#$-HKEMNiv0IKzWL(>_wU?C0L;I%cn$K+*N%JV zk8l3?j{A4ew%ZciJNOIK*c%FW5E5C3o^+-~Tq8+43YI~KDok40D;$2%0Fc0!S)~JU z7>))lw>mhx3q*!{KJwiRmzNFi1!sK($09 zgcIPyKb!*zB;i~LAQ)g_MkM}0v<0%DLp!uzl{YIw4H{5~dUPTit>{Dps+7J`2!t=f;g0~~p`NT!jp zaQ=S&|L4^16Bzy%*iQJgE-{%Bc2XdaIxNj_z)(V^v;rOm*@L)i7n?aYpf0Nh0o2=} zX4ipwlny(dke%5Gn5B9_zi<#%8d`I8Vs(*d(?+O-NNAL{-QV;Jd?0LufYD$OPz;Zp>;Xd%0fgcuaClKHW#v%JPzn_cWtu?&<xw6<_kba zIaKHR1_%M*KDlr)L;xcp z%$~P!;XeDEvwYf2BG8ttJNADCpFJlVp zt(0n-ru(^H8tN-$r4sk*o~D}Wb#fx1R#T0##_k}~Vb&!Wm*aK^%?WvY!n#lzYs=6p zXUpR47df}jmqc6BfRQSWw47rx9;XmkYJBhIs5aQm|gZa;uz+-IA0(MxOm`19n>4I^e)sv#Nm1>$KGRtWq7zWV*31$hhXAQU8=|)*HGo_Q;E->p{ zaipc?LTukmlPN;+e*Gfxel(e)szqNUa_O!$n{Gd_gq;LU#AynUfrE_>7`OY!6zd_8 zYCj@6?=-`2R;T9mP4ZV5NnPSX=#ZYc%MWY_&TTC-d8HzvXr= zmy5C!`Fx&PuIs|car@hEzwN?Z*LAt9+Bv{*S(9piOJwGm!sQ?|=+K~p&xQz_&}iMD z;8{QlslwSwl@$s?kyLP**A3$%8o0#79&BiWRS##~1X!2v;@xE~vE8hjkhJoYD7XhG zCajhVlUiAKqJ{whmnnki9b{VF-cToeyQkLS8>ay?%alr6)XUYf`tGc2ZIpue@XcXw{(9&}(~ahr z-~Zr7@`Kl!Z!}e3OE%!w0s2f`rD#v-GxbXg8(h+7)^*X+P9~dnwD0MRjFZ-WSSOoF zJKA@1e#m5g$Y#D*M(@VY$ika`z2|gxoX*aySx%=^{^s1UTm3hXI&HuBKsX%^gwx@U z!+pDV?|ZnT(CTx()wNy3+lT$knBo6EYn2$;cRc>iZo^&ju9PO7-Gvvjc-=fV3*(|-Box1TT!j#}pO<-R3U zgjgy8@GY)R+hdBbd`YapDa)f^m=Z!o_;nI0;$J^PMQjC5nXCH0_b}MwJ6_jkjJ6Ug z;xM5iNJ2%FFbs9wb_Z!lH2N>L|6t+1gJt!V4V5bod(s|WG+yc_Zt`eOmG>#q<-N@* zn>-Q&x=gA?i79%N;=XisxJA-gX>$k5+0a+MsEDli|Bf6i=R-gGtwrAA1D<~JR@b&9 zx<5Nj+*_Nv=$Wv*%c#ybjk%P)SGFv}E?e87=f=qJ;%99`iUGs*o82S(bfQHMoe>b( zXweuM8QEyj7>RtY?q5TNF!Eo+IxgjPh*0_SE9RG1*+SpfV7V;+Fk!GDnalPG7B2aS zZ}s;)-Eq?Bexxy5B;IXR~+a!v&&hJ=SF1qPkU&iT$U+wjk2 z_)-7(tE3urg?h-IfPFjmFJJ0hEb1rQjPpNfGO2K<>5#aiS)cvCyE0ilT|I1e#lGzm z-m9#m-O7n-Q}OL3H&cr9-CkqFiI*B zT>kj<<8#41sIB99g3Gv#NzS~C900aZuuvR|0OW&(cww4oLs$)| zRS(;u28~dQI&yfUGZqhPcnpgVeICUUK!M*`LPTNgEss? zREsQuC4eq;do4YEg>(jZ6{=>o=niQdA=s z1t^3Br9IvXL?aiqwrZ3fmY;wou*idw7T17*5_PCNI9LD?{4g!vV8Bw0IuXo8HIj8@ zHS({Os00ZSe6R|B@JAj*%0Z-5k#Z`s2JgEMfE2Nag%kmSH(2OWu*nfnVlA4q;2nUQ z<3(5o5>n|`q8JrqGZImVLIiw3f<%}Em|`91z*dNw5b*i}_lePK>c&rM;%VN;zQe*X G0001vc~$@b diff --git a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs index 7463addc64..54052d8773 100644 --- a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs +++ b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs @@ -3,9 +3,14 @@ // to update the font file with the latest icons. export default /** @type {const} */ ([ + 'code', 'description', 'forum', + 'help', 'integration_instructions', + 'picture_as_pdf', 'rate_review', 'report', + 'settings', + 'web_asset', ]) diff --git a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx index c73c5fe1ef..84f8ba87b2 100644 --- a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx +++ b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx @@ -16,6 +16,9 @@ import { useFeatureFlag } from '@/shared/context/split-test-context' const MainLayoutNew = lazy( () => import('@/features/ide-redesign/components/main-layout') ) +const SettingsModalNew = lazy( + () => import('@/features/ide-redesign/components/settings/settings-modal') +) export default function IdePage() { useLayoutEventTracking() // sent event when the layout changes @@ -33,6 +36,7 @@ export default function IdePage() { {newEditor ? ( + ) : ( 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 a588dfa40d..e9d21af972 100644 --- a/services/web/frontend/js/features/ide-redesign/components/rail.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/rail.tsx @@ -1,9 +1,10 @@ -import { ReactElement, useCallback, useState } from 'react' +import { ReactElement, useCallback, useMemo, useState } from 'react' import { Nav, NavLink, Tab, TabContainer } from 'react-bootstrap-5' import MaterialIcon, { AvailableUnfilledIcon, } from '@/shared/components/material-icon' import { Panel } from 'react-resizable-panels' +import { useLayoutContext } from '@/shared/context/layout-context' type RailElement = { icon: AvailableUnfilledIcon @@ -11,6 +12,14 @@ type RailElement = { component: ReactElement } +type RailActionLink = { key: string; icon: AvailableUnfilledIcon; href: string } +type RailActionButton = { + key: string + icon: AvailableUnfilledIcon + action: () => void +} +type RailAction = RailActionLink | RailActionButton + const RAIL_TABS: RailElement[] = [ // NOTE: The file tree **MUST** be the first (i.e. default) tab in the list // since the file tree is responsible for opening the initial document. @@ -45,6 +54,19 @@ export const RailLayout = () => { const [selectedTab, setSelectedTab] = useState( RAIL_TABS[0]?.key ) + const { setLeftMenuShown } = useLayoutContext() + const railActions: RailAction[] = useMemo( + () => [ + { key: 'support', icon: 'help', href: '/learn' }, + { + key: 'settings', + icon: 'settings', + action: () => setLeftMenuShown(true), + }, + ], + [setLeftMenuShown] + ) + return ( { id="ide-rail-tabs" >
    -
    ) } + +const RailActionElement = ({ action }: { action: RailAction }) => { + const icon = ( + + ) + const onActionClick = useCallback(() => { + if ('action' in action) { + action.action() + } + }, [action]) + + if ('href' in action) { + return ( + + {icon} + + ) + } else { + return ( + + ) + } +} 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 new file mode 100644 index 0000000000..c5143a2932 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/settings/settings-modal-body.tsx @@ -0,0 +1,147 @@ +import MaterialIcon, { + AvailableUnfilledIcon, +} from '@/shared/components/material-icon' +import { ReactElement, useMemo, useState } from 'react' +import { + Nav, + NavLink, + TabContainer, + TabContent, + TabPane, +} from 'react-bootstrap-5' +import { useTranslation } from 'react-i18next' + +export type SettingsEntry = SettingsLink | SettingsTab + +type SettingsTab = { + icon: AvailableUnfilledIcon + key: string + component: ReactElement + title: string + subtitle: string +} + +type SettingsLink = { + key: string + icon: AvailableUnfilledIcon + href: string + title: string +} + +export const SettingsModalBody = () => { + const { t } = useTranslation() + const settingsTabs: SettingsEntry[] = useMemo( + () => [ + { + key: 'general', + title: t('general'), + subtitle: t('general_settings'), + icon: 'settings', + component:
    General
    , + }, + { + key: 'editor', + title: t('editor'), + subtitle: t('editor_settings'), + icon: 'code', + component:
    Editor
    , + }, + { + key: 'pdf', + title: t('pdf'), + subtitle: t('pdf_settings'), + icon: 'picture_as_pdf', + component:
    PDF
    , + }, + { + key: 'interface', + title: t('interface'), + subtitle: t('interface_settings'), + icon: 'web_asset', + component:
    Interface
    , + }, + { + key: 'account_settings', + title: t('account_settings'), + icon: 'settings', + href: '/user/settings', + }, + ], + [t] + ) + const [activeTab, setActiveTab] = useState( + settingsTabs[0]?.key + ) + + return ( + +
    + + + {settingsTabs + .filter(t => 'component' in t) + .map(({ key, component, subtitle }) => ( + +

    {subtitle}

    +
    {component}
    +
    + ))} +
    +
    +
    + ) +} + +const SettingsNavLink = ({ entry }: { entry: SettingsEntry }) => { + if ('href' in entry) { + return ( + + + {entry.title} +
    + + + ) + } else { + return ( + <> + + + {entry.title} + + + ) + } +} diff --git a/services/web/frontend/js/features/ide-redesign/components/settings/settings-modal.tsx b/services/web/frontend/js/features/ide-redesign/components/settings/settings-modal.tsx new file mode 100644 index 0000000000..8ee804159f --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/settings/settings-modal.tsx @@ -0,0 +1,31 @@ +import OLModal, { + OLModalBody, + OLModalHeader, + OLModalTitle, +} from '@/features/ui/components/ol/ol-modal' +import { useLayoutContext } from '@/shared/context/layout-context' +import { useTranslation } from 'react-i18next' +import { SettingsModalBody } from './settings-modal-body' + +const SettingsModal = () => { + // TODO ide-redesign-cleanup: Either rename the field, or introduce a separate + // one + const { leftMenuShown, setLeftMenuShown } = useLayoutContext() + const { t } = useTranslation() + return ( + setLeftMenuShown(false)} + size="lg" + > + + {t('settings')} + + + + + + ) +} + +export default SettingsModal diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss index 956f28777c..454f0708c5 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss @@ -8,6 +8,7 @@ @import 'editor/ide'; @import 'editor/ide-redesign'; @import 'editor/rail'; +@import 'editor/settings'; @import 'editor/toolbar'; @import 'editor/online-users'; @import 'editor/hotkeys'; 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 17a8cd2ba7..ba7f5b8202 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/rail.scss @@ -6,6 +6,11 @@ --ide-rail-link-active-indicator-background: var(--neutral-90); } +.ide-rail-tab-button { + border: 0; + background: none; +} + .ide-rail-tab-link { border-radius: 12px; display: block; @@ -55,3 +60,7 @@ height: 100%; border: 1px solid var(--border-divider); } + +.ide-rail-tabs-nav { + height: 100%; +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/settings.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/settings.scss new file mode 100644 index 0000000000..75aba658e6 --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/settings.scss @@ -0,0 +1,45 @@ +.ide-settings-tab-nav.nav { + width: 240px; + border-right: var(--bs-modal-header-border-width) solid + var(--bs-modal-header-border-color); + padding: var(--spacing-02); + gap: var(--spacing-02); +} + +.ide-settings-tab-content { + padding: var(--spacing-06) var(--spacing-08); + max-height: 75%; + height: 400px; +} + +.ide-settings-tab-subtitle { + font-size: var(--font-size-04); + line-height: var(--line-height-03); + padding: var(--spacing-06) var(--spacing-08); +} + +.ide-settings-tab-link { + display: flex; + align-items: flex-start; + flex-direction: row; + gap: var(--spacing-02); + color: var(--neutral-90); + padding: var(--spacing-02); + border-radius: var(--border-radius-base); + font-size: var(--font-size-02); + line-height: var(--line-height-02); + text-decoration: none; + + &:visited { + color: var(--neutral-90); + } + + &.active { + color: #fff; + background-color: var(--neutral-90); + } +} + +.ide-settings-modal-body { + padding: 0; +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index f758c24979..2931f6669c 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -576,6 +576,7 @@ "editor_disconected_click_to_reconnect": "Editor disconnected, click anywhere to reconnect.", "editor_limit_exceeded_in_this_project": "Too many editors in this project", "editor_only_hide_pdf": "Editor only <0>(hide PDF)", + "editor_settings": "Editor settings", "editor_theme": "Editor theme", "educational_disclaimer": "I confirm that users will be students or faculty using Overleaf primarily for study and teaching, and can provide evidence of this if requested.", "educational_disclaimer_heading": "Educational discount confirmation", @@ -749,6 +750,8 @@ "gallery_page_items_lowercase": "gallery items", "gallery_page_title": "Gallery - Templates, Examples and Articles written in LaTeX", "gallery_show_more_tags": "Show more", + "general": "General", + "general_settings": "General settings", "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.", @@ -995,6 +998,8 @@ "institutional_login_unknown": "Sorry, we don’t know which institution issued that email address. You can browse our list of institutions to find yours, or you can use one of the other options below.", "integrations": "Integrations", "interested_in_cheaper_personal_plan": "Would you be interested in the cheaper <0>__price__ Personal plan?", + "interface": "Interface", + "interface_settings": "Interface settings", "invalid_certificate": "Invalid certificate. Please check the certificate and try again.", "invalid_confirmation_code": "That didn’t work. Please check the code and try again.", "invalid_email": "An email address is invalid", @@ -1468,6 +1473,7 @@ "payment_method_accepted": "__paymentMethod__ accepted", "payment_provider_unreachable_error": "Sorry, there was an error talking to our payment provider. Please try again in a few moments.\nIf you are using any ad or script blocking extensions in your browser, you may need to temporarily disable them.", "payment_summary": "Payment summary", + "pdf": "PDF", "pdf_compile_in_progress_error": "A previous compile is still running. Please wait a minute and try compiling again.", "pdf_compile_rate_limit_hit": "Compile rate limit hit", "pdf_compile_try_again": "Please wait for your other compile to finish before trying again.", @@ -1475,6 +1481,7 @@ "pdf_only_hide_editor": "PDF only <0>(hide editor)", "pdf_preview_error": "There was a problem displaying the compilation results for this project.", "pdf_rendering_error": "PDF Rendering Error", + "pdf_settings": "PDF settings", "pdf_unavailable_for_download": "PDF unavailable for download", "pdf_viewer": "PDF Viewer", "pdf_viewer_error": "There was a problem displaying the PDF for this project.", From 6fba73c66a389591681609c36a8197d27fddddce Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Mon, 20 Jan 2025 13:35:44 +0000 Subject: [PATCH 0076/1724] Merge pull request #22987 from overleaf/revert-22866-mj-typing-delay Revert "[web] Remove activateOnTypingDelay from CM6 autocomplete" GitOrigin-RevId: 1b598c8790bec1076db4d5a9feb551585565af05 --- .../js/features/source-editor/extensions/auto-complete.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/services/web/frontend/js/features/source-editor/extensions/auto-complete.ts b/services/web/frontend/js/features/source-editor/extensions/auto-complete.ts index f4e3568fa3..f7a6a2435e 100644 --- a/services/web/frontend/js/features/source-editor/extensions/auto-complete.ts +++ b/services/web/frontend/js/features/source-editor/extensions/auto-complete.ts @@ -74,7 +74,6 @@ const createAutoComplete = ({ enabled, ...rest }: AutoCompleteOptions) => { return `ol-cm-completion-${completion.type}` }, interactionDelay: 0, - activateOnTypingDelay: 0, }), /** * A keymap which adds Tab for accepting a completion and Ctrl-Space for opening autocomplete. From 3d0a9017a472a9f66c5d257cb2fb5f6748de70fd Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Mon, 20 Jan 2025 09:02:18 -0500 Subject: [PATCH 0077/1724] Merge pull request #22973 from overleaf/em-fix-project-snapshot-concurrency Fix concurrency in project snapshot GitOrigin-RevId: 83710b84e5ff5c10d55b1a915a310db1ca431973 --- .../js/infrastructure/project-snapshot.ts | 193 ++++-------- .../infrastructure/project-snapshot.test.ts | 276 +++++++++++++++--- 2 files changed, 288 insertions(+), 181 deletions(-) diff --git a/services/web/frontend/js/infrastructure/project-snapshot.ts b/services/web/frontend/js/infrastructure/project-snapshot.ts index b6300e505b..eb7c768adf 100644 --- a/services/web/frontend/js/infrastructure/project-snapshot.ts +++ b/services/web/frontend/js/infrastructure/project-snapshot.ts @@ -1,5 +1,4 @@ import pLimit from 'p-limit' -import OError from '@overleaf/o-error' import { Change, Chunk, Snapshot } from 'overleaf-editor-core' import { RawChange, RawChunk } from 'overleaf-editor-core/lib/types' import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json' @@ -15,16 +14,18 @@ export class ProjectSnapshot { private version: number private blobStore: SimpleBlobStore private refreshPromise: Promise - private queuedRefreshPromise: Promise - private state: ProjectSnapshotState + private initialized: boolean + private refreshing: boolean + private queued: boolean constructor(projectId: string) { this.projectId = projectId this.snapshot = new Snapshot() this.version = 0 this.refreshPromise = Promise.resolve() - this.queuedRefreshPromise = Promise.resolve() - this.state = new ProjectSnapshotState() + this.initialized = false + this.refreshing = false + this.queued = false this.blobStore = new SimpleBlobStore(this.projectId) } @@ -36,31 +37,19 @@ export class ProjectSnapshot { * function was called. */ async refresh() { - switch (this.state.getState()) { - case 'init': - this.refreshPromise = this.initialize() - await this.refreshPromise - break - - case 'ready': - this.refreshPromise = this.loadChanges() - await this.refreshPromise - break - - case 'refreshing': - this.queuedRefreshPromise = this.queueRefresh() - await this.queuedRefreshPromise - break - - case 'queued-ready': - case 'queued-waiting': - await this.queuedRefreshPromise - break - - default: - throw new OError('Unknown state for project snapshot', { - state: this.state.getState(), - }) + if (this.queued) { + // There already is a queued refresh that will run after this call. + // Just wait for it to complete. + await this.refreshPromise + } else if (this.refreshing) { + // There is a refresh running, but no queued refresh. Queue a refresh + // after this one and make it the new promise to wait for. + this.refreshPromise = this.queueRefresh() + await this.refreshPromise + } else { + // There is no refresh running. Start one. + this.refreshPromise = this.startRefresh() + await this.refreshPromise } } @@ -83,20 +72,49 @@ export class ProjectSnapshot { return file.getContent({ filterTrackedDeletes: true }) ?? null } + /** + * Immediately start a refresh + */ + private async startRefresh() { + this.refreshing = true + try { + if (!this.initialized) { + await this.initialize() + } else { + await this.loadChanges() + } + } finally { + this.refreshing = false + } + } + + /** + * Queue a refresh after the currently running refresh + */ + private async queueRefresh() { + this.queued = true + try { + await this.refreshPromise + } catch { + // Ignore errors + } + this.queued = false + await this.startRefresh() + } + /** * Initialize the snapshot using the project's latest chunk. * * This is run on the first refresh. */ private async initialize() { - this.state.startRefresh() await flushHistory(this.projectId) const chunk = await fetchLatestChunk(this.projectId) this.snapshot = chunk.getSnapshot() this.snapshot.applyAll(chunk.getChanges()) this.version = chunk.getEndVersion() await this.loadDocs() - this.state.endRefresh() + this.initialized = true } /** @@ -105,22 +123,11 @@ export class ProjectSnapshot { * This is run on the second and subsequent refreshes */ private async loadChanges() { - this.state.startRefresh() await flushHistory(this.projectId) const changes = await fetchLatestChanges(this.projectId, this.version) this.snapshot.applyAll(changes) this.version += changes.length await this.loadDocs() - this.state.endRefresh() - } - - /** - * Wait for the current refresh to complete, then start a refresh. - */ - private async queueRefresh() { - this.state.queueRefresh() - await this.refreshPromise - await this.loadChanges() } /** @@ -143,106 +150,6 @@ export class ProjectSnapshot { } } -/** - * State machine for the project snapshot - * - * There are 5 states: - * - * - init: when the snapshot is built - * - refreshing: while the snapshot is refreshing - * - queued-waiting: while the snapshot is refreshing and another refresh is queued - * - queued-ready: when a refresh is queued, but no refresh is running - * - ready: when no refresh is running and no refresh is queued - * - * There are three transitions: - * - * - start: start a refresh operation - * - end: end a refresh operation - * - queue: queue a refresh operation - * - * Valid transitions are as follows: - * - * +------------+ - * | ready | - * +------------+ - * ^ | - * | | - * end start - * | | - * | v - * +------+ +------------+ +----------------+ - * | init |----start---->| refreshing |---queue---> | queued-waiting | - * +------+ +------------+ +----------------+ - * ^ | - * | | - * start end - * | | - * | +--------------+ | - * +-----| queued-ready |<-------+ - * +--------------+ - * - * These transitions ensure that there are never two refreshes running - * concurrently. In every path, "start" and "end" transitions always alternate. - * You never have two consecutive "start" or two consecutive "end". - */ -class ProjectSnapshotState { - private state: - | 'init' - | 'refreshing' - | 'ready' - | 'queued-waiting' - | 'queued-ready' = 'init' - - getState() { - return this.state - } - - startRefresh() { - switch (this.state) { - case 'init': - case 'ready': - case 'queued-ready': - this.state = 'refreshing' - break - - default: - throw new OError("Can't start a snapshot refresh in this state", { - state: this.state, - }) - } - } - - endRefresh() { - switch (this.state) { - case 'refreshing': - this.state = 'ready' - break - - case 'queued-waiting': - this.state = 'queued-ready' - break - - default: - throw new OError("Can't end a snapshot refresh in this state", { - state: this.state, - }) - } - } - - queueRefresh() { - switch (this.state) { - case 'refreshing': - this.state = 'queued-waiting' - break - - default: - throw new OError("Can't queue a snapshot refresh in this state", { - state: this.state, - }) - } - } -} - /** * Blob store that fetches blobs from the history service */ diff --git a/services/web/test/frontend/infrastructure/project-snapshot.test.ts b/services/web/test/frontend/infrastructure/project-snapshot.test.ts index 5038738ed2..1e9fa6792b 100644 --- a/services/web/test/frontend/infrastructure/project-snapshot.test.ts +++ b/services/web/test/frontend/infrastructure/project-snapshot.test.ts @@ -76,19 +76,69 @@ describe('ProjectSnapshot', function () { startVersion: 0, } + const changes = [ + { + operations: [ + { + pathname: 'hello.txt', + textOperation: ['Quote: ', files['hello.txt'].contents.length], + }, + { + pathname: 'goodbye.txt', + file: { + hash: files['goodbye.txt'].hash, + stringLength: files['goodbye.txt'].contents.length, + }, + }, + ], + timestamp: '2025-01-01T13:00:00.000Z', + }, + ] + + function mockFlush( + opts: { repeat?: number; failOnCall?: (call: number) => boolean } = {} + ) { + let currentCall = 0 + const getResponse = () => { + currentCall += 1 + return opts.failOnCall?.(currentCall) ? 500 : 200 + } + + fetchMock.post(`/project/${projectId}/flush`, getResponse, { + name: 'flush', + repeat: opts.repeat ?? 1, + }) + } + + function mockLatestChunk() { + fetchMock.getOnce( + `/project/${projectId}/latest/history`, + { chunk }, + { name: 'latest-chunk' } + ) + } + + function mockChanges() { + fetchMock.getOnce(`/project/${projectId}/changes?since=1`, changes, { + name: 'changes-1', + }) + fetchMock.get(`/project/${projectId}/changes?since=2`, [], { + name: 'changes-2', + }) + } + + function mockBlobs(paths = Object.keys(files) as (keyof typeof files)[]) { + for (const path of paths) { + const file = files[path] + fetchMock.get(`/project/${projectId}/blob/${file.hash}`, file.contents) + } + } + async function initializeSnapshot() { - fetchMock.postOnce(`/project/${projectId}/flush`, 200) - fetchMock.getOnce(`/project/${projectId}/latest/history`, { chunk }) - fetchMock.getOnce( - `/project/${projectId}/blob/${files['main.tex'].hash}`, - files['main.tex'].contents - ) - fetchMock.getOnce( - `/project/${projectId}/blob/${files['hello.txt'].hash}`, - files['hello.txt'].contents - ) + mockFlush() + mockLatestChunk() + mockBlobs(['main.tex', 'hello.txt']) await snapshot.refresh() - expect(fetchMock.done()).to.be.true fetchMock.reset() } @@ -121,34 +171,11 @@ describe('ProjectSnapshot', function () { }) }) - const changes = [ - { - operations: [ - { - pathname: 'hello.txt', - textOperation: ['Quote: ', files['hello.txt'].contents.length], - }, - { - pathname: 'goodbye.txt', - file: { - hash: files['goodbye.txt'].hash, - stringLength: files['goodbye.txt'].contents.length, - }, - }, - ], - timestamp: '2025-01-01T13:00:00.000Z', - }, - ] - async function refreshSnapshot() { - fetchMock.postOnce(`/project/${projectId}/flush`, 200, { repeat: 2 }) - fetchMock.getOnce(`/project/${projectId}/changes?since=1`, changes) - fetchMock.getOnce( - `/project/${projectId}/blob/${files['goodbye.txt'].hash}`, - files['goodbye.txt'].contents - ) + mockFlush() + mockChanges() + mockBlobs(['goodbye.txt']) await snapshot.refresh() - expect(fetchMock.done()).to.be.true fetchMock.reset() } @@ -170,7 +197,7 @@ describe('ProjectSnapshot', function () { }) }) - describe('getDocCotents()', function () { + describe('getDocContents()', function () { it('returns the up to date content', function () { expect(snapshot.getDocContents('hello.txt')).to.equal( `Quote: ${files['hello.txt'].contents}` @@ -184,4 +211,177 @@ describe('ProjectSnapshot', function () { }) }) }) + + describe('concurrency', function () { + afterEach(function () { + fetchMock.reset() + }) + + specify('two concurrent inits', async function () { + mockFlush({ repeat: 2 }) + mockLatestChunk() + mockChanges() + mockBlobs() + + await Promise.all([snapshot.refresh(), snapshot.refresh()]) + + // The first request initializes, the second request loads changes + expect(fetchMock.calls('flush')).to.have.length(2) + expect(fetchMock.calls('latest-chunk')).to.have.length(1) + expect(fetchMock.calls('changes-1')).to.have.length(1) + }) + + specify('three concurrent inits', async function () { + mockFlush({ repeat: 2 }) + mockLatestChunk() + mockChanges() + mockBlobs() + + await Promise.all([ + snapshot.refresh(), + snapshot.refresh(), + snapshot.refresh(), + ]) + + // The first request initializes, the second and third are combined and + // load changes + expect(fetchMock.calls('flush')).to.have.length(2) + expect(fetchMock.calls('latest-chunk')).to.have.length(1) + expect(fetchMock.calls('changes-1')).to.have.length(1) + }) + + specify('two concurrent inits - first fails', async function () { + mockFlush({ repeat: 2, failOnCall: call => call === 1 }) + mockLatestChunk() + mockBlobs() + + const results = await Promise.allSettled([ + snapshot.refresh(), + snapshot.refresh(), + ]) + + // The first init fails, but the second succeeds + expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1) + expect(fetchMock.calls('flush')).to.have.length(2) + expect(fetchMock.calls('latest-chunk')).to.have.length(1) + expect(fetchMock.calls('changes-1')).to.have.length(0) + }) + + specify('three concurrent inits - second fails', async function () { + mockFlush({ repeat: 4, failOnCall: call => call === 2 }) + mockLatestChunk() + mockChanges() + mockBlobs() + + const results = await Promise.allSettled([ + snapshot.refresh(), + snapshot.refresh(), + snapshot.refresh(), + ]) + + // Another request afterwards + await snapshot.refresh() + + // The first init succeeds, the two queued requests fail, the last request + // succeeds + expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1) + expect(fetchMock.calls('flush')).to.have.length(3) + expect(fetchMock.calls('latest-chunk')).to.have.length(1) + expect(fetchMock.calls('changes-1')).to.have.length(1) + expect(fetchMock.calls('changes-2')).to.have.length(0) + }) + + specify('two concurrent load changes', async function () { + mockFlush({ repeat: 3 }) + mockLatestChunk() + mockChanges() + mockBlobs() + + // Initialize + await snapshot.refresh() + + // Two concurrent load changes + await Promise.all([snapshot.refresh(), snapshot.refresh()]) + + // One init, two load changes + expect(fetchMock.calls('flush')).to.have.length(3) + expect(fetchMock.calls('latest-chunk')).to.have.length(1) + expect(fetchMock.calls('changes-1')).to.have.length(1) + expect(fetchMock.calls('changes-2')).to.have.length(1) + }) + + specify('three concurrent load changes', async function () { + mockFlush({ repeat: 3 }) + mockLatestChunk() + mockChanges() + mockBlobs() + + // Initialize + await snapshot.refresh() + + // Three concurrent load changes + await Promise.all([ + snapshot.refresh(), + snapshot.refresh(), + snapshot.refresh(), + ]) + + // One init, two load changes (the two last are queued and combined) + expect(fetchMock.calls('flush')).to.have.length(3) + expect(fetchMock.calls('latest-chunk')).to.have.length(1) + expect(fetchMock.calls('changes-1')).to.have.length(1) + expect(fetchMock.calls('changes-2')).to.have.length(1) + }) + + specify('two concurrent load changes - first fails', async function () { + mockFlush({ repeat: 3, failOnCall: call => call === 2 }) + mockLatestChunk() + mockChanges() + mockBlobs() + + // Initialize + await snapshot.refresh() + + // Two concurrent load changes + const results = await Promise.allSettled([ + snapshot.refresh(), + snapshot.refresh(), + ]) + + // One init, one load changes fails, the second succeeds + expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1) + expect(fetchMock.calls('flush')).to.have.length(3) + expect(fetchMock.calls('latest-chunk')).to.have.length(1) + expect(fetchMock.calls('changes-1')).to.have.length(1) + expect(fetchMock.calls('changes-2')).to.have.length(0) + }) + + specify('three concurrent load changes - second fails', async function () { + mockFlush({ repeat: 4, failOnCall: call => call === 3 }) + mockLatestChunk() + mockChanges() + mockBlobs() + + // Initialize + await snapshot.refresh() + + // Two concurrent load changes + const results = await Promise.allSettled([ + snapshot.refresh(), + snapshot.refresh(), + snapshot.refresh(), + ]) + + // Another request afterwards + await snapshot.refresh() + + // One init, one load changes succeeds, the second and third are combined + // and fail, the last request succeeds + expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1) + expect(fetchMock.calls('flush')).to.have.length(4) + expect(fetchMock.calls('latest-chunk')).to.have.length(1) + expect(fetchMock.calls('changes-1')).to.have.length(1) + expect(fetchMock.calls('changes-2')).to.have.length(1) + }) + }) }) From 6ee70550c4bd1f086c4543d9c81236397595e37c Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Mon, 20 Jan 2025 09:02:27 -0500 Subject: [PATCH 0078/1724] Merge pull request #22960 from overleaf/em-promisify-filestore-controller Promisify FileStoreController GitOrigin-RevId: b5f5861a7601a1bf4af3024394f910a0f5a14206 --- .../FileStore/FileStoreController.mjs | 165 ++++++++++------- .../FileStore/FileStoreControllerTests.mjs | 171 ++++++++---------- 2 files changed, 168 insertions(+), 168 deletions(-) diff --git a/services/web/app/src/Features/FileStore/FileStoreController.mjs b/services/web/app/src/Features/FileStore/FileStoreController.mjs index 52f320f13d..0d4b3af2ad 100644 --- a/services/web/app/src/Features/FileStore/FileStoreController.mjs +++ b/services/web/app/src/Features/FileStore/FileStoreController.mjs @@ -1,79 +1,103 @@ +// @ts-check + +import { pipeline } from 'node:stream/promises' import logger from '@overleaf/logger' +import { expressify } from '@overleaf/promise-utils' import FileStoreHandler from './FileStoreHandler.js' import ProjectLocator from '../Project/ProjectLocator.js' import Errors from '../Errors/Errors.js' import { preparePlainTextResponse } from '../../infrastructure/Response.js' -export default { - getFile(req, res) { - const projectId = req.params.Project_id - const fileId = req.params.File_id - const queryString = req.query - const userAgent = req.get('User-Agent') - ProjectLocator.findElement( - { project_id: projectId, element_id: fileId, type: 'file' }, - function (err, file) { - if (err) { - if (err instanceof Errors.NotFoundError) { - logger.warn( - { err, projectId, fileId, queryString }, - 'entity not found when downloading file' - ) - // res.sendStatus() sends a description of the status as body. - // Using res.status().end() avoids sending that fake body. - return res.status(404).end() - } else { - logger.err( - { err, projectId, fileId, queryString }, - 'error finding element for downloading file' - ) - return res.status(500).end() - } - } - FileStoreHandler.getFileStream( - projectId, - fileId, - queryString, - function (err, stream) { - if (err) { - logger.err( - { err, projectId, fileId, queryString }, - 'error getting file stream for downloading file' - ) - return res.sendStatus(500) - } - // mobile safari will try to render html files, prevent this - if (isMobileSafari(userAgent) && isHtml(file)) { - preparePlainTextResponse(res) - } - res.setContentDisposition('attachment', { filename: file.name }) - // allow the browser to cache these immutable files - // note: both "private" and "max-age" appear to be required for caching - res.setHeader('Cache-Control', 'private, max-age=3600') - stream.pipe(res) - } - ) - } - ) - }, +async function getFile(req, res) { + const projectId = req.params.Project_id + const fileId = req.params.File_id + const queryString = req.query + const userAgent = req.get('User-Agent') + req.logger.addFields({ projectId, fileId, queryString }) - getFileHead(req, res) { - const projectId = req.params.Project_id - const fileId = req.params.File_id - FileStoreHandler.getFileSize(projectId, fileId, (err, fileSize) => { - if (err) { - if (err instanceof Errors.NotFoundError) { - res.status(404).end() - } else { - logger.err({ err, projectId, fileId }, 'error getting file size') - res.status(500).end() - } - return - } - res.setHeader('Content-Length', fileSize) - res.status(200).end() + let file + try { + file = await ProjectLocator.promises.findElement({ + project_id: projectId, + element_id: fileId, + type: 'file', }) - }, + } catch (err) { + if (err instanceof Errors.NotFoundError) { + logger.warn( + { err, projectId, fileId, queryString }, + 'entity not found when downloading file' + ) + // res.sendStatus() sends a description of the status as body. + // Using res.status().end() avoids sending that fake body. + return res.status(404).end() + } else { + // Instead of using the global error handler, we send an empty response in + // case the client forgets to check the response status. This is arguably + // not our responsibility, and it won't work if something else breaks in + // this endpoint, so it could be revisited in the future. + logger.err( + { err, projectId, fileId, queryString }, + 'error finding element for downloading file' + ) + return res.status(500).end() + } + } + + const stream = await FileStoreHandler.promises.getFileStream( + projectId, + fileId, + queryString + ) + + // mobile safari will try to render html files, prevent this + if (isMobileSafari(userAgent) && isHtml(file)) { + preparePlainTextResponse(res) + } + res.setContentDisposition('attachment', { filename: file.name }) + // allow the browser to cache these immutable files + // note: both "private" and "max-age" appear to be required for caching + res.setHeader('Cache-Control', 'private, max-age=3600') + try { + await pipeline(stream, res) + } catch (err) { + if ( + err instanceof Error && + 'code' in err && + err.code === 'ERR_STREAM_PREMATURE_CLOSE' + ) { + // Ignore clients closing the connection prematurely + return + } + throw err + } +} + +async function getFileHead(req, res) { + const projectId = req.params.Project_id + const fileId = req.params.File_id + + let fileSize + try { + fileSize = await FileStoreHandler.promises.getFileSize(projectId, fileId) + } catch (err) { + if (err instanceof Errors.NotFoundError) { + return res.status(404).end() + } else { + // Instead of using the global error handler, we send an empty response in + // case the client forgets to check the response status. This is arguably + // not our responsibility, and it won't work if something else breaks in + // this endpoint, so it could be revisited in the future. + logger.err( + { err, projectId, fileId }, + 'error finding element for downloading file' + ) + return res.status(500).end() + } + } + + res.setHeader('Content-Length', fileSize) + res.status(200).end() } function isHtml(file) { @@ -98,3 +122,8 @@ function isMobileSafari(userAgent) { (userAgent.indexOf('iPhone') >= 0 || userAgent.indexOf('iPad') >= 0) ) } + +export default { + getFile: expressify(getFile), + getFileHead: expressify(getFileHead), +} diff --git a/services/web/test/unit/src/FileStore/FileStoreControllerTests.mjs b/services/web/test/unit/src/FileStore/FileStoreControllerTests.mjs index a7810df02c..5781745c24 100644 --- a/services/web/test/unit/src/FileStore/FileStoreControllerTests.mjs +++ b/services/web/test/unit/src/FileStore/FileStoreControllerTests.mjs @@ -14,11 +14,15 @@ const expectedFileHeaders = { describe('FileStoreController', function () { beforeEach(async function () { this.FileStoreHandler = { - getFileStream: sinon.stub(), - getFileSize: sinon.stub(), + promises: { + getFileStream: sinon.stub(), + getFileSize: sinon.stub(), + }, } - this.ProjectLocator = { findElement: sinon.stub() } + this.ProjectLocator = { promises: { findElement: sinon.stub() } } + this.Stream = { pipeline: sinon.stub().resolves() } this.controller = await esmock.strict(MODULE_PATH, { + 'node:stream/promises': this.Stream, '@overleaf/settings': this.settings, '../../../../app/src/Features/Project/ProjectLocator': this.ProjectLocator, @@ -37,69 +41,57 @@ describe('FileStoreController', function () { get(key) { return undefined }, + logger: { + addFields: sinon.stub(), + }, } this.res = new MockResponse() + this.next = sinon.stub() this.file = { name: 'myfile.png' } }) describe('getFile', function () { beforeEach(function () { - this.FileStoreHandler.getFileStream.callsArgWith(3, null, this.stream) - this.ProjectLocator.findElement.callsArgWith(1, null, this.file) + this.FileStoreHandler.promises.getFileStream.resolves(this.stream) + this.ProjectLocator.promises.findElement.resolves(this.file) }) - it('should call the file store handler with the project_id file_id and any query string', function (done) { - this.stream.pipe = des => { - this.FileStoreHandler.getFileStream - .calledWith( - this.req.params.Project_id, - this.req.params.File_id, - this.req.query - ) - .should.equal(true) - done() - } - this.controller.getFile(this.req, this.res) + it('should call the file store handler with the project_id file_id and any query string', async function () { + await this.controller.getFile(this.req, this.res) + this.FileStoreHandler.promises.getFileStream.should.have.been.calledWith( + this.req.params.Project_id, + this.req.params.File_id, + this.req.query + ) }) - it('should pipe to res', function (done) { - this.stream.pipe = des => { - des.should.equal(this.res) - done() - } - this.controller.getFile(this.req, this.res) + it('should pipe to res', async function () { + await this.controller.getFile(this.req, this.res) + this.Stream.pipeline.should.have.been.calledWith(this.stream, this.res) }) - it('should get the file from the db', function (done) { - this.stream.pipe = des => { - const opts = { - project_id: this.projectId, - element_id: this.fileId, - type: 'file', - } - this.ProjectLocator.findElement.calledWith(opts).should.equal(true) - done() - } - this.controller.getFile(this.req, this.res) + it('should get the file from the db', async function () { + await this.controller.getFile(this.req, this.res) + this.ProjectLocator.promises.findElement.should.have.been.calledWith({ + project_id: this.projectId, + element_id: this.fileId, + type: 'file', + }) }) - it('should set the Content-Disposition header', function (done) { - this.stream.pipe = des => { - this.res.setContentDisposition.should.be.calledWith('attachment', { - filename: this.file.name, - }) - done() - } - this.controller.getFile(this.req, this.res) + it('should set the Content-Disposition header', async function () { + await this.controller.getFile(this.req, this.res) + this.res.setContentDisposition.should.be.calledWith('attachment', { + filename: this.file.name, + }) }) - it('should return a 404 when not found', function (done) { - this.res.callback = () => { - expect(this.res.statusCode).to.equal(404) - done() - } - this.ProjectLocator.findElement.yields(new Errors.NotFoundError()) - this.controller.getFile(this.req, this.res) + it('should return a 404 when not found', async function () { + this.ProjectLocator.promises.findElement.rejects( + new Errors.NotFoundError() + ) + await this.controller.getFile(this.req, this.res) + expect(this.res.statusCode).to.equal(404) }) // Test behaviour around handling html files @@ -115,14 +107,11 @@ describe('FileStoreController', function () { }) describe('from a non-ios browser', function () { - it('should not set Content-Type', function (done) { - this.stream.pipe = des => { - this.res.headers.should.deep.equal({ - ...expectedFileHeaders, - }) - done() - } - this.controller.getFile(this.req, this.res) + it('should not set Content-Type', async function () { + await this.controller.getFile(this.req, this.res) + this.res.headers.should.deep.equal({ + ...expectedFileHeaders, + }) }) }) @@ -135,16 +124,13 @@ describe('FileStoreController', function () { } }) - it("should set Content-Type to 'text/plain'", function (done) { - this.stream.pipe = des => { - this.res.headers.should.deep.equal({ - ...expectedFileHeaders, - 'Content-Type': 'text/plain; charset=utf-8', - 'X-Content-Type-Options': 'nosniff', - }) - done() - } - this.controller.getFile(this.req, this.res) + it("should set Content-Type to 'text/plain'", async function () { + await this.controller.getFile(this.req, this.res) + this.res.headers.should.deep.equal({ + ...expectedFileHeaders, + 'Content-Type': 'text/plain; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + }) }) }) @@ -157,16 +143,13 @@ describe('FileStoreController', function () { } }) - it("should set Content-Type to 'text/plain'", function (done) { - this.stream.pipe = des => { - this.res.headers.should.deep.equal({ - ...expectedFileHeaders, - 'Content-Type': 'text/plain; charset=utf-8', - 'X-Content-Type-Options': 'nosniff', - }) - done() - } - this.controller.getFile(this.req, this.res) + it("should set Content-Type to 'text/plain'", async function () { + await this.controller.getFile(this.req, this.res) + this.res.headers.should.deep.equal({ + ...expectedFileHeaders, + 'Content-Type': 'text/plain; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + }) }) }) }) @@ -194,14 +177,11 @@ describe('FileStoreController', function () { this.user_agent = `Some ${browser} thing` }) - it('Should not set the Content-type', function (done) { - this.stream.pipe = des => { - this.res.headers.should.deep.equal({ - ...expectedFileHeaders, - }) - done() - } - this.controller.getFile(this.req, this.res) + it('Should not set the Content-type', async function () { + await this.controller.getFile(this.req, this.res) + this.res.headers.should.deep.equal({ + ...expectedFileHeaders, + }) }) }) }) @@ -212,12 +192,12 @@ describe('FileStoreController', function () { describe('getFileHead', function () { it('reports the file size', function (done) { const expectedFileSize = 99393 - this.FileStoreHandler.getFileSize.yields( + this.FileStoreHandler.promises.getFileSize.rejects( new Error('getFileSize: unexpected arguments') ) - this.FileStoreHandler.getFileSize + this.FileStoreHandler.promises.getFileSize .withArgs(this.projectId, this.fileId) - .yields(null, expectedFileSize) + .resolves(expectedFileSize) this.res.end = () => { expect(this.res.status.lastCall.args).to.deep.equal([200]) @@ -232,7 +212,9 @@ describe('FileStoreController', function () { }) it('returns 404 on NotFoundError', function (done) { - this.FileStoreHandler.getFileSize.yields(new Errors.NotFoundError()) + this.FileStoreHandler.promises.getFileSize.rejects( + new Errors.NotFoundError() + ) this.res.end = () => { expect(this.res.status.lastCall.args).to.deep.equal([404]) @@ -241,16 +223,5 @@ describe('FileStoreController', function () { this.controller.getFileHead(this.req, this.res) }) - - it('returns 500 on error', function (done) { - this.FileStoreHandler.getFileSize.yields(new Error('boom!')) - - this.res.end = () => { - expect(this.res.status.lastCall.args).to.deep.equal([500]) - done() - } - - this.controller.getFileHead(this.req, this.res) - }) }) }) From 71c2dc7d2dc178bbc79db0fd9b4f7f655dba34c0 Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Mon, 20 Jan 2025 09:02:35 -0500 Subject: [PATCH 0079/1724] Merge pull request #22970 from overleaf/em-log-user-id Add userId to request logs when user is logged in GitOrigin-RevId: c7c907375af20c83f2ac762aa634b8d8cd1d9404 --- .../app/src/Features/Authentication/AuthenticationController.js | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.js b/services/web/app/src/Features/Authentication/AuthenticationController.js index 0df10e0715..7a97d2ac9c 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.js +++ b/services/web/app/src/Features/Authentication/AuthenticationController.js @@ -357,6 +357,7 @@ const AuthenticationController = { return AuthenticationController._redirectToLoginOrRegisterPage(req, res) } else { req.user = SessionManager.getSessionUser(req.session) + req.logger?.addFields({ userId: req.user._id }) return next() } } From e5a4a8606f42eeac9a84c46c57bdf09f482c22b1 Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Mon, 20 Jan 2025 15:35:48 +0100 Subject: [PATCH 0080/1724] Prevent scrolling when reply input is focused (#22968) * Prevent scrolling when reply textarea is focused * remove focusIsOnTextarea check as it is handled above * remove unnecessery setSelected GitOrigin-RevId: 5ce3fc6691a19fe2566875785607bb4faa3e9f52 --- .../components/review-panel-entry.tsx | 27 +++++++++++++++---- .../review-panel-new/utils/position-items.ts | 2 +- .../app/editor/review-panel-new.less | 3 ++- .../pages/editor/review-panel-new.scss | 3 ++- 4 files changed, 27 insertions(+), 8 deletions(-) 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 ed79727622..20784e45f9 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 @@ -1,4 +1,4 @@ -import { FC, useCallback, useEffect, useState } from 'react' +import { FC, useCallback, useEffect, useRef, useState } from 'react' import { AnyOperation } from '../../../../../types/change' import { useCodeMirrorStateContext, @@ -15,6 +15,7 @@ import { useLayoutContext } from '@/shared/context/layout-context' import { EditorSelection } from '@codemirror/state' import { EditorView } from '@codemirror/view' import MaterialIcon from '@/shared/components/material-icon' +import { OFFSET_FOR_ENTRIES_ABOVE } from '../utils/position-items' export const ReviewPanelEntry: FC<{ position: number @@ -47,8 +48,10 @@ export const ReviewPanelEntry: FC<{ const { openDocId, getCurrentDocId } = useEditorManagerContext() const [selected, setSelected] = useState(false) const [focused, setFocused] = useState(false) + const [textareaFocused, setTextareaFocused] = useState(false) const { setReviewPanelOpen, reviewPanelOpen } = useLayoutContext() const highlighted = isSelectionWithinOp(op, state.selection.main) + const entryRef = useRef(null) const openReviewPanel = useCallback(() => { setReviewPanelOpen(true) @@ -69,6 +72,17 @@ export const ReviewPanelEntry: FC<{ return } + if (event.target instanceof HTMLTextAreaElement) { + const entryBottom = + (entryRef.current?.offsetTop || 0) + + (entryRef.current?.offsetHeight || 0) + + if (entryBottom > OFFSET_FOR_ENTRIES_ABOVE) { + setTextareaFocused(true) + return + } + } + setSelected(true) if (!selectLineOnFocus) { @@ -76,10 +90,7 @@ export const ReviewPanelEntry: FC<{ } if (getCurrentDocId() !== docId) { - const focusIsOnTextarea = event.target instanceof HTMLTextAreaElement - if (focusIsOnTextarea === false) { - openDocId(docId, { gotoOffset: position, keepCurrentView: true }) - } + openDocId(docId, { gotoOffset: position, keepCurrentView: true }) } else { setTimeout(() => view.dispatch({ @@ -113,10 +124,12 @@ export const ReviewPanelEntry: FC<{ return (
    { setSelected(false) setFocused(false) + setTextareaFocused(false) }} onMouseEnter={() => { if (hoverRanges) { @@ -143,6 +156,10 @@ export const ReviewPanelEntry: FC<{ // 'highlighted' is set if the selection is within op but that doesn't necessarily mean it should be selected // multiple entries can be highlighted at the same time 'review-panel-entry-highlighted': highlighted, + // 'textarea-focused' only changes entry styling (border, shadow etc) + // it doesnt change selected entry because that moves the cursor + // and repositions entries which can cause textarea to be scrolled out of view + 'review-panel-entry-textarea-focused': textareaFocused, 'review-panel-entry-disabled': disabled, }, className 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 e5e66ac8bd..3db0244adf 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,8 @@ 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 OFFSET_FOR_ENTRIES_ABOVE = 70 const GAP_BETWEEN_ENTRIES = 4 export const positionItems = debounce( diff --git a/services/web/frontend/stylesheets/app/editor/review-panel-new.less b/services/web/frontend/stylesheets/app/editor/review-panel-new.less index fe6302dd3f..338e2897e8 100644 --- a/services/web/frontend/stylesheets/app/editor/review-panel-new.less +++ b/services/web/frontend/stylesheets/app/editor/review-panel-new.less @@ -50,7 +50,8 @@ } .review-panel-entry.review-panel-entry-selected, - .review-panel-entry.review-panel-entry-highlighted { + .review-panel-entry.review-panel-entry-highlighted, + .review-panel-entry.review-panel-entry-textarea-focused { margin-left: @spacing-01; border: 1px solid @blue-50; // shadow-md 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 01538a922d..2ea853e8e8 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 @@ -57,7 +57,8 @@ } .review-panel-entry.review-panel-entry-selected, - .review-panel-entry.review-panel-entry-highlighted { + .review-panel-entry.review-panel-entry-highlighted, + .review-panel-entry.review-panel-entry-textarea-focused { margin-left: var(--spacing-01); border: 1px solid var(--border-active); From 741b65d0ebf86b07d124a60b3cb26fd3e3c7dc4c Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Mon, 20 Jan 2025 15:36:06 +0100 Subject: [PATCH 0081/1724] Add ensureUserCanSendComment authorization middleware (#22959) * Add ensureUserCanSendComment authorization middleware * added tests GitOrigin-RevId: d1f58bd6bc63275456e5280ccb8c99aaa02c4e5f --- .../Authorization/AuthorizationManager.js | 15 ++++++++++++++ .../Authorization/AuthorizationMiddleware.js | 20 +++++++++++++++++++ .../AuthorizationManagerTests.js | 9 +++++++++ .../AuthorizationMiddlewareTests.js | 5 +++++ 4 files changed, 49 insertions(+) diff --git a/services/web/app/src/Features/Authorization/AuthorizationManager.js b/services/web/app/src/Features/Authorization/AuthorizationManager.js index c28ddf363d..a457199844 100644 --- a/services/web/app/src/Features/Authorization/AuthorizationManager.js +++ b/services/web/app/src/Features/Authorization/AuthorizationManager.js @@ -280,11 +280,25 @@ async function canUserResolveThread(userId, projectId, docId, threadId, token) { return comment.metadata.user_id === userId } +async function canUserSendComment(userId, projectId, token) { + const privilegeLevel = await getPrivilegeLevelForProject( + userId, + projectId, + token + ) + return ( + privilegeLevel === PrivilegeLevels.OWNER || + privilegeLevel === PrivilegeLevels.READ_AND_WRITE || + privilegeLevel === PrivilegeLevels.REVIEW + ) +} + module.exports = { canUserReadProject: callbackify(canUserReadProject), canUserWriteProjectContent: callbackify(canUserWriteProjectContent), canUserReviewProjectContent: callbackify(canUserReviewProjectContent), canUserResolveThread: callbackify(canUserResolveThread), + canUserSendComment: callbackify(canUserSendComment), canUserWriteProjectSettings: callbackify(canUserWriteProjectSettings), canUserRenameProject: callbackify(canUserRenameProject), canUserAdminProject: callbackify(canUserAdminProject), @@ -297,6 +311,7 @@ module.exports = { canUserWriteProjectContent, canUserReviewProjectContent, canUserResolveThread, + canUserSendComment, canUserWriteProjectSettings, canUserRenameProject, canUserAdminProject, diff --git a/services/web/app/src/Features/Authorization/AuthorizationMiddleware.js b/services/web/app/src/Features/Authorization/AuthorizationMiddleware.js index 93db5af1c1..7d0d174381 100644 --- a/services/web/app/src/Features/Authorization/AuthorizationMiddleware.js +++ b/services/web/app/src/Features/Authorization/AuthorizationMiddleware.js @@ -129,6 +129,25 @@ async function ensureUserCanResolveThread(req, res, next) { return HttpErrorHandler.forbidden(req, res) } +async function ensureUserCanSendComment(req, res, next) { + const projectId = _getProjectId(req) + const userId = _getUserId(req) + const token = TokenAccessHandler.getRequestToken(req, projectId) + + const canSendComment = await AuthorizationManager.promises.canUserSendComment( + userId, + projectId, + token + ) + if (canSendComment) { + logger.debug({ userId, projectId }, 'allowing user to send a comment') + return next() + } + + logger.debug({ userId, projectId }, 'denying user to send a comment') + return HttpErrorHandler.forbidden(req, res) +} + async function ensureUserCanWriteProjectContent(req, res, next) { const projectId = _getProjectId(req) const userId = _getUserId(req) @@ -249,6 +268,7 @@ module.exports = { ensureUserCanWriteProjectSettings ), ensureUserCanResolveThread: expressify(ensureUserCanResolveThread), + ensureUserCanSendComment: expressify(ensureUserCanSendComment), ensureUserCanWriteProjectContent: expressify( ensureUserCanWriteProjectContent ), diff --git a/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js b/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js index 4c272387d2..01b36aee47 100644 --- a/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js +++ b/services/web/test/unit/src/Authorization/AuthorizationManagerTests.js @@ -462,6 +462,15 @@ describe('AuthorizationManager', function () { tokenReadAndWrite: true, }) + testPermission('canUserSendComment', { + siteAdmin: true, + owner: true, + readAndWrite: true, + review: true, + publicReadAndWrite: true, + tokenReadAndWrite: true, + }) + testPermission('canUserWriteProjectContent', { siteAdmin: true, owner: true, diff --git a/services/web/test/unit/src/Authorization/AuthorizationMiddlewareTests.js b/services/web/test/unit/src/Authorization/AuthorizationMiddlewareTests.js index 43c5d4bf0c..415576ddd1 100644 --- a/services/web/test/unit/src/Authorization/AuthorizationMiddlewareTests.js +++ b/services/web/test/unit/src/Authorization/AuthorizationMiddlewareTests.js @@ -26,6 +26,7 @@ describe('AuthorizationMiddleware', function () { canUserWriteProjectSettings: sinon.stub(), canUserWriteProjectContent: sinon.stub(), canUserResolveThread: sinon.stub(), + canUserSendComment: sinon.stub(), canUserAdminProject: sinon.stub(), canUserRenameProject: sinon.stub(), canUserReviewProjectContent: sinon.stub(), @@ -86,6 +87,10 @@ describe('AuthorizationMiddleware', function () { ) }) + describe('ensureUserCanSendComment', function () { + testMiddleware('ensureUserCanSendComment', 'canUserSendComment') + }) + describe('ensureUserCanResolveThread', function () { beforeEach(function () { this.req.params.doc_id = this.doc_id From 1072f836aedb500a673ac4e879b5f52d08c42b19 Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Mon, 20 Jan 2025 15:36:20 +0100 Subject: [PATCH 0082/1724] Hide reply input if no comment permissions (#22955) GitOrigin-RevId: eeb323e8d7426388f4291906299397f58095b46a --- .../components/review-panel-comment-content.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-comment-content.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-comment-content.tsx index 86c995b90c..4939785c90 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-comment-content.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-comment-content.tsx @@ -11,6 +11,7 @@ import { CommentId, ThreadId, } from '../../../../../types/review-panel/review-panel' +import { usePermissionsContext } from '@/features/ide-react/context/permissions-context' export const ReviewPanelCommentContent = memo<{ comment: Change @@ -36,6 +37,7 @@ export const ReviewPanelCommentContent = memo<{ }) => { const { t } = useTranslation() const threads = useThreadsContext() + const permissions = usePermissionsContext() const handleSubmit = useCallback( (content, setContent) => onReply?.(content).then(() => setContent('')), @@ -90,7 +92,7 @@ export const ReviewPanelCommentContent = memo<{
    )} - {!isResolved && ( + {permissions.comment && !isResolved && ( Date: Mon, 20 Jan 2025 16:16:34 +0100 Subject: [PATCH 0083/1724] Merge pull request #22863 from overleaf/rd-migrate-admin-user-bs5 Migrate the admin users page to Bootstrap 5 GitOrigin-RevId: 34165b5d0f91c45e24a7fc94086871a0f22e50f9 --- .../stylesheets/bootstrap-5/abstracts/mixins.scss | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss b/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss index b35dec6212..66b58a9a0b 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss @@ -61,11 +61,13 @@ } @mixin reset-button() { - padding: 0; - cursor: pointer; + appearance: none; background: transparent; border: 0; - appearance: none; + cursor: pointer; + height: 20px; + padding: 0; + width: 20px; } @mixin modal-lg { From 6c69266c0a234971f0d28a1eccf7e2a39498e2da Mon Sep 17 00:00:00 2001 From: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> Date: Mon, 20 Jan 2025 16:16:53 +0100 Subject: [PATCH 0084/1724] Merge pull request #22934 from overleaf/rd-admin-split-test-edit-id Migrate the split test edit admin page to Bootstrap 5 GitOrigin-RevId: 34c690e00f74a68fb4018f7546d77aefd3e84a51 --- .../bootstrap-5/components/table.scss | 23 +++--- .../stylesheets/bootstrap-5/pages/admin.scss | 74 ------------------ .../bootstrap-5/pages/admin/admin.scss | 78 +++++++++++++++++++ .../pages/admin/project-url-lookup.scss | 32 ++++---- .../stylesheets/bootstrap-5/pages/all.scss | 3 +- 5 files changed, 110 insertions(+), 100 deletions(-) delete mode 100644 services/web/frontend/stylesheets/bootstrap-5/pages/admin.scss create mode 100644 services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/table.scss b/services/web/frontend/stylesheets/bootstrap-5/components/table.scss index e79408c7f9..8f918cb1bd 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/table.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/table.scss @@ -2,6 +2,7 @@ flex: 1; margin-bottom: var(--spacing-06); background-color: var(--white); + padding: var(--spacing-04); .table { margin-bottom: initial; @@ -9,20 +10,23 @@ } .table { - tr { - &:last-child { - td { - border-bottom-width: 0; - } - } - } - th, td { a { text-decoration: none; } } + + tbody { + tr { + &:last-child { + td, + th { + border-bottom-width: 0; + } + } + } + } } .table-container-bordered { @@ -43,7 +47,8 @@ .table-striped { tr, - td { + td, + th { border-top-width: 0; border-bottom-width: 0; } diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/admin.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/admin.scss deleted file mode 100644 index c34ed4b771..0000000000 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/admin.scss +++ /dev/null @@ -1,74 +0,0 @@ -.material-switch { - input[type='checkbox'] { - display: none; - - &:checked + label::before { - background: inherit; - opacity: 0.5; - } - - &:checked + label::after { - background: inherit; - left: 20px; - } - - &:disabled + label { - opacity: 0.5; - cursor: not-allowed; - } - } - - label { - cursor: pointer; - height: 0; - position: relative; - width: 40px; - background-color: var(--bg-accent-01); - - &::before { - background: rgb(0 0 0); - box-shadow: inset 0 0 10px rgb(0 0 0 / 50%); - border-radius: var(--border-radius-medium); - content: ''; - height: 16px; - margin-top: calc(var(--spacing-01) * -1); - position: absolute; - opacity: 0.3; - transition: all 0.2s ease-in-out; - width: 40px; - } - - &::after { - background: rgb(255 255 255); - border-radius: var(--border-radius-large); - box-shadow: 0 0 5px rgb(0 0 0 / 30%); - content: ''; - height: 24px; - left: -4px; - margin-top: calc(var(--spacing-01) * -1); - position: absolute; - top: -4px; - transition: all 0.2s ease-in-out; - width: 24px; - } - } -} - -.hr-sect { - display: flex; - flex-basis: 100%; - align-items: center; - color: rgb(0 0 0 / 35%); - margin: var(--spacing-04) 0; -} - -.hr-sect::before, -.hr-sect::after { - content: ''; - flex-grow: 1; - background: rgb(0 0 0 / 35%); - height: 1px; - font-size: 0; - line-height: 0; - margin: 0 var(--spacing-04); -} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss new file mode 100644 index 0000000000..c5a48aa828 --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/admin/admin.scss @@ -0,0 +1,78 @@ +#survey-form, +#split-test-edit, +#split-test-create { + .material-switch { + input[type='checkbox'] { + display: none; + + &:checked + label::before { + background: inherit; + opacity: 0.5; + } + + &:checked + label::after { + background: inherit; + left: 20px; + } + + &:disabled + label { + opacity: 0.5; + cursor: not-allowed; + } + } + + label { + cursor: pointer; + height: 0; + position: relative; + width: 40px; + background-color: var(--bg-accent-01); + + &::before { + background: rgb(0 0 0); + box-shadow: inset 0 0 10px rgb(0 0 0 / 50%); + border-radius: var(--border-radius-medium); + content: ''; + height: 16px; + margin-top: calc(var(--spacing-01) * -1); + position: absolute; + opacity: 0.3; + transition: all 0.2s ease-in-out; + width: 40px; + } + + &::after { + background: rgb(255 255 255); + border-radius: var(--border-radius-large); + box-shadow: 0 0 5px rgb(0 0 0 / 30%); + content: ''; + height: 24px; + left: -4px; + margin-top: calc(var(--spacing-01) * -1); + position: absolute; + top: -4px; + transition: all 0.2s ease-in-out; + width: 24px; + } + } + } + + .hr-sect { + display: flex; + flex-basis: 100%; + align-items: center; + color: rgb(0 0 0 / 35%); + margin: var(--spacing-04) 0; + } + + .hr-sect::before, + .hr-sect::after { + content: ''; + flex-grow: 1; + background: rgb(0 0 0 / 35%); + height: 1px; + font-size: 0; + line-height: 0; + margin: 0 var(--spacing-04); + } +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/admin/project-url-lookup.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/admin/project-url-lookup.scss index 9c2130b15e..a06d939b90 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/admin/project-url-lookup.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/admin/project-url-lookup.scss @@ -1,22 +1,22 @@ .project-url-lookup { margin-top: var(--spacing-08); -} -.project-url-lookup-results { - margin-top: var(--spacing-08); -} + .project-url-lookup-results { + margin-top: var(--spacing-08); + } -.project-url-lookup-link-box { - background-color: var(--bg-light-secondary); - border: 1px solid var(--border-primary-dark); - padding: var(--spacing-03) var(--spacing-05); - display: flex; - align-items: center; - justify-content: space-between; -} + .project-url-lookup-link-box { + background-color: var(--bg-light-secondary); + border: 1px solid var(--border-primary-dark); + padding: var(--spacing-03) var(--spacing-05); + display: flex; + align-items: center; + justify-content: space-between; + } -.project-url-lookup-hint { - display: flex; - align-items: center; - padding: var(--spacing-03); + .project-url-lookup-hint { + display: flex; + align-items: center; + padding: var(--spacing-03); + } } diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss index 454f0708c5..ecde1a0140 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/all.scss @@ -40,4 +40,5 @@ @import 'login-register'; @import 'login'; @import 'register'; -@import 'admin'; +@import 'admin/admin'; +@import 'admin/project-url-lookup'; From 84deec4e5a96a40519c4503669c91c509ff05b29 Mon Sep 17 00:00:00 2001 From: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> Date: Mon, 20 Jan 2025 19:03:10 +0100 Subject: [PATCH 0085/1724] Merge pull request #22993 from overleaf/rd-searchbar-button-fix-migration [web] Fix close button alignment of search bar GitOrigin-RevId: ef6761f6f068090d8cbf7e8922b257bd499ee5c3 --- .../features/project-list/components/search-form.tsx | 2 +- .../stories/ui/form/form-input-bs5.stories.tsx | 8 ++++---- .../web/frontend/stylesheets/app/project-list.less | 12 ------------ .../stylesheets/bootstrap-5/abstracts/mixins.scss | 8 +++----- .../stylesheets/bootstrap-5/components/form.scss | 7 +++++++ .../stylesheets/bootstrap-5/pages/project-list.scss | 4 ---- 6 files changed, 15 insertions(+), 26 deletions(-) diff --git a/services/web/frontend/js/features/project-list/components/search-form.tsx b/services/web/frontend/js/features/project-list/components/search-form.tsx index b71ea63bf0..9f64cdb0c9 100644 --- a/services/web/frontend/js/features/project-list/components/search-form.tsx +++ b/services/web/frontend/js/features/project-list/components/search-form.tsx @@ -91,7 +91,7 @@ function SearchForm({ inputValue.length > 0 && ( -
    + {!isReviewerRoleEnabled && (
    diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-overview-file.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-overview-file.tsx index 596c9bd9fc..bd236f95c6 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-overview-file.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-overview-file.tsx @@ -16,9 +16,9 @@ import { } from '../../../../../types/change' import { canAggregate } from '../utils/can-aggregate' -import MaterialIcon from '@/shared/components/material-icon' import useOverviewFileCollapsed from '../hooks/use-overview-file-collapsed' import { useThreadsContext } from '../context/threads-context' +import { CollapsibleFileHeader } from '@/shared/components/collapsible-file-header' export const ReviewPanelOverviewFile: FC<{ doc: MainDocument @@ -64,19 +64,12 @@ export const ReviewPanelOverviewFile: FC<{ return ( <>
    - + {!collapsed && (
    diff --git a/services/web/frontend/js/shared/components/collapsible-file-header.tsx b/services/web/frontend/js/shared/components/collapsible-file-header.tsx new file mode 100644 index 0000000000..efcd51a74f --- /dev/null +++ b/services/web/frontend/js/shared/components/collapsible-file-header.tsx @@ -0,0 +1,21 @@ +import MaterialIcon from '@/shared/components/material-icon' +import { Dispatch, FC, SetStateAction } from 'react' + +export const CollapsibleFileHeader: FC<{ + name: string + count: number + collapsed: boolean + toggleCollapsed: Dispatch> +}> = ({ name, count, collapsed, toggleCollapsed }) => ( + +) diff --git a/services/web/frontend/js/shared/components/panel-heading.tsx b/services/web/frontend/js/shared/components/panel-heading.tsx new file mode 100644 index 0000000000..bdec33779a --- /dev/null +++ b/services/web/frontend/js/shared/components/panel-heading.tsx @@ -0,0 +1,38 @@ +import { FC } from 'react' +import SplitTestBadge from '@/shared/components/split-test-badge' +import MaterialIcon from '@/shared/components/material-icon' +import { useTranslation } from 'react-i18next' + +export const PanelHeading: FC<{ + title: string + splitTestName?: string + children?: React.ReactNode + handleClose(): void +}> = ({ title, splitTestName, children, handleClose }) => { + const { t } = useTranslation() + + return ( +
    +
    + {title} + {splitTestName && ( + + )} +
    + + {children} + + +
    + ) +} diff --git a/services/web/frontend/stylesheets/app/editor/review-panel-new.less b/services/web/frontend/stylesheets/app/editor/review-panel-new.less index 338e2897e8..8b5e49018c 100644 --- a/services/web/frontend/stylesheets/app/editor/review-panel-new.less +++ b/services/web/frontend/stylesheets/app/editor/review-panel-new.less @@ -498,7 +498,7 @@ } } - .review-panel-overview-file-header { + .collapsible-file-header { all: unset; padding: 6px 8px; font-size: 14px; @@ -524,7 +524,7 @@ padding-bottom: 6px; } - .review-panel-overview-file-entry-count { + .collapsible-file-header-count { background-color: @neutral-20; padding: 2px 4px; margin-left: auto; diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/all.scss b/services/web/frontend/stylesheets/bootstrap-5/components/all.scss index 99f7cd2a45..269a51ec01 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/all.scss @@ -34,3 +34,5 @@ @import 'recurly'; @import 'dev-toolbar'; @import 'tos'; +@import 'collapsible-file-header'; +@import 'panel-heading'; diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/collapsible-file-header.scss b/services/web/frontend/stylesheets/bootstrap-5/components/collapsible-file-header.scss new file mode 100644 index 0000000000..4a510ae798 --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/components/collapsible-file-header.scss @@ -0,0 +1,18 @@ +.collapsible-file-header { + all: unset; + padding: var(--spacing-03) var(--spacing-04); + font-size: var(--font-size-02); + cursor: pointer; + display: flex; + align-items: center; + gap: var(--spacing-04); + box-sizing: border-box; + width: 100%; +} + +.collapsible-file-header-count { + background-color: var(--neutral-20); + padding: var(--spacing-01) var(--spacing-02); + margin-left: auto; + border-radius: var(--border-radius-base); +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/panel-heading.scss b/services/web/frontend/stylesheets/bootstrap-5/components/panel-heading.scss new file mode 100644 index 0000000000..0792665cc5 --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/components/panel-heading.scss @@ -0,0 +1,31 @@ +.panel-heading { + display: flex; + align-items: center; + padding: var(--spacing-03) var(--spacing-02); + gap: 2px; +} + +.panel-heading-label { + font-size: var(--font-size-02); + font-weight: bold; + margin: 0; + flex: 1; + text-align: start; + display: flex; + align-items: center; + gap: var(--spacing-02); +} + +.panel-heading-close-button { + display: flex; + align-items: center; + border: none; + background-color: transparent; + color: var(--content-primary); + padding: var(--spacing-01); + + &:hover, + &:focus { + background-color: var(--neutral-20); + } +} 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 2ea853e8e8..22544731ac 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 @@ -253,39 +253,6 @@ } } - .review-panel-heading { - display: flex; - align-items: center; - padding: var(--spacing-03) var(--spacing-02); - gap: 2px; - - .review-panel-label { - font-size: var(--font-size-02); - font-weight: bold; - margin: 0; - flex: 1; - text-align: start; - } - - .review-panel-split-test-badge { - margin-left: var(--spacing-02); - } - - .review-panel-close-button { - display: flex; - align-items: center; - border: none; - background-color: transparent; - color: var(--content-primary); - padding: var(--spacing-01); - - &:hover, - &:focus { - background-color: var(--neutral-20); - } - } - } - &.review-panel-resolved-comments { --bs-popover-border-width: 1px; --bs-popover-bg: var(--bg-light-secondary); @@ -505,18 +472,6 @@ } } - .review-panel-overview-file-header { - all: unset; - padding: var(--spacing-03) var(--spacing-04); - font-size: var(--font-size-02); - cursor: pointer; - display: flex; - align-items: center; - gap: var(--spacing-04); - box-sizing: border-box; - width: 100%; - } - .review-panel-overfile-divider { border-bottom: 1px solid var(--border-divider); margin: var(--spacing-01) 0; @@ -531,13 +486,6 @@ padding-bottom: var(--spacing-03); } - .review-panel-overview-file-entry-count { - background-color: var(--neutral-20); - padding: var(--spacing-01) var(--spacing-02); - margin-left: auto; - border-radius: var(--border-radius-base); - } - .review-panel-footer { position: fixed; height: 60px; From 604471bfe9f26c4febdea0872be1933400e40e6a Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Wed, 22 Jan 2025 09:39:32 +0000 Subject: [PATCH 0094/1724] Improve types for window.metaAttributesCache (#22983) GitOrigin-RevId: cc962bf7eeaac525267ba04080889b0d58051914 --- services/web/frontend/js/utils/meta.ts | 19 +++++++++ .../web/frontend/stories/hooks/use-meta.tsx | 10 ++--- .../stories/loading/loading.stories.tsx | 3 +- .../components/add-seats.spec.tsx | 4 -- .../institution-memberships.test.tsx | 5 ++- .../dashboard/states/active/active.test.tsx | 5 ++- .../active/change-plan/change-plan.test.tsx | 4 +- .../successful-subscription.test.tsx | 8 ++-- .../helpers/render-active-subscription.tsx | 39 ++++++++++--------- .../render-with-subscription-dash-context.tsx | 8 ++-- services/web/types/window.ts | 3 +- 11 files changed, 61 insertions(+), 47 deletions(-) diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 762cea1e63..6c3fac675a 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -72,6 +72,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'] 'ol-createdAt': Date @@ -163,9 +164,11 @@ export interface Meta { 'ol-postCheckoutRedirect': string 'ol-postUrl': string 'ol-prefetchedProjectsBlob': GetProjectsResponseBody | undefined + 'ol-preventCompileOnLoad'?: boolean 'ol-primaryEmail': { email: string; confirmed: boolean } 'ol-project': any // TODO 'ol-projectHistoryBlobsEnabled': boolean + 'ol-projectName': string 'ol-projectSyncSuccessMessage': string 'ol-projectTags': Tag[] 'ol-project_id': string @@ -232,6 +235,22 @@ export interface Meta { 'ol-wsUrl': string } +type DeepPartial = + T extends Record ? { [P in keyof T]?: DeepPartial } : T + +export type PartialMeta = DeepPartial + +export type MetaAttributesCache< + K extends keyof PartialMeta = keyof PartialMeta, +> = Map + +export type MetaTag = { + [K in keyof Meta]: { + name: K + value: Meta[K] + } +}[keyof Meta] + // cache for parsed values window.metaAttributesCache = window.metaAttributesCache || new Map() diff --git a/services/web/frontend/stories/hooks/use-meta.tsx b/services/web/frontend/stories/hooks/use-meta.tsx index 398af6789e..0ebbe9f29f 100644 --- a/services/web/frontend/stories/hooks/use-meta.tsx +++ b/services/web/frontend/stories/hooks/use-meta.tsx @@ -1,14 +1,10 @@ -import type { Meta } from '@/utils/meta' - -type DeepPartial = T extends object - ? { [P in keyof T]?: DeepPartial } - : T +import { PartialMeta } from '@/utils/meta' /** * Set values on window.metaAttributesCache, for use in Storybook stories */ -export const useMeta = (meta: DeepPartial) => { +export const useMeta = (meta: PartialMeta) => { for (const [key, value] of Object.entries(meta)) { - window.metaAttributesCache.set(key, value) + window.metaAttributesCache.set(key as keyof PartialMeta, value) } } diff --git a/services/web/frontend/stories/loading/loading.stories.tsx b/services/web/frontend/stories/loading/loading.stories.tsx index 0a643d7224..b4c9068737 100644 --- a/services/web/frontend/stories/loading/loading.stories.tsx +++ b/services/web/frontend/stories/loading/loading.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react' import { LoadingUI } from '@/features/ide-react/components/loading' import { EditorProviders } from '../../../test/frontend/helpers/editor-providers' import { bsVersionDecorator } from '../../../.storybook/utils/with-bootstrap-switcher' +import { PartialMeta } from '@/utils/meta' const meta: Meta = { title: 'Loading Page / Loading', @@ -35,7 +36,7 @@ const errorMessages = { export const LoadingPage: Story = { render: args => { for (const [key, value] of Object.entries(errorMessages)) { - window.metaAttributesCache.set(`ol-${key}`, value) + window.metaAttributesCache.set(`ol-${key}` as keyof PartialMeta, value) } return ( 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 5ccc611769..4110a5d0c2 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 @@ -11,10 +11,6 @@ describe('', function () { cy.window().then(win => { win.metaAttributesCache.set('ol-groupName', 'My Awesome Team') win.metaAttributesCache.set('ol-subscriptionId', '123') - win.metaAttributesCache.set( - 'ol-subscriptionEndsAt', - '2025-01-01T12:00:00.000Z' - ) win.metaAttributesCache.set('ol-totalLicenses', this.totalLicenses) win.metaAttributesCache.set('ol-isProfessional', false) }) diff --git a/services/web/test/frontend/features/subscription/components/dashboard/institution-memberships.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/institution-memberships.test.tsx index 7471c36d09..b880464588 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/institution-memberships.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/institution-memberships.test.tsx @@ -5,8 +5,9 @@ import { cleanUpContext, renderWithSubscriptionDashContext, } from '../../helpers/render-with-subscription-dash-context' +import { Institution } from '../../../../../../types/institution' -const memberships = [ +const memberships: Institution[] = [ { id: 9258, name: 'Test University', @@ -16,6 +17,7 @@ const memberships = [ ssoBeta: false, ssoEnabled: false, maxConfirmationMonths: 6, + writefullCommonsAccount: false, }, { id: 9259, @@ -26,6 +28,7 @@ const memberships = [ ssoBeta: false, ssoEnabled: true, maxConfirmationMonths: 12, + writefullCommonsAccount: false, }, ] 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 df8192030f..be8f3ba87c 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 @@ -22,6 +22,7 @@ import { subscriptionUpdateUrl, } from '../../../../../../../../frontend/js/features/subscription/data/subscription-url' import * as useLocationModule from '../../../../../../../../frontend/js/shared/hooks/use-location' +import { MetaTag } from '@/utils/meta' describe('', function () { let sendMBSpy: sinon.SinonSpy @@ -318,9 +319,9 @@ describe('', function () { }) describe('extend trial', function () { - const canExtend = { + const canExtend: MetaTag = { name: 'ol-userCanExtendTrial', - value: 'true', + value: true, } const cancelButtonText = 'No thanks, I still want to cancel' const extendTrialButtonText = 'I’ll take it!' diff --git a/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx index 5bca8c03d9..979a87743b 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx @@ -23,8 +23,6 @@ import { renderActiveSubscription } from '../../../../../helpers/render-active-s import * as useLocationModule from '../../../../../../../../../frontend/js/shared/hooks/use-location' describe('', function () { - const plansMetaTag = { name: 'ol-plans', value: plans } - let reloadStub: sinon.SinonStub beforeEach(function () { @@ -83,7 +81,7 @@ describe('', function () { { metaTags: [ { name: 'ol-subscription', value: annualActiveSubscription }, - plansMetaTag, + { name: 'ol-plans', value: plans }, ], } ) 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 d3be3acae4..58828bebe3 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 @@ -3,22 +3,22 @@ import { screen, within } from '@testing-library/react' import SuccessfulSubscription from '../../../../../../frontend/js/features/subscription/components/successful-subscription/successful-subscription' import { renderWithSubscriptionDashContext } from '../../helpers/render-with-subscription-dash-context' import { annualActiveSubscription } from '../../fixtures/subscriptions' +import { ExposedSettings } from '../../../../../../types/exposed-settings' describe('successful subscription page', function () { it('renders the invoices link', function () { const adminEmail = 'foo@example.com' - const options = { + renderWithSubscriptionDashContext(, { metaTags: [ { name: 'ol-ExposedSettings', value: { adminEmail, - }, + } as ExposedSettings, }, { name: 'ol-subscription', value: annualActiveSubscription }, ], - } - renderWithSubscriptionDashContext(, options) + }) screen.getByRole('heading', { name: /thanks for subscribing/i }) const alert = screen.getByRole('alert') diff --git a/services/web/test/frontend/features/subscription/helpers/render-active-subscription.tsx b/services/web/test/frontend/features/subscription/helpers/render-active-subscription.tsx index f790dac2dd..19566a8945 100644 --- a/services/web/test/frontend/features/subscription/helpers/render-active-subscription.tsx +++ b/services/web/test/frontend/features/subscription/helpers/render-active-subscription.tsx @@ -2,30 +2,31 @@ import { ActiveSubscription } from '../../../../../frontend/js/features/subscrip import { RecurlySubscription } from '../../../../../types/subscription/dashboard/subscription' import { groupPlans, plans } from '../fixtures/plans' import { renderWithSubscriptionDashContext } from './render-with-subscription-dash-context' +import { MetaTag } from '@/utils/meta' +import { CurrencyCode } from '../../../../../types/subscription/currency' export function renderActiveSubscription( subscription: RecurlySubscription, - tags: { name: string; value: string | object | Array }[] = [], - currencyCode?: string + tags: MetaTag[] = [], + currencyCode?: CurrencyCode ) { - const renderOptions = { - currencyCode, - metaTags: [ - ...tags, - { name: 'ol-plans', value: plans }, - { - name: 'ol-groupPlans', - value: groupPlans, - }, - { name: 'ol-subscription', value: subscription }, - { - name: 'ol-recommendedCurrency', - value: currencyCode || 'USD', - }, - ], - } renderWithSubscriptionDashContext( , - renderOptions + { + currencyCode, + metaTags: [ + ...tags, + { name: 'ol-plans', value: plans }, + { + name: 'ol-groupPlans', + value: groupPlans, + }, + { name: 'ol-subscription', value: subscription }, + { + name: 'ol-recommendedCurrency', + value: currencyCode || 'USD', + }, + ], + } ) } diff --git a/services/web/test/frontend/features/subscription/helpers/render-with-subscription-dash-context.tsx b/services/web/test/frontend/features/subscription/helpers/render-with-subscription-dash-context.tsx index f3603d484a..38a5d7c021 100644 --- a/services/web/test/frontend/features/subscription/helpers/render-with-subscription-dash-context.tsx +++ b/services/web/test/frontend/features/subscription/helpers/render-with-subscription-dash-context.tsx @@ -4,14 +4,12 @@ import { SubscriptionDashboardProvider } from '../../../../../frontend/js/featur import { groupPriceByUsageTypeAndSize, plans } from '../fixtures/plans' import fetchMock from 'fetch-mock' import { SplitTestProvider } from '@/shared/context/split-test-context' +import { MetaTag } from '@/utils/meta' export function renderWithSubscriptionDashContext( component: React.ReactElement, options?: { - metaTags?: { - name: string - value: string | object | Array | boolean - }[] + metaTags?: MetaTag[] recurlyNotLoaded?: boolean queryingRecurly?: boolean currencyCode?: string @@ -28,7 +26,7 @@ export function renderWithSubscriptionDashContext( ) options?.metaTags?.forEach(tag => - window.metaAttributesCache.set(tag.name, tag.value) + window.metaAttributesCache.set(tag!.name, tag!.value) ) window.metaAttributesCache.set('ol-user', {}) diff --git a/services/web/types/window.ts b/services/web/types/window.ts index 4096958005..2a17efeb13 100644 --- a/services/web/types/window.ts +++ b/services/web/types/window.ts @@ -1,10 +1,11 @@ import 'recurly__recurly-js' import { ScopeValueStore } from './ide/scope-value-store' +import { MetaAttributesCache } from '@/utils/meta' declare global { // eslint-disable-next-line no-unused-vars interface Window { - metaAttributesCache: Map + metaAttributesCache: MetaAttributesCache _ide: Record & { $scope: Record & { pdf?: { From 50c6b8a8312076638f254dd4e6566632030a1d66 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Wed, 22 Jan 2025 09:39:48 +0000 Subject: [PATCH 0095/1724] Avoid mutating the previous state in a set function (#22935) GitOrigin-RevId: b3613b8476bbb60a10ef6b293487b1017f56ea68 --- .../dictionary/components/dictionary-modal-content.tsx | 7 ++++--- .../project-list/context/project-list-context.tsx | 10 ++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx b/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx index 90369d2467..a83308c98f 100644 --- a/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx +++ b/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx @@ -37,9 +37,10 @@ export default function DictionaryModalContent({ word => { runAsync(postJSON('/spelling/unlearn', { body: { word } })) .then(() => { - setLearnedWords(value => { - value.delete(word) - return new Set(value) + setLearnedWords(prevLearnedWords => { + const learnedWords = new Set(prevLearnedWords) + learnedWords.delete(word) + return learnedWords }) window.dispatchEvent( new CustomEvent('editor:remove-learned-word', { detail: word }) diff --git a/services/web/frontend/js/features/project-list/context/project-list-context.tsx b/services/web/frontend/js/features/project-list/context/project-list-context.tsx index 2c1e7941ec..918a54781d 100644 --- a/services/web/frontend/js/features/project-list/context/project-list-context.tsx +++ b/services/web/frontend/js/features/project-list/context/project-list-context.tsx @@ -271,7 +271,8 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) { const toggleSelectedProject = useCallback( (projectId: string, selected?: boolean) => { - setSelectedProjectIds(selectedProjectIds => { + setSelectedProjectIds(prevSelectedProjectIds => { + const selectedProjectIds = new Set(prevSelectedProjectIds) if (selected === true) { selectedProjectIds.add(projectId) } else if (selected === false) { @@ -281,7 +282,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) { } else { selectedProjectIds.add(projectId) } - return new Set([...selectedProjectIds]) + return selectedProjectIds }) }, [] @@ -293,7 +294,8 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) { const selectOrUnselectAllProjects = useCallback( checked => { - setSelectedProjectIds(selectedProjectIds => { + setSelectedProjectIds(prevSelectedProjectIds => { + const selectedProjectIds = new Set(prevSelectedProjectIds) for (const project of visibleProjects) { if (checked) { selectedProjectIds.add(project.id) @@ -301,7 +303,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) { selectedProjectIds.delete(project.id) } } - return new Set([...selectedProjectIds]) + return selectedProjectIds }) }, [visibleProjects] From 54a4f7a75bfe658502fe06b7c553682d95590662 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Wed, 22 Jan 2025 09:40:16 +0000 Subject: [PATCH 0096/1724] Upgrade @codemirror/autocomplete (#22921) GitOrigin-RevId: e1b1205c1d577fcc338b429551038dba92ec23f4 --- package-lock.json | 20 ++++++++------------ services/web/package.json | 2 +- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 66f703afc0..6bc3fa5edf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3342,8 +3342,9 @@ "dev": true }, "node_modules/@codemirror/autocomplete": { - "version": "6.18.2", - "resolved": "git+ssh://git@github.com/overleaf/codemirror-autocomplete.git#e4e1f4d66ccfd909ef3805ac99997bb078fb7c99", + "version": "6.18.4", + "resolved": "git+ssh://git@github.com/overleaf/codemirror-autocomplete.git#6445cd056671c98d12d1c597ba705e11327ec4c5", + "integrity": "sha512-NOpHncgkcZ2w92bO+H6mIzcSToAKt1fWQRImKnDsNbYvp4X/638d6SOuEoE7pSZ7tryJbqKDrE3Zs6nVIXACUA==", "dev": true, "license": "MIT", "dependencies": { @@ -3351,12 +3352,6 @@ "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" - }, - "peerDependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.0.0" } }, "node_modules/@codemirror/commands": { @@ -43566,7 +43561,7 @@ "@babel/preset-react": "^7.24.7", "@babel/preset-typescript": "^7.24.7", "@babel/register": "^7.24.6", - "@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#v6.18.2-overleaf-1", + "@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#6445cd056671c98d12d1c597ba705e11327ec4c5", "@codemirror/commands": "^6.7.1", "@codemirror/lang-markdown": "^6.2.5", "@codemirror/language": "^6.10.6", @@ -47396,9 +47391,10 @@ "dev": true }, "@codemirror/autocomplete": { - "version": "git+ssh://git@github.com/overleaf/codemirror-autocomplete.git#e4e1f4d66ccfd909ef3805ac99997bb078fb7c99", + "version": "git+ssh://git@github.com/overleaf/codemirror-autocomplete.git#6445cd056671c98d12d1c597ba705e11327ec4c5", + "integrity": "sha512-NOpHncgkcZ2w92bO+H6mIzcSToAKt1fWQRImKnDsNbYvp4X/638d6SOuEoE7pSZ7tryJbqKDrE3Zs6nVIXACUA==", "dev": true, - "from": "@codemirror/autocomplete@github:overleaf/codemirror-autocomplete#v6.18.2-overleaf-1", + "from": "@codemirror/autocomplete@github:overleaf/codemirror-autocomplete#6445cd056671c98d12d1c597ba705e11327ec4c5", "requires": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -51995,7 +51991,7 @@ "@babel/preset-react": "^7.24.7", "@babel/preset-typescript": "^7.24.7", "@babel/register": "^7.24.6", - "@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#v6.18.2-overleaf-1", + "@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#6445cd056671c98d12d1c597ba705e11327ec4c5", "@codemirror/commands": "^6.7.1", "@codemirror/lang-markdown": "^6.2.5", "@codemirror/language": "^6.10.6", diff --git a/services/web/package.json b/services/web/package.json index ab082fba04..78672ef6d4 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -179,7 +179,7 @@ "@babel/preset-react": "^7.24.7", "@babel/preset-typescript": "^7.24.7", "@babel/register": "^7.24.6", - "@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#v6.18.2-overleaf-1", + "@codemirror/autocomplete": "github:overleaf/codemirror-autocomplete#6445cd056671c98d12d1c597ba705e11327ec4c5", "@codemirror/commands": "^6.7.1", "@codemirror/lang-markdown": "^6.2.5", "@codemirror/language": "^6.10.6", From 25c401f2a7780662f39c5db9eb9acc5b60b9c302 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Wed, 22 Jan 2025 09:40:27 +0000 Subject: [PATCH 0097/1724] Restore padding on search form inputs (#22132) GitOrigin-RevId: da5826711072fc39ecc8f8db05881bb0dc0eea87 --- .../frontend/js/features/source-editor/extensions/search.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/web/frontend/js/features/source-editor/extensions/search.ts b/services/web/frontend/js/features/source-editor/extensions/search.ts index 4661fc2037..75bdae4c10 100644 --- a/services/web/frontend/js/features/source-editor/extensions/search.ts +++ b/services/web/frontend/js/features/source-editor/extensions/search.ts @@ -268,6 +268,9 @@ const searchFormTheme = EditorView.theme({ '--ol-cm-search-form-error-shadow': 'inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px var(--red-50)', containerType: 'inline-size', + '& .form-control-sm, & .btn-sm': { + padding: 'var(--spacing-03) var(--spacing-05)', + }, }, '&.bootstrap-5 .ol-cm-search-form': { '--ol-cm-search-form-gap': 'var(--spacing-05)', From 793d900ba5249cbe272424b7ed15485bf93ca7ee Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Wed, 22 Jan 2025 09:40:42 +0000 Subject: [PATCH 0098/1724] Remove hover style from table header (#22121) GitOrigin-RevId: 02253aeadcb3047ce5eef2241658cc670a0c6d53 --- .../stylesheets/bootstrap-5/components/table.scss | 8 -------- 1 file changed, 8 deletions(-) diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/table.scss b/services/web/frontend/stylesheets/bootstrap-5/components/table.scss index 8f918cb1bd..efe3d94c0e 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/table.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/table.scss @@ -37,14 +37,6 @@ border-style: solid; } -.table-hover { - th { - &:hover { - background-color: $table-hover-bg; - } - } -} - .table-striped { tr, td, From 6cb15284955fef3ec9a2960351f6e29905954c65 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Wed, 22 Jan 2025 10:56:42 +0000 Subject: [PATCH 0099/1724] Merge pull request #23020 from overleaf/ar-prevent-rootFolder-deletion [web] Prevent deletes on a project's rootFolder GitOrigin-RevId: 6d0506f207425f65d3de990a78bb1ea9b136ed1e --- .../src/Features/Errors/ErrorController.js | 6 +++ .../web/app/src/Features/Errors/Errors.js | 7 +++ .../ProjectEntityMongoUpdateHandler.js | 11 +++- services/web/locales/en.json | 1 + .../acceptance/src/ProjectStructureTests.mjs | 51 +++++++++++++++++-- 5 files changed, 70 insertions(+), 6 deletions(-) diff --git a/services/web/app/src/Features/Errors/ErrorController.js b/services/web/app/src/Features/Errors/ErrorController.js index a23b6521c7..b7f96a8b13 100644 --- a/services/web/app/src/Features/Errors/ErrorController.js +++ b/services/web/app/src/Features/Errors/ErrorController.js @@ -77,6 +77,12 @@ async function handleError(error, req, res, next) { res.status(400) plainTextResponse(res, error.message) } + } else if (error instanceof Errors.NonDeletableEntityError) { + req.logger.setLevel('warn') + if (shouldSendErrorResponse) { + res.status(422) + plainTextResponse(res, error.message) + } } else if (error instanceof Errors.SAMLSessionDataMissing) { req.logger.setLevel('warn') if (shouldSendErrorResponse) { diff --git a/services/web/app/src/Features/Errors/Errors.js b/services/web/app/src/Features/Errors/Errors.js index c00a1097d6..40076cea6e 100644 --- a/services/web/app/src/Features/Errors/Errors.js +++ b/services/web/app/src/Features/Errors/Errors.js @@ -268,6 +268,12 @@ class InvalidInstitutionalEmailError extends OError { } } +class NonDeletableEntityError extends OError { + get i18nKey() { + return 'non_deletable_entity' + } +} + module.exports = { OError, BackwardCompatibleError, @@ -318,4 +324,5 @@ module.exports = { AffiliationError, InvalidEmailError, InvalidInstitutionalEmailError, + NonDeletableEntityError, } diff --git a/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js b/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js index dcf1bd0471..31b3efaec8 100644 --- a/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js +++ b/services/web/app/src/Features/Project/ProjectEntityMongoUpdateHandler.js @@ -375,11 +375,20 @@ async function moveEntity(projectId, entityId, destFolderId, entityType) { return { project, startPath, endPath, rev: entity.rev, changes } } -async function deleteEntity(projectId, entityId, entityType, callback) { +async function deleteEntity(projectId, entityId, entityType) { const project = await ProjectGetter.promises.getProjectWithoutLock( projectId, { name: true, rootFolder: true, overleaf: true, rootDoc_id: true } ) + if ( + entityType === 'folder' && + project.rootFolder.some( + rootFolder => rootFolder._id.toString() === entityId.toString() + ) + ) { + throw new Errors.NonDeletableEntityError('cannot delete root folder') + } + const deleteRootDoc = project.rootDoc_id && entityId && diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 2931f6669c..597424e8b1 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1352,6 +1352,7 @@ "no_symbols_found": "No symbols found", "no_thanks_cancel_now": "No thanks, I still want to cancel", "no_update_email": "No, update email", + "non_deletable_entity": "The specified entity may not be deleted", "normal": "Normal", "normally_x_price_per_month": "Normally __price__ per month", "normally_x_price_per_year": "Normally __price__ per year", diff --git a/services/web/test/acceptance/src/ProjectStructureTests.mjs b/services/web/test/acceptance/src/ProjectStructureTests.mjs index 022c16c294..a1f48a8448 100644 --- a/services/web/test/acceptance/src/ProjectStructureTests.mjs +++ b/services/web/test/acceptance/src/ProjectStructureTests.mjs @@ -1,4 +1,4 @@ -import { expect } from 'chai' +import chai, { expect } from 'chai' import mongodb from 'mongodb-legacy' import Path from 'node:path' import fs from 'node:fs' @@ -7,14 +7,14 @@ import ProjectGetter from '../../../app/src/Features/Project/ProjectGetter.js' import UserHelper from './helpers/User.mjs' import MockDocStoreApiClass from './mocks/MockDocstoreApi.mjs' import MockDocUpdaterApiClass from './mocks/MockDocUpdaterApi.mjs' -import { fileURLToPath } from 'node:url' +import chaiAsPromised from 'chai-as-promised' + +chai.use(chaiAsPromised) const User = UserHelper.promises const ObjectId = mongodb.ObjectId -const __dirname = fileURLToPath(new URL('.', import.meta.url)) - let MockDocStoreApi, MockDocUpdaterApi before(function () { @@ -76,7 +76,7 @@ describe('ProjectStructureChanges', function () { async function uploadExampleProject(owner, zipFilename, options = {}) { const zipFile = fs.createReadStream( - Path.resolve(Path.join(__dirname, '..', 'files', zipFilename)) + Path.resolve(Path.join(import.meta.dirname, '..', 'files', zipFilename)) ) const { response, body } = await owner.doRequest('POST', { @@ -209,6 +209,47 @@ describe('ProjectStructureChanges', function () { }) }) + describe('deleting folders', function () { + beforeEach(async function () { + const { projectId } = await createExampleProject(owner) + this.exampleProjectId = projectId + }) + describe('when the folder is the rootFolder', function () { + beforeEach(async function () { + const project = await ProjectGetter.promises.getProject( + this.exampleProjectId + ) + this.rootFolderId = project.rootFolder[0]._id + }) + + it('should fail with a 422 error', async function () { + await expect( + deleteItem(owner, this.exampleProjectId, 'folder', this.rootFolderId) + ) + .to.be.rejected.and.eventually.match(/status=422/) + .and.eventually.match(/body="cannot delete root folder"/) + }) + }) + + describe('when the folder is not the rootFolder', function () { + beforeEach(async function () { + const folderId = await createExampleFolder(owner, this.exampleProjectId) + this.exampleFolderId = folderId + }) + + it('should succeed', async function () { + await expect( + deleteItem( + owner, + this.exampleProjectId, + 'folder', + this.exampleFolderId + ) + ).to.be.fulfilled + }) + }) + }) + describe('deleting docs', function () { beforeEach(async function () { const { projectId } = await createExampleProject(owner) From 8ae5e1dc5d42d43cbff70537176ee97d924593fd Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Wed, 22 Jan 2025 13:16:56 +0100 Subject: [PATCH 0100/1724] Fix main height when system messages are shown (#22988) GitOrigin-RevId: 0b5c6d22f61788475fc2c8595fd34e927a9f2303 --- .../frontend/stylesheets/bootstrap-5/pages/editor/ide.scss | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss index 2fa76cfb77..c1a01fccec 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss @@ -16,6 +16,8 @@ $editor-toggler-bg-dark-color: color.adjust( } #ide-root { + display: flex; + flex-direction: column; height: 100vh; /* for backwards compatibility */ height: 100dvh; /* needed for mobile devices */ @@ -71,7 +73,7 @@ $editor-toggler-bg-dark-color: color.adjust( } .ide-react-main { - height: 100%; + flex: 1; display: flex; flex-direction: column; From 6fa75eb905337afcd7e8bd56c24bf5503d6782ad Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Wed, 22 Jan 2025 13:17:24 +0100 Subject: [PATCH 0101/1724] Workaround for editor updating file when not focused in Safari (#23023) GitOrigin-RevId: da9341b2cadf4b073eb4062619a9fa7bcba17c6b --- .../file-view/components/file-view-header.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/services/web/frontend/js/features/file-view/components/file-view-header.tsx b/services/web/frontend/js/features/file-view/components/file-view-header.tsx index f6a9143661..1aeb7b82f8 100644 --- a/services/web/frontend/js/features/file-view/components/file-view-header.tsx +++ b/services/web/frontend/js/features/file-view/components/file-view-header.tsx @@ -103,6 +103,18 @@ export default function FileViewHeader({ file }: FileViewHeaderProps) { {refreshError && ( )} + + {/* Workaround for Safari issue: https://github.com/overleaf/internal/issues/21363 + * The editor behind a file view receives key events and updates the file even if Codemirror view is not focused. + * Changing the focus to a hidden textarea prevents this + */} +